参照现有的 ExamArrangementJob / SchedulePublishJob 后台任务模式,将发布改为通过 RabbitMQ
队列异步执行,拆分查询消除笛卡尔积。
修改的文件(共 13 个)
┌───────────────────────────────────────────────────────────────┬──────────────────────────────────────────────────┐
│ 文件 │ 变更 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Domain/Academic/ExamEntities.cs │ 新增 ExamPublishJob 实体 + ExamPublishJobStatus │
│ │ / ExamPublishJobKind 枚举 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Domain/System/BackgroundJobOutboxMessage.cs │ BackgroundJobKind 新增 ExamPublish = 6 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Infrastructure/BackgroundJobs/BackgroundJobOptions.cs │ 新增 ExamPublishConcurrency 配置项 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Infrastructure/BackgroundJobs/BackgroundJobRunner.cs │ RunAsync 和 MarkJobRetryLimitExceeded 添加 │
│ │ ExamPublish 分支 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs │ JobKinds 数组和 RoutingKey 添加 ExamPublish → │
│ │ "exam.publish" │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs │ 启动恢复逻辑添加 ExamPublishJobs │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Infrastructure/Persistence/AppDbContext.cs │ 新增 ExamPublishJobs DbSet │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Controllers/OperationsController.cs │ CountFailedJobsAsync / GetFailedJobs │
│ │ 添加考试发布失败统计和筛选 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Program.cs │ 校验 ExamPublishConcurrency + 注册 │
│ │ ExamPublishJobProcessor │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Infrastructure/Exams/ExamPublishJobs.cs │ 新文件 — │
│ │ ExamPublishJobProcessor,拆分查询校验后发布 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Controllers/ExamsController.cs │ Publish 改为创建后台任务 + 202 返回;新增 GET │
│ │ publish-jobs/{id} / GET plans/{id}/publish-job │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ Controllers/MakeupExamsController.cs │ 同上改造 │
├───────────────────────────────────────────────────────────────┼──────────────────────────────────────────────────┤
│ tests/.../TeachingWorkflowRosterTests.cs │ 更新测试适配新的异步发布模式 │
└───────────────────────────────────────────────────────────────┴──────────────────────────────────────────────────┘
笛卡尔积消除
之前:一个 Include 链拉全部 → EF Core 生成 Sessions × Invigilators × RoomLinks × Seats 笛卡尔积
之后:
- 场次计数:db.ExamSessions.CountAsync(无 JOIN)
- 场次摘要:Select new { Id, ClassroomId, InvigilatorCount, RoomLinkCount }(只查所需列)
- 容量超限:db.ExamRooms.Select(r => new { SeatCount = r.Seats.Count, Capacity })(单表 JOIN)
- 课程冲突:db.ExamRoomSessions.Where(link => ...CourseId != link.ExamRoom!.CourseId)(独立查询)
- 每个查询只做自己需要的 JOIN,互不干扰
测试结果
213 通过,0 失败,0 跳过
配置方式
- BackgroundJobs__Transport=RabbitMq → 走 RabbitMQ 队列 jiaowu.background-jobs.exam.publish
- BackgroundJobs__Transport=InMemory(默认) → 走内存 Channel
- BackgroundJobs__ExamPublishConcurrency=1(默认,可调 1-16)
This commit is contained in:
@@ -140,32 +140,80 @@ public sealed class MakeupExamsController(
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
|
||||
var plan = await db.MakeupExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Enrollments)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
|
||||
|
||||
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Status,
|
||||
HasSessions = x.Sessions.Any()
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿补考计划可以发布。");
|
||||
if (plan.Sessions.Count == 0)
|
||||
if (!plan.HasSessions)
|
||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||
|
||||
var unassigned = plan.Sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
||||
if (unassigned > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var job = new ExamPublishJob
|
||||
{
|
||||
Kind = ExamPublishJobKind.MakeupExam,
|
||||
PlanId = id,
|
||||
ActivePlanId = id,
|
||||
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||
CurrentStep = "等待后台校验"
|
||||
};
|
||||
db.ExamPublishJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(
|
||||
BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.ExamPublish,
|
||||
job.Id));
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var existing = await FindActivePublishJobAsync(id, cancellationToken);
|
||||
if (existing is not null)
|
||||
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
|
||||
throw;
|
||||
}
|
||||
|
||||
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
|
||||
if (empty > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {empty} 个场次没有登记补考学生。");
|
||||
return AcceptedPublishJob(job, "补考发布任务已提交。");
|
||||
}
|
||||
|
||||
plan.Status = MakeupExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
[HttpGet("publish-jobs/{jobId:guid}")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetPublishJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId &&
|
||||
x.Kind == ExamPublishJobKind.MakeupExam,
|
||||
cancellationToken);
|
||||
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/publish-job")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> GetLatestPublishJob(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.PlanId == planId &&
|
||||
x.Kind == ExamPublishJobKind.MakeupExam)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
[HttpPost("plans/{id:guid}/archive")]
|
||||
@@ -1204,6 +1252,44 @@ public sealed class MakeupExamsController(
|
||||
job.CompletedAt
|
||||
};
|
||||
|
||||
private Task<ExamPublishJob?> FindActivePublishJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Kind == ExamPublishJobKind.MakeupExam &&
|
||||
x.ActivePlanId == planId &&
|
||||
(x.Status == ExamPublishJobStatus.Queued ||
|
||||
x.Status == ExamPublishJobStatus.Running))
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
private ActionResult AcceptedPublishJob(
|
||||
ExamPublishJob job,
|
||||
string message) =>
|
||||
AcceptedAtAction(
|
||||
nameof(GetPublishJob),
|
||||
new { jobId = job.Id },
|
||||
new
|
||||
{
|
||||
jobId = job.Id,
|
||||
status = job.Status.ToString(),
|
||||
message
|
||||
});
|
||||
|
||||
private static object ToPublishJobResponse(ExamPublishJob job) => new
|
||||
{
|
||||
job.Id,
|
||||
job.PlanId,
|
||||
Kind = job.Kind.ToString(),
|
||||
Status = job.Status.ToString(),
|
||||
job.CurrentStep,
|
||||
job.ErrorMessage,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt
|
||||
};
|
||||
|
||||
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||
CancellationToken token)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user