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

业务任务与 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
@@ -1,104 +1,11 @@
using System.Diagnostics;
using System.Text.Json;
using System.Threading.Channels;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class AutomaticScheduleJobQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
public void Enqueue(Guid jobId)
{
if (!_channel.Writer.TryWrite(jobId))
throw new InvalidOperationException("自动排课任务队列当前不可用。");
}
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
public sealed class AutomaticScheduleJobWorker(
IServiceScopeFactory scopeFactory,
AutomaticScheduleJobQueue queue,
ILogger<AutomaticScheduleJobWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RecoverInterruptedJobsAsync(stoppingToken);
try
{
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<AutomaticScheduleJobProcessor>();
await processor.ProcessAsync(jobId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Unexpected failure while dispatching automatic schedule job {JobId}.",
jobId);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Automatic schedule job worker is stopping.");
}
}
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var jobs = await db.AutomaticScheduleJobs
.Where(x =>
x.Status == AutomaticScheduleJobStatus.Queued ||
x.Status == AutomaticScheduleJobStatus.Running)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
foreach (var job in jobs)
{
job.Status = AutomaticScheduleJobStatus.Queued;
job.ActiveSchedulePlanId = job.SchedulePlanId;
job.StartedAt = null;
job.CompletedAt = null;
job.ErrorMessage = null;
}
if (jobs.Count > 0)
await db.SaveChangesAsync(cancellationToken);
foreach (var job in jobs)
queue.Enqueue(job.Id);
if (jobs.Count > 0)
{
logger.LogInformation(
"Recovered {JobCount} queued or interrupted automatic schedule jobs.",
jobs.Count);
}
}
}
public sealed class AutomaticScheduleJobProcessor(
AppDbContext db,
AutomaticScheduleGenerator generator,
@@ -1,4 +1,3 @@
using System.Threading.Channels;
using System.Diagnostics.CodeAnalysis;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
@@ -7,100 +6,6 @@ using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class SchedulePublishJobQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
public void Enqueue(Guid jobId)
{
if (!_channel.Writer.TryWrite(jobId))
throw new InvalidOperationException("课表发布任务队列当前不可用。");
}
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
public sealed class SchedulePublishJobWorker(
IServiceScopeFactory scopeFactory,
SchedulePublishJobQueue queue,
ILogger<SchedulePublishJobWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RecoverInterruptedJobsAsync(stoppingToken);
try
{
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<SchedulePublishJobProcessor>();
await processor.ProcessAsync(jobId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Unexpected failure while dispatching schedule publish job {JobId}.",
jobId);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Schedule publish job worker is stopping.");
}
}
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var jobs = await db.SchedulePublishJobs
.Where(x =>
x.Status == SchedulePublishJobStatus.Queued ||
x.Status == SchedulePublishJobStatus.Running)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
foreach (var job in jobs)
{
job.Status = SchedulePublishJobStatus.Queued;
job.ActiveAcademicTermId = job.AcademicTermId;
job.CompletedSteps = 0;
job.CurrentStep = "等待后台检查";
job.StartedAt = null;
job.CompletedAt = null;
job.ErrorMessage = null;
}
if (jobs.Count > 0)
await db.SaveChangesAsync(cancellationToken);
foreach (var job in jobs)
queue.Enqueue(job.Id);
if (jobs.Count > 0)
{
logger.LogInformation(
"Recovered {JobCount} queued or interrupted schedule publish jobs.",
jobs.Count);
}
}
}
public sealed class SchedulePublishJobProcessor(
AppDbContext db,
SchedulePlanPublisher publisher,