Files
Academic-Affairs-System/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementJobs.cs
T
biss 9e0d4062a4 自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
任务参数、状态和结果持久化到 MySQL。
接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
服务重启后可恢复未完成任务。
前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
编排期间禁止修改、删除或发布对应计划。
补考原有“一键生成”保留,并与编排任务互斥。
运维后台增加“考试与补考编排”失败任务筛选。
2026-07-27 21:40:14 +08:00

127 lines
4.5 KiB
C#

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);
}
}