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

发布接口立即返回 202 Accepted,页面轮询显示检查进度。
校验改为批量读取,减少重复数据库查询。
检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。
同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。
发布期间锁定编辑、自动排课等冲突操作。
已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
2026-07-25 12:07:03 +08:00 Unverified
parent fdca6a3edf
commit b015514115
12 changed files with 4116 additions and 81 deletions
@@ -38,6 +38,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeachingTaskAllowedClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
Set<SchedulePublishJob>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
@@ -400,6 +402,24 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.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 =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -27,6 +27,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260724_16_automatic_schedule_jobs";
private const string TeachingTaskSchedulingModesMigration =
"20260725_17_teaching_task_scheduling_modes";
private const string SchedulePublishJobsMigration =
"20260725_18_schedule_publish_jobs";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -163,6 +165,18 @@ public sealed class DevelopmentSqliteMigrator(
TeachingTaskSchedulingModesMigration,
teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements,
cancellationToken);
var schedulePublishJobsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'SchedulePublishJobs'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
SchedulePublishJobsMigration,
schedulePublishJobsExist ? [] : SchedulePublishJobStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1104,4 +1118,48 @@ public sealed class DevelopmentSqliteMigrator(
ADD COLUMN "SchedulingMode" INTEGER NOT NULL DEFAULT 1;
"""
];
private static readonly string[] SchedulePublishJobStatements =
[
"""
CREATE TABLE "SchedulePublishJobs" (
"Id" TEXT NOT NULL CONSTRAINT "PK_SchedulePublishJobs" PRIMARY KEY,
"SchedulePlanId" TEXT NOT NULL,
"AcademicTermId" TEXT NOT NULL,
"ActiveAcademicTermId" TEXT NULL,
"RequestedByUserId" TEXT NULL,
"Status" INTEGER NOT NULL,
"TotalSteps" INTEGER NOT NULL,
"CompletedSteps" INTEGER NOT NULL,
"CurrentStep" TEXT NULL,
"ErrorMessage" TEXT NULL,
"StartedAt" TEXT NULL,
"CompletedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_SchedulePublishJobs_SchedulePlans"
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_SchedulePublishJobs_RequestedBy"
FOREIGN KEY ("RequestedByUserId") REFERENCES "AspNetUsers" ("Id")
ON DELETE SET NULL
);
""",
"""
CREATE UNIQUE INDEX "IX_SchedulePublishJobs_ActiveAcademicTermId"
ON "SchedulePublishJobs" ("ActiveAcademicTermId");
""",
"""
CREATE INDEX "IX_SchedulePublishJobs_SchedulePlanId_CreatedAt"
ON "SchedulePublishJobs" ("SchedulePlanId", "CreatedAt");
""",
"""
CREATE INDEX "IX_SchedulePublishJobs_Status_CreatedAt"
ON "SchedulePublishJobs" ("Status", "CreatedAt");
""",
"""
CREATE INDEX "IX_SchedulePublishJobs_RequestedByUserId"
ON "SchedulePublishJobs" ("RequestedByUserId");
"""
];
}
@@ -0,0 +1,80 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class SchedulePublishJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SchedulePublishJobs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
ActiveAcademicTermId = 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),
TotalSteps = table.Column<int>(type: "int", nullable: false),
CompletedSteps = table.Column<int>(type: "int", nullable: false),
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, 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_SchedulePublishJobs", x => x.Id);
table.ForeignKey(
name: "FK_SchedulePublishJobs_AspNetUsers_RequestedByUserId",
column: x => x.RequestedByUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_SchedulePublishJobs_SchedulePlans_SchedulePlanId",
column: x => x.SchedulePlanId,
principalTable: "SchedulePlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_SchedulePublishJobs_ActiveAcademicTermId",
table: "SchedulePublishJobs",
column: "ActiveAcademicTermId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SchedulePublishJobs_RequestedByUserId",
table: "SchedulePublishJobs",
column: "RequestedByUserId");
migrationBuilder.CreateIndex(
name: "IX_SchedulePublishJobs_SchedulePlanId_CreatedAt",
table: "SchedulePublishJobs",
columns: new[] { "SchedulePlanId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_SchedulePublishJobs_Status_CreatedAt",
table: "SchedulePublishJobs",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SchedulePublishJobs");
}
}
}
@@ -1448,6 +1448,67 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("SchedulePlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<Guid?>("ActiveAcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CompletedSteps")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("CurrentStep")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)");
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>("TotalSteps")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("ActiveAcademicTermId")
.IsUnique();
b.HasIndex("RequestedByUserId");
b.HasIndex("SchedulePlanId", "CreatedAt");
b.HasIndex("Status", "CreatedAt");
b.ToTable("SchedulePublishJobs");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
{
b.Property<Guid>("Id")
@@ -2553,6 +2614,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", 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.ScheduleTimeSlot", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")