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

发布接口立即返回 202 Accepted,页面轮询显示检查进度。
校验改为批量读取,减少重复数据库查询。
检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。
同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。
发布期间锁定编辑、自动排课等冲突操作。
已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
biss committed 2026-07-25 12:07:03 +08:00
1 parent fdca6a3edf
commit b015514115
12 files changed
+4116 -81

No files matched your search

+149 -70
View File
@@ -16,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/schedules")] [Route("api/schedules")]
public sealed class SchedulesController( public sealed class SchedulesController(
AppDbContext db, AppDbContext db,
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase AutomaticScheduleJobQueue automaticScheduleJobQueue,
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
{ {
private const string ManagementRoles = private const string ManagementRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -130,8 +131,8 @@ public sealed class SchedulesController(
{ {
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) if (await HasActiveScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft) if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("已发布或已归档的排课版本不可直接修改。"); return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
@@ -151,8 +152,8 @@ public sealed class SchedulesController(
CloneSchedulePlanRequest request, CloneSchedulePlanRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) if (await HasActiveScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
var source = await db.SchedulePlans.AsNoTracking() var source = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries) .Include(x => x.Entries)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -185,8 +186,8 @@ public sealed class SchedulesController(
{ {
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) if (await HasActiveScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft) if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("仅草稿排课版本可以删除。"); return ConflictProblem("仅草稿排课版本可以删除。");
db.SchedulePlans.Remove(plan); db.SchedulePlans.Remove(plan);
@@ -194,76 +195,79 @@ public sealed class SchedulesController(
} }
[HttpPost("plans/{id:guid}/publish")] [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)) if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return AutomaticScheduleRunningProblem();
var plan = await db.SchedulePlans var plan = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries) .Select(x => new
.ThenInclude(x => x.TeachingTask) {
.ThenInclude(x => x!.Teachers) x.Id,
.Include(x => x.Entries) x.AcademicTermId,
.ThenInclude(x => x.TeachingTask) x.Status,
.ThenInclude(x => x!.Classes) HasEntries = x.Entries.Any()
})
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != SchedulePlanStatus.Draft) if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("只有草稿排课版本可以发布。"); return ConflictProblem("只有草稿排课版本可以发布。");
if (plan.Entries.Count == 0) if (!plan.HasEntries)
return ConflictProblem("排课版本中至少需要一条课表安排。"); return ConflictProblem("排课版本中至少需要一条课表安排。");
foreach (var entry in plan.Entries) var existing = await db.SchedulePublishJobs.AsNoTracking()
{ .FirstOrDefaultAsync(
var validation = await ValidateEntryAsync( x => x.ActiveAcademicTermId == plan.AcademicTermId,
plan,
entry.Id,
new ScheduleEntryRequest(
entry.TeachingTaskId,
entry.ClassroomId,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Notes),
cancellationToken); 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() var requestedByUserId = CurrentUserId();
.Where(x => var job = new SchedulePublishJob
x.AcademicTermId == plan.AcademicTermId && {
x.Status == TeachingTaskStatus.Published && SchedulePlanId = id,
x.SchedulingMode == TeachingTaskSchedulingMode.Standard) AcademicTermId = plan.AcademicTermId,
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) ActiveAcademicTermId = plan.AcademicTermId,
.ToListAsync(cancellationToken); RequestedByUserId = requestedByUserId,
var scheduledHours = plan.Entries CurrentStep = "等待后台检查"
.GroupBy(x => x.TeachingTaskId) };
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); db.SchedulePublishJobs.Add(job);
var incomplete = requiredTasks.FirstOrDefault(task => try
!scheduledHours.TryGetValue(task.Id, out var hours) || {
hours < task.WeeklyHours);
if (incomplete is not null)
return ConflictProblem(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。");
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 db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); }
return NoContent(); 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));
}
schedulePublishJobQueue.Enqueue(job.Id);
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = job.Id },
ToResponse(job));
} }
[HttpPost("plans/{planId:guid}/entries")] [HttpPost("plans/{planId:guid}/entries")]
@@ -272,8 +276,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request, ScheduleEntryRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken); var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken); var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
@@ -290,6 +294,8 @@ public sealed class SchedulesController(
{ {
var plan = await DraftPlanAsync(planId, cancellationToken); var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (await HasActiveSchedulePublishJobAsync(planId, cancellationToken))
return SchedulePublishRunningProblem();
var existing = await db.AutomaticScheduleJobs.AsNoTracking() var existing = await db.AutomaticScheduleJobs.AsNoTracking()
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
@@ -364,6 +370,30 @@ public sealed class SchedulesController(
return Ok(job is null ? null : ToResponse(job)); 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}")] [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> UpdateEntry( public async Task<ActionResult> UpdateEntry(
Guid planId, Guid planId,
@@ -371,8 +401,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request, ScheduleEntryRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken); var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
var entry = await db.ScheduleEntries var entry = await db.ScheduleEntries
@@ -400,8 +430,8 @@ public sealed class SchedulesController(
Guid entryId, Guid entryId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var entry = await db.ScheduleEntries var entry = await db.ScheduleEntries
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
@@ -426,6 +456,19 @@ public sealed class SchedulesController(
x => x.ActiveSchedulePlanId == planId, x => x.ActiveSchedulePlanId == planId,
cancellationToken); 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( private async Task<ActionResult?> ValidateEntryAsync(
SchedulePlan plan, SchedulePlan plan,
Guid? entryId, Guid? entryId,
@@ -585,6 +628,12 @@ public sealed class SchedulesController(
private ActionResult AutomaticScheduleRunningProblem() => private ActionResult AutomaticScheduleRunningProblem() =>
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。"); ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
private ActionResult SchedulePublishRunningProblem() =>
ConflictProblem("课表正在后台检查并发布,请等待任务完成后再修改。");
private ActionResult ScheduleJobRunningProblem() =>
ConflictProblem("后台任务正在处理该排课版本,请等待任务完成后再修改。");
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job) private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
{ {
IReadOnlyList<string> messages = []; IReadOnlyList<string> messages = [];
@@ -608,6 +657,24 @@ public sealed class SchedulesController(
job.CompletedAt); 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) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
} }
@@ -646,3 +713,15 @@ public sealed record AutomaticScheduleJobResponse(
DateTime CreatedAt, DateTime CreatedAt,
DateTime? StartedAt, DateTime? StartedAt,
DateTime? CompletedAt); 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);
@@ -83,6 +83,23 @@ public sealed class AutomaticScheduleJob : EntityBase
public DateTime? CompletedAt { get; set; } public DateTime? CompletedAt { get; set; }
} }
public sealed class SchedulePublishJob : EntityBase
{
public Guid SchedulePlanId { get; set; }
public SchedulePlan? SchedulePlan { get; set; }
public Guid AcademicTermId { get; set; }
public Guid? ActiveAcademicTermId { get; set; }
public Guid? RequestedByUserId { get; set; }
public SchedulePublishJobStatus Status { get; set; } =
SchedulePublishJobStatus.Queued;
public int TotalSteps { get; set; } = 5;
public int CompletedSteps { get; set; }
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum SchedulePlanStatus public enum SchedulePlanStatus
{ {
Draft = 1, Draft = 1,
@@ -98,6 +115,14 @@ public enum AutomaticScheduleJobStatus
Failed = 4 Failed = 4
} }
public enum SchedulePublishJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum WeekPattern public enum WeekPattern
{ {
All = 1, All = 1,
@@ -38,6 +38,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeachingTaskAllowedClassroom>(); Set<TeachingTaskAllowedClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs => public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>(); Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
Set<SchedulePublishJob>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>(); Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings => public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
@@ -400,6 +402,24 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
}); });
builder.Entity<SchedulePublishJob>(entity =>
{
entity.Property(x => x.CurrentStep).HasMaxLength(200);
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => x.ActiveAcademicTermId).IsUnique();
entity.HasIndex(x => new { x.SchedulePlanId, x.CreatedAt });
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.RequestedByUserId);
entity.HasOne(x => x.SchedulePlan)
.WithMany()
.HasForeignKey(x => x.SchedulePlanId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ApplicationUser>()
.WithMany()
.HasForeignKey(x => x.RequestedByUserId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<CourseSelectionRound>(entity => builder.Entity<CourseSelectionRound>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff. Show more