自动编排接口现在立即返回 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
+1
View File
@@ -17,6 +17,7 @@ BackgroundJobs__Transport=InMemory
BackgroundJobs__AutomaticScheduleConcurrency=1 BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1 BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1 BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
# RabbitMq__HostName=rabbitmq.example.edu.cn # RabbitMq__HostName=rabbitmq.example.edu.cn
# RabbitMq__Port=5671 # RabbitMq__Port=5671
# RabbitMq__UserName=jiaowu # RabbitMq__UserName=jiaowu
+1
View File
@@ -280,6 +280,7 @@ BackgroundJobs__Transport=RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency=1 BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1 BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1 BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
RabbitMq__HostName=rabbitmq.example.edu.cn RabbitMq__HostName=rabbitmq.example.edu.cn
RabbitMq__Port=5671 RabbitMq__Port=5671
RabbitMq__UserName=jiaowu RabbitMq__UserName=jiaowu
+1
View File
@@ -20,6 +20,7 @@ x-jiaowu-environment: &jiaowu-environment
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}" BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}" BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}" BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
RabbitMq__HostName: rabbitmq RabbitMq__HostName: rabbitmq
RabbitMq__Port: "5672" RabbitMq__Port: "5672"
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}" RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
+158 -9
View File
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Exams;
@@ -19,7 +20,6 @@ namespace Jiaowu.Api.Controllers;
public sealed class ExamsController( public sealed class ExamsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
ExamArrangementService examArrangementService,
IAppCache cache) : ControllerBase IAppCache cache) : ControllerBase
{ {
private const string Managers = private const string Managers =
@@ -82,6 +82,8 @@ public sealed class ExamsController(
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("考试计划正在后台编排,暂时不能删除。");
var plan = await db.ExamPlans var plan = await db.ExamPlans
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
@@ -343,6 +345,8 @@ public sealed class ExamsController(
CreateExamSessionRequest request, CreateExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken); var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft) if (plan.Status != ExamPlanStatus.Draft)
@@ -386,6 +390,8 @@ public sealed class ExamsController(
CreateExamSessionsBatchRequest request, CreateExamSessionsBatchRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken); var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft) if (plan.Status != ExamPlanStatus.Draft)
@@ -471,6 +477,8 @@ public sealed class ExamsController(
CreateExamSessionRequest request, CreateExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
var session = await db.ExamSessions var session = await db.ExamSessions
.Include(x => x.ExamPlan) .Include(x => x.ExamPlan)
.Include(x => x.Invigilators) .Include(x => x.Invigilators)
@@ -515,6 +523,8 @@ public sealed class ExamsController(
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
var session = await db.ExamSessions.Include(x => x.ExamPlan) var session = await db.ExamSessions.Include(x => x.ExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
if (session is null) return NotFound(); if (session is null) return NotFound();
@@ -532,6 +542,8 @@ public sealed class ExamsController(
RemoveExamSessionsBatchRequest request, RemoveExamSessionsBatchRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
var sessionIds = request.SessionIds.Distinct().ToArray(); var sessionIds = request.SessionIds.Distinct().ToArray();
if (sessionIds.Length == 0) if (sessionIds.Length == 0)
return ValidationProblem("请至少选择一个考试场次。"); return ValidationProblem("请至少选择一个考试场次。");
@@ -580,16 +592,108 @@ public sealed class ExamsController(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
request ??= new ExamAutoArrangeRequest(); request ??= new ExamAutoArrangeRequest();
var result = await examArrangementService.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.ExamPlans.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 != ExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的考试计划可以自动编排。");
if (sessionIds.Length > 0)
{
var selectedSessionCount = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == planId)
.WhereIn(sessionIds, x => x.Id)
.CountAsync(cancellationToken);
if (selectedSessionCount != sessionIds.Length)
return ConflictProblem("所选场次不存在或不属于当前考试计划。");
}
var existing = await FindActiveArrangementJobAsync(
planId, planId,
request.SessionIds,
request.AssignClassrooms,
request.AssignInvigilators,
cancellationToken); cancellationToken);
if (!result.Success) if (existing is not null)
return ConflictProblem(result.Message); return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
return Ok(new { message = result.Message }); var userId = currentUserDataScope.Current.UserId;
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.FormalExam,
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.FormalExam,
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.FormalExam)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -742,6 +846,8 @@ public sealed class ExamsController(
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("考试计划正在后台编排,完成后才能发布。");
var plan = await db.ExamPlans var plan = await db.ExamPlans
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.Invigilators) .ThenInclude(x => x.Invigilators)
@@ -1401,6 +1507,49 @@ public sealed class ExamsController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Kind == ExamArrangementKind.FormalExam &&
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, private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token) CancellationToken token)
{ {
@@ -18,8 +18,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class MakeupExamsController( public sealed class MakeupExamsController(
AppDbContext db, AppDbContext db,
ICurrentUserDataScope currentUserDataScope, ICurrentUserDataScope currentUserDataScope,
MakeupExamEligibilityService eligibilityService, MakeupExamEligibilityService eligibilityService) : ControllerBase
MakeupExamArrangementService arrangementService) : ControllerBase
{ {
private const string Managers = private const string Managers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
@@ -138,6 +137,8 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
var plan = await db.MakeupExamPlans var plan = await db.MakeupExamPlans
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.Invigilators) .ThenInclude(x => x.Invigilators)
@@ -170,6 +171,8 @@ public sealed class MakeupExamsController(
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken) 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); var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Published) if (plan.Status != MakeupExamPlanStatus.Published)
@@ -189,6 +192,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request, CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -231,6 +236,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionsBatchRequest request, CreateMakeupExamSessionsBatchRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
@@ -304,6 +311,8 @@ public sealed class MakeupExamsController(
CreateMakeupExamSessionRequest request, CreateMakeupExamSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions var session = await db.MakeupExamSessions
.Include(x => x.MakeupExamPlan) .Include(x => x.MakeupExamPlan)
.Include(x => x.Invigilators) .Include(x => x.Invigilators)
@@ -347,6 +356,8 @@ public sealed class MakeupExamsController(
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan) var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
if (session is null) return NotFound(); if (session is null) return NotFound();
@@ -368,15 +379,117 @@ public sealed class MakeupExamsController(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
request ??= new ExamAutoArrangeRequest(); 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, planId,
request.SessionIds,
request.AssignClassrooms,
request.AssignInvigilators,
cancellationToken); cancellationToken);
if (!result.Success) if (existing is not null)
return ConflictProblem(result.Message); return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
return Ok(new { message = result.Message });
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 is null) return NotFound();
if (plan.Status != MakeupExamPlanStatus.Draft) if (plan.Status != MakeupExamPlanStatus.Draft)
return ConflictProblem("只有草稿状态的补考计划可以自动生成。"); return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
// Check for existing active job // Check for existing active job
var existing = await db.MakeupExamAutoJobs.AsNoTracking() var existing = await db.MakeupExamAutoJobs.AsNoTracking()
@@ -1042,6 +1157,49 @@ public sealed class MakeupExamsController(
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); 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, private async Task<ActionResult> SaveAsync(Guid id, bool created,
CancellationToken token) CancellationToken token)
{ {
@@ -213,6 +213,34 @@ public sealed class OperationsController(
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
} }
if (normalizedKind is null or "ExamArrangement")
{
var query = db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
x.Status == ExamArrangementJobStatus.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,
"ExamArrangement",
x.Kind == ExamArrangementKind.FormalExam
? "正式考试编排"
: "补考编排",
x.Kind == ExamArrangementKind.FormalExam
? "正式考试计划"
: "补考计划",
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
var pageItems = rows var pageItems = rows
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt) .OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
@@ -435,6 +463,11 @@ public sealed class OperationsController(
.CountAsync( .CountAsync(
x => x.Status == MakeupExamAutoJobStatus.Failed && x => x.Status == MakeupExamAutoJobStatus.Failed &&
x.CreatedAt >= from, x.CreatedAt >= from,
cancellationToken) +
await db.ExamArrangementJobs.AsNoTracking()
.CountAsync(
x => x.Status == ExamArrangementJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken); cancellationToken);
private ActionResult? ValidatePaging(int page, int pageSize) private ActionResult? ValidatePaging(int page, int pageSize)
@@ -462,6 +495,7 @@ public sealed class OperationsController(
"AutomaticSchedule" => "AutomaticSchedule", "AutomaticSchedule" => "AutomaticSchedule",
"SchedulePublish" => "SchedulePublish", "SchedulePublish" => "SchedulePublish",
"MakeupExamAuto" => "MakeupExamAuto", "MakeupExamAuto" => "MakeupExamAuto",
"ExamArrangement" => "ExamArrangement",
_ => null _ => null
}; };
} }
@@ -14,6 +14,40 @@ public sealed class ExamPlan : EntityBase
public ICollection<ExamRoomAssignment> Rooms { get; set; } = []; public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
} }
public sealed class ExamArrangementJob : EntityBase
{
public ExamArrangementKind Kind { get; set; }
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? SessionIdsJson { get; set; }
public bool AssignClassrooms { get; set; }
public bool AssignInvigilators { get; set; }
public ExamArrangementJobStatus Status { get; set; } =
ExamArrangementJobStatus.Queued;
public int TotalSessions { get; set; }
public int ProcessedSessions { get; set; }
public string? CurrentStep { get; set; }
public string? ResultMessage { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum ExamArrangementKind
{
FormalExam = 1,
MakeupExam = 2
}
public enum ExamArrangementJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public sealed class ExamSession : EntityBase public sealed class ExamSession : EntityBase
{ {
public Guid ExamPlanId { get; set; } public Guid ExamPlanId { get; set; }
@@ -30,7 +30,8 @@ public enum BackgroundJobKind
{ {
AutomaticSchedule = 1, AutomaticSchedule = 1,
SchedulePublish = 2, SchedulePublish = 2,
MakeupExamAuto = 3 MakeupExamAuto = 3,
ExamArrangement = 4
} }
public enum BackgroundJobOutboxState public enum BackgroundJobOutboxState
@@ -13,6 +13,7 @@ public sealed class BackgroundJobOptions
public int AutomaticScheduleConcurrency { get; set; } = 1; public int AutomaticScheduleConcurrency { get; set; } = 1;
public int SchedulePublishConcurrency { get; set; } = 1; public int SchedulePublishConcurrency { get; set; } = 1;
public int MakeupExamAutoConcurrency { get; set; } = 1; public int MakeupExamAutoConcurrency { get; set; } = 1;
public int ExamArrangementConcurrency { get; set; } = 1;
public string Exchange { get; set; } = "jiaowu.background-jobs"; public string Exchange { get; set; } = "jiaowu.background-jobs";
public string QueuePrefix { get; set; } = "jiaowu.background-jobs"; public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
public bool UseQuorumQueues { get; set; } = true; public bool UseQuorumQueues { get; set; } = true;
@@ -29,6 +30,7 @@ public sealed class BackgroundJobOptions
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency, BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency, BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency, BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
}; };
} }
@@ -103,6 +103,15 @@ public sealed class BackgroundJobOutboxPublisher(
message.JobId == x.Id)) message.JobId == x.Id))
.Select(x => x.Id) .Select(x => x.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
.Where(x =>
(x.Status == ExamArrangementJobStatus.Queued ||
x.Status == ExamArrangementJobStatus.Running) &&
!db.BackgroundJobOutboxMessages.Any(message =>
message.JobKind == BackgroundJobKind.ExamArrangement &&
message.JobId == x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var missingKeys = automaticJobs var missingKeys = automaticJobs
.Select(id => (BackgroundJobKind.AutomaticSchedule, id)) .Select(id => (BackgroundJobKind.AutomaticSchedule, id))
@@ -110,6 +119,8 @@ public sealed class BackgroundJobOutboxPublisher(
(BackgroundJobKind.SchedulePublish, id))) (BackgroundJobKind.SchedulePublish, id)))
.Concat(makeupJobs.Select(id => .Concat(makeupJobs.Select(id =>
(BackgroundJobKind.MakeupExamAuto, id))) (BackgroundJobKind.MakeupExamAuto, id)))
.Concat(arrangementJobs.Select(id =>
(BackgroundJobKind.ExamArrangement, id)))
.ToList(); .ToList();
foreach (var (kind, jobId) in missingKeys) foreach (var (kind, jobId) in missingKeys)
{ {
@@ -85,6 +85,11 @@ public sealed class BackgroundJobRunner(
.GetRequiredService<MakeupExamAutoJobProcessor>() .GetRequiredService<MakeupExamAutoJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken); .ProcessAsync(message.JobId, cancellationToken);
break; break;
case BackgroundJobKind.ExamArrangement:
await scope.ServiceProvider
.GetRequiredService<ExamArrangementJobProcessor>()
.ProcessAsync(message.JobId, cancellationToken);
break;
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
$"Unsupported background job kind '{message.JobKind}'."); $"Unsupported background job kind '{message.JobKind}'.");
@@ -259,6 +264,23 @@ public sealed class BackgroundJobRunner(
.SetProperty(x => x.CompletedAt, completedAt), .SetProperty(x => x.CompletedAt, completedAt),
cancellationToken); cancellationToken);
break; break;
case BackgroundJobKind.ExamArrangement:
await db.ExamArrangementJobs
.Where(x =>
x.Id == message.JobId &&
x.Status != ExamArrangementJobStatus.Succeeded &&
x.Status != ExamArrangementJobStatus.Failed)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
x => x.Status,
ExamArrangementJobStatus.Failed)
.SetProperty(x => x.ActivePlanId, (Guid?)null)
.SetProperty(x => x.CurrentStep, "后台处理已停止")
.SetProperty(x => x.ErrorMessage, error)
.SetProperty(x => x.CompletedAt, completedAt),
cancellationToken);
break;
default: default:
throw new ArgumentOutOfRangeException( throw new ArgumentOutOfRangeException(
nameof(message.JobKind), nameof(message.JobKind),
@@ -316,7 +316,8 @@ internal static class RabbitMqBackgroundJobTopology
[ [
BackgroundJobKind.AutomaticSchedule, BackgroundJobKind.AutomaticSchedule,
BackgroundJobKind.SchedulePublish, BackgroundJobKind.SchedulePublish,
BackgroundJobKind.MakeupExamAuto BackgroundJobKind.MakeupExamAuto,
BackgroundJobKind.ExamArrangement
]; ];
public static async Task<IConnection> CreateConnectionAsync( public static async Task<IConnection> CreateConnectionAsync(
@@ -412,6 +413,7 @@ internal static class RabbitMqBackgroundJobTopology
BackgroundJobKind.AutomaticSchedule => "schedule.automatic", BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
BackgroundJobKind.SchedulePublish => "schedule.publish", BackgroundJobKind.SchedulePublish => "schedule.publish",
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic", BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
BackgroundJobKind.ExamArrangement => "exam.arrangement",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
}; };
@@ -0,0 +1,126 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementJobProcessor(
AppDbContext db,
ExamArrangementService examArrangementService,
MakeupExamArrangementService makeupExamArrangementService,
IAppCache cache,
ILogger<ExamArrangementJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.ExamArrangementJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is ExamArrangementJobStatus.Succeeded
or ExamArrangementJobStatus.Failed)
{
return;
}
job.Status = ExamArrangementJobStatus.Running;
job.StartedAt ??= DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.ResultMessage = null;
job.CurrentStep = job.Kind == ExamArrangementKind.FormalExam
? "正在编排正式考试"
: "正在编排补考";
await db.SaveChangesAsync(stoppingToken);
var sessionIds = DeserializeSessionIds(job.SessionIdsJson);
var result = job.Kind switch
{
ExamArrangementKind.FormalExam =>
await examArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
ExamArrangementKind.MakeupExam =>
await makeupExamArrangementService.ArrangeAsync(
job.PlanId,
sessionIds,
job.AssignClassrooms,
job.AssignInvigilators,
stoppingToken),
_ => throw new InvalidOperationException(
$"不支持的考试编排类型:{job.Kind}。")
};
if (!result.Success)
{
await MarkFailedAsync(jobId, result.Message);
return;
}
job.Status = ExamArrangementJobStatus.Succeeded;
job.ProcessedSessions = job.TotalSessions;
job.CurrentStep = "编排完成";
job.ResultMessage = result.Message;
job.ActivePlanId = null;
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
if (job.Kind == ExamArrangementKind.FormalExam)
{
await cache.RemoveByTagAsync(
AppCacheTags.Timetables,
stoppingToken);
}
logger.LogInformation(
"Exam arrangement job {JobId} for {Kind}/{PlanId} completed.",
job.Id,
job.Kind,
job.PlanId);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Exam arrangement job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Exam arrangement job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
}
}
private static IReadOnlyCollection<Guid>? DeserializeSessionIds(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
var sessionIds = JsonSerializer.Deserialize<Guid[]>(json);
return sessionIds is { Length: > 0 } ? sessionIds : null;
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
var job = await db.ExamArrangementJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
job.Status = ExamArrangementJobStatus.Failed;
job.ActivePlanId = null;
job.CurrentStep = "编排失败";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
@@ -58,6 +58,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>(); public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>(); public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>(); public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
Set<ExamArrangementJob>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>(); public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators => public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>(); Set<ExamSessionInvigilator>();
@@ -783,6 +785,19 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany() entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExamArrangementJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
.IsUnique()
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
});
builder.Entity<MakeupExamPlan>(entity => builder.Entity<MakeupExamPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -365,6 +365,14 @@ public sealed class DevelopmentSqliteMigrator(
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements, makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
cancellationToken); cancellationToken);
var examArrangementJobsExist = await db.Database
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamArrangementJobs'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamArrangementJobsMigration,
examArrangementJobsExist ? [] : ExamArrangementJobStatements,
cancellationToken);
var academicTermArchivingExists = await db.Database var academicTermArchivingExists = await db.Database
.SqlQueryRaw<int>( .SqlQueryRaw<int>(
""" """
@@ -1986,6 +1994,17 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""", """CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
]; ];
private const string ExamArrangementJobsMigration = "ExamArrangementJobs";
private static readonly string[] ExamArrangementJobStatements =
[
"""CREATE TABLE "ExamArrangementJobs" ("Id" TEXT NOT NULL, "Kind" INTEGER NOT NULL, "PlanId" TEXT NOT NULL, "ActivePlanId" TEXT NULL, "RequestedByUserId" TEXT NULL, "SessionIdsJson" TEXT NULL, "AssignClassrooms" INTEGER NOT NULL, "AssignInvigilators" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "TotalSessions" INTEGER NOT NULL, "ProcessedSessions" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ResultMessage" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamArrangementJobs" PRIMARY KEY ("Id"));""",
"""CREATE INDEX "IX_ExamArrangementJobs_Kind_Plan_CreatedAt" ON "ExamArrangementJobs" ("Kind", "PlanId", "CreatedAt");""",
"""CREATE INDEX "IX_ExamArrangementJobs_RequestedByUserId" ON "ExamArrangementJobs" ("RequestedByUserId");""",
"""CREATE INDEX "IX_ExamArrangementJobs_Status_CreatedAt" ON "ExamArrangementJobs" ("Status", "CreatedAt");""",
"""CREATE UNIQUE INDEX "UX_ExamArrangementJobs_Kind_ActivePlan" ON "ExamArrangementJobs" ("Kind", "ActivePlanId");""",
];
private static readonly string[] UnifiedMessageCenterStatements = private static readonly string[] UnifiedMessageCenterStatements =
[ [
""" """
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamArrangementJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamArrangementJobs",
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),
SessionIdsJson = table.Column<string>(type: "longtext", nullable: true),
AssignClassrooms = table.Column<bool>(type: "tinyint(1)", nullable: false),
AssignInvigilators = table.Column<bool>(type: "tinyint(1)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TotalSessions = table.Column<int>(type: "int", nullable: false),
ProcessedSessions = table.Column<int>(type: "int", nullable: false),
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
ResultMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, 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_ExamArrangementJobs", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExamArrangementJobs_Kind_Plan_CreatedAt",
table: "ExamArrangementJobs",
columns: new[] { "Kind", "PlanId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamArrangementJobs_RequestedByUserId",
table: "ExamArrangementJobs",
column: "RequestedByUserId");
migrationBuilder.CreateIndex(
name: "IX_ExamArrangementJobs_Status_CreatedAt",
table: "ExamArrangementJobs",
columns: new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "UX_ExamArrangementJobs_Kind_ActivePlan",
table: "ExamArrangementJobs",
columns: new[] { "Kind", "ActivePlanId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamArrangementJobs");
}
}
}
@@ -1521,6 +1521,82 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("EvaluationSetups"); b.ToTable("EvaluationSetups");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamArrangementJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("ActivePlanId")
.HasColumnType("char(36)");
b.Property<bool>("AssignClassrooms")
.HasColumnType("tinyint(1)");
b.Property<bool>("AssignInvigilators")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("CurrentStep")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<Guid>("PlanId")
.HasColumnType("char(36)");
b.Property<int>("ProcessedSessions")
.HasColumnType("int");
b.Property<Guid?>("RequestedByUserId")
.HasColumnType("char(36)");
b.Property<string>("ResultMessage")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("SessionIdsJson")
.HasColumnType("longtext");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("TotalSessions")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("RequestedByUserId");
b.HasIndex("Kind", "ActivePlanId")
.IsUnique()
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
b.HasIndex("Status", "CreatedAt");
b.HasIndex("Kind", "PlanId", "CreatedAt")
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
b.ToTable("ExamArrangementJobs");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
+2
View File
@@ -144,6 +144,7 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 || backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 || backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 || backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 || backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 || backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 || backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
@@ -294,6 +295,7 @@ builder.Services.AddScoped<ExamArrangementService>();
builder.Services.AddScoped<MakeupExamEligibilityService>(); builder.Services.AddScoped<MakeupExamEligibilityService>();
builder.Services.AddScoped<MakeupExamArrangementService>(); builder.Services.AddScoped<MakeupExamArrangementService>();
builder.Services.AddScoped<MakeupExamAutoJobProcessor>(); builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddScoped<ExamArrangementJobProcessor>();
builder.Services.AddSingleton<BackgroundJobTelemetry>(); builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>(); builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>(); builder.Services.AddScoped<OperationalHealthService>();
+1
View File
@@ -35,6 +35,7 @@
"AutomaticScheduleConcurrency": 1, "AutomaticScheduleConcurrency": 1,
"SchedulePublishConcurrency": 1, "SchedulePublishConcurrency": 1,
"MakeupExamAutoConcurrency": 1, "MakeupExamAutoConcurrency": 1,
"ExamArrangementConcurrency": 1,
"Exchange": "jiaowu.background-jobs", "Exchange": "jiaowu.background-jobs",
"QueuePrefix": "jiaowu.background-jobs", "QueuePrefix": "jiaowu.background-jobs",
"UseQuorumQueues": true, "UseQuorumQueues": true,
@@ -0,0 +1,205 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
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;
public sealed class ExamArrangementJobControllerTests
{
[Fact]
public async Task Formal_and_makeup_arrangement_are_enqueued_with_outbox_messages()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-EXAM-JOB",
Name = "考试后台任务测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var formalPlan = new ExamPlan
{
AcademicTerm = term,
Name = "正式考试草稿"
};
var makeupPlan = new MakeupExamPlan
{
AcademicTerm = term,
Name = "补考草稿"
};
db.AddRange(term, formalPlan, makeupPlan);
await db.SaveChangesAsync();
var scope = new ManagerDataScope();
var exams = new ExamsController(db, scope, NoOpAppCache.Instance);
var makeup = new MakeupExamsController(
db,
scope,
new MakeupExamEligibilityService(db));
var formalResult = await exams.AutoArrange(
formalPlan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None);
var makeupResult = await makeup.AutoArrange(
makeupPlan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None);
Assert.IsType<AcceptedAtActionResult>(formalResult);
Assert.IsType<AcceptedAtActionResult>(makeupResult);
var jobs = await db.ExamArrangementJobs
.OrderBy(x => x.Kind)
.ToListAsync();
Assert.Equal(2, jobs.Count);
Assert.Equal(ExamArrangementKind.FormalExam, jobs[0].Kind);
Assert.Equal(formalPlan.Id, jobs[0].ActivePlanId);
Assert.Equal(ExamArrangementKind.MakeupExam, jobs[1].Kind);
Assert.Equal(makeupPlan.Id, jobs[1].ActivePlanId);
Assert.All(jobs, job =>
Assert.Equal(ExamArrangementJobStatus.Queued, job.Status));
var outbox = await db.BackgroundJobOutboxMessages.ToListAsync();
Assert.Equal(2, outbox.Count);
Assert.All(outbox, message =>
{
Assert.Equal(BackgroundJobKind.ExamArrangement, message.JobKind);
Assert.Equal(BackgroundJobOutboxState.Pending, message.State);
});
Assert.Equal(
jobs.Select(x => x.Id).Order(),
outbox.Select(x => x.JobId).Order());
}
[Fact]
public async Task Repeated_formal_arrangement_returns_the_existing_active_job()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-EXAM-DUP",
Name = "考试任务去重测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var plan = new ExamPlan
{
AcademicTerm = term,
Name = "正式考试草稿"
};
db.AddRange(term, plan);
await db.SaveChangesAsync();
var controller = new ExamsController(
db,
new ManagerDataScope(),
NoOpAppCache.Instance);
var first = Assert.IsType<AcceptedAtActionResult>(
await controller.AutoArrange(
plan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None));
var second = Assert.IsType<AcceptedAtActionResult>(
await controller.AutoArrange(
plan.Id,
new ExamAutoArrangeRequest(),
CancellationToken.None));
Assert.Equal(202, first.StatusCode);
Assert.Equal(202, second.StatusCode);
Assert.Equal(1, await db.ExamArrangementJobs.CountAsync());
Assert.Equal(1, await db.BackgroundJobOutboxMessages.CountAsync());
}
[Fact]
public async Task Processor_records_business_failure_and_releases_active_plan()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-EXAM-FAIL",
Name = "考试任务失败测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var plan = new ExamPlan
{
AcademicTerm = term,
Name = "缺少节次配置的考试计划"
};
var job = new ExamArrangementJob
{
Kind = ExamArrangementKind.FormalExam,
PlanId = plan.Id,
ActivePlanId = plan.Id,
AssignClassrooms = true,
AssignInvigilators = true
};
db.AddRange(term, plan, job);
await db.SaveChangesAsync();
var processor = new ExamArrangementJobProcessor(
db,
new ExamArrangementService(db),
new MakeupExamArrangementService(db),
NoOpAppCache.Instance,
NullLogger<ExamArrangementJobProcessor>.Instance);
await processor.ProcessAsync(job.Id, CancellationToken.None);
db.ChangeTracker.Clear();
var failed = await db.ExamArrangementJobs.SingleAsync();
Assert.Equal(ExamArrangementJobStatus.Failed, failed.Status);
Assert.Null(failed.ActivePlanId);
Assert.Equal("编排失败", failed.CurrentStep);
Assert.False(string.IsNullOrWhiteSpace(failed.ErrorMessage));
Assert.NotNull(failed.CompletedAt);
}
private sealed class ManagerDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"考试管理员",
null,
DataScope.All,
new HashSet<string> { SystemRoles.AcademicAdmin });
}
}
@@ -225,7 +225,6 @@ public sealed class ExamDeletionControllerTests
public ExamsController CreateController() => new( public ExamsController CreateController() => new(
Db, Db,
new ManagerDataScope(), new ManagerDataScope(),
new ExamArrangementService(Db),
NoOpAppCache.Instance); NoOpAppCache.Instance);
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -83,7 +83,6 @@ public sealed class ExamPlanPaginationTests
var controller = new ExamsController( var controller = new ExamsController(
db, db,
new AllScope(), new AllScope(),
new ExamArrangementService(db),
NoOpAppCache.Instance); NoOpAppCache.Instance);
var secondPage = ReadPlan(await controller.GetPlan( var secondPage = ReadPlan(await controller.GetPlan(
@@ -11,7 +11,7 @@ namespace Jiaowu.Api.Tests;
public sealed class MySqlMigrationTests public sealed class MySqlMigrationTests
{ {
private const string LatestMigration = private const string LatestMigration =
"20260727105716_ExamRoomMixing"; "20260727133346_ExamArrangementJobs";
[Fact] [Fact]
public void Production_migration_is_discoverable_and_generates_mysql_sql() public void Production_migration_is_discoverable_and_generates_mysql_sql()
@@ -58,6 +58,10 @@ public sealed class MySqlMigrationTests
Assert.Contains("CREATE TABLE `ExamRoomSessions`", script); Assert.Contains("CREATE TABLE `ExamRoomSessions`", script);
Assert.Contains("CREATE TABLE `ExamSeats`", script); Assert.Contains("CREATE TABLE `ExamSeats`", script);
Assert.Contains("CREATE TABLE `ExamRoomInvigilators`", script); Assert.Contains("CREATE TABLE `ExamRoomInvigilators`", script);
Assert.Contains("CREATE TABLE `ExamArrangementJobs`", script);
Assert.Contains(
"CREATE UNIQUE INDEX `UX_ExamArrangementJobs_Kind_ActivePlan`",
script);
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
Assert.Contains("DEFAULT 1", script); Assert.Contains("DEFAULT 1", script);
Assert.DoesNotContain("0001-01-01", script); Assert.DoesNotContain("0001-01-01", script);
@@ -288,7 +288,6 @@ public sealed class ScheduleSettingsControllerTests
var examTimeSlotsResult = await new ExamsController( var examTimeSlotsResult = await new ExamsController(
db, db,
null!, null!,
null!,
NoOpAppCache.Instance) NoOpAppCache.Instance)
.GetTimeSlotsForTerm(term.Id, CancellationToken.None); .GetTimeSlotsForTerm(term.Id, CancellationToken.None);
var examTimeSlotsOk = var examTimeSlotsOk =
@@ -300,7 +299,6 @@ public sealed class ScheduleSettingsControllerTests
var makeupTimeSlotsResult = await new MakeupExamsController( var makeupTimeSlotsResult = await new MakeupExamsController(
db, db,
null!, null!,
null!,
null!) null!)
.GetTimeSlotsForTerm(term.Id, CancellationToken.None); .GetTimeSlotsForTerm(term.Id, CancellationToken.None);
var makeupTimeSlotsOk = var makeupTimeSlotsOk =
@@ -250,7 +250,6 @@ public sealed class TeachingWorkflowRosterTests
var exams = new ExamsController( var exams = new ExamsController(
db, db,
managerScope, managerScope,
new ExamArrangementService(db),
NoOpAppCache.Instance); NoOpAppCache.Instance);
var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None); var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None);
Assert.IsType<NoContentResult>(publishResult); Assert.IsType<NoContentResult>(publishResult);
@@ -286,14 +285,12 @@ public sealed class TeachingWorkflowRosterTests
var studentExams = new ExamsController( var studentExams = new ExamsController(
db, db,
new StudentDataScope(studentUserId), new StudentDataScope(studentUserId),
new ExamArrangementService(db),
NoOpAppCache.Instance); NoOpAppCache.Instance);
Assert.Single(ReadItems( Assert.Single(ReadItems(
await studentExams.GetMySchedule(CancellationToken.None))); await studentExams.GetMySchedule(CancellationToken.None)));
var teacherExams = new ExamsController( var teacherExams = new ExamsController(
db, db,
scope, scope,
new ExamArrangementService(db),
NoOpAppCache.Instance); NoOpAppCache.Instance);
Assert.Single(ReadItems( Assert.Single(ReadItems(
await teacherExams.GetMySchedule(CancellationToken.None))); await teacherExams.GetMySchedule(CancellationToken.None)));
+104 -8
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { Delete, Download, Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue' import { Delete, Download, Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel' import { downloadApiFile } from '../api/excel'
@@ -26,6 +26,14 @@ const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([]) const timeSlots = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const arrangeLoading = ref(false) const arrangeLoading = ref(false)
const arrangementJob = ref<any | null>(null)
let arrangementPollTimer: ReturnType<typeof setTimeout> | null = null
const arrangementJobRunning = computed(() =>
['Queued', 'Running'].includes(arrangementJob.value?.status))
const arrangementProgress = computed(() =>
arrangementJob.value?.status === 'Queued' ? 10
: arrangementJob.value?.status === 'Running' ? 60
: arrangementJob.value?.status === 'Succeeded' ? 100 : 0)
const exportLoading = ref(false) const exportLoading = ref(false)
const removeLoading = ref(false) const removeLoading = ref(false)
const deletePlanLoading = ref(false) const deletePlanLoading = ref(false)
@@ -127,10 +135,14 @@ async function load() {
finally { loading.value = false } finally { loading.value = false }
} }
async function selectPlan(id: string) { async function selectPlan(id: string) {
stopArrangementPolling()
sessionPage.value = 1 sessionPage.value = 1
selectedSessionIds.value = [] selectedSessionIds.value = []
await loadSelectedPlan(id) await loadSelectedPlan(id)
await loadPlanResources(selected.value.academicTermId) await Promise.all([
loadPlanResources(selected.value.academicTermId),
restoreArrangementJob(id),
])
} }
async function loadSelectedPlan(id: string) { async function loadSelectedPlan(id: string) {
sessionLoading.value = true sessionLoading.value = true
@@ -359,12 +371,75 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
assignClassrooms: mode !== 'invigilators', assignClassrooms: mode !== 'invigilators',
assignInvigilators: mode !== 'rooms', assignInvigilators: mode !== 'rooms',
}) })
arrangementJob.value = {
id: res.data.jobId,
planId: selected.value.id,
status: res.data.status,
currentStep: '等待后台编排',
}
ElMessage.success(res.data.message) ElMessage.success(res.data.message)
await reloadSelectedPlan() scheduleArrangementPoll(true)
} catch (error: any) { } catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false } } finally { arrangeLoading.value = false }
} }
async function restoreArrangementJob(planId: string) {
try {
const res = await http.get(`/exams/plans/${planId}/arrangement-job`)
if (res.status === 204 || !res.data ||
!['Queued', 'Running'].includes(res.data.status)) {
arrangementJob.value = null
return
}
arrangementJob.value = res.data
scheduleArrangementPoll(false)
} catch {
arrangementJob.value = null
}
}
function scheduleArrangementPoll(notifyTerminal: boolean) {
stopArrangementPolling()
arrangementPollTimer = setTimeout(
() => pollArrangementJob(notifyTerminal),
1500,
)
}
async function pollArrangementJob(notifyTerminal: boolean) {
if (!arrangementJob.value?.id) return
const jobId = arrangementJob.value.id
const planId = selected.value?.id
try {
const { data: job } = await http.get(
`/exams/arrangement-jobs/${jobId}`,
)
if (selected.value?.id !== planId ||
arrangementJob.value?.id !== jobId) return
arrangementJob.value = job
if (['Queued', 'Running'].includes(job.status)) {
scheduleArrangementPoll(notifyTerminal)
return
}
stopArrangementPolling()
if (job.status === 'Succeeded') {
selectedSessionIds.value = []
await reloadSelectedPlan()
if (notifyTerminal) ElMessage.success(job.resultMessage || '考试编排完成')
} else if (notifyTerminal) {
ElMessage.error(job.errorMessage || '考试编排失败')
}
} catch {
if (selected.value?.id === planId &&
arrangementJob.value?.id === jobId) {
scheduleArrangementPoll(notifyTerminal)
}
}
}
function stopArrangementPolling() {
if (arrangementPollTimer) {
clearTimeout(arrangementPollTimer)
arrangementPollTimer = null
}
}
async function publishPlan() { async function publishPlan() {
try { try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', { await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', {
@@ -452,6 +527,7 @@ onMounted(async () => {
await load() await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
}) })
onBeforeUnmount(stopArrangementPolling)
</script> </script>
<template> <template>
@@ -487,21 +563,41 @@ onMounted(async () => {
:disabled="planSessionTotal === 0" :disabled="planSessionTotal === 0"
@click="exportSignInSheets" @click="exportSignInSheets"
>导出考场签名单</el-button> >导出考场签名单</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button> <el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading" :disabled="arrangementJobRunning">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button> <el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading" :disabled="arrangementJobRunning">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button> <el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading" :disabled="arrangementJobRunning">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button> <el-button v-if="selected.status === 'Draft'" :icon="Plus" :disabled="arrangementJobRunning" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button> <el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" :disabled="arrangementJobRunning" @click="publishPlan">发布计划</el-button>
<el-button <el-button
v-if="selected.status === 'Draft'" v-if="selected.status === 'Draft'"
type="danger" type="danger"
plain plain
:icon="Delete" :icon="Delete"
:loading="deletePlanLoading" :loading="deletePlanLoading"
:disabled="arrangementJobRunning"
@click="deleteDraftPlan" @click="deleteDraftPlan"
>删除草稿</el-button> >删除草稿</el-button>
</div> </div>
</header> </header>
<el-alert
v-if="arrangementJob"
:title="arrangementJob.status === 'Failed' ? '后台编排失败' : arrangementJob.status === 'Succeeded' ? '后台编排完成' : '后台正在编排考试'"
:type="arrangementJob.status === 'Failed' ? 'error' : arrangementJob.status === 'Succeeded' ? 'success' : 'info'"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<p>{{ arrangementJob.errorMessage || arrangementJob.resultMessage || arrangementJob.currentStep || '等待后台任务处理' }}</p>
<el-progress
v-if="arrangementJobRunning"
:percentage="arrangementProgress"
:show-text="false"
:stroke-width="6"
:indeterminate="arrangementJob.status === 'Running'"
/>
</template>
</el-alert>
<div class="exam-filter-bar"> <div class="exam-filter-bar">
<el-input <el-input
v-model="sessionFilter.keyword" v-model="sessionFilter.keyword"
+109 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue' import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -25,6 +25,14 @@ const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([]) const timeSlots = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const arrangeLoading = ref(false) const arrangeLoading = ref(false)
const arrangementJob = ref<any | null>(null)
let arrangementPollTimer: ReturnType<typeof setTimeout> | null = null
const arrangementJobRunning = computed(() =>
['Queued', 'Running'].includes(arrangementJob.value?.status))
const arrangementProgress = computed(() =>
arrangementJob.value?.status === 'Queued' ? 10
: arrangementJob.value?.status === 'Running' ? 60
: arrangementJob.value?.status === 'Succeeded' ? 100 : 0)
const planDialog = ref(false) const planDialog = ref(false)
const sessionDialog = ref(false) const sessionDialog = ref(false)
const editingSession = ref<any | null>(null) const editingSession = ref<any | null>(null)
@@ -119,9 +127,13 @@ async function load() {
finally { loading.value = false } finally { loading.value = false }
} }
async function selectPlan(id: string) { async function selectPlan(id: string) {
stopArrangementPolling()
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
selectedSessionIds.value = [] selectedSessionIds.value = []
await loadPlanResources(selected.value.academicTermId) await Promise.all([
loadPlanResources(selected.value.academicTermId),
restoreArrangementJob(id),
])
} }
async function loadPlanResources(academicTermId: string) { async function loadPlanResources(academicTermId: string) {
const [taskRes, slotRes] = await Promise.all([ const [taskRes, slotRes] = await Promise.all([
@@ -246,12 +258,77 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
assignClassrooms: mode !== 'invigilators', assignClassrooms: mode !== 'invigilators',
assignInvigilators: mode !== 'rooms', assignInvigilators: mode !== 'rooms',
}) })
arrangementJob.value = {
id: res.data.jobId,
planId: selected.value.id,
status: res.data.status,
currentStep: '等待后台编排',
}
ElMessage.success(res.data.message) ElMessage.success(res.data.message)
await selectPlan(selected.value.id) scheduleArrangementPoll(true)
} catch (error: any) { } catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false } } finally { arrangeLoading.value = false }
} }
async function restoreArrangementJob(planId: string) {
try {
const res = await http.get(
`/makeup-exams/plans/${planId}/arrangement-job`,
)
if (res.status === 204 || !res.data ||
!['Queued', 'Running'].includes(res.data.status)) {
arrangementJob.value = null
return
}
arrangementJob.value = res.data
scheduleArrangementPoll(false)
} catch {
arrangementJob.value = null
}
}
function scheduleArrangementPoll(notifyTerminal: boolean) {
stopArrangementPolling()
arrangementPollTimer = setTimeout(
() => pollArrangementJob(notifyTerminal),
1500,
)
}
async function pollArrangementJob(notifyTerminal: boolean) {
if (!arrangementJob.value?.id) return
const jobId = arrangementJob.value.id
const planId = selected.value?.id
try {
const { data: job } = await http.get(
`/makeup-exams/arrangement-jobs/${jobId}`,
)
if (selected.value?.id !== planId ||
arrangementJob.value?.id !== jobId) return
arrangementJob.value = job
if (['Queued', 'Running'].includes(job.status)) {
scheduleArrangementPoll(notifyTerminal)
return
}
stopArrangementPolling()
if (job.status === 'Succeeded') {
const planId = selected.value?.id
if (planId) await selectPlan(planId)
if (notifyTerminal) ElMessage.success(job.resultMessage || '补考编排完成')
} else if (notifyTerminal) {
ElMessage.error(job.errorMessage || '补考编排失败')
}
} catch {
if (selected.value?.id === planId &&
arrangementJob.value?.id === jobId) {
scheduleArrangementPoll(notifyTerminal)
}
}
}
function stopArrangementPolling() {
if (arrangementPollTimer) {
clearTimeout(arrangementPollTimer)
arrangementPollTimer = null
}
}
async function publishPlan() { async function publishPlan() {
try { try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布补考计划', { await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布补考计划', {
@@ -431,6 +508,10 @@ onMounted(async () => {
await load() await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
}) })
onBeforeUnmount(() => {
stopPolling()
stopArrangementPolling()
})
</script> </script>
<template> <template>
@@ -460,15 +541,34 @@ onMounted(async () => {
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个补考场次</p> <p>{{ selected.termName }} · {{ selected.sessions.length }} 个补考场次</p>
</div> </div>
<div class="exam-actions"> <div class="exam-actions">
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" @click="startAutoCreate">一键生成</el-button> <el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" :disabled="arrangementJobRunning" @click="startAutoCreate">一键生成</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button> <el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</el-button> <el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading">一键完成</el-button> <el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">批量安排场次</el-button> <el-button v-if="selected.status === 'Draft'" :icon="Plus" :disabled="arrangementJobRunning" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button> <el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" :disabled="arrangementJobRunning" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button> <el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
</div> </div>
</header> </header>
<el-alert
v-if="arrangementJob"
:title="arrangementJob.status === 'Failed' ? '后台编排失败' : arrangementJob.status === 'Succeeded' ? '后台编排完成' : '后台正在编排补考'"
:type="arrangementJob.status === 'Failed' ? 'error' : arrangementJob.status === 'Succeeded' ? 'success' : 'info'"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<p>{{ arrangementJob.errorMessage || arrangementJob.resultMessage || arrangementJob.currentStep || '等待后台任务处理' }}</p>
<el-progress
v-if="arrangementJobRunning"
:percentage="arrangementProgress"
:show-text="false"
:stroke-width="6"
:indeterminate="arrangementJob.status === 'Running'"
/>
</template>
</el-alert>
<div v-if="autoJobId && autoJobStatus !== 'Succeeded' && autoJobStatus !== 'Failed'" style="margin-bottom: 16px"> <div v-if="autoJobId && autoJobStatus !== 'Succeeded' && autoJobStatus !== 'Failed'" style="margin-bottom: 16px">
<el-alert :title="autoJobMessage" type="info" :closable="false"> <el-alert :title="autoJobMessage" type="info" :closable="false">
<template #default> <template #default>
+1
View File
@@ -586,6 +586,7 @@ onMounted(refreshAll)
<el-option label="自动排课" value="AutomaticSchedule" /> <el-option label="自动排课" value="AutomaticSchedule" />
<el-option label="课表发布" value="SchedulePublish" /> <el-option label="课表发布" value="SchedulePublish" />
<el-option label="补考自动安排" value="MakeupExamAuto" /> <el-option label="补考自动安排" value="MakeupExamAuto" />
<el-option label="考试与补考编排" value="ExamArrangement" />
</el-select> </el-select>
<el-date-picker <el-date-picker
v-model="jobFilter.range" v-model="jobFilter.range"