新增统一缓存封装:[AppCache.cs (line 12)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs:12) 接入 HybridCache 和可选 Redis:[Program.cs (line 153)](/E:/jiaowu/src/Jiaowu.Api/Program.cs:153) 缓存学生激活选项、基础数据、公开课表和课表选项。 个人课表、选课容量、成绩、权限、通知和任务状态保持实时查询。 基础数据、课程、教师、教学任务、作息、考试和课表发布后自动失效相关缓存。 新增 /health/cache,Redis 故障不影响 /health/ready。 Compose 增加 256MB、allkeys-lfu、无持久化的 redis:8.8-alpine 服务;该镜像标签已由 Docker 官方镜像仓库核对。 更新 [.env.example (line 1)](/E:/jiaowu/.env.example:1) 和 [README.md (line 190)](/E:/jiaowu/README.md:190) 部署说明。
386 lines
16 KiB
C#
386 lines
16 KiB
C#
using System.Threading.Channels;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Caching;
|
|
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,
|
|
IAppCache cache,
|
|
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);
|
|
|
|
var publishedPlanId = plan.Id;
|
|
await db.ExecuteInRetriableTransactionAsync(
|
|
async transaction =>
|
|
{
|
|
db.ChangeTracker.Clear();
|
|
var publishJob = await db.SchedulePublishJobs
|
|
.FirstAsync(x => x.Id == jobId, stoppingToken);
|
|
var publishPlan = await db.SchedulePlans
|
|
.FirstAsync(x => x.Id == publishedPlanId, stoppingToken);
|
|
if (publishPlan.Status != SchedulePlanStatus.Draft)
|
|
{
|
|
throw new SchedulePublishValidationException(
|
|
"排课草稿状态已发生变化,请刷新后重试。");
|
|
}
|
|
|
|
var previous = await db.SchedulePlans
|
|
.Where(x =>
|
|
x.Id != publishPlan.Id &&
|
|
x.AcademicTermId == publishPlan.AcademicTermId &&
|
|
x.Status == SchedulePlanStatus.Published)
|
|
.ToListAsync(stoppingToken);
|
|
foreach (var oldPlan in previous)
|
|
oldPlan.Status = SchedulePlanStatus.Archived;
|
|
|
|
publishPlan.Status = SchedulePlanStatus.Published;
|
|
publishPlan.PublishedAt = DateTime.UtcNow;
|
|
publishJob.Status = SchedulePublishJobStatus.Succeeded;
|
|
publishJob.ActiveAcademicTermId = null;
|
|
publishJob.CompletedSteps = publishJob.TotalSteps;
|
|
publishJob.CurrentStep = "课表已发布";
|
|
publishJob.CompletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(stoppingToken);
|
|
await transaction.CommitAsync(stoppingToken);
|
|
},
|
|
stoppingToken);
|
|
|
|
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
|
|
logger.LogInformation(
|
|
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
|
jobId,
|
|
publishedPlanId);
|
|
}
|
|
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()
|
|
.WhereIn(taskIds, x => 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);
|