主要改动:

自动排课接口立即返回 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.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);
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff. Show more