主要改动:
自动排课接口立即返回 202 + jobId 后台服务独立执行,不受浏览器关闭或前端超时影响 进度、成功、失败状态持久化到数据库 服务重启后自动恢复排队中或中断的任务 同一草稿禁止重复提交后台任务 运行期间禁止编辑、发布或删除该课表 排课结果和任务成功状态在同一事务中提交 前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -14,7 +16,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/schedules")]
|
[Route("api/schedules")]
|
||||||
public sealed class SchedulesController(
|
public sealed class SchedulesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
AutomaticScheduleGenerator scheduleGenerator) : ControllerBase
|
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ManagementRoles =
|
private const string ManagementRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -128,6 +130,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))
|
||||||
|
return AutomaticScheduleRunningProblem();
|
||||||
if (plan.Status != SchedulePlanStatus.Draft)
|
if (plan.Status != SchedulePlanStatus.Draft)
|
||||||
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
|
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
|
||||||
if (!await db.AcademicTerms.AnyAsync(
|
if (!await db.AcademicTerms.AnyAsync(
|
||||||
@@ -147,6 +151,8 @@ public sealed class SchedulesController(
|
|||||||
CloneSchedulePlanRequest request,
|
CloneSchedulePlanRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||||
|
return AutomaticScheduleRunningProblem();
|
||||||
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);
|
||||||
@@ -179,6 +185,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))
|
||||||
|
return AutomaticScheduleRunningProblem();
|
||||||
if (plan.Status != SchedulePlanStatus.Draft)
|
if (plan.Status != SchedulePlanStatus.Draft)
|
||||||
return ConflictProblem("仅草稿排课版本可以删除。");
|
return ConflictProblem("仅草稿排课版本可以删除。");
|
||||||
db.SchedulePlans.Remove(plan);
|
db.SchedulePlans.Remove(plan);
|
||||||
@@ -188,6 +196,8 @@ 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> PublishPlan(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||||
|
return AutomaticScheduleRunningProblem();
|
||||||
var plan = await db.SchedulePlans
|
var plan = await db.SchedulePlans
|
||||||
.Include(x => x.Entries)
|
.Include(x => x.Entries)
|
||||||
.ThenInclude(x => x.TeachingTask)
|
.ThenInclude(x => x.TeachingTask)
|
||||||
@@ -261,6 +271,8 @@ public sealed class SchedulesController(
|
|||||||
ScheduleEntryRequest request,
|
ScheduleEntryRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||||
|
return AutomaticScheduleRunningProblem();
|
||||||
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);
|
||||||
@@ -271,14 +283,84 @@ public sealed class SchedulesController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("plans/{planId:guid}/auto-schedule")]
|
[HttpPost("plans/{planId:guid}/auto-schedule")]
|
||||||
public async Task<ActionResult> AutoSchedule(
|
public async Task<ActionResult<AutomaticScheduleJobResponse>> AutoSchedule(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
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 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}")]
|
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
|
||||||
@@ -288,6 +370,8 @@ public sealed class SchedulesController(
|
|||||||
ScheduleEntryRequest request,
|
ScheduleEntryRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||||
|
return AutomaticScheduleRunningProblem();
|
||||||
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
|
||||||
@@ -315,6 +399,8 @@ public sealed class SchedulesController(
|
|||||||
Guid entryId,
|
Guid entryId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||||
|
return AutomaticScheduleRunningProblem();
|
||||||
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(
|
||||||
@@ -332,6 +418,13 @@ public sealed class SchedulesController(
|
|||||||
x => x.Id == id && x.Status == SchedulePlanStatus.Draft,
|
x => x.Id == id && x.Status == SchedulePlanStatus.Draft,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
private Task<bool> HasActiveAutomaticScheduleJobAsync(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.AutomaticScheduleJobs.AsNoTracking().AnyAsync(
|
||||||
|
x => x.ActiveSchedulePlanId == planId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
private async Task<ActionResult?> ValidateEntryAsync(
|
private async Task<ActionResult?> ValidateEntryAsync(
|
||||||
SchedulePlan plan,
|
SchedulePlan plan,
|
||||||
Guid? entryId,
|
Guid? entryId,
|
||||||
@@ -486,6 +579,32 @@ public sealed class SchedulesController(
|
|||||||
Status = StatusCodes.Status409Conflict
|
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) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
@@ -510,3 +629,17 @@ public sealed record ScheduleEntryRequest(
|
|||||||
[Range(1, 30)] int EndWeek,
|
[Range(1, 30)] int EndWeek,
|
||||||
WeekPattern WeekPattern,
|
WeekPattern WeekPattern,
|
||||||
[MaxLength(500)] string? Notes);
|
[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 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
|
public enum SchedulePlanStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
@@ -72,6 +90,14 @@ public enum SchedulePlanStatus
|
|||||||
Archived = 3
|
Archived = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum AutomaticScheduleJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|
||||||
public enum WeekPattern
|
public enum WeekPattern
|
||||||
{
|
{
|
||||||
All = 1,
|
All = 1,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<TeachingTaskScheduleConstraint>();
|
Set<TeachingTaskScheduleConstraint>();
|
||||||
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
|
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
|
||||||
Set<TeachingTaskAllowedClassroom>();
|
Set<TeachingTaskAllowedClassroom>();
|
||||||
|
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
|
||||||
|
Set<AutomaticScheduleJob>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||||
@@ -381,6 +383,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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 =>
|
builder.Entity<CourseSelectionRound>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260724_14_scheduling_optimization";
|
"20260724_14_scheduling_optimization";
|
||||||
private const string TeacherCourseApplicationsMigration =
|
private const string TeacherCourseApplicationsMigration =
|
||||||
"20260724_15_teacher_course_applications";
|
"20260724_15_teacher_course_applications";
|
||||||
|
private const string AutomaticScheduleJobsMigration =
|
||||||
|
"20260724_16_automatic_schedule_jobs";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -135,6 +137,18 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
TeacherCourseApplicationsMigration,
|
TeacherCourseApplicationsMigration,
|
||||||
teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements,
|
teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements,
|
||||||
cancellationToken);
|
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(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1022,4 +1036,49 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "TeacherCourseApplications" ("ReviewedByUserId");
|
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");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2113,6 +2176,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Major");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus")
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ namespace Jiaowu.Api.Infrastructure.Scheduling;
|
|||||||
|
|
||||||
public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||||
{
|
{
|
||||||
|
public Task<AutomaticScheduleResult> GenerateAsync(
|
||||||
|
SchedulePlan plan,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
GenerateAsync(plan, null, true, cancellationToken);
|
||||||
|
|
||||||
public async Task<AutomaticScheduleResult> GenerateAsync(
|
public async Task<AutomaticScheduleResult> GenerateAsync(
|
||||||
SchedulePlan plan,
|
SchedulePlan plan,
|
||||||
|
Func<AutomaticScheduleProgress, CancellationToken, Task>? reportProgress,
|
||||||
|
bool saveChanges,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
@@ -50,9 +57,18 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
|
|
||||||
var created = 0;
|
var created = 0;
|
||||||
var completedTasks = 0;
|
var completedTasks = 0;
|
||||||
|
var processedTasks = 0;
|
||||||
var messages = new List<string>();
|
var messages = new List<string>();
|
||||||
|
if (reportProgress is not null)
|
||||||
|
{
|
||||||
|
await reportProgress(
|
||||||
|
new(tasks.Count, 0, 0, 0),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
constraints.TryGetValue(task.Id, out var constraint);
|
constraints.TryGetValue(task.Id, out var constraint);
|
||||||
var scheduledHours = entries
|
var scheduledHours = entries
|
||||||
.Where(x => x.TeachingTaskId == task.Id)
|
.Where(x => x.TeachingTaskId == task.Id)
|
||||||
@@ -61,6 +77,13 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
if (remainingHours == 0)
|
if (remainingHours == 0)
|
||||||
{
|
{
|
||||||
completedTasks++;
|
completedTasks++;
|
||||||
|
processedTasks++;
|
||||||
|
if (reportProgress is not null)
|
||||||
|
{
|
||||||
|
await reportProgress(
|
||||||
|
new(tasks.Count, processedTasks, created, completedTasks),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +97,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
desiredBlock,
|
desiredBlock,
|
||||||
activePeriods,
|
activePeriods,
|
||||||
classrooms,
|
classrooms,
|
||||||
entries);
|
entries,
|
||||||
|
cancellationToken);
|
||||||
if (candidate is null && desiredBlock > 1)
|
if (candidate is null && desiredBlock > 1)
|
||||||
{
|
{
|
||||||
candidate = FindBestCandidate(
|
candidate = FindBestCandidate(
|
||||||
@@ -84,7 +108,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
1,
|
1,
|
||||||
activePeriods,
|
activePeriods,
|
||||||
classrooms,
|
classrooms,
|
||||||
entries);
|
entries,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
if (candidate is null) break;
|
if (candidate is null) break;
|
||||||
|
|
||||||
@@ -103,11 +128,24 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
messages.Add(
|
messages.Add(
|
||||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
$"{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);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return new(created, completedTasks, messages);
|
return new(
|
||||||
|
created,
|
||||||
|
completedTasks,
|
||||||
|
messages,
|
||||||
|
tasks.Count,
|
||||||
|
processedTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ScheduleEntry? FindBestCandidate(
|
private static ScheduleEntry? FindBestCandidate(
|
||||||
@@ -117,7 +155,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
int periodCount,
|
int periodCount,
|
||||||
HashSet<int> activePeriods,
|
HashSet<int> activePeriods,
|
||||||
IReadOnlyList<Classroom> classrooms,
|
IReadOnlyList<Classroom> classrooms,
|
||||||
IReadOnlyList<ScheduleEntry> entries)
|
IReadOnlyList<ScheduleEntry> entries,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
||||||
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||||
@@ -129,8 +168,10 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
||||||
foreach (var day in allowedDays)
|
foreach (var day in allowedDays)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||||
{
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -145,7 +186,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
TeachingTaskId = task.Id,
|
TeachingTaskId = task.Id,
|
||||||
TeachingTask = task,
|
TeachingTask = task,
|
||||||
ClassroomId = room?.Id,
|
ClassroomId = room?.Id,
|
||||||
Classroom = room,
|
|
||||||
DayOfWeek = day,
|
DayOfWeek = day,
|
||||||
StartPeriod = start,
|
StartPeriod = start,
|
||||||
PeriodCount = periodCount,
|
PeriodCount = periodCount,
|
||||||
@@ -216,4 +256,12 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
public sealed record AutomaticScheduleResult(
|
public sealed record AutomaticScheduleResult(
|
||||||
int CreatedEntries,
|
int CreatedEntries,
|
||||||
int CompletedTasks,
|
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<DevelopmentSqliteMigrator>();
|
||||||
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
|
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||||
|
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||||
|
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
await migrator.MigrateAsync();
|
await migrator.MigrateAsync();
|
||||||
|
|
||||||
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
||||||
|
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
|
||||||
Assert.True(await db.Database
|
Assert.True(await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"""
|
"""
|
||||||
@@ -112,15 +113,167 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var progressUpdates = new List<AutomaticScheduleProgress>();
|
||||||
var result = await new AutomaticScheduleGenerator(db)
|
var result = await new AutomaticScheduleGenerator(db)
|
||||||
.GenerateAsync(plan, CancellationToken.None);
|
.GenerateAsync(
|
||||||
|
plan,
|
||||||
|
(progress, _) =>
|
||||||
|
{
|
||||||
|
progressUpdates.Add(progress);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
CancellationToken.None);
|
||||||
var entry = await db.ScheduleEntries.SingleAsync();
|
var entry = await db.ScheduleEntries.SingleAsync();
|
||||||
|
|
||||||
Assert.Equal(1, result.CreatedEntries);
|
Assert.Equal(1, result.CreatedEntries);
|
||||||
Assert.Empty(result.Messages);
|
Assert.Empty(result.Messages);
|
||||||
|
Assert.Collection(
|
||||||
|
progressUpdates,
|
||||||
|
progress =>
|
||||||
|
{
|
||||||
|
Assert.Equal(1, progress.TotalTasks);
|
||||||
|
Assert.Equal(0, progress.ProcessedTasks);
|
||||||
|
},
|
||||||
|
progress =>
|
||||||
|
{
|
||||||
|
Assert.Equal(1, progress.TotalTasks);
|
||||||
|
Assert.Equal(1, progress.ProcessedTasks);
|
||||||
|
Assert.Equal(1, progress.CreatedEntries);
|
||||||
|
});
|
||||||
Assert.Equal(3, entry.DayOfWeek);
|
Assert.Equal(3, entry.DayOfWeek);
|
||||||
Assert.Equal(1, entry.StartPeriod);
|
Assert.Equal(1, entry.StartPeriod);
|
||||||
Assert.Equal(2, entry.PeriodCount);
|
Assert.Equal(2, entry.PeriodCount);
|
||||||
Assert.Null(entry.ClassroomId);
|
Assert.Null(entry.ClassroomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generator_does_not_attach_duplicate_buildings_from_untracked_classrooms()
|
||||||
|
{
|
||||||
|
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-B",
|
||||||
|
Name = "2026 秋季",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "A",
|
||||||
|
Name = "教学楼 A",
|
||||||
|
Campus = campus
|
||||||
|
};
|
||||||
|
var smallRoom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "A101",
|
||||||
|
Name = "A101",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 20
|
||||||
|
};
|
||||||
|
var largeRoom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "A102",
|
||||||
|
Name = "A102",
|
||||||
|
Building = building,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS-01",
|
||||||
|
Name = "程序设计",
|
||||||
|
College = college,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32
|
||||||
|
};
|
||||||
|
var smallTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "TASK-SMALL",
|
||||||
|
Name = "小班教学任务",
|
||||||
|
AcademicTerm = term,
|
||||||
|
Course = course,
|
||||||
|
Capacity = 20,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeeklyHours = 2,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var largeTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "TASK-LARGE",
|
||||||
|
Name = "大班教学任务",
|
||||||
|
AcademicTerm = term,
|
||||||
|
Course = course,
|
||||||
|
Capacity = 30,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeeklyHours = 2,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "同楼多教室排课",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
term,
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
smallRoom,
|
||||||
|
largeRoom,
|
||||||
|
college,
|
||||||
|
course,
|
||||||
|
smallTask,
|
||||||
|
largeTask,
|
||||||
|
plan);
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
plan = await db.SchedulePlans.SingleAsync();
|
||||||
|
|
||||||
|
var result = await new AutomaticScheduleGenerator(db)
|
||||||
|
.GenerateAsync(plan, CancellationToken.None);
|
||||||
|
var entries = await db.ScheduleEntries
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, result.CreatedEntries);
|
||||||
|
Assert.Empty(result.Messages);
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
Assert.True(
|
||||||
|
new HashSet<Guid> { smallRoom.Id, largeRoom.Id }
|
||||||
|
.SetEquals(entries.Select(x => x.ClassroomId!.Value)));
|
||||||
|
Assert.Equal(1, await db.Buildings.CountAsync());
|
||||||
|
Assert.Equal(2, await db.Classrooms.CountAsync());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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 AutomaticScheduleJobProcessorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Processor_persists_progress_result_and_schedule_entries()
|
||||||
|
{
|
||||||
|
var databasePath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"jiaowu-auto-schedule-{Guid.NewGuid():N}.sqlite");
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDbContext<AppDbContext>(
|
||||||
|
options => options.UseSqlite(
|
||||||
|
$"Data Source={databasePath};Pooling=False"));
|
||||||
|
services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
|
services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||||
|
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-JOB",
|
||||||
|
Name = "后台排课学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var college = new College { Code = "JOB", Name = "后台任务学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "JOB-01",
|
||||||
|
Name = "后台任务课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 1,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "TASK-JOB",
|
||||||
|
Name = "后台任务教学班",
|
||||||
|
AcademicTerm = term,
|
||||||
|
Course = course,
|
||||||
|
Capacity = 100,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeeklyHours = 2,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "后台排课草稿",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
var job = new AutomaticScheduleJob
|
||||||
|
{
|
||||||
|
SchedulePlan = plan,
|
||||||
|
ActiveSchedulePlanId = plan.Id
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
AllowedDayOfWeeks = "2",
|
||||||
|
EarliestPeriod = 1,
|
||||||
|
LatestPeriod = 2
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var workerScope = provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var processor = workerScope.ServiceProvider
|
||||||
|
.GetRequiredService<AutomaticScheduleJobProcessor>();
|
||||||
|
await processor.ProcessAsync(jobId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var assertScope = provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var job = await db.AutomaticScheduleJobs.SingleAsync();
|
||||||
|
var entry = await db.ScheduleEntries.SingleAsync();
|
||||||
|
|
||||||
|
Assert.Equal(AutomaticScheduleJobStatus.Succeeded, job.Status);
|
||||||
|
Assert.Null(job.ActiveSchedulePlanId);
|
||||||
|
Assert.Equal(1, job.TotalTasks);
|
||||||
|
Assert.Equal(1, job.ProcessedTasks);
|
||||||
|
Assert.Equal(1, job.CreatedEntries);
|
||||||
|
Assert.Equal(1, job.CompletedTasks);
|
||||||
|
Assert.Equal("[]", job.MessagesJson);
|
||||||
|
Assert.NotNull(job.StartedAt);
|
||||||
|
Assert.NotNull(job.CompletedAt);
|
||||||
|
Assert.Null(job.ErrorMessage);
|
||||||
|
Assert.Equal(2, entry.DayOfWeek);
|
||||||
|
Assert.Equal(2, entry.PeriodCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await provider.DisposeAsync();
|
||||||
|
File.Delete(databasePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -323,6 +323,13 @@ button { cursor: pointer; }
|
|||||||
.schedule-sheet-head > div:first-child > span { color: var(--teal); font-size: 10px; letter-spacing: .07em; }
|
.schedule-sheet-head > div:first-child > span { color: var(--teal); font-size: 10px; letter-spacing: .07em; }
|
||||||
.schedule-sheet-head h3 { margin: 6px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; }
|
.schedule-sheet-head h3 { margin: 6px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; }
|
||||||
.schedule-sheet-head p { margin: 0; color: var(--muted); font-size: 10px; }
|
.schedule-sheet-head p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||||
|
.auto-schedule-progress { padding: 13px 18px; display: grid; grid-template-columns: minmax(260px, 1fr) minmax(220px, 38%); align-items: center; gap: 20px; border-bottom: 1px solid #c9d9dc; background: #f1f7f7; }
|
||||||
|
.auto-schedule-progress > div { display: grid; gap: 3px; }
|
||||||
|
.auto-schedule-progress b { color: #24484d; font-size: 12px; font-weight: 650; }
|
||||||
|
.auto-schedule-progress span { color: var(--muted); font-size: 10px; }
|
||||||
|
.auto-schedule-progress.is-succeeded { border-bottom-color: #bedac8; background: #f1f8f3; }
|
||||||
|
.auto-schedule-progress.is-failed { border-bottom-color: #e6c5c5; background: #fff5f4; }
|
||||||
|
.auto-schedule-progress.is-failed b { color: #9d3434; }
|
||||||
.schedule-search { padding: 12px 16px; display: flex; align-items: center; gap: 10px; background: #fafbfc; border-bottom: 1px solid var(--line); }
|
.schedule-search { padding: 12px 16px; display: flex; align-items: center; gap: 10px; background: #fafbfc; border-bottom: 1px solid var(--line); }
|
||||||
.schedule-search .el-input { width: 320px; }
|
.schedule-search .el-input { width: 320px; }
|
||||||
.schedule-search > span { margin-left: auto; color: var(--muted); font-size: 10px; }
|
.schedule-search > span { margin-left: auto; color: var(--muted); font-size: 10px; }
|
||||||
@@ -1012,6 +1019,7 @@ button { cursor: pointer; }
|
|||||||
.schedule-version-strip { margin-top: 10px; }
|
.schedule-version-strip { margin-top: 10px; }
|
||||||
.schedule-sheet-head { display: block; }
|
.schedule-sheet-head { display: block; }
|
||||||
.schedule-sheet-head .plan-actions { margin-top: 15px; }
|
.schedule-sheet-head .plan-actions { margin-top: 15px; }
|
||||||
|
.auto-schedule-progress { grid-template-columns: 1fr; gap: 10px; }
|
||||||
.schedule-search { flex-wrap: wrap; }
|
.schedule-search { flex-wrap: wrap; }
|
||||||
.schedule-search .el-input { width: 100%; }
|
.schedule-search .el-input { width: 100%; }
|
||||||
.schedule-search > span { width: 100%; margin-left: 0; }
|
.schedule-search > span { width: 100%; margin-left: 0; }
|
||||||
|
|||||||
+127
-20
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { CopyDocument, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
|
import { CopyDocument, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const timeSlots = ref<any[]>([])
|
|||||||
const constraints = ref<any[]>([])
|
const constraints = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const autoLoading = ref(false)
|
const autoJob = 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)
|
||||||
@@ -29,6 +29,7 @@ const planForm = reactive<Record<string, any>>({})
|
|||||||
const cloneForm = reactive<Record<string, any>>({})
|
const cloneForm = reactive<Record<string, any>>({})
|
||||||
const entryForm = reactive<Record<string, any>>({})
|
const entryForm = reactive<Record<string, any>>({})
|
||||||
const constraintForm = reactive<Record<string, any>>({})
|
const constraintForm = reactive<Record<string, any>>({})
|
||||||
|
let autoPollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
const weekdays = [
|
const weekdays = [
|
||||||
{ value: 1, label: '星期一' },
|
{ value: 1, label: '星期一' },
|
||||||
@@ -56,6 +57,32 @@ const patternLabels: Record<string, string> = {
|
|||||||
Even: '双周',
|
Even: '双周',
|
||||||
}
|
}
|
||||||
const isDraft = computed(() => selected.value?.status === 'Draft')
|
const isDraft = computed(() => selected.value?.status === 'Draft')
|
||||||
|
const autoLoading = computed(() =>
|
||||||
|
autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running',
|
||||||
|
)
|
||||||
|
const autoProgress = computed(() => {
|
||||||
|
if (!autoJob.value?.totalTasks) return 0
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(autoJob.value.processedTasks / autoJob.value.totalTasks * 100),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const autoProgressStatus = computed(() => {
|
||||||
|
if (autoJob.value?.status === 'Succeeded') return 'success'
|
||||||
|
if (autoJob.value?.status === 'Failed') return 'exception'
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const autoStatusText = computed(() => {
|
||||||
|
if (!autoJob.value) return ''
|
||||||
|
if (autoJob.value.status === 'Queued') return '任务已进入队列,等待后台执行'
|
||||||
|
if (autoJob.value.status === 'Running') {
|
||||||
|
return `正在处理 ${autoJob.value.processedTasks}/${autoJob.value.totalTasks} 个教学班,已规划 ${autoJob.value.createdEntries} 条安排`
|
||||||
|
}
|
||||||
|
if (autoJob.value.status === 'Succeeded') {
|
||||||
|
return `后台排课已完成,共生成 ${autoJob.value.createdEntries} 条安排`
|
||||||
|
}
|
||||||
|
return autoJob.value.errorMessage || '后台排课失败,请稍后重试'
|
||||||
|
})
|
||||||
const selectedTaskConstraint = computed(() =>
|
const selectedTaskConstraint = computed(() =>
|
||||||
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
||||||
)
|
)
|
||||||
@@ -196,21 +223,66 @@ async function autoSchedule() {
|
|||||||
'开始自动排课',
|
'开始自动排课',
|
||||||
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
|
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
|
||||||
)
|
)
|
||||||
autoLoading.value = true
|
const planId = selected.value.id
|
||||||
const { data } = await http.post(`/schedules/plans/${selected.value.id}/auto-schedule`)
|
const { data } = await http.post(`/schedules/plans/${planId}/auto-schedule`)
|
||||||
await loadDetail(selected.value.id)
|
autoJob.value = data
|
||||||
if (data.messages.length) {
|
ElMessage.success('自动排课任务已提交,可留在当前页面查看进度')
|
||||||
ElMessage.warning(`已生成 ${data.createdEntries} 条安排,仍有 ${data.messages.length} 个任务需人工处理`)
|
scheduleAutoSchedulePoll(data.id, planId)
|
||||||
} else {
|
|
||||||
ElMessage.success(`自动排课完成,共生成 ${data.createdEntries} 条安排`)
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
|
||||||
autoLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAutoSchedulePoll() {
|
||||||
|
if (autoPollTimer) clearTimeout(autoPollTimer)
|
||||||
|
autoPollTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAutoSchedulePoll(jobId: string, planId: string) {
|
||||||
|
clearAutoSchedulePoll()
|
||||||
|
autoPollTimer = setTimeout(() => pollAutoScheduleJob(jobId, planId), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAutoScheduleJob(jobId: string, planId: string) {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/schedules/auto-schedule-jobs/${jobId}`)
|
||||||
|
if (selected.value?.id !== planId) return
|
||||||
|
autoJob.value = data
|
||||||
|
if (data.status === 'Queued' || data.status === 'Running') {
|
||||||
|
scheduleAutoSchedulePoll(jobId, planId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearAutoSchedulePoll()
|
||||||
|
if (data.status === 'Succeeded') {
|
||||||
|
await loadDetail(planId, false)
|
||||||
|
const summary = plans.value.find((item) => item.id === planId)
|
||||||
|
if (summary) summary.entryCount = selected.value.entries.length
|
||||||
|
if (data.messages.length) {
|
||||||
|
ElMessage.warning(
|
||||||
|
`后台排课完成,已生成 ${data.createdEntries} 条安排,仍有 ${data.messages.length} 个任务需人工处理`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`后台排课完成,共生成 ${data.createdEntries} 条安排`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(data.errorMessage || '后台排课失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (selected.value?.id === planId) {
|
||||||
|
scheduleAutoSchedulePoll(jobId, planId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeAutoSchedule(planId: string) {
|
||||||
|
clearAutoSchedulePoll()
|
||||||
|
autoJob.value = null
|
||||||
|
const { data } = await http.get(`/schedules/plans/${planId}/auto-schedule-job`)
|
||||||
|
if (selected.value?.id !== planId || !data) return
|
||||||
|
autoJob.value = data
|
||||||
|
scheduleAutoSchedulePoll(data.id, planId)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPlans(keepSelection = true) {
|
async function loadPlans(keepSelection = true) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -220,8 +292,13 @@ async function loadPlans(keepSelection = true) {
|
|||||||
const id = keepSelection && selected.value
|
const id = keepSelection && selected.value
|
||||||
? selected.value.id
|
? selected.value.id
|
||||||
: plans.value[0]?.id
|
: plans.value[0]?.id
|
||||||
if (id && plans.value.some((item) => item.id === id)) await loadDetail(id)
|
if (id && plans.value.some((item) => item.id === id)) {
|
||||||
else selected.value = null
|
await loadDetail(id)
|
||||||
|
} else {
|
||||||
|
selected.value = null
|
||||||
|
clearAutoSchedulePoll()
|
||||||
|
autoJob.value = null
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -229,10 +306,16 @@ async function loadPlans(keepSelection = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail(id: string) {
|
async function loadDetail(id: string, resumeJob = true) {
|
||||||
detailLoading.value = true
|
detailLoading.value = 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') {
|
||||||
|
await resumeAutoSchedule(id)
|
||||||
|
} else if (resumeJob) {
|
||||||
|
clearAutoSchedulePoll()
|
||||||
|
autoJob.value = null
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -376,6 +459,8 @@ onMounted(async () => {
|
|||||||
termId.value = terms.value.find((item) => item.isCurrent)?.id
|
termId.value = terms.value.find((item) => item.isCurrent)?.id
|
||||||
await Promise.all([loadPlans(false), loadSchedulingSettings()])
|
await Promise.all([loadPlans(false), loadSchedulingSettings()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(clearAutoSchedulePoll)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -420,8 +505,8 @@ onMounted(async () => {
|
|||||||
<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" @click="openPlan(selected)">编辑版本</el-button>
|
<el-button v-if="isDraft" :disabled="autoLoading" @click="openPlan(selected)">编辑版本</el-button>
|
||||||
<el-button :icon="CopyDocument" @click="openClone">复制调整</el-button>
|
<el-button :icon="CopyDocument" :disabled="autoLoading" @click="openClone">复制调整</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isDraft"
|
v-if="isDraft"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -432,12 +517,34 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
自动排课
|
自动排课
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="isDraft" type="primary" :icon="Plus" @click="openEntry()">添加排课</el-button>
|
<el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="autoLoading" @click="openEntry()">添加排课</el-button>
|
||||||
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布课表</el-button>
|
<el-button v-if="isDraft" type="success" :icon="Promotion" :disabled="autoLoading" @click="publishPlan">发布课表</el-button>
|
||||||
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除草稿</el-button>
|
<el-button v-if="isDraft" type="danger" plain :disabled="autoLoading" @click="deletePlan">删除草稿</el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="autoJob"
|
||||||
|
class="auto-schedule-progress"
|
||||||
|
:class="`is-${String(autoJob.status).toLowerCase()}`"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<b>{{ autoStatusText }}</b>
|
||||||
|
<span v-if="autoJob.status === 'Running'">
|
||||||
|
后台运行中,离开页面不会中断任务
|
||||||
|
</span>
|
||||||
|
<span v-else-if="autoJob.status === 'Succeeded' && autoJob.messages.length">
|
||||||
|
{{ autoJob.messages.length }} 个教学任务仍需人工处理
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="autoProgress"
|
||||||
|
:status="autoProgressStatus"
|
||||||
|
:indeterminate="autoJob.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user