using System.Threading.RateLimiting;
using Dpz.Core.Infrastructure.RateLimiting;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Primitives;
using OpenIddict.Client.AspNetCore;
using Serilog.Events;
using StackExchange.Redis;
const string corsScheme = "Open-Dpz-Core";
Log.Logger = new LoggerConfiguration().Enrich.FromLogContext().CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseAgileConfig(new ConfigClient(builder.Configuration));
// 开发环境下重新加载本地配置,覆盖配置中心的特定节点
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddJsonFile(
"appsettings.Development.json",
optional: true,
reloadOnChange: true
);
}
var configuration = builder.Configuration;
LibraryHost =
configuration["LibraryHost"]
?? throw new InvalidConfigurationException(
"configuration node LibraryHost is null or empty"
);
AssetsHost =
configuration["AssetsHost"]
?? throw new InvalidConfigurationException(
"configuration node AssetsHost is null or empty"
);
var logSeq = configuration.GetSection("LogSeq").Get<LogSeq>();
// ReSharper disable once RedundantAssignment
Func<LogEvent, bool>? filter = null;
#if DEBUG
filter = x =>
{
if (
!x.Properties.TryGetValue("RequestPath", out var requestPath)
|| requestPath is not ScalarValue pathValue
)
{
return false;
}
var pathPrefixes = new[] { "/js", "/css" };
var path = pathValue.Value?.ToString();
return path != null
&& pathPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
};
#endif
builder.Host.ConfigurationLog(logSeq, filter);
builder
.WebHost.UseKestrel(
(_, option) =>
{
option.AddServerHeader = false;
option.ConfigureHttpsDefaults(x =>
x.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
);
}
)
.UseIIS()
.UseIISIntegration();
#region services
var services = builder.Services;
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
services.AddMvc(x =>
{
x.Filters.Add(typeof(GlobalFilter));
x.Filters.Add(new TypeFilterAttribute(typeof(ExceptionHandleAttribute)));
});
//压缩
services.AddResponseCompression(options =>
{
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat([
"application/font-woff2",
"image/svg+xml",
"text/plain",
"application/lrc",
]);
options.EnableForHttps = true;
});
services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Optimal;
});
services.Configure<ZstandardCompressionProviderOptions>(options =>
{
options.CompressionOptions = new ZstandardCompressionOptions
{
// 取值范围 1-22,数值越高,压缩率越高,但速度越慢
Quality = 6,
};
});
services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Optimal;
});
#if DEBUG
services.Configure<KestrelServerOptions>(options =>
{
options.Limits.MaxRequestBodySize = int.MaxValue;
});
#endif
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter(
"comment",
opt =>
{
opt.AutoReplenishment = true;
opt.PermitLimit = 3;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
opt.QueueLimit = 2;
}
);
// 前端遥测接口按客户端 IP 分区限流,避免匿名接口被滥用
options.AddPolicy(
"telemetry",
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Request.GetIpAddress(),
_ => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
}
)
);
});
services
.AddWebMarkupMin(x =>
{
x.AllowMinificationInDevelopmentEnvironment = true;
x.AllowCompressionInDevelopmentEnvironment = true;
})
.AddHtmlMinification(x =>
{
var settings = x.MinificationSettings;
settings.RemoveOptionalEndTags = false;
})
.AddXmlMinification()
.AddHttpCompression(option =>
{
/*
* WebMarkupMin 默认把所有 text/* 媒体类型(见 MediaTypeHelpers.IsTextBasedMediaType)都纳入压缩范围。
* 只有“既做 minify 又做压缩”的请求才会走缓冲分支:内容先写进 _cachedStream,压缩器在
* FinishAsync(响应尚未开始)里才补 Content-Encoding / Vary。
*
* 而 JSON / text/plain / text/css 这类只压不 minify 的内容没有缓冲,WebMarkupMin 要等到第一次
* WriteAsync 才写 Content-Encoding / Vary;此时 UseResponseCaching 已对底层流 DisableBuffering,
* 响应已经开始 → HttpHeaders 只读 → InvalidOperationException: Headers are read-only。
*
* 所以这里把 WebMarkupMin 的压缩范围收敛到它真正会 minify 的媒体类型(HTML/XHTML/XML),
* 其余类型统一交给上面的 AddResponseCompression 处理。
*/
option.SupportedMediaTypePredicate = mediaType =>
MediaTypeGroupConstants.Html.Contains(mediaType)
|| MediaTypeGroupConstants.Xhtml.Contains(mediaType)
|| MediaTypeGroupConstants.Xml.Contains(mediaType);
option.CompressorFactories = new List<ICompressorFactory>
{
new BuiltInBrotliCompressorFactory(
new BuiltInBrotliCompressionSettings { Level = CompressionLevel.Optimal }
),
new DeflateCompressorFactory(
new DeflateCompressionSettings { Level = CompressionLevel.Optimal }
),
new GZipCompressorFactory(
new GZipCompressionSettings { Level = CompressionLevel.Optimal }
),
};
});
// 设备识别
services.AddDetection();
// .AddDevice().AddBrowser()
// .AddPlatform()
// .AddEngine()
// .AddCrawler();
//注册 响应服务
services.AddResponsive();
//services.AddHostedService<TimedHostedService>();
services.AddControllersWithViews();
//注册 缓存中间件
services.AddResponseCaching();
var redisConnectionString =
configuration.GetConnectionString("redis")
?? throw new Exception("configuration node redis is null or empty");
services.AddBusinessServices(configuration);
services.AddDefaultServices().AddInject().AddMessageQueueServices();
// 注册限流服务
services.AddIpRateLimit(configuration);
// Cors
services.AddCors(options =>
{
options.AddPolicy(
corsScheme,
cfg =>
{
cfg.WithOrigins(configuration.GetSection("Origins").Get<string[]>() ?? [])
.WithMethods("GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS")
.AllowAnyHeader();
}
);
});
//注册 身份认证服务
services
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = AuthorizeCookieName;
x.DefaultSignInScheme = AuthorizeCookieName;
x.DefaultChallengeScheme = OpenIddictClientAspNetCoreDefaults.AuthenticationScheme;
})
.AddCookie(
AuthorizeCookieName,
options =>
{
options.LoginPath = "/connect/oidc";
options.AccessDeniedPath = "/connect/oidc";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
}
);
//services.AddAuthorization(x => )
services
.AddIdentityCore<VmUserInfo>(x =>
x.ClaimsIdentity.SecurityStampClaimType = AuthorizeCookieName
)
.AddUserStore<UserStore>()
.AddSignInManager<ApplicationSignInManager>();
services.AddDataProtection().SetApplicationName(AuthorizeCookieName);
services
.AddOptions<KeyManagementOptions>()
.Configure<IServiceScopeFactory>(
(options, factory) =>
{
options.XmlRepository = new XmlRepositoryService(
factory,
AuthorizeCookieName + ".Key"
);
}
);
services.AddOpenIddictClient(configuration);
//register SignalR
services
.AddSignalR(x =>
{
x.HandshakeTimeout = TimeSpan.FromMinutes(2);
x.MaximumReceiveMessageSize = 32768;
})
.AddStackExchangeRedis(
redisConnectionString,
options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("Dpz.Core.signalR");
}
);
// 添加Hangfire 服务
services.HangfireService(configuration);
#endregion
var app = builder.Build();
#region configuration
if (app.Environment.IsDevelopment())
{
//app.UseDeveloperExceptionPage();
app.UseRouteDebugger();
}
app.UseHtmlErrorPages();
app.UseForwardedHeaders();
var supportedCultures = new[] { new CultureInfo("zh-CN") };
app.UseRequestLocalization(
new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("zh-CN"),
// Formatting numbers, dates, etc.
SupportedCultures = supportedCultures,
// UI strings that we have localized.
SupportedUICultures = supportedCultures,
}
);
var webRootStaticAssets = WebRootStaticAssets.Create(app.Services);
app.UseSerilogRequestLogging(options =>
{
// wwwroot 下正常响应的静态资源请求不属于业务事件,降级到 Verbose(低于全局 Information,不进入任何 sink)
// 4xx/5xx 保持默认级别,爬虫探测与静态资源故障仍可追溯
options.GetLevel = (httpContext, _, exception) =>
{
var statusCode = httpContext.Response.StatusCode;
if (
exception == null
&& statusCode is >= 200 and <= 399
&& webRootStaticAssets.IsStaticAsset(httpContext.Request.Path)
)
{
return LogEventLevel.Verbose;
}
return statusCode > 499 ? LogEventLevel.Error : LogEventLevel.Information;
};
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
diagnosticContext.Set("IpAddress", httpContext.Request.GetIpAddress());
var referer = httpContext.Request.Headers.Referer;
if (!StringValues.IsNullOrEmpty(referer))
{
diagnosticContext.Set("Referer", referer);
}
};
});
//启用压缩
app.UseResponseCompression();
// 启用身份认证
// 必须在限流和机器人拦截之前,已登录用户的放行判断才能生效
app.UseAuthentication();
// 添加IP限流中间件
app.UseIpRateLimit();
// 设备识别
// 必须在 RejectBots 之前,浏览器版本判定依赖检测结果
app.UseDetection();
// 拒绝爬虫
app.UseRejectBots();
// app.UseHsts();
// app.UseHttpsRedirection();
//启用静态文件
app.UseStaticFiles(
new StaticFileOptions()
{
OnPrepareResponse = ctx =>
{
// 这些目录与文件体积小、访问频繁、内容基本不变,设置客户端缓存省掉重复下载
// 文件名不带内容指纹,故不追加 immutable,只承诺 max-age 内复用
var requestPath = ctx.Context.Request.Path;
if (CacheableStaticPaths.Any(requestPath.StartsWithSegments))
{
ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=2592000");
ctx.Context.Response.Headers.Append(
"Expires",
DateTime.UtcNow.AddDays(30).ToString("R", CultureInfo.InvariantCulture)
);
}
},
}
);
// 根据跨应用资料变更标记,在当前请求上下文刷新用户 Cookie Claims
// 放在静态文件之后,避免每个静态资源请求都访问用户资料缓存
app.UseUserProfileRefresh();
app.UseWebMarkupMin();
//启用缓存中间件
app.UseResponseCaching();
//启用响应服务
app.UseResponsive();
app.UseCors(corsScheme);
app.UseRouting().UseMiddleware<HttpResponseHeaderHandel>().UseRecordRequest();
app.MapGet(
"/ping",
requestDelegate: async context =>
{
await context.Response.WriteAsJsonAsync(context.Request.Headers);
}
);
app.UseDevToolsEndpoint();
app.UseVersionEndpoint();
//启用身份授权
app.UseAuthorization();
// 端点限流(EnableRateLimiting 特性依赖此中间件)
app.UseRateLimiter();
// 路由
app.ConfigurationRoute();
// 使用 hangfire UI,及周期性任务调度
app.HangfireSetting();
#endregion
Log.Information("Starting web core host");
app.Run();
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
Log.Fatal(ex, "Host terminated unexpectedly");
}
public partial class Program
{
/// <summary>
/// 身份认证Cookie名称
/// </summary>
public const string AuthorizeCookieName = "Dpz.Web.Core.Authoriza";
/// <summary>
/// library host
/// </summary>
public static string LibraryHost { get; private set; } = "";
/// <summary>
/// assets host
/// </summary>
public static string AssetsHost { get; private set; } = "";
/// <summary>
/// 需要设置客户端缓存的 wwwroot 目录与文件,内容基本不变但文件名不带内容指纹
/// </summary>
private static readonly PathString[] CacheableStaticPaths =
[
"/images",
"/hanzi-data",
"/data",
"/favicon.ico",
"/favicon.png",
];
/// <summary>
/// 任务调度面板地址
/// </summary>
public const string HangfireDashboardPath = "/runtask";
}
src/Dpz.Core.Web/Program.cs 技术档案
一句话定位
Dpz.Core 主站(Razor MVC + SignalR + Hangfire 的 Web 宿主)的唯一启动入口,用顶层语句把配置来源、日志、压缩、限流、认证、缓存、机器人拦截等横切能力按固定顺序装配成请求管道;本身不含业务逻辑,业务与可复用装配细节全部下沉到 Library/ 与各基础项目。
关键类型与方法
Program 类被声明为 partial,实际承担两种职责:顶层语句是宿主编排,类体内则是对外暴露的全局常量与启动期解析出的站点静态资源地址。
对外可用的成员(均被其他文件引用,改动会连锁):
AuthorizeCookieName = "Dpz.Web.Core.Authoriza":同时被用作认证 Scheme 名、Cookie 名、AddDataProtection().SetApplicationName(...)的应用名、Identity 的SecurityStampClaimType,以及数据保护密钥存储键AuthorizeCookieName + ".Key"。注意 Cookie 与派生 Cookie 名的约定:GroupChatHub.cs、GlobalFilter.cs、HttpResponseHeaderHandel.cs读的是AuthorizeCookieName + ".SessionId"。改动这个常量会同时改变 Cookie 名、数据保护隔离域与所有读写点。LibraryHost/AssetsHost:在启动期从配置读取并做空值强校验,缺失即抛InvalidConfigurationException。LibraryHost被 View、CodeIconTagHelper、WebToolsExtensions.ClearLinkAsync等大量地方用于拼接静态库与占位图 URL;AssetsHost用于生产环境的哈希指纹资源 URL(如Views/Shared/_IncludeJsIncludeCssPartial.cshtml、Views/Home/Index.cshtml)。二者是private set,只能在启动装配阶段赋值。HangfireDashboardPath = "/runtask":被 AppExtensions.cs 的HangfireSetting用于挂载 Dashboard。CacheableStaticPaths:仅本文件内部使用,用于UseStaticFiles的OnPrepareResponse。
启动期强校验与失败语义并不统一:LibraryHost、AssetsHost 用 InvalidConfigurationException;ConnectionStrings:redis 缺失抛的是裸 Exception("configuration node redis is null or empty");ConfigurationLog 内部用 ArgumentNullException;OpenIddict 配置校验混用 InvalidConfigurationException 与 InvalidAsynchronousStateException(见 RegisterOpenIddictExtensions.cs)。整体被外层 try/catch 兜住:先 Console.Error.WriteLine,再 Log.Fatal,然后进程结束——任何配置错误都表现为启动即崩,而不是降级运行。
依赖与协作
服务注册与管道装配大量以扩展方法形式散落在其他文件,Program.cs 只负责顺序:
- 配置与日志:
UseAgileConfig把配置源接到配置中心,开发环境再叠加本地appsettings.Development.json覆盖;ConfigurationLog定义在 LogConfiguration.cs。 - 业务与基础设施:
AddBusinessServices(ServiceExtensions.cs)、AddDefaultServices(ServiceDependencyInjection.cs)、AddInject/AddMessageQueueServices(DependencyInjection.cs)。AddInject里注册了AddBotProtection(o => o.CheckBrowserVersion = true),这正是管道中UseRejectBots依赖浏览器版本判定、因而必须排在UseDetection之后的原因。 - IP 限流:
AddIpRateLimit(configuration)/UseIpRateLimit()来自 RateLimitMiddlewareExtensions.cs,绑定RateLimit配置节,走 FusionCache + Redis。 - 端点限流:
AddRateLimiter注册了两个策略——"comment"固定窗口(1 分钟 3 次、队列 2),消费方是 CommentController.cs 与 EmailVerifyController.cs;"telemetry"按客户端 IP 分区(1 分钟 20 次、无排队),消费方是 TelemetryController.cs。IP 取自 ApplicationTools.cs 的GetIpAddress()。 - 认证:默认认证/登录 Scheme 是 Cookie,Challenge 交给
OpenIddictClientAspNetCoreDefaults,登录与拒绝路径均指向/connect/oidc。OpenIddict 客户端配置、PFX 证书加载(EphemeralKeySet)在 RegisterOpenIddictExtensions.cs;数据保护密钥库替换为 XmlRepositoryService.cs。 - 静态资源判定:
WebRootStaticAssets.Create(app.Services)(WebRootStaticAssets.cs)在启动时枚举一次wwwroot根级条目,供 Serilog 请求日志分层使用。 - 路由与 Hangfire:
ConfigurationRoute()与HangfireSetting()都在 AppExtensions.cs,后者使用Program.HangfireDashboardPath并注册每日 UTC 16:00 的SitemapTaskActivator.StartAsync周期任务。 - 通用基础设施:
UseDevToolsEndpoint()/UseVersionEndpoint()在 AspNetCoreServiceCollectionExtensions.cs。 - 全局过滤器和中间件:
GlobalFilter、ExceptionHandleAttribute挂在 MVC 上;HtmlErrorPageMiddleware.cs、RejectBotsMiddleware.cs、UserProfileRefreshMiddleware.cs 通过扩展方法入管道。
注意本文件大量类型依赖 Usings.cs 的 global using(AgileConfig.Client、WebMarkupMin.AspNetCoreLatest、ZiggyCreatures.Caching.Fusion 等),单独看 Program.cs 会出现「没 using 却能用」的现象。
请求管道时序
flowchart TD
A["开发环境: UseRouteDebugger"] --> B["UseHtmlErrorPages"]
B --> C["UseForwardedHeaders"]
C --> D["UseRequestLocalization 固定 zh-CN"]
D --> E["UseSerilogRequestLogging: 静态资源 2xx/3xx 降为 Verbose"]
E --> F["UseResponseCompression (Brotli/Gzip)"]
F --> G["UseAuthentication"]
G --> H["UseIpRateLimit"]
H --> I["UseDetection"]
I --> J["UseRejectBots"]
J --> K["UseStaticFiles + CacheableStaticPaths 30 天缓存头"]
K --> L["UseUserProfileRefresh"]
L --> M["UseWebMarkupMin"]
M --> N["UseResponseCaching"]
N --> O["UseResponsive"]
O --> P["UseCors(corsScheme)"]
P --> Q["UseRouting + HttpResponseHeaderHandel + UseRecordRequest"]
Q --> R["/ping, DevTools, /api/version"]
R --> S["UseAuthorization"]
S --> T["UseRateLimiter"]
T --> U["ConfigurationRoute: 控制器路由 + 4 个 Hub"]
U --> V["HangfireSetting: /runtask + 周期任务"]
顺序约束在源码中都有注释,属于有意设计:UseAuthentication 必须在限流与机器人拦截之前(RejectBotsMiddleware.Invoke 里 httpContext.User.Authenticated 直接放行);UseDetection 必须在 UseRejectBots 之前(低版本浏览器判定依赖检测结果);UseUserProfileRefresh 放在静态文件之后,避免静态资源请求都去查资料缓存。Serilog 的请求日志级别还依赖启动时枚举出的 WebRootStaticAssets。
需要注意的点
- WebMarkupMin 压缩范围被刻意收窄:
SupportedMediaTypePredicate只保留 HTML/XHTML/XML,其余交给AddResponseCompression。源码注释写明了原因——JSON、text/plain这类「只压不 minify」的内容没有缓冲流,WebMarkupMin 的首个WriteAsync才补Content-Encoding/Vary,此时UseResponseCaching已禁用底层缓冲,会触发Headers are read-only。后续调整压缩配置必须保留这一约束,否则会复现 500。 Configure<ZstandardCompressionProviderOptions>很可能是死配置:AddResponseCompression的Providers只加入了 Brotli 与 Gzip,全仓库检索Zstandard只命中本文件这一处,未发现任何地方注册 Zstandard 压缩提供程序。- DEBUG 下日志过滤器被整体替换,而不是叠加:
LogConfiguration里filter ??= LogFilter表示传入的filter会完全覆盖默认排除列表。于是 DEBUG 构建只排除/js、/css,默认排除的/runtask、/account、/notification、/app/notification不再生效;反之生产构建不会排除前端静态资源路径(改由SerilogRequestLogging.GetLevel降级处理)。 HangfireSetting()内部再次调用UseRequestLocalization(见 AppExtensions.cs),与 Program.cs 前面那次是同一份zh-CN配置,属于重复注册,且这个中间件被追加到了管道末端(Hangfire Dashboard 之后),对正常页面请求不会生效——只能理解为对 Hangfire 请求的补丁。GetIpAddress()在 DEBUG 下是硬编码常量113.110.235.52(见 ApplicationTools.cs)。它同时被 Serilog 富化、telemetry限流分区、IP 限流与机器人拦截使用,所以本地调试时所有「来自不同 IP」的行为都会退化成同一个键。- 转发头信任被显式放宽:
ForwardedHeadersOptions清空了KnownIPNetworks与KnownProxies,即信任任意上游代理。这与GetClientIp优先读Ali-Cdn-Real-Ip、再读X-Forwarded-For/X-Real-IP的做法叠加,意味着 IP 判定安全边界完全依赖入口网关;直连部署时这些头可被伪造,进而影响限流分区与黑名单。 /ping直接回显全部请求头,包含 Cookie 与Authorization,如果该端点在生产可达,属于信息泄露面,建议确认其是否被网关或机器人拦截覆盖。#if DEBUG放开MaxRequestBodySize = int.MaxValue,生产环境未显式设置,走 Kestrel 默认。- 静态文件缓存策略的隐含前提:
CacheableStaticPaths只覆盖/images、/hanzi-data、/data、/favicon.ico、/favicon.png,注释说明文件名不带内容指纹故不加immutable。这要求这些目录下的文件名一旦变更内容就要换名,否则客户端会拿到 30 天内缓存的旧资源。 - Kestrel 与 IIS 同时挂接(
UseKestrel+UseIIS+UseIISIntegration),说明同一套代码要同时支持容器/反代与 IIS 部署;UseHsts/UseHttpsRedirection被注释掉,HTTPS 与协议版本(TLS 1.2/1.3)依赖外部入口,Kestrel 只负责关闭Server头。 AddMvc与AddControllersWithViews同时调用,前者用于挂全局过滤器,后者是实际 MVC 服务注册;services.AddAuthorization被注释掉,说明授权策略全部依赖默认行为与 OpenIddict 校验,没有自定义策略注册点。