主要改动:
自动排课接口立即返回 202 + jobId 后台服务独立执行,不受浏览器关闭或前端超时影响 进度、成功、失败状态持久化到数据库 服务重启后自动恢复排队中或中断的任务 同一草稿禁止重复提交后台任务 运行期间禁止编辑、发布或删除该课表 排课结果和任务成功状态在同一事务中提交 前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -14,7 +16,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/schedules")]
|
||||
public sealed class SchedulesController(
|
||||
AppDbContext db,
|
||||
AutomaticScheduleGenerator scheduleGenerator) : ControllerBase
|
||||
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase
|
||||
{
|
||||
private const string ManagementRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -128,6 +130,8 @@ public sealed class SchedulesController(
|
||||
{
|
||||
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
if (plan.Status != SchedulePlanStatus.Draft)
|
||||
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
@@ -147,6 +151,8 @@ public sealed class SchedulesController(
|
||||
CloneSchedulePlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var source = await db.SchedulePlans.AsNoTracking()
|
||||
.Include(x => x.Entries)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
@@ -179,6 +185,8 @@ public sealed class SchedulesController(
|
||||
{
|
||||
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
if (plan.Status != SchedulePlanStatus.Draft)
|
||||
return ConflictProblem("仅草稿排课版本可以删除。");
|
||||
db.SchedulePlans.Remove(plan);
|
||||
@@ -188,6 +196,8 @@ public sealed class SchedulesController(
|
||||
[HttpPost("plans/{id:guid}/publish")]
|
||||
public async Task<ActionResult> PublishPlan(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var plan = await db.SchedulePlans
|
||||
.Include(x => x.Entries)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
@@ -261,6 +271,8 @@ public sealed class SchedulesController(
|
||||
ScheduleEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
|
||||
@@ -271,14 +283,84 @@ public sealed class SchedulesController(
|
||||
}
|
||||
|
||||
[HttpPost("plans/{planId:guid}/auto-schedule")]
|
||||
public async Task<ActionResult> AutoSchedule(
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse>> AutoSchedule(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var result = await scheduleGenerator.GenerateAsync(plan, cancellationToken);
|
||||
return Ok(result);
|
||||
|
||||
var existing = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.ActiveSchedulePlanId == planId,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = existing.Id },
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
var requestedByUserId = Guid.TryParse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||
out var userId)
|
||||
? userId
|
||||
: (Guid?)null;
|
||||
var job = new AutomaticScheduleJob
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
ActiveSchedulePlanId = planId,
|
||||
RequestedByUserId = requestedByUserId
|
||||
};
|
||||
db.AutomaticScheduleJobs.Add(job);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.Entry(job).State = EntityState.Detached;
|
||||
existing = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.ActiveSchedulePlanId == planId,
|
||||
cancellationToken);
|
||||
if (existing is null) throw;
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = existing.Id },
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
automaticScheduleJobQueue.Enqueue(job.Id);
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = job.Id },
|
||||
ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
||||
GetAutomaticScheduleJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||
return job is null ? NotFound() : Ok(ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/auto-schedule-job")]
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse?>>
|
||||
GetActiveAutomaticScheduleJob(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.Where(x => x.ActiveSchedulePlanId == planId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return Ok(job is null ? null : ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
|
||||
@@ -288,6 +370,8 @@ public sealed class SchedulesController(
|
||||
ScheduleEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var entry = await db.ScheduleEntries
|
||||
@@ -315,6 +399,8 @@ public sealed class SchedulesController(
|
||||
Guid entryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
|
||||
var entry = await db.ScheduleEntries
|
||||
.FirstOrDefaultAsync(
|
||||
@@ -332,6 +418,13 @@ public sealed class SchedulesController(
|
||||
x => x.Id == id && x.Status == SchedulePlanStatus.Draft,
|
||||
cancellationToken);
|
||||
|
||||
private Task<bool> HasActiveAutomaticScheduleJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.AutomaticScheduleJobs.AsNoTracking().AnyAsync(
|
||||
x => x.ActiveSchedulePlanId == planId,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult?> ValidateEntryAsync(
|
||||
SchedulePlan plan,
|
||||
Guid? entryId,
|
||||
@@ -486,6 +579,32 @@ public sealed class SchedulesController(
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private ActionResult AutomaticScheduleRunningProblem() =>
|
||||
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
|
||||
|
||||
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
|
||||
{
|
||||
IReadOnlyList<string> messages = [];
|
||||
if (!string.IsNullOrWhiteSpace(job.MessagesJson))
|
||||
{
|
||||
messages = JsonSerializer.Deserialize<string[]>(job.MessagesJson) ?? [];
|
||||
}
|
||||
|
||||
return new(
|
||||
job.Id,
|
||||
job.SchedulePlanId,
|
||||
job.Status,
|
||||
job.TotalTasks,
|
||||
job.ProcessedTasks,
|
||||
job.CreatedEntries,
|
||||
job.CompletedTasks,
|
||||
messages,
|
||||
job.ErrorMessage,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -510,3 +629,17 @@ public sealed record ScheduleEntryRequest(
|
||||
[Range(1, 30)] int EndWeek,
|
||||
WeekPattern WeekPattern,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record AutomaticScheduleJobResponse(
|
||||
Guid Id,
|
||||
Guid SchedulePlanId,
|
||||
AutomaticScheduleJobStatus Status,
|
||||
int TotalTasks,
|
||||
int ProcessedTasks,
|
||||
int CreatedEntries,
|
||||
int CompletedTasks,
|
||||
IReadOnlyList<string> Messages,
|
||||
string? ErrorMessage,
|
||||
DateTime CreatedAt,
|
||||
DateTime? StartedAt,
|
||||
DateTime? CompletedAt);
|
||||
|
||||
@@ -65,6 +65,24 @@ public sealed class TeachingTaskAllowedClassroom
|
||||
public Classroom? Classroom { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AutomaticScheduleJob : EntityBase
|
||||
{
|
||||
public Guid SchedulePlanId { get; set; }
|
||||
public SchedulePlan? SchedulePlan { get; set; }
|
||||
public Guid? ActiveSchedulePlanId { get; set; }
|
||||
public Guid? RequestedByUserId { get; set; }
|
||||
public AutomaticScheduleJobStatus Status { get; set; } =
|
||||
AutomaticScheduleJobStatus.Queued;
|
||||
public int TotalTasks { get; set; }
|
||||
public int ProcessedTasks { get; set; }
|
||||
public int CreatedEntries { get; set; }
|
||||
public int CompletedTasks { get; set; }
|
||||
public string? MessagesJson { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum SchedulePlanStatus
|
||||
{
|
||||
Draft = 1,
|
||||
@@ -72,6 +90,14 @@ public enum SchedulePlanStatus
|
||||
Archived = 3
|
||||
}
|
||||
|
||||
public enum AutomaticScheduleJobStatus
|
||||
{
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Succeeded = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
public enum WeekPattern
|
||||
{
|
||||
All = 1,
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<TeachingTaskScheduleConstraint>();
|
||||
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
|
||||
Set<TeachingTaskAllowedClassroom>();
|
||||
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
|
||||
Set<AutomaticScheduleJob>();
|
||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||
Set<CourseSelectionRound>();
|
||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||
@@ -381,6 +383,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<AutomaticScheduleJob>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||
entity.HasIndex(x => x.ActiveSchedulePlanId).IsUnique();
|
||||
entity.HasIndex(x => new { x.SchedulePlanId, x.CreatedAt });
|
||||
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
entity.HasIndex(x => x.RequestedByUserId);
|
||||
entity.HasOne(x => x.SchedulePlan)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.SchedulePlanId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne<ApplicationUser>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.RequestedByUserId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<CourseSelectionRound>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
|
||||
@@ -23,6 +23,8 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260724_14_scheduling_optimization";
|
||||
private const string TeacherCourseApplicationsMigration =
|
||||
"20260724_15_teacher_course_applications";
|
||||
private const string AutomaticScheduleJobsMigration =
|
||||
"20260724_16_automatic_schedule_jobs";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -135,6 +137,18 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
TeacherCourseApplicationsMigration,
|
||||
teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements,
|
||||
cancellationToken);
|
||||
var automaticScheduleJobsExist = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'AutomaticScheduleJobs'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AutomaticScheduleJobsMigration,
|
||||
automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1022,4 +1036,49 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ON "TeacherCourseApplications" ("ReviewedByUserId");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] AutomaticScheduleJobStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE "AutomaticScheduleJobs" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_AutomaticScheduleJobs" PRIMARY KEY,
|
||||
"SchedulePlanId" TEXT NOT NULL,
|
||||
"ActiveSchedulePlanId" TEXT NULL,
|
||||
"RequestedByUserId" TEXT NULL,
|
||||
"Status" INTEGER NOT NULL,
|
||||
"TotalTasks" INTEGER NOT NULL,
|
||||
"ProcessedTasks" INTEGER NOT NULL,
|
||||
"CreatedEntries" INTEGER NOT NULL,
|
||||
"CompletedTasks" INTEGER NOT NULL,
|
||||
"MessagesJson" TEXT NULL,
|
||||
"ErrorMessage" TEXT NULL,
|
||||
"StartedAt" TEXT NULL,
|
||||
"CompletedAt" TEXT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_AutomaticScheduleJobs_SchedulePlans"
|
||||
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id")
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_AutomaticScheduleJobs_RequestedBy"
|
||||
FOREIGN KEY ("RequestedByUserId") REFERENCES "AspNetUsers" ("Id")
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE UNIQUE INDEX "IX_AutomaticScheduleJobs_ActiveSchedulePlanId"
|
||||
ON "AutomaticScheduleJobs" ("ActiveSchedulePlanId");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_AutomaticScheduleJobs_SchedulePlanId_CreatedAt"
|
||||
ON "AutomaticScheduleJobs" ("SchedulePlanId", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_AutomaticScheduleJobs_Status_CreatedAt"
|
||||
ON "AutomaticScheduleJobs" ("Status", "CreatedAt");
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX "IX_AutomaticScheduleJobs_RequestedByUserId"
|
||||
ON "AutomaticScheduleJobs" ("RequestedByUserId");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+2873
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AutomaticScheduleJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AutomaticScheduleJobs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
ActiveSchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
TotalTasks = table.Column<int>(type: "int", nullable: false),
|
||||
ProcessedTasks = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedEntries = table.Column<int>(type: "int", nullable: false),
|
||||
CompletedTasks = table.Column<int>(type: "int", nullable: false),
|
||||
MessagesJson = table.Column<string>(type: "longtext", nullable: true),
|
||||
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AutomaticScheduleJobs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AutomaticScheduleJobs_AspNetUsers_RequestedByUserId",
|
||||
column: x => x.RequestedByUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_AutomaticScheduleJobs_SchedulePlans_SchedulePlanId",
|
||||
column: x => x.SchedulePlanId,
|
||||
principalTable: "SchedulePlans",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AutomaticScheduleJobs_ActiveSchedulePlanId",
|
||||
table: "AutomaticScheduleJobs",
|
||||
column: "ActiveSchedulePlanId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AutomaticScheduleJobs_RequestedByUserId",
|
||||
table: "AutomaticScheduleJobs",
|
||||
column: "RequestedByUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AutomaticScheduleJobs_SchedulePlanId_CreatedAt",
|
||||
table: "AutomaticScheduleJobs",
|
||||
columns: new[] { "SchedulePlanId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AutomaticScheduleJobs_Status_CreatedAt",
|
||||
table: "AutomaticScheduleJobs",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AutomaticScheduleJobs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -129,6 +129,69 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("AdministrativeClasses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid?>("ActiveSchedulePlanId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("CompletedTasks")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("CreatedEntries")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("varchar(2000)");
|
||||
|
||||
b.Property<string>("MessagesJson")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("ProcessedTasks")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid?>("RequestedByUserId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("SchedulePlanId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("TotalTasks")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ActiveSchedulePlanId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("RequestedByUserId");
|
||||
|
||||
b.HasIndex("SchedulePlanId", "CreatedAt");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("AutomaticScheduleJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2113,6 +2176,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Major");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RequestedByUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan")
|
||||
.WithMany()
|
||||
.HasForeignKey("SchedulePlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SchedulePlan");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
||||
|
||||
@@ -6,8 +6,15 @@ namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
{
|
||||
public Task<AutomaticScheduleResult> GenerateAsync(
|
||||
SchedulePlan plan,
|
||||
CancellationToken cancellationToken) =>
|
||||
GenerateAsync(plan, null, true, cancellationToken);
|
||||
|
||||
public async Task<AutomaticScheduleResult> GenerateAsync(
|
||||
SchedulePlan plan,
|
||||
Func<AutomaticScheduleProgress, CancellationToken, Task>? reportProgress,
|
||||
bool saveChanges,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||
@@ -50,9 +57,18 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
|
||||
var created = 0;
|
||||
var completedTasks = 0;
|
||||
var processedTasks = 0;
|
||||
var messages = new List<string>();
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, 0, 0, 0),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
var scheduledHours = entries
|
||||
.Where(x => x.TeachingTaskId == task.Id)
|
||||
@@ -61,6 +77,13 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
if (remainingHours == 0)
|
||||
{
|
||||
completedTasks++;
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -74,7 +97,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
desiredBlock,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries);
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is null && desiredBlock > 1)
|
||||
{
|
||||
candidate = FindBestCandidate(
|
||||
@@ -84,7 +108,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
1,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries);
|
||||
entries,
|
||||
cancellationToken);
|
||||
}
|
||||
if (candidate is null) break;
|
||||
|
||||
@@ -103,11 +128,24 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
||||
}
|
||||
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0)
|
||||
if (created > 0 && saveChanges)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return new(created, completedTasks, messages);
|
||||
return new(
|
||||
created,
|
||||
completedTasks,
|
||||
messages,
|
||||
tasks.Count,
|
||||
processedTasks);
|
||||
}
|
||||
|
||||
private static ScheduleEntry? FindBestCandidate(
|
||||
@@ -117,7 +155,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
int periodCount,
|
||||
HashSet<int> activePeriods,
|
||||
IReadOnlyList<Classroom> classrooms,
|
||||
IReadOnlyList<ScheduleEntry> entries)
|
||||
IReadOnlyList<ScheduleEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
||||
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||
@@ -129,8 +168,10 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
||||
foreach (var day in allowedDays)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||
continue;
|
||||
|
||||
@@ -145,7 +186,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
TeachingTaskId = task.Id,
|
||||
TeachingTask = task,
|
||||
ClassroomId = room?.Id,
|
||||
Classroom = room,
|
||||
DayOfWeek = day,
|
||||
StartPeriod = start,
|
||||
PeriodCount = periodCount,
|
||||
@@ -216,4 +256,12 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
public sealed record AutomaticScheduleResult(
|
||||
int CreatedEntries,
|
||||
int CompletedTasks,
|
||||
IReadOnlyList<string> Messages);
|
||||
IReadOnlyList<string> Messages,
|
||||
int TotalTasks = 0,
|
||||
int ProcessedTasks = 0);
|
||||
|
||||
public sealed record AutomaticScheduleProgress(
|
||||
int TotalTasks,
|
||||
int ProcessedTasks,
|
||||
int CreatedEntries,
|
||||
int CompletedTasks);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||
|
||||
public sealed class AutomaticScheduleJobQueue
|
||||
{
|
||||
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||
new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public void Enqueue(Guid jobId)
|
||||
{
|
||||
if (!_channel.Writer.TryWrite(jobId))
|
||||
throw new InvalidOperationException("自动排课任务队列当前不可用。");
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AutomaticScheduleJobWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
AutomaticScheduleJobQueue queue,
|
||||
ILogger<AutomaticScheduleJobWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider
|
||||
.GetRequiredService<AutomaticScheduleJobProcessor>();
|
||||
await processor.ProcessAsync(jobId, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Unexpected failure while dispatching automatic schedule job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Automatic schedule job worker is stopping.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var jobs = await db.AutomaticScheduleJobs
|
||||
.Where(x =>
|
||||
x.Status == AutomaticScheduleJobStatus.Queued ||
|
||||
x.Status == AutomaticScheduleJobStatus.Running)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
job.Status = AutomaticScheduleJobStatus.Queued;
|
||||
job.ActiveSchedulePlanId = job.SchedulePlanId;
|
||||
job.StartedAt = null;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
}
|
||||
|
||||
if (jobs.Count > 0)
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
foreach (var job in jobs)
|
||||
queue.Enqueue(job.Id);
|
||||
|
||||
if (jobs.Count > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Recovered {JobCount} queued or interrupted automatic schedule jobs.",
|
||||
jobs.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AutomaticScheduleJobProcessor(
|
||||
AppDbContext db,
|
||||
AutomaticScheduleGenerator generator,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<AutomaticScheduleJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await db.AutomaticScheduleJobs
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||
if (job is null ||
|
||||
job.Status is AutomaticScheduleJobStatus.Succeeded
|
||||
or AutomaticScheduleJobStatus.Failed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var plan = await db.SchedulePlans.FirstOrDefaultAsync(
|
||||
x => x.Id == job.SchedulePlanId,
|
||||
stoppingToken);
|
||||
if (plan is null || plan.Status != SchedulePlanStatus.Draft)
|
||||
throw new InvalidOperationException("排课草稿不存在或已不允许修改。");
|
||||
|
||||
job.Status = AutomaticScheduleJobStatus.Running;
|
||||
job.StartedAt = DateTime.UtcNow;
|
||||
job.CompletedAt = null;
|
||||
job.ErrorMessage = null;
|
||||
job.TotalTasks = await db.TeachingTasks.CountAsync(
|
||||
x =>
|
||||
x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published,
|
||||
stoppingToken);
|
||||
job.ProcessedTasks = 0;
|
||||
job.CreatedEntries = 0;
|
||||
job.CompletedTasks = 0;
|
||||
job.MessagesJson = null;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
|
||||
var progressClock = Stopwatch.StartNew();
|
||||
var lastPersistedTaskCount = 0;
|
||||
|
||||
async Task ReportProgress(
|
||||
AutomaticScheduleProgress progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isFinal = progress.ProcessedTasks >= progress.TotalTasks;
|
||||
var hasBatch = progress.ProcessedTasks - lastPersistedTaskCount >= 5;
|
||||
if (!isFinal && !hasBatch && progressClock.ElapsedMilliseconds < 500)
|
||||
return;
|
||||
|
||||
await PersistProgressAsync(jobId, progress, cancellationToken);
|
||||
lastPersistedTaskCount = progress.ProcessedTasks;
|
||||
progressClock.Restart();
|
||||
}
|
||||
|
||||
var result = await generator.GenerateAsync(
|
||||
plan,
|
||||
ReportProgress,
|
||||
false,
|
||||
stoppingToken);
|
||||
job.Status = AutomaticScheduleJobStatus.Succeeded;
|
||||
job.ActiveSchedulePlanId = null;
|
||||
job.TotalTasks = result.TotalTasks;
|
||||
job.ProcessedTasks = result.ProcessedTasks;
|
||||
job.CreatedEntries = result.CreatedEntries;
|
||||
job.CompletedTasks = result.CompletedTasks;
|
||||
job.MessagesJson = JsonSerializer.Serialize(result.Messages);
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
|
||||
job.Id,
|
||||
result.CreatedEntries);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Automatic schedule job {JobId} was interrupted by application shutdown.",
|
||||
jobId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Automatic schedule job {JobId} failed.",
|
||||
jobId);
|
||||
await MarkFailedAsync(jobId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistProgressAsync(
|
||||
Guid jobId,
|
||||
AutomaticScheduleProgress progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var progressDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var updatedAt = DateTime.UtcNow;
|
||||
await progressDb.AutomaticScheduleJobs
|
||||
.Where(x =>
|
||||
x.Id == jobId &&
|
||||
x.Status == AutomaticScheduleJobStatus.Running)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(x => x.TotalTasks, progress.TotalTasks)
|
||||
.SetProperty(x => x.ProcessedTasks, progress.ProcessedTasks)
|
||||
.SetProperty(x => x.CreatedEntries, progress.CreatedEntries)
|
||||
.SetProperty(x => x.CompletedTasks, progress.CompletedTasks)
|
||||
.SetProperty(x => x.UpdatedAt, updatedAt),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not persist progress for automatic schedule job {JobId}.",
|
||||
jobId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MarkFailedAsync(Guid jobId, Exception exception)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var job = await db.AutomaticScheduleJobs.FirstOrDefaultAsync(
|
||||
x => x.Id == jobId,
|
||||
CancellationToken.None);
|
||||
if (job is null)
|
||||
return;
|
||||
|
||||
var message = exception.GetBaseException().Message;
|
||||
job.Status = AutomaticScheduleJobStatus.Failed;
|
||||
job.ActiveSchedulePlanId = null;
|
||||
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,9 @@ builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
|
||||
Reference in New Issue
Block a user