主要改动:

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