参照现有的 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:
@@ -0,0 +1,270 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||
|
||||
public sealed class ExamPublishJobProcessor(
|
||||
AppDbContext db,
|
||||
IAppCache cache,
|
||||
ILogger<ExamPublishJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.ExamPublishJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is ExamPublishJobStatus.Succeeded
|
||||
or ExamPublishJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.Status = ExamPublishJobStatus.Running;
|
||||
job.StartedAt ??= DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.CurrentStep = "正在校验考试计划";
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
switch (job.Kind)
|
||||
{
|
||||
case ExamPublishJobKind.FormalExam:
|
||||
await PublishFormalExamAsync(job, stoppingToken);
|
||||
break;
|
||||
case ExamPublishJobKind.MakeupExam:
|
||||
await PublishMakeupExamAsync(job, stoppingToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"不支持的考试发布类型:{job.Kind}。");
|
||||
}
|
||||
|
||||
job.Status = ExamPublishJobStatus.Succeeded;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "发布完成";
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
|
||||
job.Id,
|
||||
job.Kind,
|
||||
job.PlanId);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Exam publish job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (ExamPublishValidationException validationException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
validationException,
|
||||
"Exam publish job {JobId} validation failed.",
|
||||
jobId);
|
||||
await MarkFailedAsync(jobId, validationException.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
|
||||
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishFormalExamAsync(
|
||||
ExamPublishJob job,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = await db.ExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||
if (plan is null)
|
||||
throw new ExamPublishValidationException("考试计划不存在。");
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
|
||||
|
||||
// Step 1: check sessions exist — simple count, no JOIN
|
||||
var sessionCount = await db.ExamSessions
|
||||
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
|
||||
if (sessionCount == 0)
|
||||
throw new ExamPublishValidationException(
|
||||
"至少安排一个考试场次后才能发布。");
|
||||
|
||||
// Step 2: load session summaries — only necessary columns, no Include chains
|
||||
var sessions = await db.ExamSessions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.ClassroomId,
|
||||
InvigilatorCount = x.Invigilators.Count,
|
||||
RoomLinkCount = x.RoomLinks.Count
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Step 3: load roster counts — independent query
|
||||
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
|
||||
var rosterCounts = (await TeachingTaskRosterQuery
|
||||
.LoadForTasksAsync(db, taskIds, ct))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.Count());
|
||||
|
||||
// Step 4: validate each session's assignment completeness
|
||||
var unassignedSessionIds = new List<Guid>();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
|
||||
if (session.RoomLinkCount > 0)
|
||||
{
|
||||
// Has room links — check seat assignment via separate query
|
||||
var assignedSeatCount = await db.ExamSeats
|
||||
.CountAsync(seat =>
|
||||
seat.ExamSessionId == session.Id,
|
||||
ct);
|
||||
if (assignedSeatCount != rosterCount)
|
||||
unassignedSessionIds.Add(session.Id);
|
||||
}
|
||||
else if (!session.ClassroomId.HasValue ||
|
||||
session.InvigilatorCount == 0)
|
||||
{
|
||||
unassignedSessionIds.Add(session.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (unassignedSessionIds.Count > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
|
||||
"请先完成自动编排。");
|
||||
|
||||
// Step 5: check room invigilator sufficiency — separate query
|
||||
var insufficientInvigilatorCount = await db.ExamRoomSessions
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||
link.ExamRoom.Invigilators.Count <
|
||||
link.ExamRoom.RequiredInvigilatorCount)
|
||||
.Select(link => link.ExamSessionId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
if (insufficientInvigilatorCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
|
||||
"请先完成自动编排。");
|
||||
|
||||
// Step 6: validate mixed rooms — split into two independent queries
|
||||
// to avoid Seats × SessionLinks Cartesian product
|
||||
|
||||
// 6a: capacity overflow
|
||||
var overCapacityCount = await db.ExamRooms
|
||||
.Where(r => r.ExamPlanId == job.PlanId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
SeatCount = r.Seats.Count,
|
||||
Capacity = r.Classroom!.Capacity
|
||||
})
|
||||
.CountAsync(x => x.SeatCount > x.Capacity, ct);
|
||||
if (overCapacityCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
|
||||
|
||||
// 6b: course mismatch
|
||||
var courseMismatchCount = await db.ExamRoomSessions
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||
link.ExamSession!.TeachingTask!.CourseId !=
|
||||
link.ExamRoom.CourseId)
|
||||
.Select(link => link.ExamRoomId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
if (courseMismatchCount > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
|
||||
|
||||
// Step 7: publish
|
||||
plan.Status = ExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task PublishMakeupExamAsync(
|
||||
ExamPublishJob job,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = await db.MakeupExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||
if (plan is null)
|
||||
throw new ExamPublishValidationException("补考计划不存在。");
|
||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
|
||||
|
||||
// Step 1: check sessions exist — simple count
|
||||
var sessionCount = await db.MakeupExamSessions
|
||||
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
|
||||
if (sessionCount == 0)
|
||||
throw new ExamPublishValidationException(
|
||||
"至少安排一个考试场次后才能发布。");
|
||||
|
||||
// Step 2: load session summaries — only necessary columns
|
||||
var sessions = await db.MakeupExamSessions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == job.PlanId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.ClassroomId,
|
||||
InvigilatorCount = x.Invigilators.Count,
|
||||
EnrollmentCount = x.Enrollments.Count
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Step 3: validate completeness — no Include chain needed
|
||||
var unassigned = sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
|
||||
if (unassigned > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
|
||||
// Step 4: validate enrollments
|
||||
var empty = sessions.Count(x => x.EnrollmentCount == 0);
|
||||
if (empty > 0)
|
||||
throw new ExamPublishValidationException(
|
||||
$"还有 {empty} 个场次没有登记补考学生。");
|
||||
|
||||
// Step 5: publish
|
||||
plan.Status = MakeupExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
job.Status = ExamPublishJobStatus.Failed;
|
||||
job.ActivePlanId = null;
|
||||
job.CurrentStep = "发布失败";
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExamPublishValidationException(string message)
|
||||
: InvalidOperationException(message);
|
||||
Reference in New Issue
Block a user