using AgileConfig.Client;
using Dpz.Core.AspNetCore;
using Dpz.Core.Infrastructure;
using Dpz.Core.Infrastructure.Configuration;
using Dpz.Core.Service;
using Dpz.Core.Web.Jobs;
using Dpz.Core.Web.Jobs.Hangfire;
using Dpz.Core.Web.Jobs.Hubs;
using Dpz.Core.Web.Jobs.Security;
using Microsoft.AspNetCore.HttpOverrides;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Serilog;
using Serilog.Events;
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
);
}
// Add services to the container.
var services = builder.Services;
var configuration = builder.Configuration;
var objectSerializer = new ObjectSerializer(_ => true);
BsonSerializer.RegisterSerializer(objectSerializer);
#region configuration logs
bool LogFilter(LogEvent logEvent)
{
// 检查请求路径
if (
logEvent.Properties.TryGetValue("RequestPath", out var requestPath)
&& requestPath is ScalarValue pathValue
)
{
var pathPrefixes = new[] { "/jobs", "/account", "/health" };
var path = pathValue.Value?.ToString();
if (
path != null
&& pathPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))
)
{
return true;
}
}
// 检查源上下文
if (
logEvent.Properties.TryGetValue("SourceContext", out var source)
&& source is ScalarValue sourceValue
)
{
var sourceStr = sourceValue.Value?.ToString();
if (sourceStr != null)
{
var validPrefixes = new[]
{
"Dpz.Core.Web.Jobs.Services.Implements",
"Dpz.Core.Web.Jobs.Services.Implements.JobService",
"Hangfire.Mongo.MongoNotificationObserver",
"Hangfire.Mongo.DistributedLock.MongoDistributedLock",
};
return validPrefixes.Any(p =>
sourceStr.StartsWith(p, StringComparison.OrdinalIgnoreCase)
);
}
}
return false;
}
var logSeq = configuration.GetSection("LogSeq").Get<LogSeq>();
builder.Host.ConfigurationLog(logSeq, LogFilter);
#endregion
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
services.AddBusinessServices(configuration);
services.AddDefaultServices().AddDefaultInjectServices().AddMessageQueueServices();
services.AddOpenIddictClient(configuration);
services.AddAppHangfireService(configuration);
services.AddControllersWithViews();
services.AddSignalR(options =>
{
// 增大 SignalR 消息体限制到 1MB,支持 Base64 文件分片传输
options.MaximumReceiveMessageSize = 1024 * 1024;
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
// 容器通过本机 HTTP 探测存活状态,其余请求保持 HTTPS 重定向
app.UseWhen(
context => context.Request.Path != "/health",
branch => branch.UseHttpsRedirection()
);
app.UseStaticFiles();
app.UseCors(appBuilder =>
{
appBuilder
.WithOrigins(configuration.GetSection("Origins").Get<string[]>() ?? [])
.AllowAnyHeader()
.WithMethods("GET", "PUT", "POST", "DELETE", "PATCH", "OPTION")
.AllowCredentials();
});
app.UseRouting();
//启用身份认证
app.UseAuthentication();
//启用身份授权
app.UseAuthorization();
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
// 文件传输 SignalR Hub 终结点
app.MapHub<FileTransferHub>("/transfer/hub");
// CI/CD pipeline
app.MapHub<PipelineHub>("/pipeline-notification");
app.UseDevToolsEndpoint();
app.UseVersionEndpoint();
app.MapGet("/health", () => Results.Text("Healthy", "text/plain")).AllowAnonymous();
app.HangfireSetting();
app.Run();
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
Log.Fatal(ex, "Host terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
public partial class Program
{
/// <summary>
/// 控制台地址
/// </summary>
public static string ConsoleUrl => "/jobs";
}
报告:src/Dpz.Core.Web.Jobs/Program.cs
一、文件定位
这是 Dpz.Core.Web.Jobs 这个「后台任务/定时作业宿主」的进程入口(top-level statements + 末尾 public partial class Program)。它对应项目 Dpz.Core.Web.Jobs.csproj(Microsoft.NET.Sdk.Web,net11.0),承担三类职责:
- 用统一约定装配整个应用的依赖注入、配置源与日志(AgileConfig + Serilog + Seq);
- 搭建 ASP.NET Core 请求管线(反向代理头、HTTPS、CORS、认证授权、SignalR);
- 把 Hangfire Dashboard(路径
/jobs)和一批周期任务挂载到应用上。
它自身不实现业务逻辑,而是"编排者":真正的实现分散在同目录/相邻项目里。
二、逐段职责
1) 日志引导(文件首尾)
Log.Logger = new LoggerConfiguration().Enrich.FromLogContext().CreateBootstrapLogger();
...
catch (Exception ex) { Console.Error.WriteLine(ex); Log.Fatal(ex, "Host terminated unexpectedly"); }
finally { Log.CloseAndFlush(); }
在任何 DI 容器建立之前先设置一个"引导 logger",保证构建/启动阶段的异常也能被记录;无论成功失败,finally 都会冲刷并关闭 Serilog。真正的 Sink 与级别由后面的 ConfigurationLog 接管。
2) 配置源
builder.Host.UseAgileConfig(new ConfigClient(builder.Configuration)):接入 AgileConfig 配置中心(对应AgileConfig.Client包引用)。- 仅在
Development下再AddJsonFile("appsettings.Development.json", reloadOnChange: true),用于本地覆盖配置中心的部分节点(注释已说明)。
3) MongoDB BSON 序列化器
var objectSerializer = new ObjectSerializer(_ => true);
BsonSerializer.RegisterSerializer(objectSerializer);
为 object 类型注册"接受一切"的序列化器,供 Mongo 持久化那些弱类型字段使用(具体消费方在数据访问层,本文件不涉及)。
4) 日志过滤 + Serilog 落地
- 局部函数
LogFilter(LogEvent):当事件带有RequestPath(前缀为/jobs、/account、/health)或SourceContext(前缀为Dpz.Core.Web.Jobs.Services.Implements、...JobService、Hangfire.Mongo.MongoNotificationObserver、Hangfire.Mongo.DistributedLock.MongoDistributedLock)时返回true。 - 从配置读取
LogSeq节点并调用builder.Host.ConfigurationLog(logSeq, LogFilter),实现见 LogConfiguration.cs。该扩展会把 Serilog 的 Sink 分成三条:文件(CompactJson,不做过滤)、控制台、Seq,同时按Program/Version/Server做属性富化,并把Dpz.Core.Web.Jobs.Services.Implements提到Verbose。
需要注意一个语义细节(已通过
LogConfiguration源码确认):传入的委托被用在lc.Filter.ByExcluding(filter)中,即 返回true的日志会被从控制台/Seq 排除(文件 sink 仍完整保留)。因此这里的LogFilter实际是"排除清单"(例如把/health、/account探针请求噪声挡在 Seq 之外),而非"仅保留这些"。
5) 反向代理与转发头
ForwardedHeadersOptions 只启用 XForwardedFor | XForwardedProto,并清空 KnownIPNetworks / KnownProxies,表示信任前置 nginx/网关传入的转发头(同一仓库其他宿主如 Dpz.Core.Auth/Program.cs 有类似写法)。
6) 服务注册
services.AddBusinessServices(configuration); // Dpz.Core.Service
services.AddDefaultServices().AddDefaultInjectServices().AddMessageQueueServices(); // 仓库/源生成 + Web.Jobs 专有 + 消息队列
services.AddOpenIddictClient(configuration); // Web.Jobs 的 OIDC 客户端
services.AddAppHangfireService(configuration);// Hangfire
services.AddControllersWithViews();
services.AddSignalR(o => o.MaximumReceiveMessageSize = 1024 * 1024);
AddBusinessServices定义在 ServiceExtensions.cs;AddDefaultServices定义在 ServiceDependencyInjection.cs(仓库层/源生成注册);AddDefaultInjectServices/AddMessageQueueServices定义在本项目 RegisterDependencyInjectionExtensions.cs:前者注册文件传输房间/会话服务与清理HostedService、备份记录服务、若干具名HttpClient(edge、音乐同步、PipelineVersionProbe)、S3/备份/网络/MCP 等;后者注册一组AddMessageConsumer<消息, 处理器>(处理器位于 MessageHandlers)以及 Outbox 补发 Worker;AddOpenIddictClient见 RegisterOpenIddictExtensions.cs:Cookie 认证 + OpenIddict 授权码流程客户端,并把JobsToken的 TokenStore 替换为服务层的实现,配置来自Client:Jobs:*与Server:Issuer,缺失即抛InvalidConfigurationException;AddAppHangfireService见 HangfireConfiguration.cs:以hangfire配置节初始化 Hangfire,并注册 HangfireAuthorizationFilter.cs 为单例。
7) HTTP 请求管线与端点
- 非开发环境:
UseExceptionHandler("/Home/Error")+UseHsts(); UseForwardedHeaders()→UseSerilogRequestLogging();- 条件化 HTTPS 重定向:
UseWhen(ctx => ctx.Request.Path != "/health", branch => branch.UseHttpsRedirection()),让容器内的 HTTP 存活探测不被 307 重定向; UseStaticFiles();CORS 从配置Origins读取白名单,允许任意 Header 与凭据,方法列表为GET/PUT/POST/DELETE/PATCH/OPTION;UseRouting()→UseAuthentication()→UseAuthorization();- 默认 MVC 路由
{controller=Home}/{action=Index}/{id?}(对应 Controllers 与 Views); - 两个 SignalR Hub:
MapHub<FileTransferHub>("/transfer/hub")、MapHub<PipelineHub>("/pipeline-notification")(见 FileTransferHub.cs、PipelineHub.cs,后者带[Authorize]); UseDevToolsEndpoint()(Chrome DevTools workspace 描述文件,仅开发环境给真实路径)与UseVersionEndpoint()(/api/version),实现见 AspNetCoreServiceCollectionExtensions.cs;MapGet("/health", ...).AllowAnonymous()返回纯文本Healthy;- 最后
app.HangfireSetting():设置zh-CN本地化 → 先过 HangfireDashboardAccessMiddleware.cs 做登录/权限校验 → 在Program.ConsoleUrl(即/jobs)挂载 Hangfire Dashboard,并按配置节hangfireStatus:*开关注册/移除一整套RecurringJob(IT之家抓取、Steam、备份、COS 空目录清理、代码结构同步、图片元信息回填、Outbox 清理、音乐同步等)。
8) 末尾的 public partial class Program
ConsoleUrl => "/jobs" 是 Dashboard 路径的唯一真源,被三处引用:HangfireConfiguration.cs(UseHangfireDashboard)、HangfireDashboardAccessMiddleware.cs(StartsWithSegments 前置于末尾)、以及视图中(如 Views/Home/Index.cshtml、Views/Pipeline/Index.cshtml 的"控制台"链接)。把 top-level Program 声明为 partial 也让它对外可见(常见用于集成测试或外部引用;本仓库当前未搜索到 WebApplicationFactory 用法,因此不臆断存在相关测试)。
三、启动流程
flowchart TD
A[引导 Serilog BootstrapLogger] --> B[CreateBuilder]
B --> C[UseAgileConfig 配置中心]
C --> D{IsDevelopment?}
D -- 是 --> E[追加 appsettings.Development.json 覆盖]
D -- 否 --> F[跳过]
E --> G[注册 Bson ObjectSerializer]
F --> G
G --> H[ConfigurationLog 配置 Serilog 文件/控制台/Seq + 过滤器]
H --> I[Configure ForwardedHeaders 信任代理]
I --> J[AddBusinessServices / AddDefaultServices / AddDefaultInjectServices / AddMessageQueueServices]
J --> K[AddOpenIddictClient 认证]
K --> L[AddAppHangfireService]
L --> M[AddControllersWithViews + AddSignalR 1MB]
M --> N[Build]
N --> O{非开发环境?}
O -- 是 --> P[UseExceptionHandler + UseHsts]
O -- 否 --> Q[跳过]
P --> R[UseForwardedHeaders + UseSerilogRequestLogging]
Q --> R
R --> S["UseWhen(path != /health) UseHttpsRedirection"]
S --> T[StaticFiles + CORS + Routing]
T --> U[Authentication + Authorization]
U --> V[MVC 默认路由]
V --> W["MapHub FileTransferHub /transfer/hub"]
W --> X["MapHub PipelineHub /pipeline-notification"]
X --> Y[DevTools 端点 + /api/version]
Y --> Z["MapGet /health 匿名"]
Z --> AA["HangfireSetting: 本地化 → DashboardAccess → /jobs 面板 + RecurringJob 注册"]
AA --> AB[app.Run 阻塞运行]
AB -.异常.-> AC[Log.Fatal + Console.Error]
AC --> AD[finally: CloseAndFlush 冲刷日志]
四、与相关文件的协作关系
| 协作方 | 关系 |
|---|---|
| RegisterDependencyInjectionExtensions.cs | 被 AddDefaultInjectServices / AddMessageQueueServices 调用,注入 Web.Jobs 专有服务、HttpClient、消息消费者与 Outbox Worker |
| HangfireConfiguration.cs | 被 AddAppHangfireService / HangfireSetting 调用,Hangfire 存储、面板与周期任务的落地处 |
| Security/RegisterOpenIddictExtensions.cs | 被 AddOpenIddictClient 调用,提供 Cookie + OpenIddict 客户端认证 |
| HangfireDashboardAccessMiddleware.cs / HangfireAuthorizationFilter.cs | 依赖 Program.ConsoleUrl,在 Dashboard 之前拦截未登录/无权限访问 |
| Hubs/FileTransferHub.cs、Hubs/PipelineHub.cs | 被 MapHub 暴露为 SignalR 端点 |
| Dpz.Core.Infrastructure/Configuration/LogConfiguration.cs | ConfigurationLog 的实现,消费本文件的 LogFilter 与 LogSeq 配置 |
| Dpz.Core.AspNetCore/AspNetCoreServiceCollectionExtensions.cs | 提供 UseDevToolsEndpoint / UseVersionEndpoint |
| Dpz.Core.Service/ServiceExtensions.cs、ServiceDependencyInjection.cs | 提供 AddBusinessServices、AddDefaultServices |
| Views/Home/Index.cshtml、Views/Pipeline/Index.cshtml | 渲染页面时读取 Program.ConsoleUrl 生成"控制台"链接 |
| docker-compose.yaml、Dockerfile、README.md | 部署侧配套;/health 与转发头处理与之呼应 |
五、观察到的注意点(不做臆断,仅陈述代码事实)
- CORS 方法列表写的是
"OPTION"(缺少结尾S)。.WithMethods(...)是按字面匹配的,因此标准OPTIONS预检请求可能不被该策略放行,需要结合部署中间件确认是否由代理层处理预检。 LogFilter被当作ByExcluding的排除条件使用(见上文"语义细节"),如果你的预期是"仅保留这些日志进入 Seq/控制台",则与当前行为相反。LogFilter中对SourceContext的第二个前缀"Dpz.Core.Web.Jobs.Services.Implements.JobService"是第一个前缀"Dpz.Core.Web.Jobs.Services.Implements"的子集,属冗余项,不影响行为。HangfireSetting()中的多组RecurringJob.AddOrUpdate(...)在hangfireStatus:*关闭时会执行RemoveIfExists,即配置开关会反向删除已有周期任务,是运行期行为,不只是"启动时不注册"。