Files
Ayay/SHH.CameraDashboard/Services/WebApis/WebApiService.cs

299 lines
11 KiB
C#
Raw Normal View History

2026-01-01 22:40:32 +08:00
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Diagnostics;
using System.Net.Http;
using System.Text;
namespace SHH.CameraDashboard
{
/// <summary>
/// 全局 Web API 服务
/// 职责:
/// 1. 统一管理 HttpClient 实例,避免资源耗尽。
/// 2. 封装标准的 CRUD (GET, POST, PUT, DELETE, PATCH) 操作。
/// 3. 提供详细的请求日志(耗时、状态码、请求/响应内容)。
/// 4. 处理超时和异常,提供一致的错误处理机制。
/// </summary>
public class WebApiService
{
#region --- ---
/// <summary>
/// WebAPI 设为单例
/// </summary>
public static WebApiService Instance { get; } = new WebApiService();
/// <summary>
/// 静态 HttpClient 实例。
/// 最佳实践:应用程序应共享单个 HttpClient 实例以避免套接字耗尽。
/// </summary>
private static readonly HttpClient _client;
#endregion
#region --- ---
/// <summary>
/// 当一个请求完成(无论成功或失败)时发生。
/// 可用于记录详细的 API 调用日志。
/// </summary>
public event Action<LogWebApiModel>? OnRequestCompleted;
/// <summary>
/// 日志内容的最大长度5KB防止过大的响应体撑爆内存。
/// </summary>
private const int MAX_LOG_LENGTH = 5120;
#endregion
#region --- ---
/// <summary>
/// 构造函数
/// </summary>
private WebApiService()
{
}
/// <summary>
/// 静态构造函数,用于初始化 HttpClient。
/// </summary>
static WebApiService()
{
// 配置 HttpClient 处理器
var handler = new SocketsHttpHandler
{
// 设置连接池寿命为 15 分钟。
// 这有助于解决 DNS 变更后HttpClient 仍使用旧 IP 的问题。
PooledConnectionLifetime = TimeSpan.FromMinutes(15)
};
_client = new HttpClient(handler)
{
// 设置默认请求超时时间为 15 秒。
Timeout = TimeSpan.FromSeconds(15)
};
}
#endregion
#region --- CRUD ---
/// <summary>
/// 发送一个 GET 请求。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> GetAsync(string url, string moduleName = "System", CancellationToken token = default, bool isAutoPost = false)
{
return await ExecuteRequestAsync(url, "GET", null, moduleName, token, async () =>
{
return await _client.GetAsync(url, token);
}, isAutoPost);
}
/// <summary>
/// 发送一个 POST 请求。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> PostAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "POST", jsonBody, moduleName, token, async () =>
{
using var content = CreateJsonContent(jsonBody);
return await _client.PostAsync(url, content, token);
});
}
/// <summary>
/// 发送一个 PUT 请求(通常用于替换整个资源)。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> PutAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "PUT", jsonBody, moduleName, token, async () =>
{
using var content = CreateJsonContent(jsonBody);
return await _client.PutAsync(url, content, token);
});
}
/// <summary>
/// 发送一个 PATCH 请求(通常用于更新资源的部分属性)。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="jsonBody">请求体的 JSON 字符串。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> PatchAsync(string url, string jsonBody, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "PATCH", jsonBody, moduleName, token, async () =>
{
using var content = CreateJsonContent(jsonBody);
return await _client.PatchAsync(url, content, token);
});
}
/// <summary>
/// 发送一个 DELETE 请求。
/// </summary>
/// <param name="url">请求的完整 URL。</param>
/// <param name="moduleName">发起请求的模块名称,用于日志分类。</param>
/// <param name="token">用于取消操作的令牌。</param>
/// <returns>成功时返回响应内容的字符串表示。</returns>
public async Task<string> DeleteAsync(string url, string moduleName = "System", CancellationToken token = default)
{
return await ExecuteRequestAsync(url, "DELETE", null, moduleName, token, async () =>
{
return await _client.DeleteAsync(url, token);
});
}
#endregion
#region --- ---
/// <summary>
/// 创建一个 UTF-8 编码的 JSON 内容对象。
/// </summary>
private StringContent CreateJsonContent(string json)
{
return new StringContent(json ?? "", Encoding.UTF8, "application/json");
}
/// <summary>
/// 核心执行引擎,封装了所有 HTTP 请求的通用逻辑:计时、日志、异常处理。
/// </summary>
private async Task<string> ExecuteRequestAsync(
string url,
string method,
string? requestData,
string moduleName,
CancellationToken token,
Func<Task<HttpResponseMessage>> action,
bool isAutoPost = false)
{
if (requestData == null)
requestData = "(请求数据为空)";
var log = new LogWebApiModel
{
Time = DateTime.Now,
Method = method,
Url = url,
RequestData = TruncateLog(requestData),
AppModule = moduleName,
IsAutoPost = isAutoPost,
};
var sw = Stopwatch.StartNew();
try
{
// 执行具体的 HTTP 请求
using var response = await action();
// 读取响应内容
var responseString = await response.Content.ReadAsStringAsync(token);
// 更新日志
log.StatusCode = ((int)response.StatusCode).ToString();
log.ResponseData = TruncateLog(responseString);
// 如果 HTTP 状态码不是 2xx则抛出异常
response.EnsureSuccessStatusCode();
return responseString;
}
catch (Exception ex)
{
// 捕获所有异常,更新日志状态为 "Error"
log.StatusCode = "Error";
log.ResponseData = $"Client Exception: {ex.Message}";
// 重新抛出异常,让调用者知道请求失败
throw;
}
finally
{
// 无论成功或失败,都记录耗时并触发日志事件
sw.Stop();
log.ElapsedMilliseconds = sw.ElapsedMilliseconds;
// 安全地触发事件,防止订阅者抛出异常影响主线程
try
{
OnRequestCompleted?.Invoke(log);
}
catch (Exception logEx)
{
// 记录日志系统自身的错误,但不中断程序
Debug.WriteLine($"Log System Error: {logEx.Message}");
}
}
}
/// <summary>
/// 截断过长的日志内容,并尝试格式化 JSON 以便阅读。
/// </summary>
private string TruncateLog(string? content)
{
if (string.IsNullOrEmpty(content)) return string.Empty;
// 1. 【新增步骤】尝试将内容格式化为漂亮的 JSON
// 注意:格式化会增加空格和换行,导致长度变长,这是符合预期的
string finalContent = TryFormatJson(content);
// 2. 截断逻辑 (基于格式化后的长度)
if (finalContent.Length > MAX_LOG_LENGTH)
{
// 保留前 N 个字符,并在末尾添加提示
return finalContent.Substring(0, MAX_LOG_LENGTH) +
$"\r\n\r\n... [日志过长已截断,显示前 {MAX_LOG_LENGTH} 字符]";
}
return finalContent;
}
/// <summary>
/// 尝试将字符串解析并格式化为缩进的 JSON。
/// 如果解析失败(说明不是 JSON则原样返回。
/// </summary>
private string TryFormatJson(string content)
{
// 简单的性能优化:如果不像 JSON不以 { 或 [ 开头),直接跳过
var trimmed = content.Trim();
if ((trimmed.StartsWith("{") && trimmed.EndsWith("}")) ||
(trimmed.StartsWith("[") && trimmed.EndsWith("]")))
{
try
{
// 使用 JToken 解析,它可以处理 Object {} 和 Array []
var token = JToken.Parse(content);
// 格式化输出 (Indented = 缩进模式)
return token.ToString(Formatting.Indented);
}
catch
{
// 解析失败(可能只是普通的文本消息,或者 HTML忽略异常返回原样
return content;
}
}
return content;
}
#endregion
}
}