参照现有的 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:
2026-07-28 08:10:22 +08:00 Unverified
parent b2de605680
commit 3ec0f117d3
16 changed files with 6002 additions and 68 deletions
+103 -46
View File
@@ -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>();
@@ -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");
}
}
}
@@ -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")
+3
View File
@@ -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>();
@@ -9,6 +9,7 @@ using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
@@ -252,7 +253,14 @@ public sealed class TeachingWorkflowRosterTests
managerScope,
NoOpAppCache.Instance);
var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None);
Assert.IsType<NoContentResult>(publishResult);
var acceptedPublish = Assert.IsType<AcceptedAtActionResult>(publishResult);
var publishJobId = (Guid)acceptedPublish.Value!
.GetType().GetProperty("jobId")!.GetValue(acceptedPublish.Value)!;
var publishProcessor = new ExamPublishJobProcessor(
db,
NoOpAppCache.Instance,
NullLogger<ExamPublishJobProcessor>.Instance);
await publishProcessor.ProcessAsync(publishJobId, CancellationToken.None);
var planResult = Assert.IsType<OkObjectResult>(
await exams.GetPlan(