自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。

任务参数、状态和结果持久化到 MySQL。
接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
服务重启后可恢复未完成任务。
前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
编排期间禁止修改、删除或发布对应计划。
补考原有“一键生成”保留,并与编排任务互斥。
运维后台增加“考试与补考编排”失败任务筛选。
This commit is contained in:
2026-07-27 21:40:14 +08:00 Unverified
parent 9d9f54a3b0
commit 9e0d4062a4
29 changed files with 6386 additions and 45 deletions
@@ -18,8 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class MakeupExamsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService,
MakeupExamArrangementService arrangementService) : ControllerBase
MakeupExamEligibilityService eligibilityService) : ControllerBase
{
private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
@@ -138,6 +137,8 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
var plan = await db.MakeupExamPlans
.Include(x => x.Sessions)
.ThenInclude(x => x.Invigilators)
@@ -170,6 +171,8 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)]
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Published)
@@ -189,6 +192,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -231,6 +236,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionsBatchRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -304,6 +311,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan)
.Include(x => x.Invigilators)
@@ -347,6 +356,8 @@ public sealed class MakeupExamsController(
Guid id,
CancellationToken cancellationToken)
{
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
if (session is null) return NotFound();
@@ -368,15 +379,117 @@ public sealed class MakeupExamsController(
CancellationToken cancellationToken)
{
request ??= new ExamAutoArrangeRequest();
var result = await arrangementService.ArrangeAsync(
if (!request.AssignClassrooms && !request.AssignInvigilators)
return ValidationProblem("请至少选择分配考场或分配监考教师。");
var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
if (sessionIds.Length > 100)
return ValidationProblem("一次最多处理100个补考场次。");
var plan = await db.MakeupExamPlans.AsNoTracking()
.Where(x => x.Id == planId)
.Select(x => new
{
x.Status,
TotalSessions = x.Sessions.Count
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null)
return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
if (sessionIds.Length > 0)
{
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == planId)
.WhereIn(sessionIds, x => x.Id)
.CountAsync(cancellationToken);
if (selectedSessionCount != sessionIds.Length)
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
}
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
.AnyAsync(
x => x.MakeupExamPlanId == planId &&
(x.Status == MakeupExamAutoJobStatus.Queued ||
x.Status == MakeupExamAutoJobStatus.Running),
cancellationToken);
if (autoCreateActive)
return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
var existing = await FindActiveArrangementJobAsync(
planId,
request.SessionIds,
request.AssignClassrooms,
request.AssignInvigilators,
cancellationToken);
if (!result.Success)
return ConflictProblem(result.Message);
return Ok(new { message = result.Message });
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
var userId = currentUserDataScope.Current.UserId;
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.MakeupExam,
PlanId = planId,
ActivePlanId = planId,
RequestedByUserId = userId == Guid.Empty ? null : userId,
SessionIdsJson = sessionIds.Length == 0
? null
: System.Text.Json.JsonSerializer.Serialize(sessionIds),
AssignClassrooms = request.AssignClassrooms,
AssignInvigilators = request.AssignInvigilators,
TotalSessions = sessionIds.Length == 0
? plan.TotalSessions
: sessionIds.Length,
CurrentStep = "等待后台编排"
};
db.ExamArrangementJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(
BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamArrangement,
job.Id));
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.ChangeTracker.Clear();
existing = await FindActiveArrangementJobAsync(
planId,
cancellationToken);
if (existing is not null)
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
throw;
}
return AcceptedArrangementJob(job, "补考编排任务已提交。");
}
[HttpGet("arrangement-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetArrangementJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == jobId &&
x.Kind == ExamArrangementKind.MakeupExam,
cancellationToken);
return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
}
[HttpGet("plans/{planId:guid}/arrangement-job")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetLatestArrangementJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.PlanId == planId &&
x.Kind == ExamArrangementKind.MakeupExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
}
// ═══════════════════════════════════════════
@@ -394,6 +507,8 @@ public sealed class MakeupExamsController(
if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
// Check for existing active job
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
@@ -1042,6 +1157,49 @@ public sealed class MakeupExamsController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamArrangementKind.MakeupExam &&
x.ActivePlanId == planId &&
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
private ActionResult AcceptedArrangementJob(
ExamArrangementJob job,
string message) =>
AcceptedAtAction(
nameof(GetArrangementJob),
new { jobId = job.Id },
new
{
jobId = job.Id,
status = job.Status.ToString(),
message
});
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
{
job.Id,
job.PlanId,
Kind = job.Kind.ToString(),
Status = job.Status.ToString(),
job.TotalSessions,
job.ProcessedSessions,
job.CurrentStep,
job.ResultMessage,
job.ErrorMessage,
job.AssignClassrooms,
job.AssignInvigilators,
job.CreatedAt,
job.StartedAt,
job.CompletedAt
};
private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token)
{