主要改动:
自动排课接口立即返回 202 + jobId 后台服务独立执行,不受浏览器关闭或前端超时影响 进度、成功、失败状态持久化到数据库 服务重启后自动恢复排队中或中断的任务 同一草稿禁止重复提交后台任务 运行期间禁止编辑、发布或删除该课表 排课结果和任务成功状态在同一事务中提交 前端每秒轮询进度,显示已处理教学班和已规划安排数
This commit is contained in:
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
Reference in new issue
Block a user