排课计算、课表发布、补考自动编排三类任务统一接入消息系统。

业务任务与 Outbox 消息同一事务落库,避免“数据库成功但消息没发出去”。
RabbitMQ 使用持久化消息、发布确认、手动 ACK、Quorum Queue、死信队列。
增加处理租约、心跳、异常重试、最大重试次数和幂等状态控制。
保留 InMemory 模式,开发环境无需安装 RabbitMQ。
支持多实例竞争消费,后续可以横向扩容。
新增 /health/messaging 消息系统健康检查。
This commit is contained in:
2026-07-26 20:51:42 +08:00 Unverified
parent 9f534c22f4
commit 6300645120
28 changed files with 6513 additions and 313 deletions
+74 -6
View File
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Configuration;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
@@ -70,6 +71,12 @@ var cacheOptions = builder.Configuration
var officialDocumentOptions = builder.Configuration
.GetSection(OfficialDocumentOptions.SectionName)
.Get<OfficialDocumentOptions>() ?? new OfficialDocumentOptions();
var backgroundJobOptions = builder.Configuration
.GetSection(BackgroundJobOptions.SectionName)
.Get<BackgroundJobOptions>() ?? new BackgroundJobOptions();
var rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) ||
string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) ||
@@ -119,9 +126,46 @@ if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
"Cache 缓存时间或 MaximumPayloadKilobytes 超出允许范围。");
}
if (!backgroundJobOptions.Transport.Equals(
"InMemory",
StringComparison.OrdinalIgnoreCase) &&
!backgroundJobOptions.UsesRabbitMq)
{
throw new InvalidOperationException(
"BackgroundJobs:Transport 仅支持 InMemory 或 RabbitMq。");
}
if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
backgroundJobOptions.LeaseSeconds is < 30 or > 3600 ||
backgroundJobOptions.PrefetchCount is < 1 or > 100 ||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
string.IsNullOrWhiteSpace(backgroundJobOptions.Exchange) ||
string.IsNullOrWhiteSpace(backgroundJobOptions.QueuePrefix))
{
throw new InvalidOperationException("BackgroundJobs 配置超出允许范围。");
}
if (backgroundJobOptions.UsesRabbitMq &&
(string.IsNullOrWhiteSpace(rabbitMqOptions.HostName) ||
rabbitMqOptions.Port is < 1 or > 65535 ||
string.IsNullOrWhiteSpace(rabbitMqOptions.UserName) ||
string.IsNullOrWhiteSpace(rabbitMqOptions.Password) ||
string.IsNullOrWhiteSpace(rabbitMqOptions.VirtualHost)))
{
throw new InvalidOperationException("RabbitMq 连接配置不完整。");
}
if (backgroundJobOptions.UsesRabbitMq &&
!builder.Environment.IsDevelopment() &&
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
rabbitMqOptions.Password == "guest"))
{
throw new InvalidOperationException(
"生产环境启用 RabbitMQ 时不能使用默认 guest 凭据。");
}
builder.Services.AddSingleton(databaseOptions);
builder.Services.AddSingleton(cacheOptions);
builder.Services.AddSingleton(officialDocumentOptions);
builder.Services.AddSingleton(backgroundJobOptions);
builder.Services.AddSingleton(rabbitMqOptions);
builder.Services.Configure<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
builder.Services.AddDbContextPool<AppDbContext>(options =>
@@ -218,19 +262,29 @@ builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<PersonalCalendarService>();
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
builder.Services.AddScoped<SchedulePlanPublisher>();
builder.Services.AddScoped<SchedulePublishJobProcessor>();
builder.Services.AddSingleton<SchedulePublishJobQueue>();
builder.Services.AddHostedService<SchedulePublishJobWorker>();
builder.Services.AddHostedService<WarningCheckWorker>();
builder.Services.AddScoped<ExamArrangementService>();
builder.Services.AddScoped<MakeupExamEligibilityService>();
builder.Services.AddScoped<MakeupExamArrangementService>();
builder.Services.AddSingleton<MakeupExamAutoJobQueue>();
builder.Services.AddHostedService<MakeupExamAutoJobWorker>();
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddSingleton<BackgroundJobRunner>();
if (backgroundJobOptions.UsesRabbitMq)
{
builder.Services.AddSingleton<
IBackgroundJobTransport,
RabbitMqBackgroundJobTransport>();
builder.Services.AddHostedService<RabbitMqBackgroundJobWorker>();
}
else
{
builder.Services.AddSingleton<InMemoryBackgroundJobTransport>();
builder.Services.AddSingleton<IBackgroundJobTransport>(services =>
services.GetRequiredService<InMemoryBackgroundJobTransport>());
builder.Services.AddHostedService<InMemoryBackgroundJobWorker>();
}
builder.Services.AddHostedService<BackgroundJobOutboxPublisher>();
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
builder.Services.AddScoped<OfficialDocumentService>();
@@ -391,6 +445,7 @@ app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous();
app.MapGet("/health/messaging", CheckMessagingHealthAsync).AllowAnonymous();
app.MapFallback(async context =>
{
if (context.Request.Path.StartsWithSegments("/api") ||
@@ -477,4 +532,17 @@ static async Task<IResult> CheckCacheHealthAsync(
}
}
static async Task<IResult> CheckMessagingHealthAsync(
IBackgroundJobTransport transport,
CancellationToken cancellationToken)
{
var healthy = await transport.CheckHealthAsync(cancellationToken);
var backend = transport.IsDurable ? "rabbitmq" : "memory";
return healthy
? Results.Ok(new { Status = "healthy", Backend = backend })
: Results.Json(
new { Status = "unhealthy", Backend = backend },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
public partial class Program;