using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Controllers; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; using System.Text.Json; namespace Jiaowu.Api.Infrastructure.Exams; public sealed class ExamPublishJobProcessor( AppDbContext db, IAppCache cache, ILogger logger) { public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken) { try { var job = await db.ExamPublishJobs .FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken); if (job is null || job.Status is ExamPublishJobStatus.Succeeded or ExamPublishJobStatus.Failed) { return; } job.Status = ExamPublishJobStatus.Running; job.StartedAt ??= DateTime.UtcNow; job.CompletedAt = null; job.ErrorMessage = null; job.CurrentStep = "正在校验考试计划"; await db.SaveChangesAsync(stoppingToken); switch (job.Kind) { case ExamPublishJobKind.FormalExam: await PublishFormalExamAsync(job, stoppingToken); break; case ExamPublishJobKind.MakeupExam: await PublishMakeupExamAsync(job, stoppingToken); break; case ExamPublishJobKind.ExperimentProjects: await PublishExperimentProjectsAsync(job, stoppingToken); break; default: throw new InvalidOperationException( $"不支持的考试发布类型:{job.Kind}。"); } job.Status = ExamPublishJobStatus.Succeeded; job.ActivePlanId = null; job.CurrentStep = "发布完成"; job.CompletedAt = DateTime.UtcNow; await db.SaveChangesAsync(stoppingToken); await cache.RemoveByTagAsync( AppCacheTags.Timetables, stoppingToken); logger.LogInformation( "Exam publish job {JobId} for {Kind}/{PlanId} completed.", job.Id, job.Kind, job.PlanId); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { logger.LogInformation( "Exam publish job {JobId} was interrupted by application shutdown.", jobId); throw; } catch (ExamPublishValidationException validationException) { logger.LogWarning( validationException, "Exam publish job {JobId} validation failed.", jobId); await MarkFailedAsync(jobId, validationException.Message); } catch (Exception exception) { logger.LogError(exception, "Exam publish job {JobId} failed.", jobId); await MarkFailedAsync(jobId, exception.GetBaseException().Message); } } private async Task PublishFormalExamAsync( ExamPublishJob job, CancellationToken ct) { var plan = await db.ExamPlans .FirstOrDefaultAsync(x => x.Id == job.PlanId, ct); if (plan is null) throw new ExamPublishValidationException("考试计划不存在。"); if (plan.Status != ExamPlanStatus.Draft) throw new ExamPublishValidationException("只有草稿考试计划可以发布。"); // Step 1: check sessions exist — simple count, no JOIN var sessionCount = await db.ExamSessions .CountAsync(x => x.ExamPlanId == job.PlanId, ct); if (sessionCount == 0) throw new ExamPublishValidationException( "至少安排一个考试场次后才能发布。"); // Step 2: load session summaries — only necessary columns, no Include chains var sessions = await db.ExamSessions .AsNoTracking() .Where(x => x.ExamPlanId == job.PlanId) .Select(x => new { x.Id, x.TeachingTaskId, x.ClassroomId, InvigilatorCount = x.Invigilators.Count, RoomLinkCount = x.RoomLinks.Count }) .ToListAsync(ct); // Step 3: load roster counts — independent query var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray(); var rosterCounts = (await TeachingTaskRosterQuery .LoadForTasksAsync(db, taskIds, ct)) .GroupBy(x => x.TeachingTaskId) .ToDictionary(x => x.Key, x => x.Count()); // Step 4: validate each session's assignment completeness var unassignedSessionIds = new List(); foreach (var session in sessions) { var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId); if (session.RoomLinkCount > 0) { // Has room links — check seat assignment via separate query var assignedSeatCount = await db.ExamSeats .CountAsync(seat => seat.ExamSessionId == session.Id, ct); if (assignedSeatCount != rosterCount) unassignedSessionIds.Add(session.Id); } else if (!session.ClassroomId.HasValue || session.InvigilatorCount == 0) { unassignedSessionIds.Add(session.Id); } } if (unassignedSessionIds.Count > 0) throw new ExamPublishValidationException( $"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," + "请先完成自动编排。"); // Step 5: check room invigilator sufficiency — separate query var insufficientInvigilatorCount = await db.ExamRoomSessions .Where(link => link.ExamRoom!.ExamPlanId == job.PlanId && link.ExamRoom.Invigilators.Count < link.ExamRoom.RequiredInvigilatorCount) .Select(link => link.ExamSessionId) .Distinct() .CountAsync(ct); if (insufficientInvigilatorCount > 0) throw new ExamPublishValidationException( $"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," + "请先完成自动编排。"); // Step 6: validate mixed rooms — split into two independent queries // to avoid Seats × SessionLinks Cartesian product // 6a: capacity overflow var overCapacityCount = await db.ExamRooms .Where(r => r.ExamPlanId == job.PlanId) .Select(r => new { r.Id, SeatCount = r.Seats.Count, Capacity = r.Classroom!.Capacity }) .CountAsync(x => x.SeatCount > x.Capacity, ct); if (overCapacityCount > 0) throw new ExamPublishValidationException( $"发现 {overCapacityCount} 个考场容量超限,请重新编排。"); // 6b: course mismatch var courseMismatchCount = await db.ExamRoomSessions .Where(link => link.ExamRoom!.ExamPlanId == job.PlanId && link.ExamSession!.TeachingTask!.CourseId != link.ExamRoom.CourseId) .Select(link => link.ExamRoomId) .Distinct() .CountAsync(ct); if (courseMismatchCount > 0) throw new ExamPublishValidationException( $"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。"); // Step 7: publish plan.Status = ExamPlanStatus.Published; plan.PublishedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); } private async Task PublishMakeupExamAsync( ExamPublishJob job, CancellationToken ct) { var plan = await db.MakeupExamPlans .FirstOrDefaultAsync(x => x.Id == job.PlanId, ct); if (plan is null) throw new ExamPublishValidationException("补考计划不存在。"); if (plan.Status != MakeupExamPlanStatus.Draft) throw new ExamPublishValidationException("只有草稿补考计划可以发布。"); // Step 1: check sessions exist — simple count var sessionCount = await db.MakeupExamSessions .CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct); if (sessionCount == 0) throw new ExamPublishValidationException( "至少安排一个考试场次后才能发布。"); // Step 2: load session summaries — only necessary columns var sessions = await db.MakeupExamSessions .AsNoTracking() .Where(x => x.MakeupExamPlanId == job.PlanId) .Select(x => new { x.Id, x.ClassroomId, InvigilatorCount = x.Invigilators.Count, EnrollmentCount = x.Enrollments.Count }) .ToListAsync(ct); // Step 3: validate completeness — no Include chain needed var unassigned = sessions.Count(x => !x.ClassroomId.HasValue || x.InvigilatorCount == 0); if (unassigned > 0) throw new ExamPublishValidationException( $"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。"); // Step 4: validate enrollments var empty = sessions.Count(x => x.EnrollmentCount == 0); if (empty > 0) throw new ExamPublishValidationException( $"还有 {empty} 个场次没有登记补考学生。"); // Step 5: publish plan.Status = MakeupExamPlanStatus.Published; plan.PublishedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); } private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct) { var ids = JsonSerializer.Deserialize>(job.ProjectIdsJson ?? "[]")? .Where(x => x != Guid.Empty).Distinct().ToList() ?? []; if (ids.Count is 0 or > 100) throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。"); var projects = await db.ExperimentProjects .Include(x => x.Sessions) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .Where(x => ids.Contains(x.Id)) .ToListAsync(ct); if (projects.Count != ids.Count) throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。"); foreach (var project in projects) { if (project.Status != ExperimentProjectStatus.Draft) throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。"); var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized ? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled) : project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled); if (!hasSchedule) throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。"); if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled && (x.SessionDate < project.StartDate || x.SessionDate > project.EndDate))) throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。"); } job.CurrentStep = "正在发布实验项目"; await db.SaveChangesAsync(ct); var publishedAt = DateTime.UtcNow; foreach (var project in projects) { project.Status = ExperimentProjectStatus.Published; project.PublishedAt = publishedAt; } await db.SaveChangesAsync(ct); foreach (var project in projects) { var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId) .Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct); if (userIds.Count == 0) continue; var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约"; await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布", $"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。", "/experiments", ct, NotificationCategory.Schedule); } } private async Task MarkFailedAsync(Guid jobId, string message) { db.ChangeTracker.Clear(); var job = await db.ExamPublishJobs.FirstOrDefaultAsync( x => x.Id == jobId, CancellationToken.None); if (job is null) return; job.Status = ExamPublishJobStatus.Failed; job.ActivePlanId = null; job.CurrentStep = "发布失败"; job.ErrorMessage = message.Length <= 2000 ? message : message[..2000]; job.CompletedAt = DateTime.UtcNow; await db.SaveChangesAsync(CancellationToken.None); } } public sealed class ExamPublishValidationException(string message) : InvalidOperationException(message);