主要改动:

自动排课接口立即返回 202 + jobId
后台服务独立执行,不受浏览器关闭或前端超时影响
进度、成功、失败状态持久化到数据库
服务重启后自动恢复排队中或中断的任务
同一草稿禁止重复提交后台任务
运行期间禁止编辑、发布或删除该课表
排课结果和任务成功状态在同一事务中提交
前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
biss committed 2026-07-24 21:55:07 +08:00
1 parent 49b550560a
commit d67a07f23e
14 files changed
+4011 -32

No files matched your search

@@ -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);
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff. Show more