任务参数、状态和结果持久化到 MySQL。 接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。 服务重启后可恢复未完成任务。 前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。 编排期间禁止修改、删除或发布对应计划。 补考原有“一键生成”保留,并与编排任务互斥。 运维后台增加“考试与补考编排”失败任务筛选。
127 lines
4.5 KiB
C#
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);
|
|
}
|
|
}
|