自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
任务参数、状态和结果持久化到 MySQL。 接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。 服务重启后可恢复未完成任务。 前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。 编排期间禁止修改、删除或发布对应计划。 补考原有“一键生成”保留,并与编排任务互斥。 运维后台增加“考试与补考编排”失败任务筛选。
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user