已改成后台“检查并发布”任务,原来的超时主要就是同步逐条校验导致的。

发布接口立即返回 202 Accepted,页面轮询显示检查进度。
校验改为批量读取,减少重复数据库查询。
检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。
同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。
发布期间锁定编辑、自动排课等冲突操作。
已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
2026-07-25 12:07:03 +08:00 Unverified
parent fdca6a3edf
commit b015514115
12 changed files with 4116 additions and 81 deletions
+149 -70
View File
@@ -16,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/schedules")]
public sealed class SchedulesController(
AppDbContext db,
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase
AutomaticScheduleJobQueue automaticScheduleJobQueue,
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
@@ -130,8 +131,8 @@ public sealed class SchedulesController(
{
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound();
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(id, cancellationToken))
return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
if (!await db.AcademicTerms.AnyAsync(
@@ -151,8 +152,8 @@ public sealed class SchedulesController(
CloneSchedulePlanRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(id, cancellationToken))
return ScheduleJobRunningProblem();
var source = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -185,8 +186,8 @@ public sealed class SchedulesController(
{
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound();
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(id, cancellationToken))
return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("仅草稿排课版本可以删除。");
db.SchedulePlans.Remove(plan);
@@ -194,76 +195,79 @@ public sealed class SchedulesController(
}
[HttpPost("plans/{id:guid}/publish")]
public async Task<ActionResult> PublishPlan(Guid id, CancellationToken cancellationToken)
public async Task<ActionResult<SchedulePublishJobResponse>> PublishPlan(
Guid id,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem();
var plan = await db.SchedulePlans
.Include(x => x.Entries)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Entries)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
var plan = await db.SchedulePlans.AsNoTracking()
.Select(x => new
{
x.Id,
x.AcademicTermId,
x.Status,
HasEntries = x.Entries.Any()
})
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("只有草稿排课版本可以发布。");
if (plan.Entries.Count == 0)
if (!plan.HasEntries)
return ConflictProblem("排课版本中至少需要一条课表安排。");
foreach (var entry in plan.Entries)
{
var validation = await ValidateEntryAsync(
plan,
entry.Id,
new ScheduleEntryRequest(
entry.TeachingTaskId,
entry.ClassroomId,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Notes),
var existing = await db.SchedulePublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.ActiveAcademicTermId == plan.AcademicTermId,
cancellationToken);
if (validation is not null) return validation;
if (existing is not null)
{
if (existing.SchedulePlanId != id)
return ConflictProblem(
"同一学期已有课表正在后台检查并发布,请等待任务完成。");
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = existing.Id },
ToResponse(existing));
}
var requiredTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
.ToListAsync(cancellationToken);
var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredTasks.FirstOrDefault(task =>
!scheduledHours.TryGetValue(task.Id, out var hours) ||
hours < task.WeeklyHours);
if (incomplete is not null)
return ConflictProblem(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。");
var requestedByUserId = CurrentUserId();
var job = new SchedulePublishJob
{
SchedulePlanId = id,
AcademicTermId = plan.AcademicTermId,
ActiveAcademicTermId = plan.AcademicTermId,
RequestedByUserId = requestedByUserId,
CurrentStep = "等待后台检查"
};
db.SchedulePublishJobs.Add(job);
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.Entry(job).State = EntityState.Detached;
existing = await db.SchedulePublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.ActiveAcademicTermId == plan.AcademicTermId,
cancellationToken);
if (existing is null)
throw;
if (existing.SchedulePlanId != id)
return ConflictProblem(
"同一学期已有课表正在后台检查并发布,请等待任务完成。");
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = existing.Id },
ToResponse(existing));
}
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
if (conflict is not null) return ConflictProblem(conflict);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var previous = await db.SchedulePlans
.Where(x =>
x.Id != plan.Id &&
x.AcademicTermId == plan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.ToListAsync(cancellationToken);
foreach (var oldPlan in previous) oldPlan.Status = SchedulePlanStatus.Archived;
plan.Status = SchedulePlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
schedulePublishJobQueue.Enqueue(job.Id);
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = job.Id },
ToResponse(job));
}
[HttpPost("plans/{planId:guid}/entries")]
@@ -272,8 +276,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
@@ -290,6 +294,8 @@ public sealed class SchedulesController(
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
if (await HasActiveSchedulePublishJobAsync(planId, cancellationToken))
return SchedulePublishRunningProblem();
var existing = await db.AutomaticScheduleJobs.AsNoTracking()
.FirstOrDefaultAsync(
@@ -364,6 +370,30 @@ public sealed class SchedulesController(
return Ok(job is null ? null : ToResponse(job));
}
[HttpGet("publish-jobs/{jobId:guid}")]
public async Task<ActionResult<SchedulePublishJobResponse>>
GetSchedulePublishJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.SchedulePublishJobs.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
return job is null ? NotFound() : Ok(ToResponse(job));
}
[HttpGet("plans/{planId:guid}/publish-job")]
public async Task<ActionResult<SchedulePublishJobResponse?>>
GetLatestSchedulePublishJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.SchedulePublishJobs.AsNoTracking()
.Where(x => x.SchedulePlanId == planId)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return Ok(job is null ? null : ToResponse(job));
}
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> UpdateEntry(
Guid planId,
@@ -371,8 +401,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var entry = await db.ScheduleEntries
@@ -400,8 +430,8 @@ public sealed class SchedulesController(
Guid entryId,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return ScheduleJobRunningProblem();
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var entry = await db.ScheduleEntries
.FirstOrDefaultAsync(
@@ -426,6 +456,19 @@ public sealed class SchedulesController(
x => x.ActiveSchedulePlanId == planId,
cancellationToken);
private Task<bool> HasActiveSchedulePublishJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.SchedulePublishJobs.AsNoTracking().AnyAsync(
x => x.SchedulePlanId == planId && x.ActiveAcademicTermId != null,
cancellationToken);
private async Task<bool> HasActiveScheduleJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken) ||
await HasActiveSchedulePublishJobAsync(planId, cancellationToken);
private async Task<ActionResult?> ValidateEntryAsync(
SchedulePlan plan,
Guid? entryId,
@@ -585,6 +628,12 @@ public sealed class SchedulesController(
private ActionResult AutomaticScheduleRunningProblem() =>
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
private ActionResult SchedulePublishRunningProblem() =>
ConflictProblem("课表正在后台检查并发布,请等待任务完成后再修改。");
private ActionResult ScheduleJobRunningProblem() =>
ConflictProblem("后台任务正在处理该排课版本,请等待任务完成后再修改。");
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
{
IReadOnlyList<string> messages = [];
@@ -608,6 +657,24 @@ public sealed class SchedulesController(
job.CompletedAt);
}
private static SchedulePublishJobResponse ToResponse(SchedulePublishJob job) =>
new(
job.Id,
job.SchedulePlanId,
job.Status,
job.TotalSteps,
job.CompletedSteps,
job.CurrentStep,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt);
private Guid? CurrentUserId() =>
Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)
? userId
: null;
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -646,3 +713,15 @@ public sealed record AutomaticScheduleJobResponse(
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? CompletedAt);
public sealed record SchedulePublishJobResponse(
Guid Id,
Guid SchedulePlanId,
SchedulePublishJobStatus Status,
int TotalSteps,
int CompletedSteps,
string? CurrentStep,
string? ErrorMessage,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? CompletedAt);