已改成后台“检查并发布”任务,原来的超时主要就是同步逐条校验导致的。
发布接口立即返回 202 Accepted,页面轮询显示检查进度。 校验改为批量读取,减少重复数据库查询。 检查失败保留草稿并显示具体原因;成功后原子完成旧课表归档和新课表发布。 同一学期禁止两个发布任务并发,服务重启后未完成任务会自动恢复。 发布期间锁定编辑、自动排课等冲突操作。 已补齐 SQLite 开发迁移及 MySQL 生产迁移。
This commit is contained in:
@@ -16,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/schedules")]
|
[Route("api/schedules")]
|
||||||
public sealed class SchedulesController(
|
public sealed class SchedulesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase
|
AutomaticScheduleJobQueue automaticScheduleJobQueue,
|
||||||
|
SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ManagementRoles =
|
private const string ManagementRoles =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -130,8 +131,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))
|
if (await HasActiveScheduleJobAsync(id, cancellationToken))
|
||||||
return AutomaticScheduleRunningProblem();
|
return ScheduleJobRunningProblem();
|
||||||
if (plan.Status != SchedulePlanStatus.Draft)
|
if (plan.Status != SchedulePlanStatus.Draft)
|
||||||
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
|
return ConflictProblem("已发布或已归档的排课版本不可直接修改。");
|
||||||
if (!await db.AcademicTerms.AnyAsync(
|
if (!await db.AcademicTerms.AnyAsync(
|
||||||
@@ -151,8 +152,8 @@ public sealed class SchedulesController(
|
|||||||
CloneSchedulePlanRequest request,
|
CloneSchedulePlanRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
if (await HasActiveScheduleJobAsync(id, cancellationToken))
|
||||||
return AutomaticScheduleRunningProblem();
|
return ScheduleJobRunningProblem();
|
||||||
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);
|
||||||
@@ -185,8 +186,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))
|
if (await HasActiveScheduleJobAsync(id, cancellationToken))
|
||||||
return AutomaticScheduleRunningProblem();
|
return ScheduleJobRunningProblem();
|
||||||
if (plan.Status != SchedulePlanStatus.Draft)
|
if (plan.Status != SchedulePlanStatus.Draft)
|
||||||
return ConflictProblem("仅草稿排课版本可以删除。");
|
return ConflictProblem("仅草稿排课版本可以删除。");
|
||||||
db.SchedulePlans.Remove(plan);
|
db.SchedulePlans.Remove(plan);
|
||||||
@@ -194,76 +195,79 @@ 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<SchedulePublishJobResponse>> PublishPlan(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken))
|
||||||
return AutomaticScheduleRunningProblem();
|
return AutomaticScheduleRunningProblem();
|
||||||
var plan = await db.SchedulePlans
|
var plan = await db.SchedulePlans.AsNoTracking()
|
||||||
.Include(x => x.Entries)
|
.Select(x => new
|
||||||
.ThenInclude(x => x.TeachingTask)
|
{
|
||||||
.ThenInclude(x => x!.Teachers)
|
x.Id,
|
||||||
.Include(x => x.Entries)
|
x.AcademicTermId,
|
||||||
.ThenInclude(x => x.TeachingTask)
|
x.Status,
|
||||||
.ThenInclude(x => x!.Classes)
|
HasEntries = x.Entries.Any()
|
||||||
|
})
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != SchedulePlanStatus.Draft)
|
if (plan.Status != SchedulePlanStatus.Draft)
|
||||||
return ConflictProblem("只有草稿排课版本可以发布。");
|
return ConflictProblem("只有草稿排课版本可以发布。");
|
||||||
if (plan.Entries.Count == 0)
|
if (!plan.HasEntries)
|
||||||
return ConflictProblem("排课版本中至少需要一条课表安排。");
|
return ConflictProblem("排课版本中至少需要一条课表安排。");
|
||||||
|
|
||||||
foreach (var entry in plan.Entries)
|
var existing = await db.SchedulePublishJobs.AsNoTracking()
|
||||||
{
|
.FirstOrDefaultAsync(
|
||||||
var validation = await ValidateEntryAsync(
|
x => x.ActiveAcademicTermId == plan.AcademicTermId,
|
||||||
plan,
|
|
||||||
entry.Id,
|
|
||||||
new ScheduleEntryRequest(
|
|
||||||
entry.TeachingTaskId,
|
|
||||||
entry.ClassroomId,
|
|
||||||
entry.DayOfWeek,
|
|
||||||
entry.StartPeriod,
|
|
||||||
entry.PeriodCount,
|
|
||||||
entry.StartWeek,
|
|
||||||
entry.EndWeek,
|
|
||||||
entry.WeekPattern,
|
|
||||||
entry.Notes),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
if (existing.SchedulePlanId != id)
|
||||||
|
return ConflictProblem(
|
||||||
|
"同一学期已有课表正在后台检查并发布,请等待任务完成。");
|
||||||
|
return AcceptedAtAction(
|
||||||
|
nameof(GetSchedulePublishJob),
|
||||||
|
new { jobId = existing.Id },
|
||||||
|
ToResponse(existing));
|
||||||
}
|
}
|
||||||
|
|
||||||
var requiredTasks = await db.TeachingTasks.AsNoTracking()
|
var requestedByUserId = CurrentUserId();
|
||||||
.Where(x =>
|
var job = new SchedulePublishJob
|
||||||
x.AcademicTermId == plan.AcademicTermId &&
|
{
|
||||||
x.Status == TeachingTaskStatus.Published &&
|
SchedulePlanId = id,
|
||||||
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
AcademicTermId = plan.AcademicTermId,
|
||||||
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
|
ActiveAcademicTermId = plan.AcademicTermId,
|
||||||
.ToListAsync(cancellationToken);
|
RequestedByUserId = requestedByUserId,
|
||||||
var scheduledHours = plan.Entries
|
CurrentStep = "等待后台检查"
|
||||||
.GroupBy(x => x.TeachingTaskId)
|
};
|
||||||
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
db.SchedulePublishJobs.Add(job);
|
||||||
var incomplete = requiredTasks.FirstOrDefault(task =>
|
try
|
||||||
!scheduledHours.TryGetValue(task.Id, out var hours) ||
|
{
|
||||||
hours < task.WeeklyHours);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
if (incomplete is not null)
|
}
|
||||||
return ConflictProblem(
|
catch (DbUpdateException)
|
||||||
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。");
|
{
|
||||||
|
db.Entry(job).State = EntityState.Detached;
|
||||||
|
existing = await db.SchedulePublishJobs.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.ActiveAcademicTermId == plan.AcademicTermId,
|
||||||
|
cancellationToken);
|
||||||
|
if (existing is null)
|
||||||
|
throw;
|
||||||
|
if (existing.SchedulePlanId != id)
|
||||||
|
return ConflictProblem(
|
||||||
|
"同一学期已有课表正在后台检查并发布,请等待任务完成。");
|
||||||
|
return AcceptedAtAction(
|
||||||
|
nameof(GetSchedulePublishJob),
|
||||||
|
new { jobId = existing.Id },
|
||||||
|
ToResponse(existing));
|
||||||
|
}
|
||||||
|
|
||||||
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
|
schedulePublishJobQueue.Enqueue(job.Id);
|
||||||
if (conflict is not null) return ConflictProblem(conflict);
|
return AcceptedAtAction(
|
||||||
|
nameof(GetSchedulePublishJob),
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
new { jobId = job.Id },
|
||||||
var previous = await db.SchedulePlans
|
ToResponse(job));
|
||||||
.Where(x =>
|
|
||||||
x.Id != plan.Id &&
|
|
||||||
x.AcademicTermId == plan.AcademicTermId &&
|
|
||||||
x.Status == SchedulePlanStatus.Published)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
foreach (var oldPlan in previous) oldPlan.Status = SchedulePlanStatus.Archived;
|
|
||||||
plan.Status = SchedulePlanStatus.Published;
|
|
||||||
plan.PublishedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
return NoContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("plans/{planId:guid}/entries")]
|
[HttpPost("plans/{planId:guid}/entries")]
|
||||||
@@ -272,8 +276,8 @@ public sealed class SchedulesController(
|
|||||||
ScheduleEntryRequest request,
|
ScheduleEntryRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
|
||||||
return AutomaticScheduleRunningProblem();
|
return ScheduleJobRunningProblem();
|
||||||
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);
|
||||||
@@ -290,6 +294,8 @@ public sealed class SchedulesController(
|
|||||||
{
|
{
|
||||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
|
if (await HasActiveSchedulePublishJobAsync(planId, cancellationToken))
|
||||||
|
return SchedulePublishRunningProblem();
|
||||||
|
|
||||||
var existing = await db.AutomaticScheduleJobs.AsNoTracking()
|
var existing = await db.AutomaticScheduleJobs.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
@@ -364,6 +370,30 @@ public sealed class SchedulesController(
|
|||||||
return Ok(job is null ? null : ToResponse(job));
|
return Ok(job is null ? null : ToResponse(job));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("publish-jobs/{jobId:guid}")]
|
||||||
|
public async Task<ActionResult<SchedulePublishJobResponse>>
|
||||||
|
GetSchedulePublishJob(
|
||||||
|
Guid jobId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.SchedulePublishJobs.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken);
|
||||||
|
return job is null ? NotFound() : Ok(ToResponse(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("plans/{planId:guid}/publish-job")]
|
||||||
|
public async Task<ActionResult<SchedulePublishJobResponse?>>
|
||||||
|
GetLatestSchedulePublishJob(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.SchedulePublishJobs.AsNoTracking()
|
||||||
|
.Where(x => x.SchedulePlanId == 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}")]
|
||||||
public async Task<ActionResult> UpdateEntry(
|
public async Task<ActionResult> UpdateEntry(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
@@ -371,8 +401,8 @@ public sealed class SchedulesController(
|
|||||||
ScheduleEntryRequest request,
|
ScheduleEntryRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
|
||||||
return AutomaticScheduleRunningProblem();
|
return ScheduleJobRunningProblem();
|
||||||
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
|
||||||
@@ -400,8 +430,8 @@ public sealed class SchedulesController(
|
|||||||
Guid entryId,
|
Guid entryId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken))
|
if (await HasActiveScheduleJobAsync(planId, cancellationToken))
|
||||||
return AutomaticScheduleRunningProblem();
|
return ScheduleJobRunningProblem();
|
||||||
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(
|
||||||
@@ -426,6 +456,19 @@ public sealed class SchedulesController(
|
|||||||
x => x.ActiveSchedulePlanId == planId,
|
x => x.ActiveSchedulePlanId == planId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
private Task<bool> HasActiveSchedulePublishJobAsync(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.SchedulePublishJobs.AsNoTracking().AnyAsync(
|
||||||
|
x => x.SchedulePlanId == planId && x.ActiveAcademicTermId != null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<bool> HasActiveScheduleJobAsync(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken) ||
|
||||||
|
await HasActiveSchedulePublishJobAsync(planId, cancellationToken);
|
||||||
|
|
||||||
private async Task<ActionResult?> ValidateEntryAsync(
|
private async Task<ActionResult?> ValidateEntryAsync(
|
||||||
SchedulePlan plan,
|
SchedulePlan plan,
|
||||||
Guid? entryId,
|
Guid? entryId,
|
||||||
@@ -585,6 +628,12 @@ public sealed class SchedulesController(
|
|||||||
private ActionResult AutomaticScheduleRunningProblem() =>
|
private ActionResult AutomaticScheduleRunningProblem() =>
|
||||||
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
|
ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。");
|
||||||
|
|
||||||
|
private ActionResult SchedulePublishRunningProblem() =>
|
||||||
|
ConflictProblem("课表正在后台检查并发布,请等待任务完成后再修改。");
|
||||||
|
|
||||||
|
private ActionResult ScheduleJobRunningProblem() =>
|
||||||
|
ConflictProblem("后台任务正在处理该排课版本,请等待任务完成后再修改。");
|
||||||
|
|
||||||
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
|
private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job)
|
||||||
{
|
{
|
||||||
IReadOnlyList<string> messages = [];
|
IReadOnlyList<string> messages = [];
|
||||||
@@ -608,6 +657,24 @@ public sealed class SchedulesController(
|
|||||||
job.CompletedAt);
|
job.CompletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SchedulePublishJobResponse ToResponse(SchedulePublishJob job) =>
|
||||||
|
new(
|
||||||
|
job.Id,
|
||||||
|
job.SchedulePlanId,
|
||||||
|
job.Status,
|
||||||
|
job.TotalSteps,
|
||||||
|
job.CompletedSteps,
|
||||||
|
job.CurrentStep,
|
||||||
|
job.ErrorMessage,
|
||||||
|
job.CreatedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt);
|
||||||
|
|
||||||
|
private Guid? CurrentUserId() =>
|
||||||
|
Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)
|
||||||
|
? userId
|
||||||
|
: null;
|
||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
@@ -646,3 +713,15 @@ public sealed record AutomaticScheduleJobResponse(
|
|||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime? StartedAt,
|
DateTime? StartedAt,
|
||||||
DateTime? CompletedAt);
|
DateTime? CompletedAt);
|
||||||
|
|
||||||
|
public sealed record SchedulePublishJobResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid SchedulePlanId,
|
||||||
|
SchedulePublishJobStatus Status,
|
||||||
|
int TotalSteps,
|
||||||
|
int CompletedSteps,
|
||||||
|
string? CurrentStep,
|
||||||
|
string? ErrorMessage,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? StartedAt,
|
||||||
|
DateTime? CompletedAt);
|
||||||
|
|||||||
@@ -83,6 +83,23 @@ public sealed class AutomaticScheduleJob : EntityBase
|
|||||||
public DateTime? CompletedAt { get; set; }
|
public DateTime? CompletedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SchedulePublishJob : EntityBase
|
||||||
|
{
|
||||||
|
public Guid SchedulePlanId { get; set; }
|
||||||
|
public SchedulePlan? SchedulePlan { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public Guid? ActiveAcademicTermId { get; set; }
|
||||||
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public SchedulePublishJobStatus Status { get; set; } =
|
||||||
|
SchedulePublishJobStatus.Queued;
|
||||||
|
public int TotalSteps { get; set; } = 5;
|
||||||
|
public int CompletedSteps { get; set; }
|
||||||
|
public string? CurrentStep { 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,
|
||||||
@@ -98,6 +115,14 @@ public enum AutomaticScheduleJobStatus
|
|||||||
Failed = 4
|
Failed = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum SchedulePublishJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|
||||||
public enum WeekPattern
|
public enum WeekPattern
|
||||||
{
|
{
|
||||||
All = 1,
|
All = 1,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<TeachingTaskAllowedClassroom>();
|
Set<TeachingTaskAllowedClassroom>();
|
||||||
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
|
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
|
||||||
Set<AutomaticScheduleJob>();
|
Set<AutomaticScheduleJob>();
|
||||||
|
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
|
||||||
|
Set<SchedulePublishJob>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||||
@@ -400,6 +402,24 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<SchedulePublishJob>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||||
|
entity.HasIndex(x => x.ActiveAcademicTermId).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);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260724_16_automatic_schedule_jobs";
|
"20260724_16_automatic_schedule_jobs";
|
||||||
private const string TeachingTaskSchedulingModesMigration =
|
private const string TeachingTaskSchedulingModesMigration =
|
||||||
"20260725_17_teaching_task_scheduling_modes";
|
"20260725_17_teaching_task_scheduling_modes";
|
||||||
|
private const string SchedulePublishJobsMigration =
|
||||||
|
"20260725_18_schedule_publish_jobs";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -163,6 +165,18 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
TeachingTaskSchedulingModesMigration,
|
TeachingTaskSchedulingModesMigration,
|
||||||
teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements,
|
teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var schedulePublishJobsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'SchedulePublishJobs'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
SchedulePublishJobsMigration,
|
||||||
|
schedulePublishJobsExist ? [] : SchedulePublishJobStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1104,4 +1118,48 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ADD COLUMN "SchedulingMode" INTEGER NOT NULL DEFAULT 1;
|
ADD COLUMN "SchedulingMode" INTEGER NOT NULL DEFAULT 1;
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] SchedulePublishJobStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "SchedulePublishJobs" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_SchedulePublishJobs" PRIMARY KEY,
|
||||||
|
"SchedulePlanId" TEXT NOT NULL,
|
||||||
|
"AcademicTermId" TEXT NOT NULL,
|
||||||
|
"ActiveAcademicTermId" TEXT NULL,
|
||||||
|
"RequestedByUserId" TEXT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"TotalSteps" INTEGER NOT NULL,
|
||||||
|
"CompletedSteps" INTEGER NOT NULL,
|
||||||
|
"CurrentStep" TEXT NULL,
|
||||||
|
"ErrorMessage" TEXT NULL,
|
||||||
|
"StartedAt" TEXT NULL,
|
||||||
|
"CompletedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_SchedulePublishJobs_SchedulePlans"
|
||||||
|
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_SchedulePublishJobs_RequestedBy"
|
||||||
|
FOREIGN KEY ("RequestedByUserId") REFERENCES "AspNetUsers" ("Id")
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_SchedulePublishJobs_ActiveAcademicTermId"
|
||||||
|
ON "SchedulePublishJobs" ("ActiveAcademicTermId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_SchedulePublishJobs_SchedulePlanId_CreatedAt"
|
||||||
|
ON "SchedulePublishJobs" ("SchedulePlanId", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_SchedulePublishJobs_Status_CreatedAt"
|
||||||
|
ON "SchedulePublishJobs" ("Status", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_SchedulePublishJobs_RequestedByUserId"
|
||||||
|
ON "SchedulePublishJobs" ("RequestedByUserId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+2953
File diff suppressed because it is too large
Load Diff
+80
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SchedulePublishJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SchedulePublishJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ActiveAcademicTermId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalSteps = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CompletedSteps = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SchedulePublishJobs", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SchedulePublishJobs_AspNetUsers_RequestedByUserId",
|
||||||
|
column: x => x.RequestedByUserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_SchedulePublishJobs_SchedulePlans_SchedulePlanId",
|
||||||
|
column: x => x.SchedulePlanId,
|
||||||
|
principalTable: "SchedulePlans",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SchedulePublishJobs_ActiveAcademicTermId",
|
||||||
|
table: "SchedulePublishJobs",
|
||||||
|
column: "ActiveAcademicTermId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SchedulePublishJobs_RequestedByUserId",
|
||||||
|
table: "SchedulePublishJobs",
|
||||||
|
column: "RequestedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SchedulePublishJobs_SchedulePlanId_CreatedAt",
|
||||||
|
table: "SchedulePublishJobs",
|
||||||
|
columns: new[] { "SchedulePlanId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_SchedulePublishJobs_Status_CreatedAt",
|
||||||
|
table: "SchedulePublishJobs",
|
||||||
|
columns: new[] { "Status", "CreatedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SchedulePublishJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -1448,6 +1448,67 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("SchedulePlans");
|
b.ToTable("SchedulePlans");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("AcademicTermId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActiveAcademicTermId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("CompletedSteps")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("CurrentStep")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("varchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("varchar(2000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RequestedByUserId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid>("SchedulePlanId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("TotalSteps")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ActiveAcademicTermId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("RequestedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SchedulePlanId", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("Status", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("SchedulePublishJobs");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -2553,6 +2614,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("AcademicTerm");
|
b.Navigation("AcademicTerm");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RequestedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SchedulePlanId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("SchedulePlan");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
|
||||||
|
public sealed class SchedulePublishJobQueue
|
||||||
|
{
|
||||||
|
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||||
|
new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false
|
||||||
|
});
|
||||||
|
|
||||||
|
public void Enqueue(Guid jobId)
|
||||||
|
{
|
||||||
|
if (!_channel.Writer.TryWrite(jobId))
|
||||||
|
throw new InvalidOperationException("课表发布任务队列当前不可用。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Guid> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||||
|
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SchedulePublishJobWorker(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
SchedulePublishJobQueue queue,
|
||||||
|
ILogger<SchedulePublishJobWorker> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await RecoverInterruptedJobsAsync(stoppingToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var jobId in queue.ReadAllAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var processor = scope.ServiceProvider
|
||||||
|
.GetRequiredService<SchedulePublishJobProcessor>();
|
||||||
|
await processor.ProcessAsync(jobId, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
exception,
|
||||||
|
"Unexpected failure while dispatching schedule publish job {JobId}.",
|
||||||
|
jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Schedule publish job worker is stopping.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var jobs = await db.SchedulePublishJobs
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == SchedulePublishJobStatus.Queued ||
|
||||||
|
x.Status == SchedulePublishJobStatus.Running)
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
job.Status = SchedulePublishJobStatus.Queued;
|
||||||
|
job.ActiveAcademicTermId = job.AcademicTermId;
|
||||||
|
job.CompletedSteps = 0;
|
||||||
|
job.CurrentStep = "等待后台检查";
|
||||||
|
job.StartedAt = null;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobs.Count > 0)
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
foreach (var job in jobs)
|
||||||
|
queue.Enqueue(job.Id);
|
||||||
|
|
||||||
|
if (jobs.Count > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Recovered {JobCount} queued or interrupted schedule publish jobs.",
|
||||||
|
jobs.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SchedulePublishJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
SchedulePlanPublisher publisher,
|
||||||
|
ILogger<SchedulePublishJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var job = await db.SchedulePublishJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||||
|
if (job is null ||
|
||||||
|
job.Status is SchedulePublishJobStatus.Succeeded
|
||||||
|
or SchedulePublishJobStatus.Failed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = SchedulePublishJobStatus.Running;
|
||||||
|
job.StartedAt = DateTime.UtcNow;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.CompletedSteps = 0;
|
||||||
|
job.CurrentStep = "读取排课版本";
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
async Task ReportProgress(
|
||||||
|
int completedSteps,
|
||||||
|
string currentStep,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
job.CompletedSteps = completedSteps;
|
||||||
|
job.CurrentStep = currentStep;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = await publisher.ValidateAsync(
|
||||||
|
job.SchedulePlanId,
|
||||||
|
ReportProgress,
|
||||||
|
stoppingToken);
|
||||||
|
|
||||||
|
await using var transaction =
|
||||||
|
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||||
|
if (plan.Status != SchedulePlanStatus.Draft)
|
||||||
|
throw new SchedulePublishValidationException(
|
||||||
|
"排课草稿状态已发生变化,请刷新后重试。");
|
||||||
|
|
||||||
|
var previous = await db.SchedulePlans
|
||||||
|
.Where(x =>
|
||||||
|
x.Id != plan.Id &&
|
||||||
|
x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == SchedulePlanStatus.Published)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
foreach (var oldPlan in previous)
|
||||||
|
oldPlan.Status = SchedulePlanStatus.Archived;
|
||||||
|
|
||||||
|
plan.Status = SchedulePlanStatus.Published;
|
||||||
|
plan.PublishedAt = DateTime.UtcNow;
|
||||||
|
job.Status = SchedulePublishJobStatus.Succeeded;
|
||||||
|
job.ActiveAcademicTermId = null;
|
||||||
|
job.CompletedSteps = job.TotalSteps;
|
||||||
|
job.CurrentStep = "课表已发布";
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
await transaction.CommitAsync(stoppingToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
||||||
|
job.Id,
|
||||||
|
plan.Id);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Schedule publish job {JobId} was interrupted by application shutdown.",
|
||||||
|
jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Schedule publish job {JobId} failed.", jobId);
|
||||||
|
await MarkFailedAsync(jobId, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(Guid jobId, Exception exception)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var job = await db.SchedulePublishJobs.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (job is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var message = exception.GetBaseException().Message;
|
||||||
|
job.Status = SchedulePublishJobStatus.Failed;
|
||||||
|
job.ActiveAcademicTermId = null;
|
||||||
|
job.CurrentStep = "检查未通过";
|
||||||
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<SchedulePlan> ValidateAsync(
|
||||||
|
Guid planId,
|
||||||
|
Func<int, string, CancellationToken, Task> reportProgress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var plan = await db.SchedulePlans
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(x => x.Entries)
|
||||||
|
.ThenInclude(x => x.Classroom)
|
||||||
|
.ThenInclude(x => x!.Building)
|
||||||
|
.Include(x => x.Entries)
|
||||||
|
.ThenInclude(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.Include(x => x.Entries)
|
||||||
|
.ThenInclude(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Classes)
|
||||||
|
.ThenInclude(x => x.AdministrativeClass)
|
||||||
|
.ThenInclude(x => x!.Students)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken)
|
||||||
|
?? throw new SchedulePublishValidationException("排课草稿不存在。");
|
||||||
|
|
||||||
|
if (plan.Status != SchedulePlanStatus.Draft)
|
||||||
|
throw new SchedulePublishValidationException("只有草稿排课版本可以发布。");
|
||||||
|
if (plan.Entries.Count == 0)
|
||||||
|
throw new SchedulePublishValidationException(
|
||||||
|
"排课版本中至少需要一条课表安排。");
|
||||||
|
|
||||||
|
await reportProgress(1, "校验课程、节次与教室", cancellationToken);
|
||||||
|
var activePeriods = (await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
|
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
||||||
|
.Select(x => x.PeriodNumber)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet();
|
||||||
|
if (activePeriods.Count == 0)
|
||||||
|
throw new SchedulePublishValidationException(
|
||||||
|
"请先维护该学期的上课时间表。");
|
||||||
|
|
||||||
|
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
|
||||||
|
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
|
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var entry in plan.Entries)
|
||||||
|
{
|
||||||
|
ValidateEntry(plan, entry, activePeriods, constraints);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reportProgress(2, "校验教学任务完整性", cancellationToken);
|
||||||
|
var requiredTasks = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.AcademicTermId == plan.AcademicTermId &&
|
||||||
|
x.Status == TeachingTaskStatus.Published &&
|
||||||
|
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||||
|
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var scheduledHours = plan.Entries
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
||||||
|
var incomplete = requiredTasks.FirstOrDefault(task =>
|
||||||
|
!scheduledHours.TryGetValue(task.Id, out var hours) ||
|
||||||
|
hours < task.WeeklyHours);
|
||||||
|
if (incomplete is not null)
|
||||||
|
{
|
||||||
|
throw new SchedulePublishValidationException(
|
||||||
|
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " +
|
||||||
|
$"{incomplete.WeeklyHours} 学时,不能发布。");
|
||||||
|
}
|
||||||
|
|
||||||
|
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
||||||
|
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
|
||||||
|
if (conflict is not null)
|
||||||
|
throw new SchedulePublishValidationException(conflict);
|
||||||
|
|
||||||
|
await reportProgress(4, "写入正式课表", cancellationToken);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateEntry(
|
||||||
|
SchedulePlan plan,
|
||||||
|
ScheduleEntry entry,
|
||||||
|
HashSet<int> activePeriods,
|
||||||
|
IReadOnlyDictionary<Guid, TeachingTaskScheduleConstraint> constraints)
|
||||||
|
{
|
||||||
|
var task = entry.TeachingTask;
|
||||||
|
if (entry.StartWeek > entry.EndWeek)
|
||||||
|
Fail(entry, "开始周不能晚于结束周");
|
||||||
|
if (Enumerable.Range(entry.StartPeriod, entry.PeriodCount)
|
||||||
|
.Any(period => !activePeriods.Contains(period)))
|
||||||
|
Fail(entry, "所选节次包含未启用或不存在的上课时间");
|
||||||
|
if (task is null ||
|
||||||
|
task.Status != TeachingTaskStatus.Published ||
|
||||||
|
task.AcademicTermId != plan.AcademicTermId)
|
||||||
|
Fail(entry, "只能安排同一学期内已发布的教学任务");
|
||||||
|
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
||||||
|
Fail(entry, "非排时课程不应进入正常课表");
|
||||||
|
if (entry.StartWeek < task.StartWeek || entry.EndWeek > task.EndWeek)
|
||||||
|
Fail(entry, "排课周次不在教学任务的授课周次内");
|
||||||
|
|
||||||
|
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
|
||||||
|
var requiresClassroom = constraint?.RequiresClassroom ?? true;
|
||||||
|
if (requiresClassroom && entry.ClassroomId is null)
|
||||||
|
Fail(entry, "该课程需要占用教室");
|
||||||
|
if (!requiresClassroom && entry.ClassroomId is not null)
|
||||||
|
Fail(entry, "该课程已设置为不占用教室");
|
||||||
|
|
||||||
|
var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks);
|
||||||
|
if (allowedDays.Count > 0 && !allowedDays.Contains(entry.DayOfWeek))
|
||||||
|
Fail(entry, "上课日不在教学任务允许范围内");
|
||||||
|
if (constraint?.EarliestPeriod is int earliest &&
|
||||||
|
entry.StartPeriod < earliest)
|
||||||
|
Fail(entry, $"最早只能从第 {earliest} 节开始");
|
||||||
|
if (constraint?.LatestPeriod is int latest &&
|
||||||
|
entry.StartPeriod + entry.PeriodCount - 1 > latest)
|
||||||
|
Fail(entry, $"最晚必须在第 {latest} 节结束");
|
||||||
|
|
||||||
|
var classroom = entry.Classroom;
|
||||||
|
if (entry.ClassroomId.HasValue)
|
||||||
|
{
|
||||||
|
if (classroom is null || !classroom.IsEnabled)
|
||||||
|
Fail(entry, "所选教室不存在或已停用");
|
||||||
|
if (constraint?.RequiredCampusId is Guid campusId &&
|
||||||
|
classroom.Building!.CampusId != campusId)
|
||||||
|
Fail(entry, "所选教室不在指定校区");
|
||||||
|
if (constraint?.RequiredBuildingId is Guid buildingId &&
|
||||||
|
classroom.BuildingId != buildingId)
|
||||||
|
Fail(entry, "所选教室不在指定教学楼");
|
||||||
|
var allowedClassroomIds = constraint?.AllowedClassrooms
|
||||||
|
.Select(x => x.ClassroomId)
|
||||||
|
.ToHashSet() ?? [];
|
||||||
|
if (allowedClassroomIds.Count > 0 &&
|
||||||
|
!allowedClassroomIds.Contains(classroom.Id))
|
||||||
|
Fail(entry, "所选教室不在指定教室范围内");
|
||||||
|
}
|
||||||
|
|
||||||
|
var studentCount = task.Classes.Sum(x =>
|
||||||
|
x.AdministrativeClass!.Students.Count(student =>
|
||||||
|
student.Status == StudentStatus.Active));
|
||||||
|
var requiredCapacity = Math.Max(task.Capacity, studentCount);
|
||||||
|
if (classroom is not null && requiredCapacity > classroom.Capacity)
|
||||||
|
{
|
||||||
|
Fail(entry,
|
||||||
|
$"教室容量不足:需要 {requiredCapacity} 人,教室仅容纳 {classroom.Capacity} 人");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<int> ParseDays(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? []
|
||||||
|
: value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(int.Parse)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
[DoesNotReturn]
|
||||||
|
private static void Fail(ScheduleEntry entry, string message)
|
||||||
|
{
|
||||||
|
var name = entry.TeachingTask?.Name ?? entry.TeachingTaskId.ToString();
|
||||||
|
throw new SchedulePublishValidationException($"“{name}”:{message}。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SchedulePublishValidationException(string message)
|
||||||
|
: InvalidOperationException(message);
|
||||||
@@ -96,6 +96,10 @@ builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
|||||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||||
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
builder.Services.AddHostedService<AutomaticScheduleJobWorker>();
|
||||||
|
builder.Services.AddScoped<SchedulePlanPublisher>();
|
||||||
|
builder.Services.AddScoped<SchedulePublishJobProcessor>();
|
||||||
|
builder.Services.AddSingleton<SchedulePublishJobQueue>();
|
||||||
|
builder.Services.AddHostedService<SchedulePublishJobWorker>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class SchedulePublishJobProcessorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Processor_validates_and_publishes_plan_atomically()
|
||||||
|
{
|
||||||
|
var result = await RunPublishAsync(weeklyHours: 2);
|
||||||
|
|
||||||
|
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
||||||
|
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
||||||
|
Assert.Null(result.ActiveAcademicTermId);
|
||||||
|
Assert.Equal(5, result.CompletedSteps);
|
||||||
|
Assert.Equal("课表已发布", result.CurrentStep);
|
||||||
|
Assert.Null(result.ErrorMessage);
|
||||||
|
Assert.NotNull(result.PublishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Processor_keeps_draft_when_validation_fails()
|
||||||
|
{
|
||||||
|
var result = await RunPublishAsync(weeklyHours: 4);
|
||||||
|
|
||||||
|
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
||||||
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||||
|
Assert.Null(result.ActiveAcademicTermId);
|
||||||
|
Assert.Equal("检查未通过", result.CurrentStep);
|
||||||
|
Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage);
|
||||||
|
Assert.Null(result.PublishedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PublishResult> RunPublishAsync(int weeklyHours)
|
||||||
|
{
|
||||||
|
var databasePath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"jiaowu-publish-{Guid.NewGuid():N}.sqlite");
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDbContext<AppDbContext>(
|
||||||
|
options => options.UseSqlite(
|
||||||
|
$"Data Source={databasePath};Pooling=False"));
|
||||||
|
services.AddScoped<SchedulePlanPublisher>();
|
||||||
|
services.AddScoped<SchedulePublishJobProcessor>();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid jobId;
|
||||||
|
await using (var seedScope = provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = seedScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = $"2026-PUBLISH-{weeklyHours}",
|
||||||
|
Name = "后台发布学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "PUB-01",
|
||||||
|
Name = "发布测试课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 1,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 32
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"TASK-PUB-{weeklyHours}",
|
||||||
|
Name = "发布测试教学班",
|
||||||
|
AcademicTerm = term,
|
||||||
|
Course = course,
|
||||||
|
Capacity = 60,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeeklyHours = weeklyHours,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "待发布课表",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
plan.Entries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
var job = new SchedulePublishJob
|
||||||
|
{
|
||||||
|
SchedulePlan = plan,
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
ActiveAcademicTermId = term.Id,
|
||||||
|
CurrentStep = "等待后台检查"
|
||||||
|
};
|
||||||
|
jobId = job.Id;
|
||||||
|
db.AddRange(term, college, course, task, plan, job);
|
||||||
|
db.ScheduleTimeSlots.AddRange(
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
PeriodNumber = 1,
|
||||||
|
Name = "第 1 节",
|
||||||
|
StartsAt = new TimeOnly(8, 0),
|
||||||
|
EndsAt = new TimeOnly(8, 45)
|
||||||
|
},
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
PeriodNumber = 2,
|
||||||
|
Name = "第 2 节",
|
||||||
|
StartsAt = new TimeOnly(8, 55),
|
||||||
|
EndsAt = new TimeOnly(9, 40)
|
||||||
|
});
|
||||||
|
db.TeachingTaskScheduleConstraints.Add(
|
||||||
|
new TeachingTaskScheduleConstraint
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
RequiresClassroom = false
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var workerScope = provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var processor = workerScope.ServiceProvider
|
||||||
|
.GetRequiredService<SchedulePublishJobProcessor>();
|
||||||
|
await processor.ProcessAsync(jobId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var assertScope = provider.CreateAsyncScope();
|
||||||
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var jobResult = await assertDb.SchedulePublishJobs.SingleAsync();
|
||||||
|
var planResult = await assertDb.SchedulePlans.SingleAsync();
|
||||||
|
return new(
|
||||||
|
jobResult.Status,
|
||||||
|
planResult.Status,
|
||||||
|
jobResult.ActiveAcademicTermId,
|
||||||
|
jobResult.CompletedSteps,
|
||||||
|
jobResult.CurrentStep,
|
||||||
|
jobResult.ErrorMessage,
|
||||||
|
planResult.PublishedAt);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await provider.DisposeAsync();
|
||||||
|
File.Delete(databasePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PublishResult(
|
||||||
|
SchedulePublishJobStatus JobStatus,
|
||||||
|
SchedulePlanStatus PlanStatus,
|
||||||
|
Guid? ActiveAcademicTermId,
|
||||||
|
int CompletedSteps,
|
||||||
|
string? CurrentStep,
|
||||||
|
string? ErrorMessage,
|
||||||
|
DateTime? PublishedAt);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -10,6 +11,87 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class SchedulesControllerTests
|
public sealed class SchedulesControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_returns_accepted_before_background_validation_runs()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
await using var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-P",
|
||||||
|
Name = "2026 发布测试",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 7),
|
||||||
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
|
};
|
||||||
|
var college = new College { Code = "P", Name = "发布学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "P101",
|
||||||
|
Name = "发布课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 1,
|
||||||
|
TotalHours = 16,
|
||||||
|
LectureHours = 16
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "TASK-P",
|
||||||
|
Name = "发布教学班",
|
||||||
|
AcademicTerm = term,
|
||||||
|
Course = course,
|
||||||
|
Capacity = 30,
|
||||||
|
WeeklyHours = 1,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "发布草稿",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
plan.Entries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 1,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
db.AddRange(term, college, course, task, plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new SchedulesController(
|
||||||
|
db,
|
||||||
|
new AutomaticScheduleJobQueue(),
|
||||||
|
new SchedulePublishJobQueue())
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var result = await controller.PublishPlan(plan.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var accepted = Assert.IsType<AcceptedAtActionResult>(result.Result);
|
||||||
|
var response = Assert.IsType<SchedulePublishJobResponse>(accepted.Value);
|
||||||
|
Assert.Equal(SchedulePublishJobStatus.Queued, response.Status);
|
||||||
|
Assert.Equal(plan.Id, response.SchedulePlanId);
|
||||||
|
Assert.Equal(
|
||||||
|
SchedulePlanStatus.Draft,
|
||||||
|
(await db.SchedulePlans.SingleAsync()).Status);
|
||||||
|
Assert.Equal(1, await db.SchedulePublishJobs.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Latest_auto_schedule_job_remains_available_after_completion()
|
public async Task Latest_auto_schedule_job_remains_available_after_completion()
|
||||||
{
|
{
|
||||||
@@ -52,7 +134,8 @@ public sealed class SchedulesControllerTests
|
|||||||
|
|
||||||
var controller = new SchedulesController(
|
var controller = new SchedulesController(
|
||||||
db,
|
db,
|
||||||
new AutomaticScheduleJobQueue());
|
new AutomaticScheduleJobQueue(),
|
||||||
|
new SchedulePublishJobQueue());
|
||||||
var result = await controller.GetLatestAutomaticScheduleJob(
|
var result = await controller.GetLatestAutomaticScheduleJob(
|
||||||
plan.Id,
|
plan.Id,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|||||||
+119
-10
@@ -15,6 +15,7 @@ const constraints = ref<any[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const autoJob = ref<any | null>(null)
|
const autoJob = ref<any | null>(null)
|
||||||
|
const publishJob = ref<any | null>(null)
|
||||||
const planDialog = ref(false)
|
const planDialog = ref(false)
|
||||||
const cloneDialog = ref(false)
|
const cloneDialog = ref(false)
|
||||||
const entryDialog = ref(false)
|
const entryDialog = ref(false)
|
||||||
@@ -41,6 +42,7 @@ const constraintFilters = reactive({
|
|||||||
constraintState: undefined as string | undefined,
|
constraintState: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
let autoPollTimer: ReturnType<typeof setTimeout> | undefined
|
let autoPollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let publishPollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
const weekdays = [
|
const weekdays = [
|
||||||
{ value: 1, label: '星期一' },
|
{ value: 1, label: '星期一' },
|
||||||
@@ -71,6 +73,10 @@ const isDraft = computed(() => selected.value?.status === 'Draft')
|
|||||||
const autoLoading = computed(() =>
|
const autoLoading = computed(() =>
|
||||||
autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running',
|
autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running',
|
||||||
)
|
)
|
||||||
|
const publishLoading = computed(() =>
|
||||||
|
publishJob.value?.status === 'Queued' || publishJob.value?.status === 'Running',
|
||||||
|
)
|
||||||
|
const scheduleJobLoading = computed(() => autoLoading.value || publishLoading.value)
|
||||||
const autoProgress = computed(() => {
|
const autoProgress = computed(() => {
|
||||||
if (!autoJob.value?.totalTasks) return 0
|
if (!autoJob.value?.totalTasks) return 0
|
||||||
return Math.min(
|
return Math.min(
|
||||||
@@ -94,6 +100,27 @@ const autoStatusText = computed(() => {
|
|||||||
}
|
}
|
||||||
return autoJob.value.errorMessage || '后台排课失败,请稍后重试'
|
return autoJob.value.errorMessage || '后台排课失败,请稍后重试'
|
||||||
})
|
})
|
||||||
|
const publishProgress = computed(() => {
|
||||||
|
if (!publishJob.value?.totalSteps) return 0
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(publishJob.value.completedSteps / publishJob.value.totalSteps * 100),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const publishProgressStatus = computed(() => {
|
||||||
|
if (publishJob.value?.status === 'Succeeded') return 'success'
|
||||||
|
if (publishJob.value?.status === 'Failed') return 'exception'
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const publishStatusText = computed(() => {
|
||||||
|
if (!publishJob.value) return ''
|
||||||
|
if (publishJob.value.status === 'Queued') return '发布任务已进入队列,等待后台检查'
|
||||||
|
if (publishJob.value.status === 'Running') {
|
||||||
|
return publishJob.value.currentStep || '正在检查并发布课表'
|
||||||
|
}
|
||||||
|
if (publishJob.value.status === 'Succeeded') return '检查已通过,课表发布成功'
|
||||||
|
return publishJob.value.errorMessage || '课表检查未通过'
|
||||||
|
})
|
||||||
const selectedTaskConstraint = computed(() =>
|
const selectedTaskConstraint = computed(() =>
|
||||||
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
|
||||||
)
|
)
|
||||||
@@ -402,6 +429,50 @@ async function autoSchedule() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPublishPoll() {
|
||||||
|
if (publishPollTimer) clearTimeout(publishPollTimer)
|
||||||
|
publishPollTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePublishPoll(jobId: string, planId: string) {
|
||||||
|
clearPublishPoll()
|
||||||
|
publishPollTimer = setTimeout(() => pollPublishJob(jobId, planId), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollPublishJob(jobId: string, planId: string) {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/schedules/publish-jobs/${jobId}`)
|
||||||
|
if (selected.value?.id !== planId) return
|
||||||
|
publishJob.value = data
|
||||||
|
if (data.status === 'Queued' || data.status === 'Running') {
|
||||||
|
schedulePublishPoll(jobId, planId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearPublishPoll()
|
||||||
|
if (data.status === 'Succeeded') {
|
||||||
|
ElMessage.success('课表检查通过,已发布')
|
||||||
|
await loadPlans()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(data.errorMessage || '课表检查未通过,请调整后重试')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (selected.value?.id === planId) {
|
||||||
|
schedulePublishPoll(jobId, planId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumePublish(planId: string) {
|
||||||
|
clearPublishPoll()
|
||||||
|
publishJob.value = null
|
||||||
|
const { data } = await http.get(`/schedules/plans/${planId}/publish-job`)
|
||||||
|
if (selected.value?.id !== planId || !data) return
|
||||||
|
publishJob.value = data
|
||||||
|
if (data.status === 'Queued' || data.status === 'Running') {
|
||||||
|
schedulePublishPoll(data.id, planId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearAutoSchedulePoll() {
|
function clearAutoSchedulePoll() {
|
||||||
if (autoPollTimer) clearTimeout(autoPollTimer)
|
if (autoPollTimer) clearTimeout(autoPollTimer)
|
||||||
autoPollTimer = undefined
|
autoPollTimer = undefined
|
||||||
@@ -467,6 +538,8 @@ async function loadPlans(keepSelection = true) {
|
|||||||
selected.value = null
|
selected.value = null
|
||||||
clearAutoSchedulePoll()
|
clearAutoSchedulePoll()
|
||||||
autoJob.value = null
|
autoJob.value = null
|
||||||
|
clearPublishPoll()
|
||||||
|
publishJob.value = null
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -480,10 +553,11 @@ async function loadDetail(id: string, resumeJob = true) {
|
|||||||
try {
|
try {
|
||||||
selected.value = (await http.get(`/schedules/plans/${id}`)).data
|
selected.value = (await http.get(`/schedules/plans/${id}`)).data
|
||||||
if (resumeJob && selected.value.status === 'Draft') {
|
if (resumeJob && selected.value.status === 'Draft') {
|
||||||
await resumeAutoSchedule(id)
|
await Promise.all([resumeAutoSchedule(id), resumePublish(id)])
|
||||||
} else if (resumeJob) {
|
} else if (resumeJob) {
|
||||||
clearAutoSchedulePoll()
|
clearAutoSchedulePoll()
|
||||||
autoJob.value = null
|
autoJob.value = null
|
||||||
|
await resumePublish(id)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
@@ -556,9 +630,11 @@ async function publishPlan() {
|
|||||||
'发布课表',
|
'发布课表',
|
||||||
{ type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' },
|
{ type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' },
|
||||||
)
|
)
|
||||||
await http.post(`/schedules/plans/${selected.value.id}/publish`)
|
const planId = selected.value.id
|
||||||
ElMessage.success('课表已发布')
|
const { data } = await http.post(`/schedules/plans/${planId}/publish`)
|
||||||
await loadPlans()
|
publishJob.value = data
|
||||||
|
ElMessage.success('检查并发布任务已提交,可在当前页面查看进度')
|
||||||
|
schedulePublishPoll(data.id, planId)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
@@ -677,7 +753,10 @@ onMounted(async () => {
|
|||||||
await Promise.all([loadPlans(false), loadSchedulingSettings()])
|
await Promise.all([loadPlans(false), loadSchedulingSettings()])
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(clearAutoSchedulePoll)
|
onBeforeUnmount(() => {
|
||||||
|
clearAutoSchedulePoll()
|
||||||
|
clearPublishPoll()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -722,7 +801,7 @@ onBeforeUnmount(clearAutoSchedulePoll)
|
|||||||
<p>共 {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
|
<p>共 {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-actions">
|
<div class="plan-actions">
|
||||||
<el-button v-if="isDraft" :disabled="autoLoading" @click="openPlan(selected)">编辑版本</el-button>
|
<el-button v-if="isDraft" :disabled="scheduleJobLoading" @click="openPlan(selected)">编辑版本</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="selected.status === 'Published'"
|
v-if="selected.status === 'Published'"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -735,7 +814,7 @@ onBeforeUnmount(clearAutoSchedulePoll)
|
|||||||
<el-button
|
<el-button
|
||||||
v-else
|
v-else
|
||||||
:icon="CopyDocument"
|
:icon="CopyDocument"
|
||||||
:disabled="autoLoading"
|
:disabled="scheduleJobLoading"
|
||||||
@click="openClone"
|
@click="openClone"
|
||||||
>
|
>
|
||||||
复制调整
|
复制调整
|
||||||
@@ -746,13 +825,23 @@ onBeforeUnmount(clearAutoSchedulePoll)
|
|||||||
plain
|
plain
|
||||||
:icon="Promotion"
|
:icon="Promotion"
|
||||||
:loading="autoLoading"
|
:loading="autoLoading"
|
||||||
|
:disabled="publishLoading"
|
||||||
@click="autoSchedule"
|
@click="autoSchedule"
|
||||||
>
|
>
|
||||||
自动排课
|
自动排课
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="autoLoading" @click="openEntry()">添加排课</el-button>
|
<el-button v-if="isDraft" type="primary" :icon="Plus" :disabled="scheduleJobLoading" @click="openEntry()">添加排课</el-button>
|
||||||
<el-button v-if="isDraft" type="success" :icon="Promotion" :disabled="autoLoading" @click="publishPlan">发布课表</el-button>
|
<el-button
|
||||||
<el-button v-if="isDraft" type="danger" plain :disabled="autoLoading" @click="deletePlan">删除草稿</el-button>
|
v-if="isDraft"
|
||||||
|
type="success"
|
||||||
|
:icon="Promotion"
|
||||||
|
:loading="publishLoading"
|
||||||
|
:disabled="autoLoading"
|
||||||
|
@click="publishPlan"
|
||||||
|
>
|
||||||
|
{{ publishLoading ? '检查并发布中' : '发布课表' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="isDraft" type="danger" plain :disabled="scheduleJobLoading" @click="deletePlan">删除草稿</el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -792,6 +881,26 @@ onBeforeUnmount(clearAutoSchedulePoll)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="publishJob"
|
||||||
|
class="auto-schedule-progress"
|
||||||
|
:class="`is-${String(publishJob.status).toLowerCase()}`"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<b>{{ publishStatusText }}</b>
|
||||||
|
<span v-if="publishLoading">后台运行中,离开页面不会中断任务</span>
|
||||||
|
<span v-else-if="publishJob.status === 'Failed'">
|
||||||
|
草稿未发布,请根据上方原因调整后重试
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="publishProgress"
|
||||||
|
:status="publishProgressStatus"
|
||||||
|
:indeterminate="publishJob.status === 'Queued'"
|
||||||
|
:duration="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="schedule-search">
|
<div class="schedule-search">
|
||||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" />
|
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" />
|
||||||
<el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button>
|
<el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button>
|
||||||
|
|||||||
Reference in New Issue
Block a user