主要改动:
自动排课接口立即返回 202 + jobId 后台服务独立执行,不受浏览器关闭或前端超时影响 进度、成功、失败状态持久化到数据库 服务重启后自动恢复排队中或中断的任务 同一草稿禁止重复提交后台任务 运行期间禁止编辑、发布或删除该课表 排课结果和任务成功状态在同一事务中提交 前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -14,7 +16,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/schedules")]
|
||||
public sealed class SchedulesController(
|
||||
AppDbContext db,
|
||||
AutomaticScheduleGenerator scheduleGenerator) : ControllerBase
|
||||
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase
|
||||
{
|
||||
private const string ManagementRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -128,6 +130,8 @@ public sealed class SchedulesController(
|
||||
{
|
||||
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
if (plan.Status != SchedulePlanStatus.Draft)
|
||||
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
@@ -147,6 +151,8 @@ public sealed class SchedulesController(
|
||||
CloneSchedulePlanRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var source = await db.SchedulePlans.AsNoTracking()
|
||||
.Include(x => x.Entries)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
@@ -179,6 +185,8 @@ public sealed class SchedulesController(
|
||||
{
|
||||
var plan = await db.SchedulePlans.FindAsync([id], cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
if (plan.Status != SchedulePlanStatus.Draft)
|
||||
return ConflictProblem("仅草稿排课版本可以删除。");
|
||||
db.SchedulePlans.Remove(plan);
|
||||
@@ -188,6 +196,8 @@ public sealed class SchedulesController(
|
||||
[HttpPost("plans/{id:guid}/publish")]
|
||||
public async Task<ActionResult> PublishPlan(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var plan = await db.SchedulePlans
|
||||
.Include(x => x.Entries)
|
||||
.ThenInclude(x => x.TeachingTask)
|
||||
@@ -261,6 +271,8 @@ public sealed class SchedulesController(
|
||||
ScheduleEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var validation = await ValidateEntryAsync(plan, null, request, cancellationToken);
|
||||
@@ -271,14 +283,84 @@ public sealed class SchedulesController(
|
||||
}
|
||||
|
||||
[HttpPost("plans/{planId:guid}/auto-schedule")]
|
||||
public async Task<ActionResult> AutoSchedule(
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse>> AutoSchedule(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var result = await scheduleGenerator.GenerateAsync(plan, cancellationToken);
|
||||
return Ok(result);
|
||||
|
||||
var existing = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.ActiveSchedulePlanId == planId,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = existing.Id },
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
var requestedByUserId = Guid.TryParse(
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||
out var userId)
|
||||
? userId
|
||||
: (Guid?)null;
|
||||
var job = new AutomaticScheduleJob
|
||||
{
|
||||
SchedulePlanId = planId,
|
||||
ActiveSchedulePlanId = planId,
|
||||
RequestedByUserId = requestedByUserId
|
||||
};
|
||||
db.AutomaticScheduleJobs.Add(job);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.Entry(job).State = EntityState.Detached;
|
||||
existing = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.ActiveSchedulePlanId == planId,
|
||||
cancellationToken);
|
||||
if (existing is null) throw;
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = existing.Id },
|
||||
ToResponse(existing));
|
||||
}
|
||||
|
||||
automaticScheduleJobQueue.Enqueue(job.Id);
|
||||
return AcceptedAtAction(
|
||||
nameof(GetAutomaticScheduleJob),
|
||||
new { jobId = job.Id },
|
||||
ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
||||
GetAutomaticScheduleJob(
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||
return job is null ? NotFound() : Ok(ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/auto-schedule-job")]
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse?>>
|
||||
GetActiveAutomaticScheduleJob(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||
.Where(x => x.ActiveSchedulePlanId == planId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return Ok(job is null ? null : ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
|
||||
@@ -288,6 +370,8 @@ public sealed class SchedulesController(
|
||||
ScheduleEntryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var entry = await db.ScheduleEntries
|
||||
@@ -315,6 +399,8 @@ public sealed class SchedulesController(
|
||||
Guid entryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
||||
return AutomaticScheduleRunningProblem();
|
||||
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
|
||||
var entry = await db.ScheduleEntries
|
||||
.FirstOrDefaultAsync(
|
||||
@@ -332,6 +418,13 @@ public sealed class SchedulesController(
|
||||
x => x.Id == id && x.Status == SchedulePlanStatus.Draft,
|
||||
cancellationToken);
|
||||
|
||||
private Task<bool> HasActiveAutomaticScheduleJobAsync(
|
||||
Guid planId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.AutomaticScheduleJobs.AsNoTracking().AnyAsync(
|
||||
x => x.ActiveSchedulePlanId == planId,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult?> ValidateEntryAsync(
|
||||
SchedulePlan plan,
|
||||
Guid? entryId,
|
||||
@@ -486,6 +579,32 @@ public sealed class SchedulesController(
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private ActionResult AutomaticScheduleRunningProblem() =>
|
||||
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
|
||||
|
||||
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
|
||||
{
|
||||
IReadOnlyList<string> messages = [];
|
||||
if (!string.IsNullOrWhiteSpace(job.MessagesJson))
|
||||
{
|
||||
messages = JsonSerializer.Deserialize<string[]>(job.MessagesJson) ?? [];
|
||||
}
|
||||
|
||||
return new(
|
||||
job.Id,
|
||||
job.SchedulePlanId,
|
||||
job.Status,
|
||||
job.TotalTasks,
|
||||
job.ProcessedTasks,
|
||||
job.CreatedEntries,
|
||||
job.CompletedTasks,
|
||||
messages,
|
||||
job.ErrorMessage,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -510,3 +629,17 @@ public sealed record ScheduleEntryRequest(
|
||||
[Range(1, 30)] int EndWeek,
|
||||
WeekPattern WeekPattern,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record AutomaticScheduleJobResponse(
|
||||
Guid Id,
|
||||
Guid SchedulePlanId,
|
||||
AutomaticScheduleJobStatus Status,
|
||||
int TotalTasks,
|
||||
int ProcessedTasks,
|
||||
int CreatedEntries,
|
||||
int CompletedTasks,
|
||||
IReadOnlyList<string> Messages,
|
||||
string? ErrorMessage,
|
||||
DateTime CreatedAt,
|
||||
DateTime? StartedAt,
|
||||
DateTime? CompletedAt);
|
||||
|
||||
Reference in New Issue
Block a user