主要改动:

自动排课接口立即返回 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
@@ -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);
}
}