feat(server): 添加静态状态页与调试入口

- 将状态页、调试页改为 `wwwroot` 静态资源
  - 补充调试配置接口与前端脚本
  - 为兼容层、规划层和运行时补充日志
  - 更新集成测试覆盖新入口
This commit is contained in:
2026-04-29 14:05:02 +08:00
parent 0724efebed
commit c38faddbf0
27 changed files with 2630 additions and 1894 deletions

View File

@@ -0,0 +1,147 @@
using System.Diagnostics;
using System.Text;
namespace Flyshot.Server.Host.Middleware;
/// <summary>
/// HTTP 请求与响应日志中间件。
/// 记录每个 HTTP 请求的进入时间、方法、路径、查询串、请求体,
/// 以及响应的状态码、耗时和响应体(调试级别)。
/// </summary>
public sealed class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
/// <summary>
/// 请求体最大日志长度,超出则截断并附加省略标记。
/// </summary>
private const int MaxBodyLogLength = 4096;
/// <summary>
/// 初始化请求响应日志中间件。
/// </summary>
/// <param name="next">下一个中间件委托。</param>
/// <param name="logger">日志记录器。</param>
public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger<RequestResponseLoggingMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 处理 HTTP 请求并记录输入输出。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
var request = context.Request;
var requestId = Activity.Current?.Id ?? context.TraceIdentifier;
// 记录请求进入信息Info 级别:方法、路径、查询参数)。
_logger.LogInformation(
"[HTTP-REQ] [{RequestId}] {Method} {Path}{QueryString} — 客户端 {RemoteIp}",
requestId,
request.Method,
request.Path,
request.QueryString.HasValue ? request.QueryString.Value : string.Empty,
context.Connection.RemoteIpAddress);
// 读取并记录请求体Debug 级别)。
string? requestBody = null;
if (request.ContentLength > 0 && request.Body.CanRead)
{
request.EnableBuffering();
requestBody = await ReadBodyAsync(request.Body, context.RequestAborted).ConfigureAwait(false);
request.Body.Position = 0;
if (!string.IsNullOrEmpty(requestBody))
{
_logger.LogDebug(
"[HTTP-REQ-BODY] [{RequestId}] {Body}",
requestId,
TruncateBody(requestBody));
}
}
// 拦截响应流以便读取响应体。
var originalResponseBody = context.Response.Body;
using var responseBodyStream = new MemoryStream();
context.Response.Body = responseBodyStream;
try
{
await _next(context).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogError(
exception,
"[HTTP-ERR] [{RequestId}] {Method} {Path} 处理过程中发生未捕获异常",
requestId,
request.Method,
request.Path);
throw;
}
finally
{
stopwatch.Stop();
responseBodyStream.Position = 0;
var responseBody = await ReadBodyAsync(responseBodyStream, context.RequestAborted).ConfigureAwait(false);
responseBodyStream.Position = 0;
await responseBodyStream.CopyToAsync(originalResponseBody, context.RequestAborted).ConfigureAwait(false);
context.Response.Body = originalResponseBody;
var statusCode = context.Response.StatusCode;
var level = statusCode >= 500 ? LogLevel.Error : statusCode >= 400 ? LogLevel.Warning : LogLevel.Information;
// 记录响应概要Info/Warning/Error 级别)。
_logger.Log(
level,
"[HTTP-RES] [{RequestId}] {Method} {Path} => {StatusCode} ({ElapsedMs}ms)",
requestId,
request.Method,
request.Path,
statusCode,
stopwatch.ElapsedMilliseconds);
// 记录响应体Debug 级别)。
if (!string.IsNullOrEmpty(responseBody))
{
_logger.LogDebug(
"[HTTP-RES-BODY] [{RequestId}] {Body}",
requestId,
TruncateBody(responseBody));
}
}
}
/// <summary>
/// 从流中读取文本内容。
/// </summary>
private static async Task<string> ReadBodyAsync(Stream stream, CancellationToken cancellationToken)
{
if (!stream.CanRead)
{
return string.Empty;
}
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var body = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
return body;
}
/// <summary>
/// 截断过长内容,避免日志膨胀。
/// </summary>
private static string TruncateBody(string body)
{
if (body.Length <= MaxBodyLogLength)
{
return body;
}
return body[..MaxBodyLogLength] + " ... [截断,总长度=" + body.Length + "]";
}
}