using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/makeup-exams")] public sealed class MakeupExamsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, MakeupExamEligibilityService eligibilityService) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; private const string ScoreEnterers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.Teacher; // ═══════════════════════════════════════════ // Plans // ═══════════════════════════════════════════ [HttpGet("plans")] public async Task GetPlans( Guid? academicTermId, CancellationToken cancellationToken) { var source = db.MakeupExamPlans.AsNoTracking().AsQueryable(); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); if (!IsManager()) source = source.Where(x => x.Status == MakeupExamPlanStatus.Published); return Ok(await source.OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenByDescending(x => x.CreatedAt) .Select(x => new { x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, TermIsCurrent = x.AcademicTerm.IsCurrent, TermIsArchived = x.AcademicTerm.IsArchived, x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt }).ToListAsync(cancellationToken)); } [HttpPost("plans")] [Authorize(Roles = Managers)] public async Task CreatePlan( MakeupExamPlanRequest request, CancellationToken cancellationToken) { if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选学期不存在或已停用。"); var plan = new MakeupExamPlan { AcademicTermId = request.AcademicTermId, Name = request.Name.Trim(), Notes = Normalize(request.Notes) }; db.MakeupExamPlans.Add(plan); return await SaveAsync(plan.Id, true, cancellationToken); } [HttpGet("plans/{id:guid}")] public async Task GetPlan(Guid id, CancellationToken cancellationToken) { var manager = IsManager(); var plan = await db.MakeupExamPlans.AsNoTracking() .Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published)) .Select(x => new { x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Status, x.Notes, x.PublishedAt, Sessions = x.Sessions.OrderBy(item => item.ExamDate) .ThenBy(item => item.StartPeriod).Select(item => new { item.Id, item.TeachingTaskId, item.TeachingTask!.TaskNumber, TaskName = item.TeachingTask.Name, CourseCode = item.TeachingTask.Course!.Code, CourseName = item.TeachingTask.Course.Name, item.ClassroomId, ClassroomName = item.Classroom != null ? item.Classroom.Name : null, BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null, ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null, item.ExamDate, item.StartPeriod, item.PeriodCount, item.StartsAt, item.EndsAt, item.RequiredBuildingId, item.RequiredBuildingIds, RequiredBuildingName = item.RequiredBuilding != null ? item.RequiredBuilding.Name : null, item.RequiredInvigilatorCount, item.Notes, InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), EnrolledCount = item.Enrollments.Count, Enrollments = item.Enrollments.Select(e => new { e.StudentId, e.Student!.StudentNumber, e.Student.Name, ClassName = e.Student.AdministrativeClass!.Name, e.Reason, e.SourceGradeRecordId, e.SourceDeferredExamId, e.MakeupScore }) }) }).FirstOrDefaultAsync(cancellationToken); return plan is null ? NotFound() : Ok(plan); } [HttpPost("plans/{id:guid}/publish")] [Authorize(Roles = Managers)] public async Task Publish(Guid id, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null) return ConflictProblem("补考计划正在后台编排,完成后才能发布。"); if (await FindActivePublishJobAsync(id, cancellationToken) is not null) return ConflictProblem("补考计划正在后台发布,请等待任务完成。"); var plan = await db.MakeupExamPlans.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Status, HasSessions = x.Sessions.Any() }) .FirstOrDefaultAsync(cancellationToken); if (plan is null) return NotFound(); if (plan.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("只有草稿补考计划可以发布。"); if (!plan.HasSessions) return ConflictProblem("至少安排一个考试场次后才能发布。"); var userId = currentUserDataScope.Current.UserId; var job = new ExamPublishJob { Kind = ExamPublishJobKind.MakeupExam, PlanId = id, ActivePlanId = id, RequestedByUserId = userId == Guid.Empty ? null : userId, CurrentStep = "等待后台校验" }; db.ExamPublishJobs.Add(job); db.BackgroundJobOutboxMessages.Add( BackgroundJobOutboxMessage.Create( BackgroundJobKind.ExamPublish, job.Id)); try { await db.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { db.ChangeTracker.Clear(); var existing = await FindActivePublishJobAsync(id, cancellationToken); if (existing is not null) return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。"); throw; } return AcceptedPublishJob(job, "补考发布任务已提交。"); } [HttpGet("publish-jobs/{jobId:guid}")] [Authorize(Roles = Managers)] public async Task GetPublishJob( Guid jobId, CancellationToken cancellationToken) { var job = await db.ExamPublishJobs.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == jobId && x.Kind == ExamPublishJobKind.MakeupExam, cancellationToken); return job is null ? NotFound() : Ok(ToPublishJobResponse(job)); } [HttpGet("plans/{planId:guid}/publish-job")] [Authorize(Roles = Managers)] public async Task GetLatestPublishJob( Guid planId, CancellationToken cancellationToken) { var job = await db.ExamPublishJobs.AsNoTracking() .Where(x => x.PlanId == planId && x.Kind == ExamPublishJobKind.MakeupExam) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); return job is null ? NoContent() : Ok(ToPublishJobResponse(job)); } [HttpPost("plans/{id:guid}/archive")] [Authorize(Roles = Managers)] public async Task Archive(Guid id, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null) return ConflictProblem("补考计划正在后台编排,暂时不能归档。"); var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken); if (plan is null) return NotFound(); if (plan.Status != MakeupExamPlanStatus.Published) return ConflictProblem("只有已发布的补考计划可以归档。"); plan.Status = MakeupExamPlanStatus.Archived; return await SaveAsync(id, false, cancellationToken); } // ═══════════════════════════════════════════ // Sessions // ═══════════════════════════════════════════ [HttpPost("plans/{planId:guid}/sessions")] [Authorize(Roles = Managers)] public async Task CreateSession( Guid planId, CreateMakeupExamSessionRequest request, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。"); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); if (plan is null) return NotFound(); if (plan.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("已发布的补考计划不能调整场次。"); var timeResult = ResolveExamTime(plan.AcademicTermId, request.ExamDate, request.StartPeriod, request.PeriodCount); if (timeResult.Error is not null) return timeResult.Error; var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); var validation = await ValidateSessionAsync(plan, null, request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds, startsAt, endsAt, cancellationToken); if (validation is not null) return validation; var session = new MakeupExamSession { MakeupExamPlanId = planId, TeachingTaskId = request.TeachingTaskId, ClassroomId = request.ClassroomId, ExamDate = request.ExamDate, StartPeriod = request.StartPeriod, PeriodCount = request.PeriodCount, StartsAt = startsAt, EndsAt = endsAt, RequiredBuildingId = request.RequiredBuildingId, RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds), RequiredInvigilatorCount = request.RequiredInvigilatorCount, Notes = Normalize(request.Notes), Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id => new MakeupExamSessionInvigilator { TeacherId = id }).ToList() }; db.MakeupExamSessions.Add(session); return await SaveAsync(session.Id, true, cancellationToken); } [HttpPost("plans/{planId:guid}/sessions/batch")] [Authorize(Roles = Managers)] public async Task CreateSessionsBatch( Guid planId, CreateMakeupExamSessionsBatchRequest request, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。"); var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); if (plan is null) return NotFound(); if (plan.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("已发布的补考计划不能调整场次。"); var taskIds = request.TeachingTaskIds.Distinct().ToArray(); if (taskIds.Length == 0) return ValidationProblem("请至少选择一个教学班。"); if (taskIds.Length > 100) return ValidationProblem("一次最多选择100个教学班。"); var timeResult = ResolveExamTime(plan.AcademicTermId, request.ExamDate, request.StartPeriod, request.PeriodCount); if (timeResult.Error is not null) return timeResult.Error; var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); var validTaskIds = await db.TeachingTasks.AsNoTracking() .Where(x => x.AcademicTermId == plan.AcademicTermId && x.Status == TeachingTaskStatus.Published) .WhereIn(taskIds, x => x.Id) .Select(x => x.Id) .ToListAsync(cancellationToken); if (validTaskIds.Count != taskIds.Length) return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。"); var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking() .Where(x => x.MakeupExamPlanId == planId) .WhereIn(taskIds, x => x.TeachingTaskId) .Select(x => x.TeachingTaskId) .ToListAsync(cancellationToken); if (duplicateTaskIds.Count > 0) return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。"); foreach (var taskId in taskIds) { var validation = await ValidateSessionAsync(plan, null, taskId, null, null, startsAt, endsAt, cancellationToken); if (validation is not null) return validation; } db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession { MakeupExamPlanId = planId, TeachingTaskId = taskId, ExamDate = request.ExamDate, StartPeriod = request.StartPeriod, PeriodCount = request.PeriodCount, StartsAt = startsAt, EndsAt = endsAt, RequiredBuildingId = request.RequiredBuildingId, RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds), RequiredInvigilatorCount = request.RequiredInvigilatorCount, Notes = Normalize(request.Notes) })); try { await db.SaveChangesAsync(cancellationToken); return Ok(new { createdCount = taskIds.Length }); } catch (DbUpdateException) { return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。"); } } [HttpPut("plans/{planId:guid}/sessions/{id:guid}")] [Authorize(Roles = Managers)] public async Task UpdateSession( Guid planId, Guid id, CreateMakeupExamSessionRequest request, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。"); var session = await db.MakeupExamSessions .Include(x => x.MakeupExamPlan) .Include(x => x.Invigilators) .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken); if (session is null) return NotFound(); if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("已发布的补考计划不能调整场次。"); var timeResult = ResolveExamTime(session.MakeupExamPlan.AcademicTermId, request.ExamDate, request.StartPeriod, request.PeriodCount); if (timeResult.Error is not null) return timeResult.Error; var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); var validation = await ValidateSessionAsync(session.MakeupExamPlan, id, request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds, startsAt, endsAt, cancellationToken); if (validation is not null) return validation; session.TeachingTaskId = request.TeachingTaskId; session.ClassroomId = request.ClassroomId; session.ExamDate = request.ExamDate; session.StartPeriod = request.StartPeriod; session.PeriodCount = request.PeriodCount; session.StartsAt = startsAt; session.EndsAt = endsAt; session.RequiredBuildingId = request.RequiredBuildingId; session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds); session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; session.Notes = Normalize(request.Notes); db.MakeupExamSessionInvigilators.RemoveRange(session.Invigilators); session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid => new MakeupExamSessionInvigilator { TeacherId = tid }).ToList(); return await SaveAsync(id, false, cancellationToken); } [HttpDelete("plans/{planId:guid}/sessions/{id:guid}")] [Authorize(Roles = Managers)] public async Task DeleteSession( Guid planId, Guid id, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。"); var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan) .FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken); if (session is null) return NotFound(); if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("已发布的补考计划不能调整场次。"); db.MakeupExamSessions.Remove(session); return await SaveAsync(id, false, cancellationToken); } // ═══════════════════════════════════════════ // Auto-arrange // ═══════════════════════════════════════════ [HttpPost("plans/{planId:guid}/auto-arrange")] [Authorize(Roles = Managers)] public async Task AutoArrange( Guid planId, ExamAutoArrangeRequest? request, CancellationToken cancellationToken) { request ??= new ExamAutoArrangeRequest(); if (!request.AssignClassrooms && !request.AssignInvigilators) return ValidationProblem("请至少选择分配考场或分配监考教师。"); var sessionIds = (request.SessionIds ?? []).Distinct().ToArray(); if (sessionIds.Length > 100) return ValidationProblem("一次最多处理100个补考场次。"); var plan = await db.MakeupExamPlans.AsNoTracking() .Where(x => x.Id == planId) .Select(x => new { x.Status, TotalSessions = x.Sessions.Count }) .FirstOrDefaultAsync(cancellationToken); if (plan is null) return NotFound(); if (plan.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("只有草稿状态的补考计划可以自动编排。"); if (sessionIds.Length > 0) { var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking() .Where(x => x.MakeupExamPlanId == planId) .WhereIn(sessionIds, x => x.Id) .CountAsync(cancellationToken); if (selectedSessionCount != sessionIds.Length) return ConflictProblem("所选场次不存在或不属于当前补考计划。"); } var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking() .AnyAsync( x => x.MakeupExamPlanId == planId && (x.Status == MakeupExamAutoJobStatus.Queued || x.Status == MakeupExamAutoJobStatus.Running), cancellationToken); if (autoCreateActive) return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。"); var existing = await FindActiveArrangementJobAsync( planId, cancellationToken); if (existing is not null) return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。"); var userId = currentUserDataScope.Current.UserId; var job = new ExamArrangementJob { Kind = ExamArrangementKind.MakeupExam, PlanId = planId, ActivePlanId = planId, RequestedByUserId = userId == Guid.Empty ? null : userId, SessionIdsJson = sessionIds.Length == 0 ? null : System.Text.Json.JsonSerializer.Serialize(sessionIds), AssignClassrooms = request.AssignClassrooms, AssignInvigilators = request.AssignInvigilators, TotalSessions = sessionIds.Length == 0 ? plan.TotalSessions : sessionIds.Length, CurrentStep = "等待后台编排" }; db.ExamArrangementJobs.Add(job); db.BackgroundJobOutboxMessages.Add( BackgroundJobOutboxMessage.Create( BackgroundJobKind.ExamArrangement, job.Id)); try { await db.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { db.ChangeTracker.Clear(); existing = await FindActiveArrangementJobAsync( planId, cancellationToken); if (existing is not null) return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。"); throw; } return AcceptedArrangementJob(job, "补考编排任务已提交。"); } [HttpGet("arrangement-jobs/{jobId:guid}")] [Authorize(Roles = Managers)] public async Task GetArrangementJob( Guid jobId, CancellationToken cancellationToken) { var job = await db.ExamArrangementJobs.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == jobId && x.Kind == ExamArrangementKind.MakeupExam, cancellationToken); return job is null ? NotFound() : Ok(ToArrangementJobResponse(job)); } [HttpGet("plans/{planId:guid}/arrangement-job")] [Authorize(Roles = Managers)] public async Task GetLatestArrangementJob( Guid planId, CancellationToken cancellationToken) { var job = await db.ExamArrangementJobs.AsNoTracking() .Where(x => x.PlanId == planId && x.Kind == ExamArrangementKind.MakeupExam) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); return job is null ? NoContent() : Ok(ToArrangementJobResponse(job)); } // ═══════════════════════════════════════════ // Auto-create (background job) // ═══════════════════════════════════════════ [HttpPost("plans/{planId:guid}/auto-create")] [Authorize(Roles = Managers)] public async Task AutoCreate( Guid planId, CancellationToken cancellationToken) { var plan = await db.MakeupExamPlans .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("只有草稿状态的补考计划可以自动生成。"); if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。"); // Check for existing active job var existing = await db.MakeupExamAutoJobs.AsNoTracking() .Where(x => x.MakeupExamPlanId == planId && (x.Status == MakeupExamAutoJobStatus.Queued || x.Status == MakeupExamAutoJobStatus.Running)) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (existing is not null) return Ok(new { jobId = existing.Id, status = existing.Status.ToString(), message = "该计划已有正在执行的任务。" }); var job = new MakeupExamAutoJob { MakeupExamPlanId = planId }; db.MakeupExamAutoJobs.Add(job); db.BackgroundJobOutboxMessages.Add( BackgroundJobOutboxMessage.Create( BackgroundJobKind.MakeupExamAuto, job.Id)); await db.SaveChangesAsync(cancellationToken); return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id }, new { jobId = job.Id, status = job.Status.ToString() }); } [HttpGet("auto-jobs/{jobId:guid}")] [Authorize(Roles = Managers)] public async Task GetAutoJob( Guid jobId, CancellationToken cancellationToken) { var job = await db.MakeupExamAutoJobs.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken); if (job is null) return NotFound(); return Ok(new { job.Id, job.MakeupExamPlanId, Status = job.Status.ToString(), job.TotalCourses, job.ProcessedCourses, job.CreatedSessions, job.EnrolledStudents, Messages = !string.IsNullOrEmpty(job.MessagesJson) ? System.Text.Json.JsonSerializer.Deserialize>(job.MessagesJson) : new List(), job.ErrorMessage, job.CreatedAt, job.StartedAt, job.CompletedAt }); } // ═══════════════════════════════════════════ // Enrollments // ═══════════════════════════════════════════ [HttpGet("eligible-students")] [Authorize(Roles = Managers)] public async Task GetEligibleStudents( Guid teachingTaskId, CancellationToken cancellationToken) { var students = await eligibilityService.GetEligibleStudentsAsync( teachingTaskId, cancellationToken); return Ok(students); } [HttpPost("sessions/{id:guid}/enroll")] [Authorize(Roles = Managers)] public async Task EnrollStudents( Guid id, EnrollStudentsRequest request, CancellationToken cancellationToken) { var session = await db.MakeupExamSessions .Include(x => x.MakeupExamPlan) .Include(x => x.Enrollments) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (session is null) return NotFound(); if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("已发布的补考计划不能调整登记。"); var eligible = await eligibilityService.GetEligibleStudentsAsync( session.TeachingTaskId, cancellationToken); var eligibleIds = eligible.Select(e => e.StudentId).ToHashSet(); var alreadyEnrolled = session.Enrollments.Select(e => e.StudentId).ToHashSet(); var validIds = request.StudentIds .Where(sid => !alreadyEnrolled.Contains(sid)) .Distinct() .ToList(); if (validIds.Count == 0) return ConflictProblem("所选学生均已登记或无效。"); var invalidIds = validIds.Where(sid => !eligibleIds.Contains(sid)).ToList(); if (invalidIds.Count > 0) return ConflictProblem($"{invalidIds.Count} 名学生不符合补考资格。"); // Check for time conflicts with other makeup sessions var conflictIds = await db.MakeupExamEnrollments.AsNoTracking() .Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt && session.StartsAt < x.MakeupExamSession.EndsAt) .WhereIn(validIds, x => x.StudentId) .Select(x => x.StudentId) .Distinct() .ToListAsync(cancellationToken); if (conflictIds.Count > 0) { var conflictNumbers = await db.Students .WhereIn(conflictIds, s => s.Id) .Select(s => s.StudentNumber) .ToListAsync(cancellationToken); return ConflictProblem( $"以下学生该时段已有其他补考:{string.Join("、", conflictNumbers)}"); } foreach (var studentId in validIds) { var info = eligible.First(e => e.StudentId == studentId); session.Enrollments.Add(new MakeupExamEnrollment { MakeupExamSessionId = id, StudentId = studentId, Reason = info.Reason, SourceGradeRecordId = info.SourceGradeRecordId, SourceDeferredExamId = info.SourceDeferredExamId }); } return await SaveAsync(id, false, cancellationToken); } [HttpDelete("sessions/{id:guid}/enrollments/{studentId:guid}")] [Authorize(Roles = Managers)] public async Task RemoveEnrollment( Guid id, Guid studentId, CancellationToken cancellationToken) { var enrollment = await db.MakeupExamEnrollments .Include(x => x.MakeupExamSession) .ThenInclude(x => x!.MakeupExamPlan) .FirstOrDefaultAsync(x => x.MakeupExamSessionId == id && x.StudentId == studentId, cancellationToken); if (enrollment is null) return NotFound(); if (enrollment.MakeupExamSession!.MakeupExamPlan!.Status != MakeupExamPlanStatus.Draft) return ConflictProblem("已发布的补考计划不能调整登记。"); db.MakeupExamEnrollments.Remove(enrollment); return await SaveAsync(id, false, cancellationToken); } // ═══════════════════════════════════════════ // Scores // ═══════════════════════════════════════════ [HttpPut("sessions/{id:guid}/scores")] [Authorize(Roles = ScoreEnterers)] public async Task RecordScores( Guid id, List scores, CancellationToken cancellationToken) { var session = await db.MakeupExamSessions .Include(x => x.MakeupExamPlan) .Include(x => x.Enrollments) .Include(x => x.TeachingTask!) .ThenInclude(x => x.Teachers) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (session is null) return NotFound(); if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published) return ConflictProblem("只有已发布的补考计划可以录入成绩。"); // Teachers can only record scores for courses they teach if (!IsManager()) { var userId = currentUserDataScope.Current.UserId; var teaches = session.TeachingTask!.Teachers .Any(t => t.Teacher!.UserId == userId); if (!teaches) return Forbid(); } var enrollmentByStudent = session.Enrollments.ToDictionary(e => e.StudentId); foreach (var entry in scores) { if (!enrollmentByStudent.TryGetValue(entry.StudentId, out var enrollment)) continue; enrollment.MakeupScore = entry.Score; // Update the grade record — 补考合格按60分记 if (enrollment.SourceGradeRecordId.HasValue) { var record = await db.GradeRecords .FirstOrDefaultAsync(x => x.Id == enrollment.SourceGradeRecordId.Value, cancellationToken); if (record is not null) { record.ExamStatus = GradeExamStatus.Makeup; // Cap passing score at 60 var cappedScore = entry.Score >= 60 ? 60m : entry.Score; record.TotalScore = cappedScore; record.GradePoint = GradeCalculator.CalculateGradePoint(cappedScore); record.Notes = entry.Score >= 60 ? $"补考合格(原始{entry.Score}分,按60分记)" : $"补考不合格({entry.Score}分)"; } } } return await SaveAsync(id, false, cancellationToken); } // ═══════════════════════════════════════════ // Roster // ═══════════════════════════════════════════ [HttpGet("sessions/{id:guid}/roster")] [Authorize(Roles = Managers)] public async Task GetRoster(Guid id, CancellationToken cancellationToken) { var session = await db.MakeupExamSessions.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, CourseName = x.TeachingTask.Course!.Name, ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配", x.StartsAt }).FirstOrDefaultAsync(cancellationToken); if (session is null) return NotFound(); var students = await db.MakeupExamEnrollments.AsNoTracking() .Where(e => e.MakeupExamSessionId == id) .OrderBy(e => e.Student!.StudentNumber) .Select(e => new { e.StudentId, e.Student!.StudentNumber, e.Student.Name, ClassName = e.Student.AdministrativeClass!.Name, e.Reason, e.MakeupScore }) .ToListAsync(cancellationToken); return Ok(new { session.Id, session.TeachingTaskId, session.TaskNumber, session.CourseName, session.ClassroomName, session.StartsAt, Students = students.Select((student, index) => new { student.StudentId, student.StudentNumber, student.Name, student.ClassName, student.Reason, student.MakeupScore, SeatNumber = (index + 1).ToString("D3") }) }); } // ═══════════════════════════════════════════ // Query helpers // ═══════════════════════════════════════════ [HttpGet("available-classrooms")] [Authorize(Roles = Managers)] public async Task GetAvailableClassrooms( Guid academicTermId, DateOnly examDate, int startPeriod, int periodCount, Guid? requiredBuildingId, Guid? planId, Guid? excludeSessionId, CancellationToken cancellationToken) { var timeResult = ResolveExamTime( academicTermId, examDate, startPeriod, periodCount); if (timeResult.Error is not null) return timeResult.Error; var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); var query = db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled); if (requiredBuildingId.HasValue) query = query.Where(x => x.BuildingId == requiredBuildingId.Value); var occupiedQuery = db.MakeupExamSessions.AsNoTracking() .Where(x => x.ClassroomId != null && x.StartsAt < endsAt && startsAt < x.EndsAt); if (planId.HasValue) occupiedQuery = occupiedQuery.Where(x => x.MakeupExamPlanId == planId.Value); if (excludeSessionId.HasValue) occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value); var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value) .ToListAsync(cancellationToken); if (occupiedIds.Count > 0) query = query.WhereNotIn(occupiedIds, x => x.Id); return Ok(await query.OrderBy(x => x.Building!.Name) .ThenBy(x => x.Capacity) .Select(x => new { x.Id, x.Name, x.Capacity, x.RoomType, x.BuildingId, BuildingName = x.Building!.Name }).ToListAsync(cancellationToken)); } [HttpGet("available-teachers")] [Authorize(Roles = Managers)] public async Task GetAvailableTeachers( Guid academicTermId, DateOnly examDate, int startPeriod, int periodCount, Guid? planId, Guid? excludeSessionId, CancellationToken cancellationToken) { var timeResult = ResolveExamTime( academicTermId, examDate, startPeriod, periodCount); if (timeResult.Error is not null) return timeResult.Error; var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); var busyQuery = db.MakeupExamSessionInvigilators.AsNoTracking() .Where(x => x.MakeupExamSession!.StartsAt < endsAt && startsAt < x.MakeupExamSession!.EndsAt); if (planId.HasValue) busyQuery = busyQuery.Where(x => x.MakeupExamSession!.MakeupExamPlanId == planId.Value); if (excludeSessionId.HasValue) busyQuery = busyQuery.Where(x => x.MakeupExamSessionId != excludeSessionId.Value); var busyIds = await busyQuery.Select(x => x.TeacherId) .ToListAsync(cancellationToken); return Ok(await db.Teachers.AsNoTracking() .Where(x => x.Status == TeacherStatus.Active) .WhereNotIn(busyIds, x => x.Id) .OrderBy(x => x.Name) .Select(x => new { x.Id, x.TeacherNumber, x.Name, x.Title, CollegeName = x.College!.Name }).ToListAsync(cancellationToken)); } [HttpGet("time-slots-for-term")] [Authorize(Roles = Managers)] public async Task GetTimeSlotsForTerm( Guid academicTermId, CancellationToken cancellationToken) => Ok(await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled) .OrderBy(x => x.PeriodNumber) .Select(x => new { x.Id, x.PeriodNumber, x.Name, StartsAt = x.StartsAt.ToString("HH:mm"), EndsAt = x.EndsAt.ToString("HH:mm"), x.IsEnabled }) .ToListAsync(cancellationToken)); // ═══════════════════════════════════════════ // My schedule // ═══════════════════════════════════════════ [HttpGet("my-schedule")] public async Task GetMySchedule(CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; if (scope.IsInRole(SystemRoles.Student)) { var studentId = await db.Students.Where(x => x.UserId == scope.UserId) .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); var schedule = await db.MakeupExamEnrollments.AsNoTracking() .Where(x => x.StudentId == studentId && x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published) .OrderBy(x => x.MakeupExamSession!.ExamDate) .ThenBy(x => x.MakeupExamSession!.StartPeriod) .Select(x => new { x.MakeupExamSession!.Id, PlanName = x.MakeupExamSession.MakeupExamPlan!.Name, x.MakeupExamSession.ExamDate, x.MakeupExamSession.StartPeriod, x.MakeupExamSession.PeriodCount, x.MakeupExamSession.StartsAt, x.MakeupExamSession.EndsAt, x.MakeupExamSession.TeachingTask!.TaskNumber, CourseCode = x.MakeupExamSession.TeachingTask.Course!.Code, CourseName = x.MakeupExamSession.TeachingTask.Course.Name, ClassroomName = x.MakeupExamSession.Classroom != null ? x.MakeupExamSession.Classroom.Name : null, BuildingName = x.MakeupExamSession.Classroom != null ? x.MakeupExamSession.Classroom.Building!.Name : null, x.Reason, x.MakeupScore, IsMakeup = true }) .ToListAsync(cancellationToken); if (schedule.Count == 0) return Ok(schedule); var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray(); var seatRows = await db.MakeupExamEnrollments.AsNoTracking() .WhereIn(sessionIds, x => x.MakeupExamSessionId) .OrderBy(x => x.Student!.StudentNumber) .Select(x => new { x.MakeupExamSessionId, x.StudentId, x.Student!.StudentNumber }) .ToListAsync(cancellationToken); var seatNumbers = seatRows .GroupBy(x => x.MakeupExamSessionId) .SelectMany(group => group.Select((row, index) => new { row.MakeupExamSessionId, row.StudentId, SeatNumber = (index + 1).ToString("D3") })) .ToDictionary(x => (x.MakeupExamSessionId, x.StudentId), x => x.SeatNumber); return Ok(schedule.Select(x => new { x.Id, x.PlanName, x.ExamDate, x.StartPeriod, x.PeriodCount, x.StartsAt, x.EndsAt, x.TaskNumber, x.CourseCode, x.CourseName, x.ClassroomName, x.BuildingName, x.Reason, x.MakeupScore, SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)), x.IsMakeup })); } if (scope.IsInRole(SystemRoles.Teacher)) { return Ok(await db.MakeupExamSessions.AsNoTracking() .Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published && x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .Select(x => new { x.Id, PlanName = x.MakeupExamPlan!.Name, x.ExamDate, x.StartPeriod, x.PeriodCount, x.StartsAt, x.EndsAt, x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, ClassroomName = x.Classroom != null ? x.Classroom.Name : null, BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name), EnrolledCount = x.Enrollments.Count, IsMakeup = true }) .ToListAsync(cancellationToken)); } return Ok(Array.Empty()); } // ═══════════════════════════════════════════ // My teaching sessions (for score entry) // ═══════════════════════════════════════════ [HttpGet("my-teaching-sessions")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetMyTeachingSessions(CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; return Ok(await db.MakeupExamSessions.AsNoTracking() .Where(x => x.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published && x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId)) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .Select(x => new { x.Id, PlanName = x.MakeupExamPlan!.Name, x.ExamDate, x.StartPeriod, x.PeriodCount, x.StartsAt, x.EndsAt, x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, ClassroomName = x.Classroom != null ? x.Classroom.Name : null, BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, EnrolledCount = x.Enrollments.Count, GradedCount = x.Enrollments.Count(e => e.MakeupScore != null), Enrollments = x.Enrollments.OrderBy(e => e.Student!.StudentNumber) .Select(e => new { e.StudentId, e.Student!.StudentNumber, e.Student.Name, ClassName = e.Student.AdministrativeClass!.Name, e.Reason, e.MakeupScore }).ToList(), IsMakeup = true }) .ToListAsync(cancellationToken)); } // ═══════════════════════════════════════════ // Private helpers // ═══════════════════════════════════════════ private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime( Guid academicTermId, DateOnly examDate, int startPeriod, int periodCount) { var slots = db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled) .OrderBy(x => x.PeriodNumber) .ToList(); if (slots.Count == 0) return (default, default, ValidationProblem("当前学期未配置上课时间表,请先在排课设置中配置节次时间。")); var slotDict = slots.ToDictionary(x => x.PeriodNumber); var startSlot = slotDict.GetValueOrDefault(startPeriod); var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1); if (startSlot is null) return (default, default, ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。")); if (endSlot is null) return (default, default, ValidationProblem( $"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。")); return ( examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc), examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc), null ); } private async Task ValidateSessionAsync( MakeupExamPlan plan, Guid? currentId, Guid teachingTaskId, Guid? classroomId, IReadOnlyCollection? invigilatorIds, DateTime startsAt, DateTime endsAt, CancellationToken cancellationToken) { if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。"); var task = await db.TeachingTasks.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); if (task is null || task.AcademicTermId != plan.AcademicTermId) return ValidationProblem("教学班与补考计划必须属于同一学期。"); if (classroomId.HasValue) { var room = await db.Classrooms.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken); if (room is null) return ValidationProblem("所选考场不存在或已停用。"); } var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); if (teacherIds.Length > 0) { if (await db.Teachers .Where(x => x.Status == TeacherStatus.Active) .WhereIn(teacherIds, x => x.Id) .CountAsync(cancellationToken) != teacherIds.Length) return ValidationProblem("存在无效监考教师。"); } var overlaps = db.MakeupExamSessions.Where(x => x.MakeupExamPlanId == plan.Id && x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt); if (classroomId.HasValue) { if (await overlaps.AnyAsync( x => x.ClassroomId == classroomId.Value, cancellationToken)) return ConflictProblem("该时段考场已被占用。"); } if (teacherIds.Length > 0) { if (await db.MakeupExamSessionInvigilators .Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId)) .WhereIn(teacherIds, i => i.TeacherId) .AnyAsync(cancellationToken)) return ConflictProblem("监考教师在该时段已有补考任务。"); } return null; } private bool IsManager() => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); private Task FindActiveArrangementJobAsync( Guid planId, CancellationToken cancellationToken) => db.ExamArrangementJobs.AsNoTracking() .Where(x => x.Kind == ExamArrangementKind.MakeupExam && x.ActivePlanId == planId && (x.Status == ExamArrangementJobStatus.Queued || x.Status == ExamArrangementJobStatus.Running)) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); private ActionResult AcceptedArrangementJob( ExamArrangementJob job, string message) => AcceptedAtAction( nameof(GetArrangementJob), new { jobId = job.Id }, new { jobId = job.Id, status = job.Status.ToString(), message }); private static object ToArrangementJobResponse(ExamArrangementJob job) => new { job.Id, job.PlanId, Kind = job.Kind.ToString(), Status = job.Status.ToString(), job.TotalSessions, job.ProcessedSessions, job.CurrentStep, job.ResultMessage, job.ErrorMessage, job.AssignClassrooms, job.AssignInvigilators, job.CreatedAt, job.StartedAt, job.CompletedAt }; private Task FindActivePublishJobAsync( Guid planId, CancellationToken cancellationToken) => db.ExamPublishJobs.AsNoTracking() .Where(x => x.Kind == ExamPublishJobKind.MakeupExam && x.ActivePlanId == planId && (x.Status == ExamPublishJobStatus.Queued || x.Status == ExamPublishJobStatus.Running)) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); private ActionResult AcceptedPublishJob( ExamPublishJob job, string message) => AcceptedAtAction( nameof(GetPublishJob), new { jobId = job.Id }, new { jobId = job.Id, status = job.Status.ToString(), message }); private static object ToPublishJobResponse(ExamPublishJob job) => new { job.Id, job.PlanId, Kind = job.Kind.ToString(), Status = job.Status.ToString(), job.CurrentStep, job.ErrorMessage, job.CreatedAt, job.StartedAt, job.CompletedAt }; private async Task SaveAsync(Guid id, bool created, CancellationToken token) { try { await db.SaveChangesAsync(token); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) { return ConflictProblem("补考场次重复,或关联数据已发生变化。"); } } private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成补考操作", Detail = detail, Status = StatusCodes.Status409Conflict }); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string? SerializeBuildingIds(IReadOnlyCollection? ids) => ids is { Count: > 0 } ? System.Text.Json.JsonSerializer.Serialize(ids) : null; } // ═══════════════════════════════════════════ // Request/Response models // ═══════════════════════════════════════════ public sealed record MakeupExamPlanRequest( Guid AcademicTermId, [Required, MaxLength(120)] string Name, [MaxLength(500)] string? Notes); public sealed record CreateMakeupExamSessionRequest( Guid TeachingTaskId, Guid? ClassroomId, DateOnly ExamDate, [Range(1, 30)] int StartPeriod, [Range(1, 6)] int PeriodCount, Guid? RequiredBuildingId, IReadOnlyCollection? RequiredBuildingIds, [Range(1, 10)] int RequiredInvigilatorCount, IReadOnlyCollection? InvigilatorIds, [MaxLength(500)] string? Notes); public sealed record CreateMakeupExamSessionsBatchRequest( [Required] IReadOnlyCollection TeachingTaskIds, DateOnly ExamDate, [Range(1, 30)] int StartPeriod, [Range(1, 6)] int PeriodCount, Guid? RequiredBuildingId, IReadOnlyCollection? RequiredBuildingIds, [Range(1, 10)] int RequiredInvigilatorCount, [MaxLength(500)] string? Notes); public sealed record EnrollStudentsRequest( [Required] IReadOnlyCollection StudentIds); public sealed record RecordMakeupScoreRequest( Guid StudentId, [Range(0, 100)] decimal Score);