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.Caching; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; 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, 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); } [HttpDelete("plans/{id:guid}")] [Authorize(Roles = Managers)] public async Task DeletePlan( Guid id, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null) return ConflictProblem("考试计划正在后台编排,暂时不能删除。"); var plan = await db.ExamPlans .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.Draft) return ConflictProblem("只有草稿考试计划可以删除。"); var rooms = await db.ExamRooms .Where(x => x.ExamPlanId == id) .ToListAsync(cancellationToken); db.ExamRooms.RemoveRange(rooms); db.ExamPlans.Remove(plan); try { await db.SaveChangesAsync(cancellationToken); await cache.RemoveByTagAsync( AppCacheTags.Timetables, cancellationToken); return NoContent(); } catch (DbUpdateException) { return ConflictProblem("删除考试计划失败,关联数据可能已发生变化。"); } } [HttpGet("plans/{id:guid}")] public async Task GetPlan( Guid id, int page = 1, int pageSize = 20, string? keyword = null, string? allocation = null, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); keyword = Normalize(keyword); allocation = Normalize(allocation)?.ToLowerInvariant(); if (allocation is not null and not ("room" or "invigilator" or "complete")) return ValidationProblem("分配状态筛选值无效。"); 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, TotalSessionCount = x.Sessions.Count }).FirstOrDefaultAsync(cancellationToken); if (plan is null) return NotFound(); var sessionQuery = db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlanId == id); if (keyword is not null) { sessionQuery = sessionQuery.Where(x => x.TeachingTask!.TaskNumber.Contains(keyword) || x.TeachingTask.Name.Contains(keyword) || x.TeachingTask.Course!.Code.Contains(keyword) || x.TeachingTask.Course.Name.Contains(keyword)); } sessionQuery = allocation switch { "room" => sessionQuery.Where(x => !x.RoomLinks.Any() && !x.ClassroomId.HasValue), "invigilator" => sessionQuery.Where(x => (x.RoomLinks.Any() && x.RoomLinks.Any(link => link.ExamRoom!.Invigilators.Count < x.RequiredInvigilatorCount)) || (!x.RoomLinks.Any() && x.Invigilators.Count < x.RequiredInvigilatorCount)), "complete" => sessionQuery.Where(x => (x.RoomLinks.Any() || x.ClassroomId.HasValue) && ((x.RoomLinks.Any() && x.RoomLinks.All(link => link.ExamRoom!.Invigilators.Count >= x.RequiredInvigilatorCount)) || (!x.RoomLinks.Any() && x.Invigilators.Count >= x.RequiredInvigilatorCount))), _ => sessionQuery }; var filteredSessionCount = await sessionQuery.CountAsync(cancellationToken); var lastPage = Math.Max(1, (int)Math.Ceiling(filteredSessionCount / (double)pageSize)); page = Math.Min(page, lastPage); var sessions = await sessionQuery .OrderBy(item => item.ExamDate) .ThenBy(item => item.StartPeriod) .ThenBy(item => item.TeachingTask!.TaskNumber) .ThenBy(item => item.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .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) }) .ToListAsync(cancellationToken); var pageSessionIds = sessions.Select(x => x.Id).ToArray(); var scheduledTeachingTaskIds = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlanId == id) .OrderBy(x => x.TeachingTaskId) .Select(x => x.TeachingTaskId) .ToListAsync(cancellationToken); var roomSessionLinks = await db.ExamRoomSessions.AsNoTracking() .WhereIn(pageSessionIds, x => x.ExamSessionId) .Select(x => new { x.ExamRoomId, x.ExamSessionId, SeatCount = x.ExamRoom!.Seats.Count(seat => seat.ExamSessionId == x.ExamSessionId) }) .ToListAsync(cancellationToken); var pageRoomIds = roomSessionLinks.Select(x => x.ExamRoomId).Distinct().ToArray(); var roomSummaries = await db.ExamRooms.AsNoTracking() .WhereIn(pageRoomIds, x => x.Id) .OrderBy(x => x.Classroom!.Building!.Name) .ThenBy(x => x.Classroom!.Name) .ThenBy(x => x.Id) .Select(x => new { ExamRoomId = x.Id, x.ClassroomId, ClassroomName = x.Classroom!.Name, BuildingName = x.Classroom.Building!.Name, ClassroomCapacity = x.Classroom.Capacity, TotalSeatCount = x.Seats.Count, SessionCount = x.SessionLinks.Count }) .ToListAsync(cancellationToken); var roomInvigilators = await db.ExamRoomInvigilators.AsNoTracking() .WhereIn(pageRoomIds, x => x.ExamRoomId) .OrderBy(x => x.Teacher!.TeacherNumber) .ThenBy(x => x.Teacher!.Name) .Select(x => new { x.ExamRoomId, x.TeacherId, TeacherName = x.Teacher!.Name }) .ToListAsync(cancellationToken); var linksByRoom = roomSessionLinks.ToLookup(x => x.ExamRoomId); var invigilatorsByRoom = roomInvigilators.ToLookup(x => x.ExamRoomId); var roomsBySession = roomSummaries .SelectMany(room => linksByRoom[room.ExamRoomId].Select(link => new { link.ExamSessionId, Room = new { room.ExamRoomId, room.ClassroomId, room.ClassroomName, room.BuildingName, room.ClassroomCapacity, link.SeatCount, room.TotalSeatCount, IsMixed = room.SessionCount > 1, InvigilatorIds = invigilatorsByRoom[room.ExamRoomId] .Select(invigilator => invigilator.TeacherId) .ToList(), InvigilatorNames = invigilatorsByRoom[room.ExamRoomId] .Select(invigilator => invigilator.TeacherName) .ToList() } })) .ToLookup(x => x.ExamSessionId, x => x.Room); var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( db, sessions.Select(x => x.TeachingTaskId), cancellationToken)) .GroupBy(x => x.TeachingTaskId) .ToDictionary(x => x.Key, x => x.Count()); return Ok(new { plan.Id, plan.Name, plan.AcademicTermId, plan.TermName, plan.Status, plan.Notes, plan.PublishedAt, plan.TotalSessionCount, FilteredSessionCount = filteredSessionCount, SessionPage = page, SessionPageSize = pageSize, ScheduledTeachingTaskIds = scheduledTeachingTaskIds, Sessions = sessions.Select(item => new { item.Id, item.TeachingTaskId, item.TaskNumber, item.TaskName, item.CourseCode, item.CourseName, item.ClassroomId, item.ClassroomName, item.BuildingName, item.ClassroomCapacity, item.ExamDate, item.StartPeriod, item.PeriodCount, item.StartsAt, item.EndsAt, item.RequiredBuildingId, item.RequiredBuildingIds, item.RequiredBuildingName, item.RequiredInvigilatorCount, item.Notes, item.InvigilatorIds, item.InvigilatorNames, ExamRooms = roomsBySession[item.Id], StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId) }) }); } // ═══════════════════════════════════════════ // Sessions // ═══════════════════════════════════════════ [HttpPost("plans/{planId:guid}/sessions")] [Authorize(Roles = Managers)] public async Task CreateSession( Guid planId, CreateExamSessionRequest request, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。"); 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, RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds), 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) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。"); 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 rosterPairs = await TeachingTaskRosterQuery.LoadForTasksAsync( db, taskIds, cancellationToken); var conflictedStudentCount = rosterPairs .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, RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds), 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) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。"); 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.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds); session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; session.Notes = Normalize(request.Notes); await RemoveRoomsForSessionsAsync([id], cancellationToken); 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) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。"); 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("已发布的考试计划不能调整场次。"); await RemoveRoomsForSessionsAsync([id], cancellationToken); db.ExamSessions.Remove(session); return await SaveAsync(id, false, cancellationToken); } [HttpPost("plans/{planId:guid}/sessions/batch-remove")] [Authorize(Roles = Managers)] public async Task RemoveSessionsBatch( Guid planId, RemoveExamSessionsBatchRequest request, CancellationToken cancellationToken) { if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null) return ConflictProblem("考试计划正在后台编排,暂时不能调整场次。"); var sessionIds = request.SessionIds.Distinct().ToArray(); if (sessionIds.Length == 0) return ValidationProblem("请至少选择一个考试场次。"); if (sessionIds.Length > 100) return ValidationProblem("一次最多移除100个考试场次。"); var plan = await db.ExamPlans.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.Draft) return ConflictProblem("已发布的考试计划不能调整场次。"); var sessions = await db.ExamSessions .Where(x => x.ExamPlanId == planId) .WhereIn(sessionIds, x => x.Id) .ToListAsync(cancellationToken); if (sessions.Count != sessionIds.Length) return ConflictProblem( "所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。"); await RemoveRoomsForSessionsAsync(sessionIds, cancellationToken); db.ExamSessions.RemoveRange(sessions); try { await db.SaveChangesAsync(cancellationToken); await cache.RemoveByTagAsync( AppCacheTags.Timetables, cancellationToken); return Ok(new { removedCount = sessions.Count }); } catch (DbUpdateException) { return ConflictProblem("批量移除考试场次失败,关联数据可能已发生变化。"); } } // ═══════════════════════════════════════════ // 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.ExamPlans.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 != ExamPlanStatus.Draft) return ConflictProblem("只有草稿状态的考试计划可以自动编排。"); if (sessionIds.Length > 0) { var selectedSessionCount = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlanId == planId) .WhereIn(sessionIds, x => x.Id) .CountAsync(cancellationToken); if (selectedSessionCount != sessionIds.Length) 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.FormalExam, 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.FormalExam, 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.FormalExam) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); return job is null ? NoContent() : Ok(ToArrangementJobResponse(job)); } // ═══════════════════════════════════════════ // 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); var occupiedRoomQuery = db.ExamRooms.AsNoTracking() .Where(x => x.StartsAt < endsAt && startsAt < x.EndsAt); if (planId.HasValue) occupiedRoomQuery = occupiedRoomQuery.Where( x => x.ExamPlanId == planId.Value); if (excludeSessionId.HasValue) occupiedRoomQuery = occupiedRoomQuery.Where( x => !x.SessionLinks.Any(link => link.ExamSessionId == excludeSessionId.Value)); occupiedIds.AddRange(await occupiedRoomQuery .Select(x => x.ClassroomId) .ToListAsync(cancellationToken)); occupiedIds = occupiedIds.Distinct().ToList(); 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); var roomBusyQuery = db.ExamRoomInvigilators.AsNoTracking() .Where(x => x.ExamRoom!.StartsAt < endsAt && startsAt < x.ExamRoom.EndsAt); if (planId.HasValue) roomBusyQuery = roomBusyQuery.Where( x => x.ExamRoom!.ExamPlanId == planId.Value); if (excludeSessionId.HasValue) roomBusyQuery = roomBusyQuery.Where( x => !x.ExamRoom!.SessionLinks.Any(link => link.ExamSessionId == excludeSessionId.Value)); busyIds.AddRange(await roomBusyQuery .Select(x => x.TeacherId) .ToListAsync(cancellationToken)); busyIds = busyIds.Distinct().ToList(); 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) { if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null) return ConflictProblem("考试计划正在后台编排,完成后才能发布。"); if (await FindActivePublishJobAsync(id, cancellationToken) is not null) return ConflictProblem("考试计划正在后台发布,请等待任务完成。"); var plan = await db.ExamPlans.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 != ExamPlanStatus.Draft) return ConflictProblem("只有草稿考试计划可以发布。"); if (!plan.HasSessions) return ConflictProblem("至少安排一个考试场次后才能发布。"); var userId = currentUserDataScope.Current.UserId; var job = new ExamPublishJob { Kind = ExamPublishJobKind.FormalExam, 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.FormalExam, 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.FormalExam) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); return job is null ? NoContent() : Ok(ToPublishJobResponse(job)); } // ═══════════════════════════════════════════ // 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 assignedStudents = await db.ExamSeats.AsNoTracking() .Where(x => x.ExamSessionId == id) .OrderBy(x => x.ExamRoom!.Classroom!.Building!.Name) .ThenBy(x => x.ExamRoom!.Classroom!.Name) .ThenBy(x => x.SeatNumber) .Select(x => new { x.StudentId, x.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, x.ExamRoomId, ClassroomName = x.ExamRoom!.Classroom!.Name, BuildingName = x.ExamRoom.Classroom.Building!.Name, x.SeatNumber }) .ToListAsync(cancellationToken); if (assignedStudents.Count > 0) { return Ok(new { session.Id, session.TeachingTaskId, session.TaskNumber, session.CourseName, ClassroomName = string.Join( "、", assignedStudents .Select(x => $"{x.BuildingName} · {x.ClassroomName}") .Distinct()), session.StartsAt, Students = assignedStudents.Select(student => new { student.StudentId, student.StudentNumber, student.Name, student.ClassName, student.ExamRoomId, student.ClassroomName, student.BuildingName, SeatNumber = student.SeatNumber.ToString("D3") }) }); } var students = await TeachingTaskRosterQuery.LoadForTasksAsync( db, [session.TeachingTaskId], 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") }) }); } [HttpGet("plans/{id:guid}/sign-in-sheets.xlsx")] [Authorize(Roles = Managers)] public async Task ExportSignInSheets( Guid id, CancellationToken cancellationToken) { var plan = await db.ExamPlans.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Name, TermName = x.AcademicTerm!.Name }) .FirstOrDefaultAsync(cancellationToken); if (plan is null) return NotFound(); var rooms = await db.ExamRooms.AsNoTracking() .Include(x => x.Course) .Include(x => x.Classroom) .ThenInclude(x => x!.Building) .Include(x => x.Invigilators) .ThenInclude(x => x.Teacher) .Include(x => x.SessionLinks) .ThenInclude(x => x.ExamSession) .ThenInclude(x => x!.TeachingTask) .Include(x => x.Seats) .ThenInclude(x => x.Student) .ThenInclude(x => x!.AdministrativeClass) .Where(x => x.ExamPlanId == id) .OrderBy(x => x.ExamDate) .ThenBy(x => x.StartPeriod) .ThenBy(x => x.Classroom!.Building!.Name) .ThenBy(x => x.Classroom!.Name) .ToListAsync(cancellationToken); var legacySessions = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlanId == id) .Where(x => !x.RoomLinks.Any()) .OrderBy(x => x.ExamDate) .ThenBy(x => x.StartPeriod) .Select(session => new { session.Id, session.TeachingTaskId, session.ExamDate, session.StartsAt, session.EndsAt, CourseCode = session.TeachingTask!.Course!.Code, CourseName = session.TeachingTask.Course.Name, session.TeachingTask.TaskNumber, BuildingName = session.Classroom != null ? session.Classroom.Building!.Name : null, ClassroomName = session.Classroom != null ? session.Classroom.Name : null, InvigilatorNames = session.Invigilators .OrderBy(item => item.Teacher!.TeacherNumber) .Select(item => item.Teacher!.Name) }) .ToListAsync(cancellationToken); if (rooms.Count == 0 && legacySessions.Count == 0) return ConflictProblem("当前考试计划没有可导出的考试场次。"); var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( db, legacySessions.Select(x => x.TeachingTaskId), cancellationToken); var studentsByTask = roster.ToLookup(x => x.TeachingTaskId); var sheets = rooms.Select(room => new ExamSignInSessionData( room.Id, room.ExamDate, room.StartsAt, room.EndsAt, room.Course!.Code, room.Course.Name, string.Join( "、", room.SessionLinks .Select(link => link.ExamSession!.TeachingTask!.TaskNumber) .Distinct() .OrderBy(x => x)), room.Classroom!.Building!.Name, room.Classroom.Name, room.Invigilators .OrderBy(item => item.Teacher!.TeacherNumber) .Select(item => item.Teacher!.Name) .ToList(), room.Seats .OrderBy(seat => seat.SeatNumber) .Select(seat => new ExamSignInStudentData( seat.StudentId, seat.Student!.StudentNumber, seat.Student.Name, seat.Student.AdministrativeClass!.Name, seat.SeatNumber)) .ToList())) .Concat(legacySessions.Select(session => new ExamSignInSessionData( session.Id, session.ExamDate, session.StartsAt, session.EndsAt, session.CourseCode, session.CourseName, session.TaskNumber, session.BuildingName, session.ClassroomName, session.InvigilatorNames.ToList(), studentsByTask[session.TeachingTaskId] .Select((student, index) => new ExamSignInStudentData( student.StudentId, student.StudentNumber, student.Name, student.ClassName, index + 1)) .ToList()))) .ToList(); var data = new ExamSignInWorkbookData( plan.Name, plan.TermName, sheets); var bytes = ExamSignInWorkbookExporter.Create(data); return File( bytes, ExcelWorkbookHelper.ContentType, $"考场签名单-{FileNamePart(plan.Name)}.xlsx"); } // ═══════════════════════════════════════════ // Sign-in sheet export (background job) // ═══════════════════════════════════════════ [HttpPost("plans/{id:guid}/sign-in-export")] [Authorize(Roles = Managers)] public async Task StartSignInExport( Guid id, CancellationToken cancellationToken) { var planExists = await db.ExamPlans.AsNoTracking() .AnyAsync(x => x.Id == id, cancellationToken); if (!planExists) return NotFound(); var userId = currentUserDataScope.Current.UserId; var job = new ExamSignInExportJob { PlanId = id, RequestedByUserId = userId == Guid.Empty ? null : userId, CurrentStep = "等待后台生成" }; db.ExamSignInExportJobs.Add(job); db.BackgroundJobOutboxMessages.Add( BackgroundJobOutboxMessage.Create( BackgroundJobKind.ExamSignInExport, job.Id)); await db.SaveChangesAsync(cancellationToken); return AcceptedAtAction( nameof(GetSignInExportJob), new { jobId = job.Id }, new { jobId = job.Id, status = job.Status.ToString(), message = "考场签名单导出任务已提交。" }); } [HttpGet("sign-in-exports/{jobId:guid}")] [Authorize(Roles = Managers)] public async Task GetSignInExportJob( Guid jobId, CancellationToken cancellationToken) { var job = await db.ExamSignInExportJobs.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken); if (job is null) return NotFound(); return Ok(new { job.Id, job.PlanId, Status = job.Status.ToString(), job.FileName, job.FileSize, job.CurrentStep, job.ErrorMessage, job.CreatedAt, job.StartedAt, job.CompletedAt }); } [HttpGet("sign-in-exports/{jobId:guid}/download")] [Authorize(Roles = Managers)] public async Task DownloadSignInExport( Guid jobId, CancellationToken cancellationToken) { var job = await db.ExamSignInExportJobs.AsNoTracking() .FirstOrDefaultAsync( x => x.Id == jobId && x.Status == ExamSignInExportJobStatus.Succeeded, cancellationToken); if (job is null || job.FileBytes is null || job.FileBytes.Length == 0) return NotFound(); return File( job.FileBytes, ExcelWorkbookHelper.ContentType, job.FileName ?? "考场签名单.xlsx"); } // ═══════════════════════════════════════════ // 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 assignedSchedule = await db.ExamSeats.AsNoTracking() .Where(x => x.StudentId == studentId.Value && x.ExamRoom!.ExamPlan!.Status == ExamPlanStatus.Published) .Select(x => new { x.ExamSessionId, PlanName = x.ExamRoom!.ExamPlan!.Name, x.ExamRoom.ExamDate, x.ExamRoom.StartPeriod, x.ExamRoom.PeriodCount, x.ExamRoom.StartsAt, x.ExamRoom.EndsAt, x.ExamSession!.TeachingTask!.TaskNumber, CourseCode = x.ExamRoom.Course!.Code, CourseName = x.ExamRoom.Course.Name, ClassroomName = x.ExamRoom.Classroom!.Name, BuildingName = x.ExamRoom.Classroom.Building!.Name, InvigilatorNames = x.ExamRoom.Invigilators .Select(i => i.Teacher!.Name), x.SeatNumber }) .ToListAsync(cancellationToken); var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent( db, studentId.Value); var legacySchedule = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && !x.RoomLinks.Any() && rosterTaskIds.Contains(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); var taskIds = legacySchedule .Select(x => x.TeachingTaskId) .Distinct() .ToArray(); var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync( db, taskIds, 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(assignedSchedule.Select(x => new ExamScheduleResponse( x.ExamSessionId, x.PlanName, x.ExamDate, x.StartPeriod, x.PeriodCount, x.StartsAt, x.EndsAt, x.TaskNumber, x.CourseCode, x.CourseName, x.ClassroomName, x.BuildingName, x.InvigilatorNames.ToList(), x.SeatNumber.ToString("D3"), null, true)) .Concat(legacySchedule.Select(x => new ExamScheduleResponse( 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.ToList(), seatNumbers.GetValueOrDefault( (x.TeachingTaskId, studentId.Value)), null, x.IsExam))) .OrderBy(x => x.ExamDate) .ThenBy(x => x.StartPeriod)); } if (scope.IsInRole(SystemRoles.Teacher)) { var roomSchedule = await db.ExamRooms.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, TaskNumbers = x.SessionLinks .Select(link => link.ExamSession!.TeachingTask!.TaskNumber), CourseCode = x.Course!.Code, CourseName = x.Course.Name, ClassroomName = x.Classroom!.Name, BuildingName = x.Classroom.Building!.Name, InvigilatorNames = x.Invigilators .Select(i => i.Teacher!.Name), StudentCount = x.Seats.Count }) .ToListAsync(cancellationToken); var legacySchedule = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && !x.RoomLinks.Any() && 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), x.TeachingTaskId, IsExam = true }) .ToListAsync(cancellationToken); var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( db, legacySchedule.Select(x => x.TeachingTaskId), cancellationToken)) .GroupBy(x => x.TeachingTaskId) .ToDictionary(x => x.Key, x => x.Count()); return Ok(roomSchedule.Select(x => new ExamScheduleResponse( x.Id, x.PlanName, x.ExamDate, x.StartPeriod, x.PeriodCount, x.StartsAt, x.EndsAt, string.Join("、", x.TaskNumbers.OrderBy(value => value)), x.CourseCode, x.CourseName, x.ClassroomName, x.BuildingName, x.InvigilatorNames.ToList(), null, x.StudentCount, true)) .Concat(legacySchedule.Select(x => new ExamScheduleResponse( 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.ToList(), null, rosterCounts.GetValueOrDefault(x.TeachingTaskId), x.IsExam))) .OrderBy(x => x.ExamDate) .ThenBy(x => x.StartPeriod)); } return Ok(Array.Empty()); } // ═══════════════════════════════════════════ // Private helpers // ═══════════════════════════════════════════ private async Task RemoveRoomsForSessionsAsync( IReadOnlyCollection sessionIds, CancellationToken cancellationToken) { var ids = sessionIds.Distinct().ToArray(); if (ids.Length == 0) return; var roomIds = await db.ExamRoomSessions.AsNoTracking() .WhereIn(ids, x => x.ExamSessionId) .Select(x => x.ExamRoomId) .Distinct() .ToListAsync(cancellationToken); if (roomIds.Count == 0) return; var rooms = await db.ExamRooms .WhereIn(roomIds, x => x.Id) .ToListAsync(cancellationToken); db.ExamRooms.RemoveRange(rooms); } 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 TeachingTaskRosterQuery .ForTask(db, task.Id) .CountAsync(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 (await db.ExamRooms.AsNoTracking().AnyAsync( room => room.ClassroomId == classroomId.Value && room.StartsAt < endsAt && startsAt < room.EndsAt && (!currentId.HasValue || !room.SessionLinks.Any(link => link.ExamSessionId == currentId.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("监考教师在该时段已有考试任务。"); if (await db.ExamRoomInvigilators.AsNoTracking() .Where(i => i.ExamRoom!.StartsAt < endsAt && startsAt < i.ExamRoom.EndsAt && (!currentId.HasValue || !i.ExamRoom.SessionLinks.Any(link => link.ExamSessionId == currentId.Value))) .WhereIn(teacherIds, i => i.TeacherId) .AnyAsync(cancellationToken)) return ConflictProblem("监考教师在该时段已有混排考场任务。"); } var overlappingTaskIds = await overlaps .Select(x => x.TeachingTaskId) .Distinct() .ToListAsync(cancellationToken); if (overlappingTaskIds.Count > 0) { var rosterRows = await TeachingTaskRosterQuery.LoadForTasksAsync( db, overlappingTaskIds.Append(task.Id), cancellationToken); var currentStudentIds = rosterRows .Where(x => x.TeachingTaskId == task.Id) .Select(x => x.StudentId) .ToHashSet(); if (rosterRows.Any(x => x.TeachingTaskId != task.Id && currentStudentIds.Contains(x.StudentId))) 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.FormalExam && 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.FormalExam && 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); 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(); private static string? SerializeBuildingIds(IReadOnlyCollection? ids) => ids is { Count: > 0 } ? System.Text.Json.JsonSerializer.Serialize(ids) : null; private static string FileNamePart(string value) { var invalidCharacters = Path.GetInvalidFileNameChars().ToHashSet(); var normalized = new string(value .Select(character => invalidCharacters.Contains(character) ? '-' : character) .ToArray()).Trim(); if (normalized.Length == 0) return "考试计划"; return normalized.Length <= 80 ? normalized : normalized[..80]; } } // ═══════════════════════════════════════════ // 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, IReadOnlyCollection? RequiredBuildingIds, [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, IReadOnlyCollection? RequiredBuildingIds, [Range(1, 10)] int RequiredInvigilatorCount, [MaxLength(500)] string? Notes); public sealed record RemoveExamSessionsBatchRequest( [Required] IReadOnlyCollection SessionIds); public sealed record ExamAutoArrangeRequest( IReadOnlyCollection? SessionIds = null, bool AssignClassrooms = true, bool AssignInvigilators = true); public sealed record ExamScheduleResponse( Guid Id, string PlanName, DateOnly ExamDate, int StartPeriod, int PeriodCount, DateTime StartsAt, DateTime EndsAt, string TaskNumber, string CourseCode, string CourseName, string? ClassroomName, string? BuildingName, IReadOnlyCollection InvigilatorNames, string? SeatNumber, int? StudentCount, bool IsExam);