using Dpz.Core.Service;
using HandlebarsDotNet;

namespace Dpz.Core.Web.Library.Middleware;

/// <summary>
/// 面向 HTML 文档请求的友好错误页中间件。
/// </summary>
/// <remarks>
/// 该中间件负责两件事:
/// <list type="bullet">
/// <item>在非开发环境下,将渲染页面时抛出的未处理异常统一转为 HTTP 500;</item>
/// <item>对所有 4xx/5xx 状态码且响应尚未开始的 HTML 请求,渲染一个面向最终用户的错误页面。</item>
/// </list>
/// 以下请求不会被处理:<c>X-Requested-With: XMLHttpRequest</c>、<c>X-PJAX: true</c>、静态文件扩展名
/// 请求,以及 Accept 头不包含 <c>text/html</c> 的请求。
/// </remarks>
public sealed class HtmlErrorPageMiddleware(
    RequestDelegate next,
    IWebHostEnvironment environment,
    ILogger<HtmlErrorPageMiddleware> logger
)
{
    /// <summary>
    /// HttpContext.Items 的键:错误页的自定义描述文案。
    /// 由授权过滤器(CheckAuthorizeAttribute)写入,渲染时优先于目录中的默认描述。
    /// </summary>
    public const string ErrorMessageItemKey = "HtmlErrorPage.Message";

    /// <summary>
    /// HttpContext.Items 的键:标记异常是否已经记录过日志。
    /// 由异常过滤器(ExceptionHandleAttribute)写入,避免这里重复记录同一次异常。
    /// </summary>
    public const string ExceptionLoggedItemKey = "HtmlErrorPage.ExceptionLogged";

    /// <summary>
    /// 视为静态文件的扩展名集合。此类请求不会被当作 HTML 页面处理。
    /// </summary>
    private static readonly HashSet<string> StaticFileExtensions = new(
        StringComparer.OrdinalIgnoreCase
    )
    {
        ".avif",
        ".css",
        ".eot",
        ".gif",
        ".ico",
        ".jpeg",
        ".jpg",
        ".js",
        ".json",
        ".map",
        ".mp3",
        ".mp4",
        ".ogg",
        ".otf",
        ".pdf",
        ".png",
        ".svg",
        ".ttf",
        ".wav",
        ".webm",
        ".webp",
        ".woff",
        ".woff2",
        ".xml",
    };

    /// <summary>
    /// 编译后的错误页模板。模板文件是部署时固定的静态资源,因此首次使用时才读取并编译一次,之后
    /// 所有请求复用同一个编译结果(编译后的模板可安全地并发渲染)。
    /// </summary>
    private static HandlebarsTemplate<object, object>? _compiledErrorPageTemplate;

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await next(context);
        }
        catch (Exception exception) when (IsHtmlDocumentRequest(context.Request))
        {
            // 响应已经开始发送,无法改写状态码,只能继续向上抛出
            if (context.Response.HasStarted)
            {
                throw;
            }

            // 异常过滤器已记录过日志时,这里不再重复记录
            if (!context.Items.ContainsKey(ExceptionLoggedItemKey))
            {
                logger.LogError(
                    exception,
                    "Unhandled exception while rendering frontend request {RequestPath}",
                    context.Request.Path
                );
            }

            // 清空已写入的响应内容,统一返回 500,交由下方逻辑渲染错误页
            context.Response.Clear();
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        }

        if (!ShouldRenderErrorPage(context))
        {
            return;
        }

        await RenderErrorPageAsync(context);
    }

    /// <summary>
    /// 判断当前请求是否需要渲染友好错误页。
    /// </summary>
    /// <remarks>
    /// 需要同时满足以下条件:状态码为 4xx/5xx、响应尚未开始、未写入内容且未设置 Content-Type、
    /// 并且请求是 HTML 文档请求。
    /// </remarks>
    private static bool ShouldRenderErrorPage(HttpContext context)
    {
        var response = context.Response;
        return response.StatusCode is >= 400 and <= 599
            && response is { HasStarted: false, ContentLength: null }
            && string.IsNullOrEmpty(response.ContentType)
            && IsHtmlDocumentRequest(context.Request);
    }

    /// <summary>
    /// 判断请求是否期望返回 HTML 文档(而不是 AJAX、PJAX 或静态文件请求)。
    /// </summary>
    private static bool IsHtmlDocumentRequest(HttpRequest request)
    {
        if (
            string.Equals(
                request.Headers["X-Requested-With"],
                "XMLHttpRequest",
                StringComparison.OrdinalIgnoreCase
            )
            || string.Equals(request.Headers["X-PJAX"], "true", StringComparison.OrdinalIgnoreCase)
        )
        {
            return false;
        }

        var extension = Path.GetExtension(request.Path.Value);
        if (!string.IsNullOrEmpty(extension) && StaticFileExtensions.Contains(extension))
        {
            return false;
        }

        return request
            .GetTypedHeaders()
            .Accept.Any(mediaType =>
                string.Equals(
                    mediaType.MediaType.Value,
                    "text/html",
                    StringComparison.OrdinalIgnoreCase
                )
            );
    }

    /// <summary>
    /// 获取编译后的错误页模板(延迟加载并缓存:只读一次 wwwroot/error-page.html、只编译一次)。
    /// 并发首访时可能重复读取/编译,结果相同且只写入一次,可接受。
    /// </summary>
    private static HandlebarsTemplate<object, object> GetErrorPageTemplate(
        IWebHostEnvironment environment
    )
    {
        if (_compiledErrorPageTemplate is not null)
        {
            return _compiledErrorPageTemplate;
        }

        var templatePath = Path.Combine(environment.WebRootPath, "error-page.html");
        var templateContent = File.ReadAllText(templatePath);
        var handlebars = Handlebars.Create();
        handlebars.Configuration.TextEncoder = new IgnoreTextEncoder();
        var compiled = handlebars.Compile(templateContent);
        _compiledErrorPageTemplate = compiled;
        return compiled;
    }

    /// <summary>
    /// 渲染最终的错误页面 HTML(带站点头部信息与返回首页入口)。
    /// </summary>
    private async Task RenderErrorPageAsync(HttpContext context)
    {
        var page = HtmlErrorPageCatalog.Get(context.Response.StatusCode);
        var message = context.Items[ErrorMessageItemKey] as string;
        var description = string.IsNullOrWhiteSpace(message) ? page.Description : message;

        var html = GetErrorPageTemplate(environment)(
            new
            {
                page.StatusCode,
                page.Title,
                page.Kind,
                page.ImagePath,
                Description = description,
                context.TraceIdentifier,
            }
        );

        // 错误页不缓存,避免用户看到过期的错误内容
        context.Response.ContentType = "text/html; charset=utf-8";
        context.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
        context.Response.Headers.Pragma = "no-cache";

        await context.Response.WriteAsync(html, context.RequestAborted);
    }
}

