主要改动:

自动排课接口立即返回 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
@@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -14,7 +16,7 @@ namespace Jiaowu.Api.Controllers;
[Route("api/schedules")]
public sealed class SchedulesController(
AppDbContext db,
AutomaticScheduleGenerator scheduleGenerator) : ControllerBase
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
@@ -128,6 +130,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 (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
if (!await db.AcademicTerms.AnyAsync(
@@ -147,6 +151,8 @@ public sealed class SchedulesController(
CloneSchedulePlanRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem();
var source = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -179,6 +185,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 (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("仅草稿排课版本可以删除。");
db.SchedulePlans.Remove(plan);
@@ -188,6 +196,8 @@ public sealed class SchedulesController(
[HttpPost("plans/{id:guid}/publish")]
public async Task<ActionResult> 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)
@@ -261,6 +271,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
@@ -271,14 +283,84 @@ public sealed class SchedulesController(
}
[HttpPost("plans/{planId:guid}/auto-schedule")]
public async Task<ActionResult> AutoSchedule(
public async Task<ActionResult<AutomaticScheduleJobResponse>> AutoSchedule(
Guid planId,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var result = await scheduleGenerator.GenerateAsync(plan, cancellationToken);
return Ok(result);
var existing = await db.AutomaticScheduleJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.ActiveSchedulePlanId == planId,
cancellationToken);
if (existing is not null)
{
return AcceptedAtAction(
nameof(GetAutomaticScheduleJob),
new { jobId = existing.Id },
ToResponse(existing));
}
var requestedByUserId = Guid.TryParse(
User.FindFirstValue(ClaimTypes.NameIdentifier),
out var userId)
? userId
: (Guid?)null;
var job = new AutomaticScheduleJob
{
SchedulePlanId = planId,
ActiveSchedulePlanId = planId,
RequestedByUserId = requestedByUserId
};
db.AutomaticScheduleJobs.Add(job);
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.Entry(job).State = EntityState.Detached;
existing = await db.AutomaticScheduleJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.ActiveSchedulePlanId == planId,
cancellationToken);
if (existing is null) throw;
return AcceptedAtAction(
nameof(GetAutomaticScheduleJob),
new { jobId = existing.Id },
ToResponse(existing));
}
automaticScheduleJobQueue.Enqueue(job.Id);
return AcceptedAtAction(
nameof(GetAutomaticScheduleJob),
new { jobId = job.Id },
ToResponse(job));
}
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
public async Task<ActionResult<AutomaticScheduleJobResponse>>
GetAutomaticScheduleJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.AutomaticScheduleJobs.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
return job is null ? NotFound() : Ok(ToResponse(job));
}
[HttpGet("plans/{planId:guid}/auto-schedule-job")]
public async Task<ActionResult<AutomaticScheduleJobResponse?>>
GetActiveAutomaticScheduleJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.AutomaticScheduleJobs.AsNoTracking()
.Where(x => x.ActiveSchedulePlanId == planId)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return Ok(job is null ? null : ToResponse(job));
}
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
@@ -288,6 +370,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var entry = await db.ScheduleEntries
@@ -315,6 +399,8 @@ public sealed class SchedulesController(
Guid entryId,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var entry = await db.ScheduleEntries
.FirstOrDefaultAsync(
@@ -332,6 +418,13 @@ public sealed class SchedulesController(
x => x.Id == id && x.Status == SchedulePlanStatus.Draft,
cancellationToken);
private Task<bool> HasActiveAutomaticScheduleJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.AutomaticScheduleJobs.AsNoTracking().AnyAsync(
x => x.ActiveSchedulePlanId == planId,
cancellationToken);
private async Task<ActionResult?> ValidateEntryAsync(
SchedulePlan plan,
Guid? entryId,
@@ -486,6 +579,32 @@ public sealed class SchedulesController(
Status = StatusCodes.Status409Conflict
});
private ActionResult AutomaticScheduleRunningProblem() =>
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
{
IReadOnlyList<string> messages = [];
if (!string.IsNullOrWhiteSpace(job.MessagesJson))
{
messages = JsonSerializer.Deserialize<string[]>(job.MessagesJson) ?? [];
}
return new(
job.Id,
job.SchedulePlanId,
job.Status,
job.TotalTasks,
job.ProcessedTasks,
job.CreatedEntries,
job.CompletedTasks,
messages,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt);
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -510,3 +629,17 @@ public sealed record ScheduleEntryRequest(
[Range(1, 30)] int EndWeek,
WeekPattern WeekPattern,
[MaxLength(500)] string? Notes);
public sealed record AutomaticScheduleJobResponse(
Guid Id,
Guid SchedulePlanId,
AutomaticScheduleJobStatus Status,
int TotalTasks,
int ProcessedTasks,
int CreatedEntries,
int CompletedTasks,
IReadOnlyList<string> Messages,
string? ErrorMessage,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? CompletedAt);
@@ -65,6 +65,24 @@ public sealed class TeachingTaskAllowedClassroom
public Classroom? Classroom { get; set; }
}
public sealed class AutomaticScheduleJob : EntityBase
{
public Guid SchedulePlanId { get; set; }
public SchedulePlan? SchedulePlan { get; set; }
public Guid? ActiveSchedulePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public AutomaticScheduleJobStatus Status { get; set; } =
AutomaticScheduleJobStatus.Queued;
public int TotalTasks { get; set; }
public int ProcessedTasks { get; set; }
public int CreatedEntries { get; set; }
public int CompletedTasks { get; set; }
public string? MessagesJson { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum SchedulePlanStatus
{
Draft = 1,
@@ -72,6 +90,14 @@ public enum SchedulePlanStatus
Archived = 3
}
public enum AutomaticScheduleJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum WeekPattern
{
All = 1,
@@ -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);
}
}
+3
View File
@@ -89,6 +89,9 @@ builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)