自动编排接口现在立即返回 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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user