/// <summary>
/// 错误页目录:按状态码提供标题、描述文案与配图。
/// </summary>
internal static class HtmlErrorPageCatalog
{
    private static readonly IReadOnlyDictionary<int, HtmlErrorPageDefinition> Pages =
        new Dictionary<int, HtmlErrorPageDefinition>
        {
            [400] = Lost(400, "请求无法处理", "请求内容似乎不太完整,请检查后再试一次。"),
            [401] = Access(401, "需要登录", "登录后才能继续访问这里。"),
            [403] = Access(403, "这里暂不开放", "当前账号没有访问这个页面的权限。"),
            [404] = Lost(404, "页面走丢了", "没有在这里找到你要看的内容,它可能已经搬家了。"),
            [405] = Lost(405, "操作方式不对", "这个地址不支持当前的请求方式。"),
            [408] = Lost(408, "请求等待太久", "连接等待超时,请稍后重新尝试。"),
            [429] = Access(429, "操作有点频繁", "请稍作休息,再继续访问。"),
            [500] = Repair(500, "服务出了点状况", "问题已经被记录,请稍后再来看看。"),
            [502] = Repair(502, "上游服务没有回应", "暂时无法取得需要的数据,请稍后再试。"),
            [503] = Repair(503, "服务正在休息", "当前服务暂时不可用,请稍后再来。"),
            [504] = Repair(504, "上游响应超时", "等待服务响应的时间有点久,请稍后再试。"),
        };

    public static HtmlErrorPageDefinition Get(int statusCode)
    {
        if (Pages.TryGetValue(statusCode, out var page))
        {
            return page;
        }

        return statusCode < 500
            ? Lost(statusCode, "请求未能完成", "当前请求暂时无法处理,请检查后再试。")
            : Repair(statusCode, "服务暂时不可用", "服务遇到了一点问题,请稍后再试。");
    }

    /// <summary>访问类错误(与登录、权限、频率限制相关)。</summary>
    private static HtmlErrorPageDefinition Access(
        int statusCode,
        string title,
        string description
    ) => new(statusCode, title, description, "access", "/images/errors/error-access.svg");

