using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Diagnostics; using System.Net.Http; using System.Text; namespace SHH.CameraDashboard { /// /// 全局 Web API 服务 /// 职责: /// 1. 统一管理 HttpClient 实例,避免资源耗尽。 /// 2. 封装标准的 CRUD (GET, POST, PUT, DELETE, PATCH) 操作。 /// 3. 提供详细的请求日志(耗时、状态码、请求/响应内容)。 /// 4. 处理超时和异常,提供一致的错误处理机制。 /// public class WebApiService { #region --- 静态成员 --- /// /// WebAPI 设为单例 /// public static WebApiService Instance { get; } = new WebApiService(); /// /// 静态 HttpClient 实例。 /// 最佳实践:应用程序应共享单个 HttpClient 实例以避免套接字耗尽。 /// private static readonly HttpClient _client; #endregion #region --- 实例成员 --- /// /// 当一个请求完成(无论成功或失败)时发生。 /// 可用于记录详细的 API 调用日志。 /// public event Action? OnRequestCompleted; /// /// 日志内容的最大长度(5KB),防止过大的响应体撑爆内存。 /// private const int MAX_LOG_LENGTH = 5120; #endregion #region --- 构造函数 --- /// /// 构造函数 /// private WebApiService() { } /// /// 静态构造函数,用于初始化 HttpClient。 /// 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 方法 --- /// /// 发送一个 GET 请求。 /// /// 请求的完整 URL。 /// 发起请求的模块名称,用于日志分类。 /// 用于取消操作的令牌。 /// 成功时返回响应内容的字符串表示。 public async Task 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); } /// /// 发送一个 POST 请求。 /// /// 请求的完整 URL。 /// 请求体的 JSON 字符串。 /// 发起请求的模块名称,用于日志分类。 /// 用于取消操作的令牌。 /// 成功时返回响应内容的字符串表示。 public async Task 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); }); } /// /// 发送一个 PUT 请求(通常用于替换整个资源)。 /// /// 请求的完整 URL。 /// 请求体的 JSON 字符串。 /// 发起请求的模块名称,用于日志分类。 /// 用于取消操作的令牌。 /// 成功时返回响应内容的字符串表示。 public async Task 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); }); } /// /// 发送一个 PATCH 请求(通常用于更新资源的部分属性)。 /// /// 请求的完整 URL。 /// 请求体的 JSON 字符串。 /// 发起请求的模块名称,用于日志分类。 /// 用于取消操作的令牌。 /// 成功时返回响应内容的字符串表示。 public async Task 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); }); } /// /// 发送一个 DELETE 请求。 /// /// 请求的完整 URL。 /// 发起请求的模块名称,用于日志分类。 /// 用于取消操作的令牌。 /// 成功时返回响应内容的字符串表示。 public async Task 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 --- 私有辅助方法 --- /// /// 创建一个 UTF-8 编码的 JSON 内容对象。 /// private StringContent CreateJsonContent(string json) { return new StringContent(json ?? "", Encoding.UTF8, "application/json"); } /// /// 核心执行引擎,封装了所有 HTTP 请求的通用逻辑:计时、日志、异常处理。 /// private async Task ExecuteRequestAsync( string url, string method, string? requestData, string moduleName, CancellationToken token, Func> 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}"); } } } /// /// 截断过长的日志内容,并尝试格式化 JSON 以便阅读。 /// 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; } /// /// 尝试将字符串解析并格式化为缩进的 JSON。 /// 如果解析失败(说明不是 JSON),则原样返回。 /// 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 } }