主要改动:

自动排课接口立即返回 202 + jobId
后台服务独立执行,不受浏览器关闭或前端超时影响
进度、成功、失败状态持久化到数据库
服务重启后自动恢复排队中或中断的任务
同一草稿禁止重复提交后台任务
运行期间禁止编辑、发布或删除该课表
排课结果和任务成功状态在同一事务中提交
前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
2026-07-24 21:55:07 +08:00 Unverified
parent 49b550560a
commit d67a07f23e
14 changed files with 4011 additions and 32 deletions
@@ -36,6 +36,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeachingTaskScheduleConstraint>();
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
Set<TeachingTaskAllowedClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
@@ -381,6 +383,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AutomaticScheduleJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.HasIndex(x => x.ActiveSchedulePlanId).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 =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -23,6 +23,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260724_14_scheduling_optimization";
private const string TeacherCourseApplicationsMigration =
"20260724_15_teacher_course_applications";
private const string AutomaticScheduleJobsMigration =
"20260724_16_automatic_schedule_jobs";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -135,6 +137,18 @@ public sealed class DevelopmentSqliteMigrator(
TeacherCourseApplicationsMigration,
teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements,
cancellationToken);
var automaticScheduleJobsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'AutomaticScheduleJobs'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AutomaticScheduleJobsMigration,
automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1022,4 +1036,49 @@ public sealed class DevelopmentSqliteMigrator(
ON "TeacherCourseApplications" ("ReviewedByUserId");
"""
];
private static readonly string[] AutomaticScheduleJobStatements =
[
"""
CREATE TABLE "AutomaticScheduleJobs" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AutomaticScheduleJobs" PRIMARY KEY,
"SchedulePlanId" TEXT NOT NULL,
"ActiveSchedulePlanId" TEXT NULL,
"RequestedByUserId" TEXT NULL,
"Status" INTEGER NOT NULL,
"TotalTasks" INTEGER NOT NULL,
"ProcessedTasks" INTEGER NOT NULL,
"CreatedEntries" INTEGER NOT NULL,
"CompletedTasks" INTEGER NOT NULL,
"MessagesJson" TEXT NULL,
"ErrorMessage" TEXT NULL,
"StartedAt" TEXT NULL,
"CompletedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_AutomaticScheduleJobs_SchedulePlans"
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_AutomaticScheduleJobs_RequestedBy"
FOREIGN KEY ("RequestedByUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE SET NULL
);
""",
"""
CREATE UNIQUE INDEX "IX_AutomaticScheduleJobs_ActiveSchedulePlanId"
ON "AutomaticScheduleJobs" ("ActiveSchedulePlanId");
""",
"""
CREATE INDEX "IX_AutomaticScheduleJobs_SchedulePlanId_CreatedAt"
ON "AutomaticScheduleJobs" ("SchedulePlanId", "CreatedAt");
""",
"""
CREATE INDEX "IX_AutomaticScheduleJobs_Status_CreatedAt"
ON "AutomaticScheduleJobs" ("Status", "CreatedAt");
""",
"""
CREATE INDEX "IX_AutomaticScheduleJobs_RequestedByUserId"
ON "AutomaticScheduleJobs" ("RequestedByUserId");
"""
];
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AutomaticScheduleJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AutomaticScheduleJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
ActiveSchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
Status = table.Column<int>(type: "int", nullable: false),
TotalTasks = table.Column<int>(type: "int", nullable: false),
ProcessedTasks = table.Column<int>(type: "int", nullable: false),
CreatedEntries = table.Column<int>(type: "int", nullable: false),
CompletedTasks = table.Column<int>(type: "int", nullable: false),
MessagesJson = table.Column<string>(type: "longtext", 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_AutomaticScheduleJobs", x => x.Id);
table.ForeignKey(
name: "FK_AutomaticScheduleJobs_AspNetUsers_RequestedByUserId",
column: x => x.RequestedByUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_AutomaticScheduleJobs_SchedulePlans_SchedulePlanId",
column: x => x.SchedulePlanId,
principalTable: "SchedulePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AutomaticScheduleJobs_ActiveSchedulePlanId",
table: "AutomaticScheduleJobs",
column: "ActiveSchedulePlanId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AutomaticScheduleJobs_RequestedByUserId",
table: "AutomaticScheduleJobs",
column: "RequestedByUserId");
migrationBuilder.CreateIndex(
name: "IX_AutomaticScheduleJobs_SchedulePlanId_CreatedAt",
table: "AutomaticScheduleJobs",
columns: new[] { "SchedulePlanId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AutomaticScheduleJobs_Status_CreatedAt",
table: "AutomaticScheduleJobs",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AutomaticScheduleJobs");
}
}
}
@@ -129,6 +129,69 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("AdministrativeClasses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("ActiveSchedulePlanId")
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CompletedTasks")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CreatedEntries")
.HasColumnType("int");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
b.Property<string>("MessagesJson")
.HasColumnType("longtext");
b.Property<int>("ProcessedTasks")
.HasColumnType("int");
b.Property<Guid?>("RequestedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("SchedulePlanId")
.HasColumnType("char(36)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("TotalTasks")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("ActiveSchedulePlanId")
.IsUnique();
b.HasIndex("RequestedByUserId");
b.HasIndex("SchedulePlanId", "CreatedAt");
b.HasIndex("Status", "CreatedAt");
b.ToTable("AutomaticScheduleJobs");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
{
b.Property<Guid>("Id")
@@ -2113,6 +2176,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Major");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b =>
{
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("RequestedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan")
.WithMany()
.HasForeignKey("SchedulePlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SchedulePlan");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
@@ -6,8 +6,15 @@ namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class AutomaticScheduleGenerator(AppDbContext db)
{
public Task<AutomaticScheduleResult> GenerateAsync(
SchedulePlan plan,
CancellationToken cancellationToken) =>
GenerateAsync(plan, null, true, cancellationToken);
public async Task<AutomaticScheduleResult> GenerateAsync(
SchedulePlan plan,
Func<AutomaticScheduleProgress, CancellationToken, Task>? reportProgress,
bool saveChanges,
CancellationToken cancellationToken)
{
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
@@ -50,9 +57,18 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var created = 0;
var completedTasks = 0;
var processedTasks = 0;
var messages = new List<string>();
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, 0, 0, 0),
cancellationToken);
}
foreach (var task in tasks)
{
cancellationToken.ThrowIfCancellationRequested();
constraints.TryGetValue(task.Id, out var constraint);
var scheduledHours = entries
.Where(x => x.TeachingTaskId == task.Id)
@@ -61,6 +77,13 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
if (remainingHours == 0)
{
completedTasks++;
processedTasks++;
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
}
continue;
}
@@ -74,7 +97,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
desiredBlock,
activePeriods,
classrooms,
entries);
entries,
cancellationToken);
if (candidate is null && desiredBlock > 1)
{
candidate = FindBestCandidate(
@@ -84,7 +108,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
1,
activePeriods,
classrooms,
entries);
entries,
cancellationToken);
}
if (candidate is null) break;
@@ -103,11 +128,24 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
}
processedTasks++;
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
}
}
if (created > 0)
if (created > 0 && saveChanges)
await db.SaveChangesAsync(cancellationToken);
return new(created, completedTasks, messages);
return new(
created,
completedTasks,
messages,
tasks.Count,
processedTasks);
}
private static ScheduleEntry? FindBestCandidate(
@@ -117,7 +155,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
int periodCount,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries)
IReadOnlyList<ScheduleEntry> entries,
CancellationToken cancellationToken)
{
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
@@ -129,8 +168,10 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var candidates = new List<(ScheduleEntry Entry, int Score)>();
foreach (var day in allowedDays)
{
cancellationToken.ThrowIfCancellationRequested();
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
{
cancellationToken.ThrowIfCancellationRequested();
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
continue;
@@ -145,7 +186,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
TeachingTaskId = task.Id,
TeachingTask = task,
ClassroomId = room?.Id,
Classroom = room,
DayOfWeek = day,
StartPeriod = start,
PeriodCount = periodCount,
@@ -216,4 +256,12 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
public sealed record AutomaticScheduleResult(
int CreatedEntries,
int CompletedTasks,
IReadOnlyList<string> Messages);
IReadOnlyList<string> Messages,
int TotalTasks = 0,
int ProcessedTasks = 0);
public sealed record AutomaticScheduleProgress(
int TotalTasks,
int ProcessedTasks,
int CreatedEntries,
int CompletedTasks);
@@ -0,0 +1,251 @@
using System.Diagnostics;
using System.Text.Json;
using System.Threading.Channels;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class AutomaticScheduleJobQueue
{
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
public void Enqueue(Guid jobId)
{
if (!_channel.Writer.TryWrite(jobId))
throw new InvalidOperationException("自动排课任务队列当前不可用。");
}
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
public sealed class AutomaticScheduleJobWorker(
IServiceScopeFactory scopeFactory,
AutomaticScheduleJobQueue queue,
ILogger<AutomaticScheduleJobWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RecoverInterruptedJobsAsync(stoppingToken);
try
{
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<AutomaticScheduleJobProcessor>();
await processor.ProcessAsync(jobId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Unexpected failure while dispatching automatic schedule job {JobId}.",
jobId);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Automatic schedule job worker is stopping.");
}
}
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var jobs = await db.AutomaticScheduleJobs
.Where(x =>
x.Status == AutomaticScheduleJobStatus.Queued ||
x.Status == AutomaticScheduleJobStatus.Running)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
foreach (var job in jobs)
{
job.Status = AutomaticScheduleJobStatus.Queued;
job.ActiveSchedulePlanId = job.SchedulePlanId;
job.StartedAt = null;
job.CompletedAt = null;
job.ErrorMessage = null;
}
if (jobs.Count > 0)
await db.SaveChangesAsync(cancellationToken);
foreach (var job in jobs)
queue.Enqueue(job.Id);
if (jobs.Count > 0)
{
logger.LogInformation(
"Recovered {JobCount} queued or interrupted automatic schedule jobs.",
jobs.Count);
}
}
}
public sealed class AutomaticScheduleJobProcessor(
AppDbContext db,
AutomaticScheduleGenerator generator,
IServiceScopeFactory scopeFactory,
ILogger<AutomaticScheduleJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.AutomaticScheduleJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is AutomaticScheduleJobStatus.Succeeded
or AutomaticScheduleJobStatus.Failed)
{
return;
}
var plan = await db.SchedulePlans.FirstOrDefaultAsync(
x => x.Id == job.SchedulePlanId,
stoppingToken);
if (plan is null || plan.Status != SchedulePlanStatus.Draft)
throw new InvalidOperationException("排课草稿不存在或已不允许修改。");
job.Status = AutomaticScheduleJobStatus.Running;
job.StartedAt = DateTime.UtcNow;
job.CompletedAt = null;
job.ErrorMessage = null;
job.TotalTasks = await db.TeachingTasks.CountAsync(
x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published,
stoppingToken);
job.ProcessedTasks = 0;
job.CreatedEntries = 0;
job.CompletedTasks = 0;
job.MessagesJson = null;
await db.SaveChangesAsync(stoppingToken);
var progressClock = Stopwatch.StartNew();
var lastPersistedTaskCount = 0;
async Task ReportProgress(
AutomaticScheduleProgress progress,
CancellationToken cancellationToken)
{
var isFinal = progress.ProcessedTasks >= progress.TotalTasks;
var hasBatch = progress.ProcessedTasks - lastPersistedTaskCount >= 5;
if (!isFinal && !hasBatch && progressClock.ElapsedMilliseconds < 500)
return;
await PersistProgressAsync(jobId, progress, cancellationToken);
lastPersistedTaskCount = progress.ProcessedTasks;
progressClock.Restart();
}
var result = await generator.GenerateAsync(
plan,
ReportProgress,
false,
stoppingToken);
job.Status = AutomaticScheduleJobStatus.Succeeded;
job.ActiveSchedulePlanId = null;
job.TotalTasks = result.TotalTasks;
job.ProcessedTasks = result.ProcessedTasks;
job.CreatedEntries = result.CreatedEntries;
job.CompletedTasks = result.CompletedTasks;
job.MessagesJson = JsonSerializer.Serialize(result.Messages);
job.CompletedAt = DateTime.UtcNow;
await using var transaction =
await db.Database.BeginTransactionAsync(stoppingToken);
await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
logger.LogInformation(
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
job.Id,
result.CreatedEntries);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Automatic schedule job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Automatic schedule job {JobId} failed.",
jobId);
await MarkFailedAsync(jobId, exception);
}
}
private async Task PersistProgressAsync(
Guid jobId,
AutomaticScheduleProgress progress,
CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var progressDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var updatedAt = DateTime.UtcNow;
await progressDb.AutomaticScheduleJobs
.Where(x =>
x.Id == jobId &&
x.Status == AutomaticScheduleJobStatus.Running)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(x => x.TotalTasks, progress.TotalTasks)
.SetProperty(x => x.ProcessedTasks, progress.ProcessedTasks)
.SetProperty(x => x.CreatedEntries, progress.CreatedEntries)
.SetProperty(x => x.CompletedTasks, progress.CompletedTasks)
.SetProperty(x => x.UpdatedAt, updatedAt),
cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not persist progress for automatic schedule job {JobId}.",
jobId);
}
}
private async Task MarkFailedAsync(Guid jobId, Exception exception)
{
db.ChangeTracker.Clear();
var job = await db.AutomaticScheduleJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
var message = exception.GetBaseException().Message;
job.Status = AutomaticScheduleJobStatus.Failed;
job.ActiveSchedulePlanId = null;
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}