    /// <summary>丢失类错误(页面或资源不存在、请求方式不对等)。</summary>
    private static HtmlErrorPageDefinition Lost(int statusCode, string title, string description) =>
        new(statusCode, title, description, "lost", "/images/errors/error-lost.svg");

    /// <summary>维修类错误(服务端异常或上游不可用)。</summary>
    private static HtmlErrorPageDefinition Repair(
        int statusCode,
        string title,
        string description
    ) => new(statusCode, title, description, "repair", "/images/errors/error-repair.svg");
}

/// <summary>
/// 单个错误页的定义:状态码、标题、描述、样式类别(决定 CSS 主题与配图)以及配图路径。
/// </summary>
internal sealed record HtmlErrorPageDefinition(
    int StatusCode,
    string Title,
    string Description,
    string Kind,
    string ImagePath
);

/// <summary>
/// 友好错误页中间件的注册扩展方法。
/// </summary>
public static class HtmlErrorPageApplicationBuilderExtensions
{
    public static IApplicationBuilder UseHtmlErrorPages(this IApplicationBuilder app)
    {
        return app.UseMiddleware<HtmlErrorPageMiddleware>();
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码解释

这是一个 ASP.NET Core 中间件,用于处理 HTML 页面请求的友好错误页面展示。以下是详细解释:

核心功能

1. HtmlErrorPageMiddleware(主中间件类)

这个中间件负责两个主要任务:

  • 异常捕获:在非开发环境下,捕获渲染页面时的未处理异常,统一转为 HTTP 500
  • 错误页渲染:为所有 4xx/5xx 状态码的 HTML 请求渲染用户友好的错误页面

关键设计点:

// 静态文件扩展名白名单
private static readonly HashSet<string> StaticFileExtensions
// 包含 .js, .css, .png 等,这些请求不会被处理
// HttpContext.Items 的两个键
ErrorMessageItemKey      // 自定义错误描述(优先级高)
ExceptionLoggedItemKey   // 标记异常是否已记录(避免重复日志)

2. 核心流程(InvokeAsync 方法)

try {
    await next(context);  // 执行后续中间件
}
catch (Exception) {
    // 如果响应已开始,无法修改,继续抛出
    // 否则记录日志,设置 500 状态码
}

// 判断是否需要渲染错误页
if (ShouldRenderErrorPage(context)) {
    await RenderErrorPageAsync(context);
}

3. 请求过滤逻辑(IsHtmlDocumentRequest)

以下请求不会被处理:

  • AJAX 请求(X-Requested-With: XMLHttpRequest
  • PJAX 请求(X-PJAX: true
  • 静态文件请求(扩展名在白名单中)
  • Accept 头不包含 text/html 的请求

4. HtmlErrorPageCatalog(错误页目录)

预定义了常见 HTTP 状态码的友好提示:

状态码类型标题示例说明
401/403/429Access"需要登录"访问控制类错误
404/405/408Lost"页面走丢了"资源丢失类错误
500/502/503Repair"服务出了点状况"服务端错误

每个错误页包含:

  • 状态码
  • 标题(用户友好的描述)
  • 详细说明
  • 样式类别(决定 CSS 主题)
  • 配图路径(SVG 图片)

5. 模板渲染机制

private static HandlebarsTemplate<object, object>? _compiledErrorPageTemplate;

使用 Handlebars 模板引擎

  • 首次使用时从 wwwroot/error-page.html 读取模板
  • 编译后缓存,所有后续请求复用(线程安全)
  • 使用 IgnoreTextEncoder 避免 HTML 转义

渲染时注入的数据:

{
    StatusCode,      // 状态码
    Title,           // 标题
    Kind,            // 样式类别
    ImagePath,       // 配图路径
    Description,     // 描述(优先使用自定义消息)
    TraceIdentifier  // 请求追踪 ID
}

6. 响应头设置

context.Response.ContentType = "text/html; charset=utf-8";
context.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
context.Response.Headers.Pragma = "no-cache";

确保错误页不被缓存,避免用户看到过期内容。

使用方式

app.UseHtmlErrorPages();  // 在 Startup 中注册

设计亮点

  1. 延迟加载:模板只在首次使用时编译
  2. 避免重复日志:通过 HttpContext.Items 标记
  3. 灵活的自定义:支持通过 ErrorMessageItemKey 注入自定义描述
  4. 性能优化:编译后的模板可安全并发使用
  5. 用户友好:提供符合中文语境的错误提示

这是一个生产级的错误处理中间件,既保证了用户体验,又兼顾了日志记录和性能。

评论加载中...