主要改动:

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