Files
Ayay/SHH.CameraDashboard/Services/WebApis/WebApiService.cs
2026-01-01 22:40:32 +08:00

299 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}