自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
任务参数、状态和结果持久化到 MySQL。 接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。 服务重启后可恢复未完成任务。 前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。 编排期间禁止修改、删除或发布对应计划。 补考原有“一键生成”保留,并与编排任务互斥。 运维后台增加“考试与补考编排”失败任务筛选。
This commit is contained in:
@@ -17,6 +17,7 @@ BackgroundJobs__Transport=InMemory
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
BackgroundJobs__ExamArrangementConcurrency=1
|
||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
# RabbitMq__Port=5671
|
||||
# RabbitMq__UserName=jiaowu
|
||||
|
||||
@@ -280,6 +280,7 @@ BackgroundJobs__Transport=RabbitMq
|
||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||
BackgroundJobs__SchedulePublishConcurrency=1
|
||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||
BackgroundJobs__ExamArrangementConcurrency=1
|
||||
RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||
RabbitMq__Port=5671
|
||||
RabbitMq__UserName=jiaowu
|
||||
|
||||
@@ -20,6 +20,7 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
|
||||
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
|
||||
RabbitMq__HostName: rabbitmq
|
||||
RabbitMq__Port: "5672"
|
||||
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
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;
|
||||
@@ -19,7 +20,6 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class ExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
ExamArrangementService examArrangementService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
@@ -82,6 +82,8 @@ public sealed class ExamsController(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,暂时不能删除。");
|
||||
var plan = await db.ExamPlans
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
@@ -343,6 +345,8 @@ public sealed class ExamsController(
|
||||
CreateExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
||||
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
@@ -386,6 +390,8 @@ public sealed class ExamsController(
|
||||
CreateExamSessionsBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
||||
var plan = await db.ExamPlans.FindAsync([planId], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
@@ -471,6 +477,8 @@ public sealed class ExamsController(
|
||||
CreateExamSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
||||
var session = await db.ExamSessions
|
||||
.Include(x => x.ExamPlan)
|
||||
.Include(x => x.Invigilators)
|
||||
@@ -515,6 +523,8 @@ public sealed class ExamsController(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
||||
var session = await db.ExamSessions.Include(x => x.ExamPlan)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
@@ -532,6 +542,8 @@ public sealed class ExamsController(
|
||||
RemoveExamSessionsBatchRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。");
|
||||
var sessionIds = request.SessionIds.Distinct().ToArray();
|
||||
if (sessionIds.Length == 0)
|
||||
return ValidationProblem("请至少选择一个考试场次。");
|
||||
@@ -580,16 +592,108 @@ public sealed class ExamsController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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,
|
||||
request.SessionIds,
|
||||
request.AssignClassrooms,
|
||||
request.AssignInvigilators,
|
||||
cancellationToken);
|
||||
if (!result.Success)
|
||||
return ConflictProblem(result.Message);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
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.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)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||
return ConflictProblem("考试计划正在后台编排,完成后才能发布。");
|
||||
var plan = await db.ExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
@@ -1401,6 +1507,49 @@ public sealed class ExamsController(
|
||||
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.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,
|
||||
CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -213,6 +213,34 @@ public sealed class OperationsController(
|
||||
.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
|
||||
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
@@ -435,6 +463,11 @@ public sealed class OperationsController(
|
||||
.CountAsync(
|
||||
x => x.Status == MakeupExamAutoJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken) +
|
||||
await db.ExamArrangementJobs.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.Status == ExamArrangementJobStatus.Failed &&
|
||||
x.CreatedAt >= from,
|
||||
cancellationToken);
|
||||
|
||||
private ActionResult? ValidatePaging(int page, int pageSize)
|
||||
@@ -462,6 +495,7 @@ public sealed class OperationsController(
|
||||
"AutomaticSchedule" => "AutomaticSchedule",
|
||||
"SchedulePublish" => "SchedulePublish",
|
||||
"MakeupExamAuto" => "MakeupExamAuto",
|
||||
"ExamArrangement" => "ExamArrangement",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,40 @@ public sealed class ExamPlan : EntityBase
|
||||
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 Guid ExamPlanId { get; set; }
|
||||
|
||||
@@ -30,7 +30,8 @@ public enum BackgroundJobKind
|
||||
{
|
||||
AutomaticSchedule = 1,
|
||||
SchedulePublish = 2,
|
||||
MakeupExamAuto = 3
|
||||
MakeupExamAuto = 3,
|
||||
ExamArrangement = 4
|
||||
}
|
||||
|
||||
public enum BackgroundJobOutboxState
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class BackgroundJobOptions
|
||||
public int AutomaticScheduleConcurrency { get; set; } = 1;
|
||||
public int SchedulePublishConcurrency { 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 QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||
public bool UseQuorumQueues { get; set; } = true;
|
||||
@@ -29,6 +30,7 @@ public sealed class BackgroundJobOptions
|
||||
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
|
||||
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
|
||||
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
||||
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +103,15 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
message.JobId == x.Id))
|
||||
.Select(x => x.Id)
|
||||
.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
|
||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||
@@ -110,6 +119,8 @@ public sealed class BackgroundJobOutboxPublisher(
|
||||
(BackgroundJobKind.SchedulePublish, id)))
|
||||
.Concat(makeupJobs.Select(id =>
|
||||
(BackgroundJobKind.MakeupExamAuto, id)))
|
||||
.Concat(arrangementJobs.Select(id =>
|
||||
(BackgroundJobKind.ExamArrangement, id)))
|
||||
.ToList();
|
||||
foreach (var (kind, jobId) in missingKeys)
|
||||
{
|
||||
|
||||
@@ -85,6 +85,11 @@ public sealed class BackgroundJobRunner(
|
||||
.GetRequiredService<MakeupExamAutoJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
case BackgroundJobKind.ExamArrangement:
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ExamArrangementJobProcessor>()
|
||||
.ProcessAsync(message.JobId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported background job kind '{message.JobKind}'.");
|
||||
@@ -259,6 +264,23 @@ public sealed class BackgroundJobRunner(
|
||||
.SetProperty(x => x.CompletedAt, completedAt),
|
||||
cancellationToken);
|
||||
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:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(message.JobKind),
|
||||
|
||||
@@ -316,7 +316,8 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
[
|
||||
BackgroundJobKind.AutomaticSchedule,
|
||||
BackgroundJobKind.SchedulePublish,
|
||||
BackgroundJobKind.MakeupExamAuto
|
||||
BackgroundJobKind.MakeupExamAuto,
|
||||
BackgroundJobKind.ExamArrangement
|
||||
];
|
||||
|
||||
public static async Task<IConnection> CreateConnectionAsync(
|
||||
@@ -412,6 +413,7 @@ internal static class RabbitMqBackgroundJobTopology
|
||||
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
|
||||
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
||||
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
||||
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||
_ => 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<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||
Set<ExamArrangementJob>();
|
||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||
Set<ExamSessionInvigilator>();
|
||||
@@ -783,6 +785,19 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.HasOne(x => x.Teacher).WithMany()
|
||||
.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 =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
|
||||
@@ -365,6 +365,14 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
|
||||
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
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
@@ -1986,6 +1994,17 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""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 =
|
||||
[
|
||||
"""
|
||||
|
||||
+5215
File diff suppressed because it is too large
Load Diff
+72
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -1521,6 +1521,82 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -144,6 +144,7 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
||||
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
|
||||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||
@@ -294,6 +295,7 @@ builder.Services.AddScoped<ExamArrangementService>();
|
||||
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
||||
builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||
builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"AutomaticScheduleConcurrency": 1,
|
||||
"SchedulePublishConcurrency": 1,
|
||||
"MakeupExamAutoConcurrency": 1,
|
||||
"ExamArrangementConcurrency": 1,
|
||||
"Exchange": "jiaowu.background-jobs",
|
||||
"QueuePrefix": "jiaowu.background-jobs",
|
||||
"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(
|
||||
Db,
|
||||
new ManagerDataScope(),
|
||||
new ExamArrangementService(Db),
|
||||
NoOpAppCache.Instance);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -83,7 +83,6 @@ public sealed class ExamPlanPaginationTests
|
||||
var controller = new ExamsController(
|
||||
db,
|
||||
new AllScope(),
|
||||
new ExamArrangementService(db),
|
||||
NoOpAppCache.Instance);
|
||||
|
||||
var secondPage = ReadPlan(await controller.GetPlan(
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Jiaowu.Api.Tests;
|
||||
public sealed class MySqlMigrationTests
|
||||
{
|
||||
private const string LatestMigration =
|
||||
"20260727105716_ExamRoomMixing";
|
||||
"20260727133346_ExamArrangementJobs";
|
||||
|
||||
[Fact]
|
||||
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 `ExamSeats`", 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("DEFAULT 1", script);
|
||||
Assert.DoesNotContain("0001-01-01", script);
|
||||
|
||||
@@ -288,7 +288,6 @@ public sealed class ScheduleSettingsControllerTests
|
||||
var examTimeSlotsResult = await new ExamsController(
|
||||
db,
|
||||
null!,
|
||||
null!,
|
||||
NoOpAppCache.Instance)
|
||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||
var examTimeSlotsOk =
|
||||
@@ -300,7 +299,6 @@ public sealed class ScheduleSettingsControllerTests
|
||||
var makeupTimeSlotsResult = await new MakeupExamsController(
|
||||
db,
|
||||
null!,
|
||||
null!,
|
||||
null!)
|
||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||
var makeupTimeSlotsOk =
|
||||
|
||||
@@ -250,7 +250,6 @@ public sealed class TeachingWorkflowRosterTests
|
||||
var exams = new ExamsController(
|
||||
db,
|
||||
managerScope,
|
||||
new ExamArrangementService(db),
|
||||
NoOpAppCache.Instance);
|
||||
var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None);
|
||||
Assert.IsType<NoContentResult>(publishResult);
|
||||
@@ -286,14 +285,12 @@ public sealed class TeachingWorkflowRosterTests
|
||||
var studentExams = new ExamsController(
|
||||
db,
|
||||
new StudentDataScope(studentUserId),
|
||||
new ExamArrangementService(db),
|
||||
NoOpAppCache.Instance);
|
||||
Assert.Single(ReadItems(
|
||||
await studentExams.GetMySchedule(CancellationToken.None)));
|
||||
var teacherExams = new ExamsController(
|
||||
db,
|
||||
scope,
|
||||
new ExamArrangementService(db),
|
||||
NoOpAppCache.Instance);
|
||||
Assert.Single(ReadItems(
|
||||
await teacherExams.GetMySchedule(CancellationToken.None)));
|
||||
|
||||
+104
-8
@@ -1,5 +1,5 @@
|
||||
<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 http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
@@ -26,6 +26,14 @@ const buildings = ref<any[]>([])
|
||||
const timeSlots = ref<any[]>([])
|
||||
const loading = 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 removeLoading = ref(false)
|
||||
const deletePlanLoading = ref(false)
|
||||
@@ -127,10 +135,14 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function selectPlan(id: string) {
|
||||
stopArrangementPolling()
|
||||
sessionPage.value = 1
|
||||
selectedSessionIds.value = []
|
||||
await loadSelectedPlan(id)
|
||||
await loadPlanResources(selected.value.academicTermId)
|
||||
await Promise.all([
|
||||
loadPlanResources(selected.value.academicTermId),
|
||||
restoreArrangementJob(id),
|
||||
])
|
||||
}
|
||||
async function loadSelectedPlan(id: string) {
|
||||
sessionLoading.value = true
|
||||
@@ -359,12 +371,75 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
|
||||
assignClassrooms: mode !== 'invigilators',
|
||||
assignInvigilators: mode !== 'rooms',
|
||||
})
|
||||
arrangementJob.value = {
|
||||
id: res.data.jobId,
|
||||
planId: selected.value.id,
|
||||
status: res.data.status,
|
||||
currentStep: '等待后台编排',
|
||||
}
|
||||
ElMessage.success(res.data.message)
|
||||
await reloadSelectedPlan()
|
||||
scheduleArrangementPoll(true)
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
} 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() {
|
||||
try {
|
||||
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布考试计划', {
|
||||
@@ -452,6 +527,7 @@ onMounted(async () => {
|
||||
await load()
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
})
|
||||
onBeforeUnmount(stopArrangementPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -487,21 +563,41 @@ onMounted(async () => {
|
||||
:disabled="planSessionTotal === 0"
|
||||
@click="exportSignInSheets"
|
||||
>导出考场签名单</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('invigilators')" :loading="arrangeLoading">一键分配监考</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="Plus" @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'" @click="autoArrange('rooms')" :loading="arrangeLoading" :disabled="arrangementJobRunning">一键分配考场</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" :disabled="arrangementJobRunning">一键完成</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" :disabled="arrangementJobRunning" @click="publishPlan">发布计划</el-button>
|
||||
<el-button
|
||||
v-if="selected.status === 'Draft'"
|
||||
type="danger"
|
||||
plain
|
||||
:icon="Delete"
|
||||
:loading="deletePlanLoading"
|
||||
:disabled="arrangementJobRunning"
|
||||
@click="deleteDraftPlan"
|
||||
>删除草稿</el-button>
|
||||
</div>
|
||||
</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">
|
||||
<el-input
|
||||
v-model="sessionFilter.keyword"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -25,6 +25,14 @@ const buildings = ref<any[]>([])
|
||||
const timeSlots = ref<any[]>([])
|
||||
const loading = 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 sessionDialog = ref(false)
|
||||
const editingSession = ref<any | null>(null)
|
||||
@@ -119,9 +127,13 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function selectPlan(id: string) {
|
||||
stopArrangementPolling()
|
||||
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
|
||||
selectedSessionIds.value = []
|
||||
await loadPlanResources(selected.value.academicTermId)
|
||||
await Promise.all([
|
||||
loadPlanResources(selected.value.academicTermId),
|
||||
restoreArrangementJob(id),
|
||||
])
|
||||
}
|
||||
async function loadPlanResources(academicTermId: string) {
|
||||
const [taskRes, slotRes] = await Promise.all([
|
||||
@@ -246,12 +258,77 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
|
||||
assignClassrooms: mode !== 'invigilators',
|
||||
assignInvigilators: mode !== 'rooms',
|
||||
})
|
||||
arrangementJob.value = {
|
||||
id: res.data.jobId,
|
||||
planId: selected.value.id,
|
||||
status: res.data.status,
|
||||
currentStep: '等待后台编排',
|
||||
}
|
||||
ElMessage.success(res.data.message)
|
||||
await selectPlan(selected.value.id)
|
||||
scheduleArrangementPoll(true)
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
} 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() {
|
||||
try {
|
||||
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布补考计划', {
|
||||
@@ -431,6 +508,10 @@ onMounted(async () => {
|
||||
await load()
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
stopArrangementPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -460,15 +541,34 @@ onMounted(async () => {
|
||||
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个补考场次</p>
|
||||
</div>
|
||||
<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'" @click="autoArrange('rooms')" :loading="arrangeLoading">一键分配考场</el-button>
|
||||
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading">一键分配监考</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="Plus" @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'" :icon="MagicStick" type="success" :disabled="arrangementJobRunning" @click="startAutoCreate">一键生成</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" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键分配监考</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" :disabled="arrangementJobRunning" @click="openSession()">批量安排场次</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>
|
||||
</div>
|
||||
</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">
|
||||
<el-alert :title="autoJobMessage" type="info" :closable="false">
|
||||
<template #default>
|
||||
|
||||
@@ -586,6 +586,7 @@ onMounted(refreshAll)
|
||||
<el-option label="自动排课" value="AutomaticSchedule" />
|
||||
<el-option label="课表发布" value="SchedulePublish" />
|
||||
<el-option label="补考自动安排" value="MakeupExamAuto" />
|
||||
<el-option label="考试与补考编排" value="ExamArrangement" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="jobFilter.range"
|
||||
|
||||
Reference in New Issue
Block a user