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

发布接口立即返回 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")] [Route("api/schedules")]
public sealed class SchedulesController( public sealed class SchedulesController(
AppDbContext db, AppDbContext db,
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase AutomaticScheduleJobQueue automaticScheduleJobQueue,
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
{ {
private const string ManagementRoles = private const string ManagementRoles =
SystemRoles.SuperAdmin + "," + SystemRoles.SuperAdmin + "," +
@@ -130,8 +131,8 @@ public sealed class SchedulesController(
{ {
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) if (await HasActiveScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft) if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("已发布或已归档的排课版本不可直接修改。"); return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
@@ -151,8 +152,8 @@ public sealed class SchedulesController(
CloneSchedulePlanRequest request, CloneSchedulePlanRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) if (await HasActiveScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
var source = await db.SchedulePlans.AsNoTracking() var source = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries) .Include(x => x.Entries)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -185,8 +186,8 @@ public sealed class SchedulesController(
{ {
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) if (await HasActiveScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
if (plan.Status != SchedulePlanStatus.Draft) if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("仅草稿排课版本可以删除。"); return ConflictProblem("仅草稿排课版本可以删除。");
db.SchedulePlans.Remove(plan); db.SchedulePlans.Remove(plan);
@@ -194,76 +195,79 @@ public sealed class SchedulesController(
} }
[HttpPost("plans/{id:guid}/publish")] [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)) if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
return AutomaticScheduleRunningProblem(); return AutomaticScheduleRunningProblem();
var plan = await db.SchedulePlans var plan = await db.SchedulePlans.AsNoTracking()
.Include(x => x.Entries) .Select(x => new
.ThenInclude(x => x.TeachingTask) {
.ThenInclude(x => x!.Teachers) x.Id,
.Include(x => x.Entries) x.AcademicTermId,
.ThenInclude(x => x.TeachingTask) x.Status,
.ThenInclude(x => x!.Classes) HasEntries = x.Entries.Any()
})
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != SchedulePlanStatus.Draft) if (plan.Status != SchedulePlanStatus.Draft)
return ConflictProblem("只有草稿排课版本可以发布。"); return ConflictProblem("只有草稿排课版本可以发布。");
if (plan.Entries.Count == 0) if (!plan.HasEntries)
return ConflictProblem("排课版本中至少需要一条课表安排。"); return ConflictProblem("排课版本中至少需要一条课表安排。");
foreach (var entry in plan.Entries) var existing = await db.SchedulePublishJobs.AsNoTracking()
{ .FirstOrDefaultAsync(
var validation = await ValidateEntryAsync( x => x.ActiveAcademicTermId == plan.AcademicTermId,
plan,
entry.Id,
new ScheduleEntryRequest(
entry.TeachingTaskId,
entry.ClassroomId,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Notes),
cancellationToken); 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() var requestedByUserId = CurrentUserId();
.Where(x => var job = new SchedulePublishJob
x.AcademicTermId == plan.AcademicTermId && {
x.Status == TeachingTaskStatus.Published && SchedulePlanId = id,
x.SchedulingMode == TeachingTaskSchedulingMode.Standard) AcademicTermId = plan.AcademicTermId,
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) ActiveAcademicTermId = plan.AcademicTermId,
.ToListAsync(cancellationToken); RequestedByUserId = requestedByUserId,
var scheduledHours = plan.Entries CurrentStep = "等待后台检查"
.GroupBy(x => x.TeachingTaskId) };
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); db.SchedulePublishJobs.Add(job);
var incomplete = requiredTasks.FirstOrDefault(task => try
!scheduledHours.TryGetValue(task.Id, out var hours) || {
hours < task.WeeklyHours); await db.SaveChangesAsync(cancellationToken);
if (incomplete is not null) }
return ConflictProblem( catch (DbUpdateException)
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。"); {
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()); schedulePublishJobQueue.Enqueue(job.Id);
if (conflict is not null) return ConflictProblem(conflict); return AcceptedAtAction(
nameof(GetSchedulePublishJob),
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); new { jobId = job.Id },
var previous = await db.SchedulePlans ToResponse(job));
.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();
} }
[HttpPost("plans/{planId:guid}/entries")] [HttpPost("plans/{planId:guid}/entries")]
@@ -272,8 +276,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request, ScheduleEntryRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken); var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken); var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
@@ -290,6 +294,8 @@ public sealed class SchedulesController(
{ {
var plan = await DraftPlanAsync(planId, cancellationToken); var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (await HasActiveSchedulePublishJobAsync(planId, cancellationToken))
return SchedulePublishRunningProblem();
var existing = await db.AutomaticScheduleJobs.AsNoTracking() var existing = await db.AutomaticScheduleJobs.AsNoTracking()
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
@@ -364,6 +370,30 @@ public sealed class SchedulesController(
return Ok(job is null ? null : ToResponse(job)); 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}")] [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> UpdateEntry( public async Task<ActionResult> UpdateEntry(
Guid planId, Guid planId,
@@ -371,8 +401,8 @@ public sealed class SchedulesController(
ScheduleEntryRequest request, ScheduleEntryRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
var plan = await DraftPlanAsync(planId, cancellationToken); var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
var entry = await db.ScheduleEntries var entry = await db.ScheduleEntries
@@ -400,8 +430,8 @@ public sealed class SchedulesController(
Guid entryId, Guid entryId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) if (await HasActiveScheduleJobAsync(planId, cancellationToken))
return AutomaticScheduleRunningProblem(); return ScheduleJobRunningProblem();
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var entry = await db.ScheduleEntries var entry = await db.ScheduleEntries
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
@@ -426,6 +456,19 @@ public sealed class SchedulesController(
x => x.ActiveSchedulePlanId == planId, x => x.ActiveSchedulePlanId == planId,
cancellationToken); 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( private async Task<ActionResult?> ValidateEntryAsync(
SchedulePlan plan, SchedulePlan plan,
Guid? entryId, Guid? entryId,
@@ -585,6 +628,12 @@ public sealed class SchedulesController(
private ActionResult AutomaticScheduleRunningProblem() => private ActionResult AutomaticScheduleRunningProblem() =>
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。"); ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
private ActionResult SchedulePublishRunningProblem() =>
ConflictProblem("课表正在后台检查并发布,请等待任务完成后再修改。");
private ActionResult ScheduleJobRunningProblem() =>
ConflictProblem("后台任务正在处理该排课版本,请等待任务完成后再修改。");
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job) private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
{ {
IReadOnlyList<string> messages = []; IReadOnlyList<string> messages = [];
@@ -608,6 +657,24 @@ public sealed class SchedulesController(
job.CompletedAt); 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) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
} }
@@ -646,3 +713,15 @@ public sealed record AutomaticScheduleJobResponse(
DateTime CreatedAt, DateTime CreatedAt,
DateTime? StartedAt, DateTime? StartedAt,
DateTime? CompletedAt); 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 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 public enum SchedulePlanStatus
{ {
Draft = 1, Draft = 1,
@@ -98,6 +115,14 @@ public enum AutomaticScheduleJobStatus
Failed = 4 Failed = 4
} }
public enum SchedulePublishJobStatus
{
Queued = 1,
Running = 2,
Succeeded = 3,
Failed = 4
}
public enum WeekPattern public enum WeekPattern
{ {
All = 1, All = 1,
@@ -38,6 +38,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeachingTaskAllowedClassroom>(); Set<TeachingTaskAllowedClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs => public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>(); Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
Set<SchedulePublishJob>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>(); Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings => public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
@@ -400,6 +402,24 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.SetNull); .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 => builder.Entity<CourseSelectionRound>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -27,6 +27,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260724_16_automatic_schedule_jobs"; "20260724_16_automatic_schedule_jobs";
private const string TeachingTaskSchedulingModesMigration = private const string TeachingTaskSchedulingModesMigration =
"20260725_17_teaching_task_scheduling_modes"; "20260725_17_teaching_task_scheduling_modes";
private const string SchedulePublishJobsMigration =
"20260725_18_schedule_publish_jobs";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -163,6 +165,18 @@ public sealed class DevelopmentSqliteMigrator(
TeachingTaskSchedulingModesMigration, TeachingTaskSchedulingModesMigration,
teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements, teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements,
cancellationToken); 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( private async Task ApplyMigrationAsync(
@@ -1104,4 +1118,48 @@ public sealed class DevelopmentSqliteMigrator(
ADD COLUMN "SchedulingMode" INTEGER NOT NULL DEFAULT 1; 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"); 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 => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2553,6 +2614,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("AcademicTerm"); 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 => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") 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.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>(); builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
builder.Services.AddHostedService<AutomaticScheduleJobWorker>(); builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
builder.Services.AddScoped<SchedulePlanPublisher>();
builder.Services.AddScoped<SchedulePublishJobProcessor>();
builder.Services.AddSingleton<SchedulePublishJobQueue>();
builder.Services.AddHostedService<SchedulePublishJobWorker>();
builder.Services builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -0,0 +1,176 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class SchedulePublishJobProcessorTests
{
[Fact]
public async Task Processor_validates_and_publishes_plan_atomically()
{
var result = await RunPublishAsync(weeklyHours: 2);
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
Assert.Null(result.ActiveAcademicTermId);
Assert.Equal(5, result.CompletedSteps);
Assert.Equal("课表已发布", result.CurrentStep);
Assert.Null(result.ErrorMessage);
Assert.NotNull(result.PublishedAt);
}
[Fact]
public async Task Processor_keeps_draft_when_validation_fails()
{
var result = await RunPublishAsync(weeklyHours: 4);
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
Assert.Null(result.ActiveAcademicTermId);
Assert.Equal("检查未通过", result.CurrentStep);
Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage);
Assert.Null(result.PublishedAt);
}
private static async Task<PublishResult> RunPublishAsync(int weeklyHours)
{
var databasePath = Path.Combine(
Path.GetTempPath(),
$"jiaowu-publish-{Guid.NewGuid():N}.sqlite");
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<AppDbContext>(
options => options.UseSqlite(
$"Data Source={databasePath};Pooling=False"));
services.AddScoped<SchedulePlanPublisher>();
services.AddScoped<SchedulePublishJobProcessor>();
var provider = services.BuildServiceProvider();
try
{
Guid jobId;
await using (var seedScope = provider.CreateAsyncScope())
{
var db = seedScope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = $"2026-PUBLISH-{weeklyHours}",
Name = "后台发布学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
var college = new College { Code = "PUB", Name = "发布测试学院" };
var course = new Course
{
Code = "PUB-01",
Name = "发布测试课程",
College = college,
Credits = 1,
TotalHours = 32,
LectureHours = 32
};
var task = new TeachingTask
{
TaskNumber = $"TASK-PUB-{weeklyHours}",
Name = "发布测试教学班",
AcademicTerm = term,
Course = course,
Capacity = 60,
StartWeek = 1,
EndWeek = 16,
WeeklyHours = weeklyHours,
Status = TeachingTaskStatus.Published
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "待发布课表",
Version = "V1"
};
plan.Entries.Add(new ScheduleEntry
{
TeachingTask = task,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
});
var job = new SchedulePublishJob
{
SchedulePlan = plan,
AcademicTermId = term.Id,
ActiveAcademicTermId = term.Id,
CurrentStep = "等待后台检查"
};
jobId = job.Id;
db.AddRange(term, college, course, task, plan, job);
db.ScheduleTimeSlots.AddRange(
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 1,
Name = "第 1 节",
StartsAt = new TimeOnly(8, 0),
EndsAt = new TimeOnly(8, 45)
},
new ScheduleTimeSlot
{
AcademicTerm = term,
PeriodNumber = 2,
Name = "第 2 节",
StartsAt = new TimeOnly(8, 55),
EndsAt = new TimeOnly(9, 40)
});
db.TeachingTaskScheduleConstraints.Add(
new TeachingTaskScheduleConstraint
{
TeachingTask = task,
RequiresClassroom = false
});
await db.SaveChangesAsync();
}
await using (var workerScope = provider.CreateAsyncScope())
{
var processor = workerScope.ServiceProvider
.GetRequiredService<SchedulePublishJobProcessor>();
await processor.ProcessAsync(jobId, CancellationToken.None);
}
await using var assertScope = provider.CreateAsyncScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
var jobResult = await assertDb.SchedulePublishJobs.SingleAsync();
var planResult = await assertDb.SchedulePlans.SingleAsync();
return new(
jobResult.Status,
planResult.Status,
jobResult.ActiveAcademicTermId,
jobResult.CompletedSteps,
jobResult.CurrentStep,
jobResult.ErrorMessage,
planResult.PublishedAt);
}
finally
{
await provider.DisposeAsync();
File.Delete(databasePath);
}
}
private sealed record PublishResult(
SchedulePublishJobStatus JobStatus,
SchedulePlanStatus PlanStatus,
Guid? ActiveAcademicTermId,
int CompletedSteps,
string? CurrentStep,
string? ErrorMessage,
DateTime? PublishedAt);
}
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -10,6 +11,87 @@ namespace Jiaowu.Api.Tests;
public sealed class SchedulesControllerTests public sealed class SchedulesControllerTests
{ {
[Fact]
public async Task Publish_returns_accepted_before_background_validation_runs()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-P",
Name = "2026 发布测试",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17)
};
var college = new College { Code = "P", Name = "发布学院" };
var course = new Course
{
Code = "P101",
Name = "发布课程",
College = college,
Credits = 1,
TotalHours = 16,
LectureHours = 16
};
var task = new TeachingTask
{
TaskNumber = "TASK-P",
Name = "发布教学班",
AcademicTerm = term,
Course = course,
Capacity = 30,
WeeklyHours = 1,
Status = TeachingTaskStatus.Published
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "发布草稿",
Version = "V1"
};
plan.Entries.Add(new ScheduleEntry
{
TeachingTask = task,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 1,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
});
db.AddRange(term, college, course, task, plan);
await db.SaveChangesAsync();
var controller = new SchedulesController(
db,
new AutomaticScheduleJobQueue(),
new SchedulePublishJobQueue())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
var result = await controller.PublishPlan(plan.Id, CancellationToken.None);
var accepted = Assert.IsType<AcceptedAtActionResult>(result.Result);
var response = Assert.IsType<SchedulePublishJobResponse>(accepted.Value);
Assert.Equal(SchedulePublishJobStatus.Queued, response.Status);
Assert.Equal(plan.Id, response.SchedulePlanId);
Assert.Equal(
SchedulePlanStatus.Draft,
(await db.SchedulePlans.SingleAsync()).Status);
Assert.Equal(1, await db.SchedulePublishJobs.CountAsync());
}
[Fact] [Fact]
public async Task Latest_auto_schedule_job_remains_available_after_completion() public async Task Latest_auto_schedule_job_remains_available_after_completion()
{ {
@@ -52,7 +134,8 @@ public sealed class SchedulesControllerTests
var controller = new SchedulesController( var controller = new SchedulesController(
db, db,
new AutomaticScheduleJobQueue()); new AutomaticScheduleJobQueue(),
new SchedulePublishJobQueue());
var result = await controller.GetLatestAutomaticScheduleJob( var result = await controller.GetLatestAutomaticScheduleJob(
plan.Id, plan.Id,
CancellationToken.None); CancellationToken.None);
+119 -10
View File
@@ -15,6 +15,7 @@ const constraints = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const autoJob = ref<any | null>(null) const autoJob = ref<any | null>(null)
const publishJob = ref<any | null>(null)
const planDialog = ref(false) const planDialog = ref(false)
const cloneDialog = ref(false) const cloneDialog = ref(false)
const entryDialog = ref(false) const entryDialog = ref(false)
@@ -41,6 +42,7 @@ const constraintFilters = reactive({
constraintState: undefined as string | undefined, constraintState: undefined as string | undefined,
}) })
let autoPollTimer: ReturnType<typeof setTimeout> | undefined let autoPollTimer: ReturnType<typeof setTimeout> | undefined
let publishPollTimer: ReturnType<typeof setTimeout> | undefined
const weekdays = [ const weekdays = [
{ value: 1, label: '星期一' }, { value: 1, label: '星期一' },
@@ -71,6 +73,10 @@ const isDraft = computed(() => selected.value?.status === 'Draft')
const autoLoading = computed(() => const autoLoading = computed(() =>
autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running', autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running',
) )
const publishLoading = computed(() =>
publishJob.value?.status === 'Queued' || publishJob.value?.status === 'Running',
)
const scheduleJobLoading = computed(() => autoLoading.value || publishLoading.value)
const autoProgress = computed(() => { const autoProgress = computed(() => {
if (!autoJob.value?.totalTasks) return 0 if (!autoJob.value?.totalTasks) return 0
return Math.min( return Math.min(
@@ -94,6 +100,27 @@ const autoStatusText = computed(() => {
} }
return autoJob.value.errorMessage || '后台排课失败,请稍后重试' return autoJob.value.errorMessage || '后台排课失败,请稍后重试'
}) })
const publishProgress = computed(() => {
if (!publishJob.value?.totalSteps) return 0
return Math.min(
100,
Math.round(publishJob.value.completedSteps / publishJob.value.totalSteps * 100),
)
})
const publishProgressStatus = computed(() => {
if (publishJob.value?.status === 'Succeeded') return 'success'
if (publishJob.value?.status === 'Failed') return 'exception'
return undefined
})
const publishStatusText = computed(() => {
if (!publishJob.value) return ''
if (publishJob.value.status === 'Queued') return '发布任务已进入队列,等待后台检查'
if (publishJob.value.status === 'Running') {
return publishJob.value.currentStep || '正在检查并发布课表'
}
if (publishJob.value.status === 'Succeeded') return '检查已通过,课表发布成功'
return publishJob.value.errorMessage || '课表检查未通过'
})
const selectedTaskConstraint = computed(() => const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId), constraints.value.find((item) => item.id === entryForm.teachingTaskId),
) )
@@ -402,6 +429,50 @@ async function autoSchedule() {
} }
} }
function clearPublishPoll() {
if (publishPollTimer) clearTimeout(publishPollTimer)
publishPollTimer = undefined
}
function schedulePublishPoll(jobId: string, planId: string) {
clearPublishPoll()
publishPollTimer = setTimeout(() => pollPublishJob(jobId, planId), 1000)
}
async function pollPublishJob(jobId: string, planId: string) {
try {
const { data } = await http.get(`/schedules/publish-jobs/${jobId}`)
if (selected.value?.id !== planId) return
publishJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
schedulePublishPoll(jobId, planId)
return
}
clearPublishPoll()
if (data.status === 'Succeeded') {
ElMessage.success('课表检查通过,已发布')
await loadPlans()
} else {
ElMessage.error(data.errorMessage || '课表检查未通过,请调整后重试')
}
} catch {
if (selected.value?.id === planId) {
schedulePublishPoll(jobId, planId)
}
}
}
async function resumePublish(planId: string) {
clearPublishPoll()
publishJob.value = null
const { data } = await http.get(`/schedules/plans/${planId}/publish-job`)
if (selected.value?.id !== planId || !data) return
publishJob.value = data
if (data.status === 'Queued' || data.status === 'Running') {
schedulePublishPoll(data.id, planId)
}
}
function clearAutoSchedulePoll() { function clearAutoSchedulePoll() {
if (autoPollTimer) clearTimeout(autoPollTimer) if (autoPollTimer) clearTimeout(autoPollTimer)
autoPollTimer = undefined autoPollTimer = undefined
@@ -467,6 +538,8 @@ async function loadPlans(keepSelection = true) {
selected.value = null selected.value = null
clearAutoSchedulePoll() clearAutoSchedulePoll()
autoJob.value = null autoJob.value = null
clearPublishPoll()
publishJob.value = null
} }
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -480,10 +553,11 @@ async function loadDetail(id: string, resumeJob = true) {
try { try {
selected.value = (await http.get(`/schedules/plans/${id}`)).data selected.value = (await http.get(`/schedules/plans/${id}`)).data
if (resumeJob && selected.value.status === 'Draft') { if (resumeJob && selected.value.status === 'Draft') {
await resumeAutoSchedule(id) await Promise.all([resumeAutoSchedule(id), resumePublish(id)])
} else if (resumeJob) { } else if (resumeJob) {
clearAutoSchedulePoll() clearAutoSchedulePoll()
autoJob.value = null autoJob.value = null
await resumePublish(id)
} }
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -556,9 +630,11 @@ async function publishPlan() {
'发布课表', '发布课表',
{ type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' }, { type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' },
) )
await http.post(`/schedules/plans/${selected.value.id}/publish`) const planId = selected.value.id
ElMessage.success('课表已发布') const { data } = await http.post(`/schedules/plans/${planId}/publish`)
await loadPlans() publishJob.value = data
ElMessage.success('检查并发布任务已提交,可在当前页面查看进度')
schedulePublishPoll(data.id, planId)
} catch (error: any) { } catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} }
@@ -677,7 +753,10 @@ onMounted(async () => {
await Promise.all([loadPlans(false), loadSchedulingSettings()]) await Promise.all([loadPlans(false), loadSchedulingSettings()])
}) })
onBeforeUnmount(clearAutoSchedulePoll) onBeforeUnmount(() => {
clearAutoSchedulePoll()
clearPublishPoll()
})
</script> </script>
<template> <template>
@@ -722,7 +801,7 @@ onBeforeUnmount(clearAutoSchedulePoll)
<p> {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p> <p> {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
</div> </div>
<div class="plan-actions"> <div class="plan-actions">
<el-button v-if="isDraft" :disabled="autoLoading" @click="openPlan(selected)">编辑版本</el-button> <el-button v-if="isDraft" :disabled="scheduleJobLoading" @click="openPlan(selected)">编辑版本</el-button>
<el-button <el-button
v-if="selected.status === 'Published'" v-if="selected.status === 'Published'"
type="warning" type="warning"
@@ -735,7 +814,7 @@ onBeforeUnmount(clearAutoSchedulePoll)
<el-button <el-button
v-else v-else
:icon="CopyDocument" :icon="CopyDocument"
:disabled="autoLoading" :disabled="scheduleJobLoading"
@click="openClone" @click="openClone"
> >
复制调整 复制调整
@@ -746,13 +825,23 @@ onBeforeUnmount(clearAutoSchedulePoll)
plain plain
:icon="Promotion" :icon="Promotion"
:loading="autoLoading" :loading="autoLoading"
:disabled="publishLoading"
@click="autoSchedule" @click="autoSchedule"
> >
自动排课 自动排课
</el-button> </el-button>
<el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="autoLoading" @click="openEntry()">添加排课</el-button> <el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="scheduleJobLoading" @click="openEntry()">添加排课</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" :disabled="autoLoading" @click="publishPlan">发布课表</el-button> <el-button
<el-button v-if="isDraft" type="danger" plain :disabled="autoLoading" @click="deletePlan">删除草稿</el-button> v-if="isDraft"
type="success"
:icon="Promotion"
:loading="publishLoading"
:disabled="autoLoading"
@click="publishPlan"
>
{{ publishLoading ? '检查并发布中' : '发布课表' }}
</el-button>
<el-button v-if="isDraft" type="danger" plain :disabled="scheduleJobLoading" @click="deletePlan">删除草稿</el-button>
</div> </div>
</header> </header>
@@ -792,6 +881,26 @@ onBeforeUnmount(clearAutoSchedulePoll)
</div> </div>
</div> </div>
<div
v-if="publishJob"
class="auto-schedule-progress"
:class="`is-${String(publishJob.status).toLowerCase()}`"
>
<div>
<b>{{ publishStatusText }}</b>
<span v-if="publishLoading">后台运行中离开页面不会中断任务</span>
<span v-else-if="publishJob.status === 'Failed'">
草稿未发布请根据上方原因调整后重试
</span>
</div>
<el-progress
:percentage="publishProgress"
:status="publishProgressStatus"
:indeterminate="publishJob.status === 'Queued'"
:duration="2"
/>
</div>
<div class="schedule-search"> <div class="schedule-search">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" /> <el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" />
<el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button> <el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button>