using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/exams")] public sealed class ExamsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, ExamArrangementService examArrangementService, IAppCache cache) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; // ═══════════════════════════════════════════ // Plans // ═══════════════════════════════════════════ [HttpGet("plans")] public async Task GetPlans( Guid? academicTermId, CancellationToken cancellationToken) { var source = db.ExamPlans.AsNoTracking().AsQueryable(); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); if (!IsManager()) source = source.Where(x => x.Status == ExamPlanStatus.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( ExamPlanRequest request, CancellationToken cancellationToken) { if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选学期不存在或已停用。"); var plan = new ExamPlan { AcademicTermId = request.AcademicTermId, Name = request.Name.Trim(), Notes = Normalize(request.Notes) }; db.ExamPlans.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.ExamPlans.AsNoTracking() .Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.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, 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), StudentCount = db.CourseEnrollments.Count(e => e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId) }) }).FirstOrDefaultAsync(cancellationToken); return plan is null ? NotFound() : Ok(plan); } // ═══════════════════════════════════════════ // Sessions // ═══════════════════════════════════════════ [HttpPost("plans/{planId:guid}/sessions")] [Authorize(Roles = Managers)] public async Task CreateSession( Guid planId, CreateExamSessionRequest request, CancellationToken cancellationToken) { var plan = await db.ExamPlans.FindAsync([planId], cancellationToken); if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.Draft) return ConflictProblem("已发布的考试计划不能调整场次。"); // Resolve time from slots 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 ExamSession { ExamPlanId = planId, TeachingTaskId = request.TeachingTaskId, ClassroomId = request.ClassroomId, ExamDate = request.ExamDate, StartPeriod = request.StartPeriod, PeriodCount = request.PeriodCount, StartsAt = startsAt, EndsAt = endsAt, RequiredBuildingId = request.RequiredBuildingId, RequiredInvigilatorCount = request.RequiredInvigilatorCount, Notes = Normalize(request.Notes), Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id => new ExamSessionInvigilator { TeacherId = id }).ToList() }; db.ExamSessions.Add(session); return await SaveAsync(session.Id, true, cancellationToken); } [HttpPost("plans/{planId:guid}/sessions/batch")] [Authorize(Roles = Managers)] public async Task CreateSessionsBatch( Guid planId, CreateExamSessionsBatchRequest request, CancellationToken cancellationToken) { var plan = await db.ExamPlans.FindAsync([planId], cancellationToken); if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.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.ExamSessions.AsNoTracking() .Where(x => x.ExamPlanId == planId) .WhereIn(taskIds, x => x.TeachingTaskId) .Select(x => x.TeachingTaskId) .ToListAsync(cancellationToken); if (duplicateTaskIds.Count > 0) return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。"); var enrollmentPairs = await db.CourseEnrollments.AsNoTracking() .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) .WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) .Select(x => new { x.StudentId, x.CourseSelectionOffering!.TeachingTaskId }) .ToListAsync(cancellationToken); var conflictedStudentCount = enrollmentPairs .GroupBy(x => x.StudentId) .Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any()); if (conflictedStudentCount > 0) return ConflictProblem( $"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突,请分批选择不同时间。"); 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.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession { ExamPlanId = planId, TeachingTaskId = taskId, ExamDate = request.ExamDate, StartPeriod = request.StartPeriod, PeriodCount = request.PeriodCount, StartsAt = startsAt, EndsAt = endsAt, RequiredBuildingId = request.RequiredBuildingId, RequiredInvigilatorCount = request.RequiredInvigilatorCount, Notes = Normalize(request.Notes) })); try { await db.SaveChangesAsync(cancellationToken); await cache.RemoveByTagAsync(AppCacheTags.Timetables, 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, CreateExamSessionRequest request, CancellationToken cancellationToken) { var session = await db.ExamSessions .Include(x => x.ExamPlan) .Include(x => x.Invigilators) .FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken); if (session is null) return NotFound(); if (session.ExamPlan!.Status != ExamPlanStatus.Draft) return ConflictProblem("已发布的考试计划不能调整场次。"); var timeResult = ResolveExamTime(session.ExamPlan.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.ExamPlan, 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.RequiredInvigilatorCount = request.RequiredInvigilatorCount; session.Notes = Normalize(request.Notes); db.ExamSessionInvigilators.RemoveRange(session.Invigilators); session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid => new ExamSessionInvigilator { 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) { var session = await db.ExamSessions.Include(x => x.ExamPlan) .FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken); if (session is null) return NotFound(); if (session.ExamPlan!.Status != ExamPlanStatus.Draft) return ConflictProblem("已发布的考试计划不能调整场次。"); db.ExamSessions.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(); var result = await examArrangementService.ArrangeAsync( planId, request.SessionIds, request.AssignClassrooms, request.AssignInvigilators, cancellationToken); if (!result.Success) return ConflictProblem(result.Message); await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return Ok(new { message = result.Message }); } // ═══════════════════════════════════════════ // 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); // Exclude classrooms already occupied var occupiedQuery = db.ExamSessions.AsNoTracking() .Where(x => x.ClassroomId != null && x.StartsAt < endsAt && startsAt < x.EndsAt); if (planId.HasValue) occupiedQuery = occupiedQuery.Where(x => x.ExamPlanId == 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.ExamSessionInvigilators.AsNoTracking() .Where(x => x.ExamSession!.StartsAt < endsAt && startsAt < x.ExamSession!.EndsAt); if (planId.HasValue) busyQuery = busyQuery.Where(x => x.ExamSession!.ExamPlanId == planId.Value); if (excludeSessionId.HasValue) busyQuery = busyQuery.Where(x => x.ExamSessionId != 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)); // ═══════════════════════════════════════════ // Publish // ═══════════════════════════════════════════ [HttpPost("plans/{id:guid}/publish")] [Authorize(Roles = Managers)] public async Task Publish(Guid id, CancellationToken cancellationToken) { var plan = await db.ExamPlans.Include(x => x.Sessions) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.Draft) return ConflictProblem("只有草稿考试计划可以发布。"); if (plan.Sessions.Count == 0) return ConflictProblem("至少安排一个考试场次后才能发布。"); var unassigned = plan.Sessions.Count(x => !x.ClassroomId.HasValue || x.Invigilators.Count == 0); if (unassigned > 0) return ConflictProblem( $"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。"); plan.Status = ExamPlanStatus.Published; plan.PublishedAt = DateTime.UtcNow; 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.ExamSessions.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.CourseEnrollments.AsNoTracking() .Where(e => e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId) .OrderBy(e => e.Student!.StudentNumber) .Select(e => new { e.StudentId, e.Student!.StudentNumber, e.Student.Name, ClassName = e.Student.AdministrativeClass!.Name }) .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, SeatNumber = (index + 1).ToString("D3") }) }); } // ═══════════════════════════════════════════ // 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.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && db.CourseEnrollments.Any(e => e.StudentId == studentId && e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .Select(x => new { x.Id, PlanName = x.ExamPlan!.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), x.TeachingTaskId, IsExam = true }) .ToListAsync(cancellationToken); if (schedule.Count == 0) return Ok(schedule); var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray(); var seatRows = await db.CourseEnrollments.AsNoTracking() .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) .WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) .OrderBy(x => x.Student!.StudentNumber) .Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.StudentId, x.Student!.StudentNumber }) .ToListAsync(cancellationToken); var seatNumbers = seatRows .GroupBy(x => x.TeachingTaskId) .SelectMany(group => group.Select((row, index) => new { row.TeachingTaskId, row.StudentId, SeatNumber = (index + 1).ToString("D3") })) .ToDictionary(x => (x.TeachingTaskId, 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.InvigilatorNames, SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)), x.IsExam })); } if (scope.IsInRole(SystemRoles.Teacher)) { return Ok(await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.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.ExamPlan!.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), StudentCount = db.CourseEnrollments.Count(e => e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId), IsExam = true }) .ToListAsync(cancellationToken)); } return Ok(Array.Empty()); } // ═══════════════════════════════════════════ // 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( ExamPlan 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("教学班与考试计划必须属于同一学期。"); var studentCount = await db.CourseEnrollments.CountAsync(x => x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken); if (classroomId.HasValue) { var room = await db.Classrooms.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken); if (room is null) return ValidationProblem("所选考场不存在或已停用。"); if (studentCount > room.Capacity) return ConflictProblem( $"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。"); } 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.ExamSessions.Where(x => x.ExamPlanId == 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.ExamSessionInvigilators .Where(i => overlaps.Any(x => x.Id == i.ExamSessionId)) .WhereIn(teacherIds, i => i.TeacherId) .AnyAsync(cancellationToken)) return ConflictProblem("监考教师在该时段已有考试任务。"); } var studentIds = db.CourseEnrollments.Where(x => x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTaskId == task.Id) .Select(x => x.StudentId); if (await overlaps.AnyAsync(other => db.CourseEnrollments.Any(e => studentIds.Contains(e.StudentId) && e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == other.TeachingTaskId), cancellationToken)) return ConflictProblem("存在学生考试时间冲突。"); return null; } private bool IsManager() => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); private async Task SaveAsync(Guid id, bool created, CancellationToken token) { try { await db.SaveChangesAsync(token); await cache.RemoveByTagAsync(AppCacheTags.Timetables, 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(); } // ═══════════════════════════════════════════ // Request/Response models // ═══════════════════════════════════════════ public sealed record ExamPlanRequest( Guid AcademicTermId, [Required, MaxLength(120)] string Name, [MaxLength(500)] string? Notes); public sealed record CreateExamSessionRequest( Guid TeachingTaskId, Guid? ClassroomId, DateOnly ExamDate, [Range(1, 30)] int StartPeriod, [Range(1, 6)] int PeriodCount, Guid? RequiredBuildingId, [Range(1, 10)] int RequiredInvigilatorCount, IReadOnlyCollection? InvigilatorIds, [MaxLength(500)] string? Notes); public sealed record CreateExamSessionsBatchRequest( [Required] IReadOnlyCollection TeachingTaskIds, DateOnly ExamDate, [Range(1, 30)] int StartPeriod, [Range(1, 6)] int PeriodCount, Guid? RequiredBuildingId, [Range(1, 10)] int RequiredInvigilatorCount, [MaxLength(500)] string? Notes); public sealed record ExamAutoArrangeRequest( IReadOnlyCollection? SessionIds = null, bool AssignClassrooms = true, bool AssignInvigilators = true);