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

发布接口立即返回 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
+149 -70
View File
@@ -16,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[Route("api/schedules")]
public sealed class SchedulesController(
AppDbContext db,
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase
AutomaticScheduleJobQueue automaticScheduleJobQueue,
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
@@ -130,8 +131,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 (await HasActiveScheduleJobAsync(id, cancellationToken))
return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
if (!await db.AcademicTerms.AnyAsync(
@@ -151,8 +152,8 @@ public sealed class SchedulesController(
CloneSchedulePlanRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(id, cancellationToken))
return ScheduleJobRunningProblem();
var source = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -185,8 +186,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 (await HasActiveScheduleJobAsync(id, cancellationToken))
return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("仅草稿排课版本可以删除。");
db.SchedulePlans.Remove(plan);
@@ -194,76 +195,79 @@ public sealed class SchedulesController(
}
[HttpPost("plans/{id:guid}/publish")]
public async Task<ActionResult> PublishPlan(Guid id, CancellationToken cancellationToken)
public async Task<ActionResult<SchedulePublishJobResponse>> 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)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Entries)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
var plan = await db.SchedulePlans.AsNoTracking()
.Select(x => new
{
x.Id,
x.AcademicTermId,
x.Status,
HasEntries = x.Entries.Any()
})
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("只有草稿排课版本可以发布。");
if (plan.Entries.Count == 0)
if (!plan.HasEntries)
return ConflictProblem("排课版本中至少需要一条课表安排。");
foreach (var entry in plan.Entries)
{
var validation = await ValidateEntryAsync(
plan,
entry.Id,
new ScheduleEntryRequest(
entry.TeachingTaskId,
entry.ClassroomId,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Notes),
var existing = await db.SchedulePublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.ActiveAcademicTermId == plan.AcademicTermId,
cancellationToken);
if (validation is not null) return validation;
if (existing is not null)
{
if (existing.SchedulePlanId != id)
return ConflictProblem(
"同一学期已有课表正在后台检查并发布,请等待任务完成。");
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = existing.Id },
ToResponse(existing));
}
var requiredTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
.ToListAsync(cancellationToken);
var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredTasks.FirstOrDefault(task =>
!scheduledHours.TryGetValue(task.Id, out var hours) ||
hours < task.WeeklyHours);
if (incomplete is not null)
return ConflictProblem(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。");
var requestedByUserId = CurrentUserId();
var job = new SchedulePublishJob
{
SchedulePlanId = id,
AcademicTermId = plan.AcademicTermId,
ActiveAcademicTermId = plan.AcademicTermId,
RequestedByUserId = requestedByUserId,
CurrentStep = "等待后台检查"
};
db.SchedulePublishJobs.Add(job);
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
db.Entry(job).State = EntityState.Detached;
existing = await db.SchedulePublishJobs.AsNoTracking()
.FirstOrDefaultAsync(
x => x.ActiveAcademicTermId == plan.AcademicTermId,
cancellationToken);
if (existing is null)
throw;
if (existing.SchedulePlanId != id)
return ConflictProblem(
"同一学期已有课表正在后台检查并发布,请等待任务完成。");
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = existing.Id },
ToResponse(existing));
}
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
if (conflict is not null) return ConflictProblem(conflict);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var previous = await db.SchedulePlans
.Where(x =>
x.Id != plan.Id &&
x.AcademicTermId == plan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.ToListAsync(cancellationToken);
foreach (var oldPlan in previous) oldPlan.Status = SchedulePlanStatus.Archived;
plan.Status = SchedulePlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
schedulePublishJobQueue.Enqueue(job.Id);
return AcceptedAtAction(
nameof(GetSchedulePublishJob),
new { jobId = job.Id },
ToResponse(job));
}
[HttpPost("plans/{planId:guid}/entries")]
@@ -272,8 +276,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
@@ -290,6 +294,8 @@ public sealed class SchedulesController(
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
if (await HasActiveSchedulePublishJobAsync(planId, cancellationToken))
return SchedulePublishRunningProblem();
var existing = await db.AutomaticScheduleJobs.AsNoTracking()
.FirstOrDefaultAsync(
@@ -364,6 +370,30 @@ public sealed class SchedulesController(
return Ok(job is null ? null : ToResponse(job));
}
[HttpGet("publish-jobs/{jobId:guid}")]
public async Task<ActionResult<SchedulePublishJobResponse>>
GetSchedulePublishJob(
Guid jobId,
CancellationToken cancellationToken)
{
var job = await db.SchedulePublishJobs.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
return job is null ? NotFound() : Ok(ToResponse(job));
}
[HttpGet("plans/{planId:guid}/publish-job")]
public async Task<ActionResult<SchedulePublishJobResponse?>>
GetLatestSchedulePublishJob(
Guid planId,
CancellationToken cancellationToken)
{
var job = await db.SchedulePublishJobs.AsNoTracking()
.Where(x => x.SchedulePlanId == planId)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return Ok(job is null ? null : ToResponse(job));
}
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> UpdateEntry(
Guid planId,
@@ -371,8 +401,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var entry = await db.ScheduleEntries
@@ -400,8 +430,8 @@ public sealed class SchedulesController(
Guid entryId,
CancellationToken cancellationToken)
{
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem();
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return ScheduleJobRunningProblem();
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var entry = await db.ScheduleEntries
.FirstOrDefaultAsync(
@@ -426,6 +456,19 @@ public sealed class SchedulesController(
x => x.ActiveSchedulePlanId == planId,
cancellationToken);
private Task<bool> HasActiveSchedulePublishJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
db.SchedulePublishJobs.AsNoTracking().AnyAsync(
x => x.SchedulePlanId == planId && x.ActiveAcademicTermId != null,
cancellationToken);
private async Task<bool> HasActiveScheduleJobAsync(
Guid planId,
CancellationToken cancellationToken) =>
await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken) ||
await HasActiveSchedulePublishJobAsync(planId, cancellationToken);
private async Task<ActionResult?> ValidateEntryAsync(
SchedulePlan plan,
Guid? entryId,
@@ -585,6 +628,12 @@ public sealed class SchedulesController(
private ActionResult AutomaticScheduleRunningProblem() =>
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
private ActionResult SchedulePublishRunningProblem() =>
ConflictProblem("课表正在后台检查并发布,请等待任务完成后再修改。");
private ActionResult ScheduleJobRunningProblem() =>
ConflictProblem("后台任务正在处理该排课版本,请等待任务完成后再修改。");
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
{
IReadOnlyList<string> messages = [];
@@ -608,6 +657,24 @@ public sealed class SchedulesController(
job.CompletedAt);
}
private static SchedulePublishJobResponse ToResponse(SchedulePublishJob job) =>
new(
job.Id,
job.SchedulePlanId,
job.Status,
job.TotalSteps,
job.CompletedSteps,
job.CurrentStep,
job.ErrorMessage,
job.CreatedAt,
job.StartedAt,
job.CompletedAt);
private Guid? CurrentUserId() =>
Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)
? userId
: null;
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -646,3 +713,15 @@ public sealed record AutomaticScheduleJobResponse(
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? CompletedAt);
public sealed record SchedulePublishJobResponse(
Guid Id,
Guid SchedulePlanId,
SchedulePublishJobStatus Status,
int TotalSteps,
int CompletedSteps,
string? CurrentStep,
string? ErrorMessage,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? CompletedAt);
@@ -83,6 +83,23 @@ public sealed class AutomaticScheduleJob : EntityBase
public DateTime? CompletedAt { get; set; }
}
public sealed class SchedulePublishJob : EntityBase
{
public Guid SchedulePlanId { get; set; }
public SchedulePlan? SchedulePlan { get; set; }
public Guid AcademicTermId { get; set; }
public Guid? ActiveAcademicTermId { get; set; }
public Guid? RequestedByUserId { get; set; }
public SchedulePublishJobStatus Status { get; set; } =
SchedulePublishJobStatus.Queued;
public int TotalSteps { get; set; } = 5;
public int CompletedSteps { get; set; }
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
public enum SchedulePlanStatus
{
Draft = 1,
@@ -98,6 +115,14 @@ public enum AutomaticScheduleJobStatus
Failed = 4
}
public enum SchedulePublishJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum WeekPattern
{
All = 1,
@@ -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")
@@ -0,0 +1,371 @@
using System.Threading.Channels;
using System.Diagnostics.CodeAnalysis;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class SchedulePublishJobQueue
{
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 SchedulePublishJobWorker(
IServiceScopeFactory scopeFactory,
SchedulePublishJobQueue queue,
ILogger<SchedulePublishJobWorker> 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<SchedulePublishJobProcessor>();
await processor.ProcessAsync(jobId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Unexpected failure while dispatching schedule publish job {JobId}.",
jobId);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Schedule publish 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.SchedulePublishJobs
.Where(x =>
x.Status == SchedulePublishJobStatus.Queued ||
x.Status == SchedulePublishJobStatus.Running)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
foreach (var job in jobs)
{
job.Status = SchedulePublishJobStatus.Queued;
job.ActiveAcademicTermId = job.AcademicTermId;
job.CompletedSteps = 0;
job.CurrentStep = "等待后台检查";
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 schedule publish jobs.",
jobs.Count);
}
}
}
public sealed class SchedulePublishJobProcessor(
AppDbContext db,
SchedulePlanPublisher publisher,
ILogger<SchedulePublishJobProcessor> logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
{
try
{
var job = await db.SchedulePublishJobs
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
if (job is null ||
job.Status is SchedulePublishJobStatus.Succeeded
or SchedulePublishJobStatus.Failed)
{
return;
}
job.Status = SchedulePublishJobStatus.Running;
job.StartedAt = DateTime.UtcNow;
job.CompletedAt = null;
job.CompletedSteps = 0;
job.CurrentStep = "读取排课版本";
job.ErrorMessage = null;
await db.SaveChangesAsync(stoppingToken);
async Task ReportProgress(
int completedSteps,
string currentStep,
CancellationToken cancellationToken)
{
job.CompletedSteps = completedSteps;
job.CurrentStep = currentStep;
await db.SaveChangesAsync(cancellationToken);
}
var plan = await publisher.ValidateAsync(
job.SchedulePlanId,
ReportProgress,
stoppingToken);
await using var transaction =
await db.Database.BeginTransactionAsync(stoppingToken);
if (plan.Status != SchedulePlanStatus.Draft)
throw new SchedulePublishValidationException(
"排课草稿状态已发生变化,请刷新后重试。");
var previous = await db.SchedulePlans
.Where(x =>
x.Id != plan.Id &&
x.AcademicTermId == plan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.ToListAsync(stoppingToken);
foreach (var oldPlan in previous)
oldPlan.Status = SchedulePlanStatus.Archived;
plan.Status = SchedulePlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
job.Status = SchedulePublishJobStatus.Succeeded;
job.ActiveAcademicTermId = null;
job.CompletedSteps = job.TotalSteps;
job.CurrentStep = "课表已发布";
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
logger.LogInformation(
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
job.Id,
plan.Id);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
logger.LogInformation(
"Schedule publish job {JobId} was interrupted by application shutdown.",
jobId);
throw;
}
catch (Exception exception)
{
logger.LogError(exception, "Schedule publish job {JobId} failed.", jobId);
await MarkFailedAsync(jobId, exception);
}
}
private async Task MarkFailedAsync(Guid jobId, Exception exception)
{
db.ChangeTracker.Clear();
var job = await db.SchedulePublishJobs.FirstOrDefaultAsync(
x => x.Id == jobId,
CancellationToken.None);
if (job is null)
return;
var message = exception.GetBaseException().Message;
job.Status = SchedulePublishJobStatus.Failed;
job.ActiveAcademicTermId = null;
job.CurrentStep = "检查未通过";
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
job.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(CancellationToken.None);
}
}
public sealed class SchedulePlanPublisher(AppDbContext db)
{
public async Task<SchedulePlan> ValidateAsync(
Guid planId,
Func<int, string, CancellationToken, Task> reportProgress,
CancellationToken cancellationToken)
{
var plan = await db.SchedulePlans
.AsSplitQuery()
.Include(x => x.Entries)
.ThenInclude(x => x.Classroom)
.ThenInclude(x => x!.Building)
.Include(x => x.Entries)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Entries)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken)
?? throw new SchedulePublishValidationException("排课草稿不存在。");
if (plan.Status != SchedulePlanStatus.Draft)
throw new SchedulePublishValidationException("只有草稿排课版本可以发布。");
if (plan.Entries.Count == 0)
throw new SchedulePublishValidationException(
"排课版本中至少需要一条课表安排。");
await reportProgress(1, "校验课程、节次与教室", cancellationToken);
var activePeriods = (await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.Select(x => x.PeriodNumber)
.ToListAsync(cancellationToken))
.ToHashSet();
if (activePeriods.Count == 0)
throw new SchedulePublishValidationException(
"请先维护该学期的上课时间表。");
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var entry in plan.Entries)
{
ValidateEntry(plan, entry, activePeriods, constraints);
}
await reportProgress(2, "校验教学任务完整性", cancellationToken);
var requiredTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
.ToListAsync(cancellationToken);
var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredTasks.FirstOrDefault(task =>
!scheduledHours.TryGetValue(task.Id, out var hours) ||
hours < task.WeeklyHours);
if (incomplete is not null)
{
throw new SchedulePublishValidationException(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " +
$"{incomplete.WeeklyHours} 学时,不能发布。");
}
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
if (conflict is not null)
throw new SchedulePublishValidationException(conflict);
await reportProgress(4, "写入正式课表", cancellationToken);
return plan;
}
private static void ValidateEntry(
SchedulePlan plan,
ScheduleEntry entry,
HashSet<int> activePeriods,
IReadOnlyDictionary<Guid, TeachingTaskScheduleConstraint> constraints)
{
var task = entry.TeachingTask;
if (entry.StartWeek > entry.EndWeek)
Fail(entry, "开始周不能晚于结束周");
if (Enumerable.Range(entry.StartPeriod, entry.PeriodCount)
.Any(period => !activePeriods.Contains(period)))
Fail(entry, "所选节次包含未启用或不存在的上课时间");
if (task is null ||
task.Status != TeachingTaskStatus.Published ||
task.AcademicTermId != plan.AcademicTermId)
Fail(entry, "只能安排同一学期内已发布的教学任务");
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
Fail(entry, "非排时课程不应进入正常课表");
if (entry.StartWeek < task.StartWeek || entry.EndWeek > task.EndWeek)
Fail(entry, "排课周次不在教学任务的授课周次内");
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
var requiresClassroom = constraint?.RequiresClassroom ?? true;
if (requiresClassroom && entry.ClassroomId is null)
Fail(entry, "该课程需要占用教室");
if (!requiresClassroom && entry.ClassroomId is not null)
Fail(entry, "该课程已设置为不占用教室");
var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks);
if (allowedDays.Count > 0 && !allowedDays.Contains(entry.DayOfWeek))
Fail(entry, "上课日不在教学任务允许范围内");
if (constraint?.EarliestPeriod is int earliest &&
entry.StartPeriod < earliest)
Fail(entry, $"最早只能从第 {earliest} 节开始");
if (constraint?.LatestPeriod is int latest &&
entry.StartPeriod + entry.PeriodCount - 1 > latest)
Fail(entry, $"最晚必须在第 {latest} 节结束");
var classroom = entry.Classroom;
if (entry.ClassroomId.HasValue)
{
if (classroom is null || !classroom.IsEnabled)
Fail(entry, "所选教室不存在或已停用");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
Fail(entry, "所选教室不在指定校区");
if (constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId)
Fail(entry, "所选教室不在指定教学楼");
var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id))
Fail(entry, "所选教室不在指定教室范围内");
}
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active));
var requiredCapacity = Math.Max(task.Capacity, studentCount);
if (classroom is not null && requiredCapacity > classroom.Capacity)
{
Fail(entry,
$"教室容量不足:需要 {requiredCapacity} 人,教室仅容纳 {classroom.Capacity} 人");
}
}
private static HashSet<int> ParseDays(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToHashSet();
[DoesNotReturn]
private static void Fail(ScheduleEntry entry, string message)
{
var name = entry.TeachingTask?.Name ?? entry.TeachingTaskId.ToString();
throw new SchedulePublishValidationException($"“{name}”:{message}。");
}
}
public sealed class SchedulePublishValidationException(string message)
: InvalidOperationException(message);
+4
View File
@@ -96,6 +96,10 @@ builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
builder.Services.AddScoped<SchedulePlanPublisher>();
builder.Services.AddScoped<SchedulePublishJobProcessor>();
builder.Services.AddSingleton<SchedulePublishJobQueue>();
builder.Services.AddHostedService<SchedulePublishJobWorker>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)