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 _channel = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); public void Enqueue(Guid jobId) { if (!_channel.Writer.TryWrite(jobId)) throw new InvalidOperationException("课表发布任务队列当前不可用。"); } public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => _channel.Reader.ReadAllAsync(cancellationToken); } public sealed class SchedulePublishJobWorker( IServiceScopeFactory scopeFactory, SchedulePublishJobQueue queue, ILogger 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(); 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(); 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 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 ValidateAsync( Guid planId, Func 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 activePeriods, IReadOnlyDictionary 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 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);