参照现有的 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:
@@ -853,61 +853,80 @@ public sealed class ExamsController(
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,完成后才能发布。");
|
||||
var plan = await db.ExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.RoomLinks)
|
||||
.ThenInclude(x => x.ExamRoom)
|
||||
.ThenInclude(x => x!.Seats)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.RoomLinks)
|
||||
.ThenInclude(x => x.ExamRoom)
|
||||
.ThenInclude(x => x!.Invigilators)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台发布,请等待任务完成。");
|
||||
|
||||
var plan = await db.ExamPlans.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 != ExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿考试计划可以发布。");
|
||||
if (!plan.Sessions.Any())
|
||||
if (!plan.HasSessions)
|
||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||
|
||||
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
plan.Sessions.Select(x => x.TeachingTaskId),
|
||||
cancellationToken))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.Count());
|
||||
var unassigned = plan.Sessions.Count(x =>
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var job = new ExamPublishJob
|
||||
{
|
||||
if (x.RoomLinks.Count == 0)
|
||||
return !x.ClassroomId.HasValue || x.Invigilators.Count == 0;
|
||||
var assignedSeatCount = x.RoomLinks
|
||||
.SelectMany(link => link.ExamRoom!.Seats)
|
||||
.Count(seat => seat.ExamSessionId == x.Id);
|
||||
return assignedSeatCount !=
|
||||
rosterCounts.GetValueOrDefault(x.TeachingTaskId) ||
|
||||
x.RoomLinks.Any(link =>
|
||||
link.ExamRoom!.Invigilators.Count <
|
||||
link.ExamRoom.RequiredInvigilatorCount);
|
||||
});
|
||||
if (unassigned > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {unassigned} 个教学班未完成考场座位或监考安排,请先完成自动编排。");
|
||||
Kind = ExamPublishJobKind.FormalExam,
|
||||
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 invalidMixedRoomCount = await db.ExamRooms.AsNoTracking()
|
||||
.CountAsync(room =>
|
||||
room.ExamPlanId == id &&
|
||||
(room.Seats.Count > room.Classroom!.Capacity ||
|
||||
room.SessionLinks.Any(link =>
|
||||
link.ExamSession!.TeachingTask!.CourseId != room.CourseId)),
|
||||
return AcceptedPublishJob(job, "考试发布任务已提交。");
|
||||
}
|
||||
|
||||
[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.FormalExam,
|
||||
cancellationToken);
|
||||
if (invalidMixedRoomCount > 0)
|
||||
return ConflictProblem(
|
||||
$"发现 {invalidMixedRoomCount} 个考场容量超限或混入不同课程,请重新编排。");
|
||||
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
plan.Status = ExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
[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.FormalExam)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -1638,6 +1657,44 @@ public sealed class ExamsController(
|
||||
job.CompletedAt
|
||||
};
|
||||
|
||||
private Task<ExamPublishJob?> FindActivePublishJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Kind == ExamPublishJobKind.FormalExam &&
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -241,6 +241,34 @@ public sealed class OperationsController(
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
if (normalizedKind is null or "ExamPublish")
|
||||
{
|
||||
var query = db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == ExamPublishJobStatus.Failed &&
|
||||
x.CreatedAt >= effectiveFrom);
|
||||
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
|
||||
total += await query.CountAsync(cancellationToken);
|
||||
rows.AddRange(await query
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
|
||||
.Take(take)
|
||||
.Select(x => new FailedBackgroundJobItem(
|
||||
x.Id,
|
||||
"ExamPublish",
|
||||
x.Kind == ExamPublishJobKind.FormalExam
|
||||
? "正式考试发布"
|
||||
: "补考发布",
|
||||
x.Kind == ExamPublishJobKind.FormalExam
|
||||
? "正式考试计划"
|
||||
: "补考计划",
|
||||
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||
x.CreatedAt,
|
||||
x.StartedAt,
|
||||
x.CompletedAt,
|
||||
0))
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
var pageItems = rows
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
@@ -468,6 +496,11 @@ public sealed class OperationsController(
|
||||
.CountAsync(
|
||||
x => x.Status == ExamArrangementJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.ExamPublishJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == ExamPublishJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken);
|
||||
|
||||
private ActionResult? ValidatePaging(int page, int pageSize)
|
||||
|
||||
@@ -131,3 +131,30 @@ public enum ExamPlanStatus
|
||||
Published = 2,
|
||||
Archived = 3
|
||||
}
|
||||
|
||||
public sealed class ExamPublishJob : EntityBase
|
||||
{
|
||||
public ExamPublishJobKind Kind { get; set; }
|
||||
public Guid PlanId { get; set; }
|
||||
public Guid? ActivePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||
public string? CurrentStep { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum ExamPublishJobKind
|
||||
{
|
||||
FormalExam = 1,
|
||||
MakeupExam = 2
|
||||
}
|
||||
|
||||
public enum ExamPublishJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ public enum BackgroundJobKind
|
||||
SchedulePublish = 2,
|
||||
MakeupExamAuto = 3,
|
||||
ExamArrangement = 4,
|
||||
ExamSignInExport = 5
|
||||
ExamSignInExport = 5,
|
||||
ExamPublish = 6
|
||||
}
|
||||
|
||||
public enum BackgroundJobOutboxState
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class BackgroundJobOptions
|
||||
public int MakeupExamAutoConcurrency { get; set; } = 1;
|
||||
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||
public int ExamPublishConcurrency { get; set; } = 1;
|
||||
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||
public bool UseQuorumQueues { get; set; } = true;
|
||||
@@ -33,6 +34,7 @@ public sealed class BackgroundJobOptions
|
||||
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,6 +121,15 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
|
||||
.Where(x =>
|
||||
(x.Status == ExamPublishJobStatus.Queued ||
|
||||
x.Status == ExamPublishJobStatus.Running) &&
|
||||
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||
message.JobKind == BackgroundJobKind.ExamPublish &&
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missingKeys = automaticJobs
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
@@ -132,6 +141,8 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
(BackgroundJobKind.ExamArrangement, id)))
|
||||
.Concat(exportJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamSignInExport, id)))
|
||||
.Concat(publishJobs2.Select(id =>
|
||||
(BackgroundJobKind.ExamPublish, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
|
||||
@@ -95,6 +95,11 @@ public sealed class BackgroundJobRunner(
|
||||
.GetRequiredService<ExamSignInExportJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamPublish:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamPublishJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
@@ -286,6 +291,23 @@ public sealed class BackgroundJobRunner(
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamPublish:
|
||||
await db.ExamPublishJobs
|
||||
.Where(x =>
|
||||
x.Id == message.JobId &&
|
||||
x.Status != ExamPublishJobStatus.Succeeded &&
|
||||
x.Status != ExamPublishJobStatus.Failed)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(
|
||||
x => x.Status,
|
||||
ExamPublishJobStatus.Failed)
|
||||
.SetProperty(x => x.ActivePlanId, (Guid?)null)
|
||||
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||
.SetProperty(x => x.ErrorMessage, error)
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(message.JobKind),
|
||||
|
||||
@@ -318,7 +318,8 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
BackgroundJobKind.ExamArrangement,
|
||||
BackgroundJobKind.ExamSignInExport
|
||||
BackgroundJobKind.ExamSignInExport,
|
||||
BackgroundJobKind.ExamPublish
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
@@ -416,6 +417,7 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||
BackgroundJobKind.ExamPublish => "exam.publish",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -62,6 +62,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<ExamArrangementJob>();
|
||||
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
|
||||
Set<ExamSignInExportJob>();
|
||||
public DbSet<ExamPublishJob> ExamPublishJobs =>
|
||||
Set<ExamPublishJob>();
|
||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||
Set<ExamSessionInvigilator>();
|
||||
|
||||
+5321
File diff suppressed because it is too large
Load Diff
+45
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExamPublishJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExamPublishJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
Kind = table.Column<int>(type: "int", nullable: false),
|
||||
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ActivePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CurrentStep = table.Column<string>(type: "longtext", nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "longtext", nullable: true),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExamPublishJobs", x => x.Id);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExamPublishJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -1634,6 +1634,50 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("ExamPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPublishJob", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("ActivePlanId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CurrentStep")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("PlanId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ExamPublishJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -145,6 +145,8 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
||||
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||
@@ -297,6 +299,7 @@ builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||
builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
|
||||
Reference in New Issue
Block a user