已改成后台“检查并发布”任务,原来的超时主要就是同步逐条校验导致的。
发布接口立即返回 202 Accepted,页面轮询显示检查进度。 校验改为批量读取,减少重复数据库查询。 检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。 同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。 发布期间锁定编辑、自动排课等冲突操作。 已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user