using System.ComponentModel.DataAnnotations; using System.Data; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Experiments; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/experiments")] public sealed class ExperimentsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, ClassroomReservationAvailabilityService classroomAvailability) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Teacher; [HttpGet("options")] [Authorize(Roles = Managers)] public async Task GetOptions( Guid? academicTermId, CancellationToken cancellationToken) { var tasks = AccessibleTeachingTasks().AsNoTracking() .Where(x => x.Status == TeachingTaskStatus.Published); if (academicTermId.HasValue) tasks = tasks.Where(x => x.AcademicTermId == academicTermId); var periods = db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.IsEnabled); if (academicTermId.HasValue) periods = periods.Where(x => x.AcademicTermId == academicTermId); var periodItems = await periods .OrderBy(x => x.PeriodNumber) .Select(x => new ExperimentPeriodOption( x.AcademicTermId, x.PeriodNumber, x.Name, x.StartsAt.ToString("HH:mm"), x.EndsAt.ToString("HH:mm"))) .ToListAsync(cancellationToken); if (academicTermId.HasValue && periodItems.Count == 0) { periodItems = Enumerable.Range(1, 12) .Select(period => new ExperimentPeriodOption( academicTermId.Value, period, $"第 {period} 节", "", "")) .ToList(); } return Ok(new { Tasks = await tasks .OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenBy(x => x.Course!.Code) .ThenBy(x => x.TaskNumber) .Select(x => new { x.Id, x.TaskNumber, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, TermStartDate = x.AcademicTerm.StartDate, TermEndDate = x.AcademicTerm.EndDate, CourseCode = x.Course!.Code, CourseName = x.Course.Name, CollegeName = x.Course.College!.Name, TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) .ThenBy(item => item.Teacher!.TeacherNumber) .Select(item => item.Teacher!.Name), ClassNames = x.Classes .OrderBy(item => item.AdministrativeClass!.Code) .Select(item => item.AdministrativeClass!.Name) }) .ToListAsync(cancellationToken), Classrooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.Building!.Campus!.SortOrder) .ThenBy(x => x.Building!.SortOrder) .ThenBy(x => x.SortOrder) .Select(x => new { x.Id, x.Name, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name, x.Capacity }) .ToListAsync(cancellationToken), Periods = periodItems }); } [HttpGet("management")] [Authorize(Roles = Managers)] public async Task GetManagementProjects( Guid? academicTermId, ExperimentArrangementMode? arrangementMode, ExperimentProjectStatus? status, CancellationToken cancellationToken) { var source = ScopedProjects().AsNoTracking(); if (academicTermId.HasValue) source = source.Where(x => x.TeachingTask!.AcademicTermId == academicTermId); if (arrangementMode.HasValue) source = source.Where(x => x.ArrangementMode == arrangementMode); if (status.HasValue) source = source.Where(x => x.Status == status); return Ok(await source .OrderByDescending(x => x.Status == ExperimentProjectStatus.Published) .ThenBy(x => x.StartDate) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.TeachingTaskId, x.Code, x.Name, x.ArrangementMode, x.Description, x.Requirements, x.StartDate, x.EndDate, x.Status, x.PublishedAt, x.ClosedAt, AcademicTermId = x.TeachingTask!.AcademicTermId, TermName = x.TeachingTask.AcademicTerm!.Name, TaskNumber = x.TeachingTask.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, CollegeName = x.TeachingTask.Course.College!.Name, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), ClassNames = x.TeachingTask.Classes .OrderBy(item => item.AdministrativeClass!.Code) .Select(item => item.AdministrativeClass!.Name), Sessions = x.Sessions .OrderBy(item => item.SessionDate) .ThenBy(item => item.StartPeriod) .Select(item => new { item.Id, item.SessionDate, item.StartPeriod, item.PeriodCount, item.Capacity, item.ReservedCount, item.Notes, item.Status, item.ClassroomId, ClassroomName = item.Classroom!.Name, BuildingName = item.Classroom.Building!.Name, CampusName = item.Classroom.Building.Campus!.Name }) }) .ToListAsync(cancellationToken)); } [HttpGet("student")] [Authorize(Roles = SystemRoles.Student)] public async Task GetStudentProjects( Guid? academicTermId, CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ConflictProblem("当前账号未关联有效学生档案。"); var taskIds = TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id); var source = db.ExperimentProjects.AsNoTracking() .Where(x => taskIds.Contains(x.TeachingTaskId) && (x.Status == ExperimentProjectStatus.Published || x.Status == ExperimentProjectStatus.Closed)); if (academicTermId.HasValue) source = source.Where(x => x.TeachingTask!.AcademicTermId == academicTermId); return Ok(await source .OrderByDescending(x => x.Status == ExperimentProjectStatus.Published) .ThenBy(x => x.EndDate) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.ArrangementMode, x.Description, x.Requirements, x.StartDate, x.EndDate, x.Status, AcademicTermId = x.TeachingTask!.AcademicTermId, TermName = x.TeachingTask.AcademicTerm!.Name, TaskNumber = x.TeachingTask.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), Sessions = x.Sessions .Where(item => item.Status == ExperimentSessionStatus.Scheduled) .OrderBy(item => item.SessionDate) .ThenBy(item => item.StartPeriod) .Select(item => new { item.Id, item.SessionDate, item.StartPeriod, item.PeriodCount, item.Capacity, item.ReservedCount, RemainingCount = item.Capacity - item.ReservedCount, item.Notes, ClassroomName = item.Classroom!.Name, BuildingName = item.Classroom.Building!.Name, CampusName = item.Classroom.Building.Campus!.Name }), MyBooking = x.Bookings .Where(item => item.StudentId == student.Id && item.Status == ExperimentBookingStatus.Booked) .Select(item => new { item.Id, item.ExperimentSessionId, item.BookedAt }) .FirstOrDefault() }) .ToListAsync(cancellationToken)); } [HttpPost] [Authorize(Roles = Managers)] public async Task CreateProject( ExperimentProjectRequest request, CancellationToken cancellationToken) { var task = await AccessibleTeachingTasks().AsNoTracking() .Include(x => x.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId && x.Status == TeachingTaskStatus.Published, cancellationToken); if (task is null) return ValidationProblem("教学任务不存在、未发布或不在当前管理范围内。"); var problem = ValidateProjectRequest(request, task.AcademicTerm!); if (problem is not null) return ValidationProblem(problem); var code = request.Code.Trim(); if (await db.ExperimentProjects.AnyAsync(x => x.TeachingTaskId == request.TeachingTaskId && x.Code == code, cancellationToken)) return ConflictProblem("该教学任务下已存在相同实验项目编码。"); var project = new ExperimentProject { TeachingTaskId = request.TeachingTaskId, Code = code, Name = request.Name.Trim(), ArrangementMode = request.ArrangementMode, Description = Normalize(request.Description), Requirements = Normalize(request.Requirements), StartDate = request.StartDate, EndDate = request.EndDate }; db.ExperimentProjects.Add(project); await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new { project.Id }); } [HttpPut("{id:guid}")] [Authorize(Roles = Managers)] public async Task UpdateProject( Guid id, ExperimentProjectRequest request, CancellationToken cancellationToken) { var project = await ScopedProjects() .Include(x => x.TeachingTask) .ThenInclude(x => x!.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); if (project.Status != ExperimentProjectStatus.Draft) return ConflictProblem("只有草稿实验项目可以修改。"); if (project.TeachingTaskId != request.TeachingTaskId) return ValidationProblem("创建后不能更换实验项目所属教学任务。"); var problem = ValidateProjectRequest( request, project.TeachingTask!.AcademicTerm!); if (problem is not null) return ValidationProblem(problem); var code = request.Code.Trim(); if (await db.ExperimentProjects.AnyAsync(x => x.Id != id && x.TeachingTaskId == request.TeachingTaskId && x.Code == code, cancellationToken)) return ConflictProblem("该教学任务下已存在相同实验项目编码。"); project.Code = code; project.Name = request.Name.Trim(); project.ArrangementMode = request.ArrangementMode; project.Description = Normalize(request.Description); project.Requirements = Normalize(request.Requirements); project.StartDate = request.StartDate; project.EndDate = request.EndDate; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpDelete("{id:guid}")] [Authorize(Roles = Managers)] public async Task DeleteProject( Guid id, CancellationToken cancellationToken) { var project = await ScopedProjects() .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); if (project.Status != ExperimentProjectStatus.Draft) return ConflictProblem("只有草稿实验项目可以删除。"); db.ExperimentProjects.Remove(project); await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("{id:guid}/publish")] [Authorize(Roles = Managers)] public async Task PublishProject( Guid id, CancellationToken cancellationToken) { var project = await ScopedProjects() .Include(x => x.Sessions) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); if (project.Status != ExperimentProjectStatus.Draft) return ConflictProblem("只有草稿实验项目可以发布。"); if (!project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)) return ConflictProblem("请至少安排一个有效实验场次后再发布。"); if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled && (x.SessionDate < project.StartDate || x.SessionDate > project.EndDate))) return ConflictProblem("存在不在项目开放日期范围内的实验场次。"); project.Status = ExperimentProjectStatus.Published; project.PublishedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); var userIds = await TeachingTaskRosterQuery .ForTask(db, project.TeachingTaskId) .Where(x => x.UserId.HasValue) .Select(x => x.UserId!.Value) .Distinct() .ToListAsync(cancellationToken); if (userIds.Count > 0) { var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约"; await NotificationService.SendToUserIdsAsync( db, userIds, "实验项目已发布", $"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。", "/experiments", cancellationToken, NotificationCategory.Schedule); } return NoContent(); } [HttpPost("{id:guid}/close")] [Authorize(Roles = Managers)] public async Task CloseProject( Guid id, CancellationToken cancellationToken) { var project = await ScopedProjects() .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); if (project.Status != ExperimentProjectStatus.Published) return ConflictProblem("只有已发布实验项目可以关闭。"); project.Status = ExperimentProjectStatus.Closed; project.ClosedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("{projectId:guid}/sessions")] [Authorize(Roles = Managers)] public async Task CreateSession( Guid projectId, ExperimentSessionRequest request, CancellationToken cancellationToken) { var project = await ScopedProjects() .Include(x => x.TeachingTask) .ThenInclude(x => x!.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == projectId, cancellationToken); if (project is null) return NotFound(); if (project.Status == ExperimentProjectStatus.Closed) return ConflictProblem("已关闭实验项目不能再增加场次。"); var problem = await ValidateSessionAsync( project, request, cancellationToken); if (problem is not null) return ConflictProblem(problem); var session = new ExperimentSession { ExperimentProjectId = project.Id, ClassroomId = request.ClassroomId, SessionDate = request.SessionDate, StartPeriod = request.StartPeriod, PeriodCount = request.PeriodCount, Capacity = await ResolveSessionCapacityAsync( project, request.Capacity, cancellationToken), Notes = Normalize(request.Notes) }; db.ExperimentSessions.Add(session); await db.SaveChangesAsync(cancellationToken); if (project.Status == ExperimentProjectStatus.Published) { var userIds = await RosterUserIdsAsync( project.TeachingTaskId, cancellationToken); if (userIds.Count > 0) { await NotificationService.SendToUserIdsAsync( db, userIds, "新增实验场次", $"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。", "/experiments", cancellationToken, NotificationCategory.Schedule); } } return Created(string.Empty, new { session.Id }); } [HttpDelete("sessions/{id:guid}")] [Authorize(Roles = Managers)] public async Task CancelSession( Guid id, CancellationToken cancellationToken) { var session = await db.ExperimentSessions .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (session is null || !await ScopedProjects().AnyAsync( x => x.Id == session.ExperimentProjectId, cancellationToken)) return NotFound(); if (session.Status == ExperimentSessionStatus.Cancelled) return NoContent(); if (session.ExperimentProject!.Status == ExperimentProjectStatus.Draft) { db.ExperimentSessions.Remove(session); await db.SaveChangesAsync(cancellationToken); return NoContent(); } var affectedUserIds = session.ExperimentProject.ArrangementMode == ExperimentArrangementMode.Centralized ? await RosterUserIdsAsync( session.ExperimentProject.TeachingTaskId, cancellationToken) : await db.ExperimentBookings .Where(x => x.ExperimentSessionId == id && x.Status == ExperimentBookingStatus.Booked && x.Student!.UserId.HasValue) .Select(x => x.Student!.UserId!.Value) .Distinct() .ToListAsync(cancellationToken); var bookings = await db.ExperimentBookings .Where(x => x.ExperimentSessionId == id && x.Status == ExperimentBookingStatus.Booked) .ToListAsync(cancellationToken); foreach (var booking in bookings) { booking.Status = ExperimentBookingStatus.Cancelled; booking.CancelledAt = DateTime.UtcNow; } session.Status = ExperimentSessionStatus.Cancelled; session.CancelledAt = DateTime.UtcNow; session.ReservedCount = 0; await db.SaveChangesAsync(cancellationToken); if (affectedUserIds.Count > 0) { await NotificationService.SendToUserIdsAsync( db, affectedUserIds, "实验场次已取消", $"“{session.ExperimentProject.Name}”原定于 {session.SessionDate:yyyy-MM-dd} 的实验场次已取消,请重新查看安排。", "/experiments", cancellationToken, NotificationCategory.Schedule); } return NoContent(); } [HttpGet("sessions/{id:guid}/participants")] [Authorize(Roles = Managers)] public async Task GetParticipants( Guid id, CancellationToken cancellationToken) { var session = await db.ExperimentSessions.AsNoTracking() .Include(x => x.ExperimentProject) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (session is null || !await ScopedProjects().AnyAsync( x => x.Id == session.ExperimentProjectId, cancellationToken)) return NotFound(); if (session.ExperimentProject!.ArrangementMode == ExperimentArrangementMode.Centralized) { var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( db, [session.ExperimentProject.TeachingTaskId], cancellationToken); return Ok(roster.Select(x => new { x.StudentId, x.StudentNumber, x.Name, x.ClassName, ParticipationType = "Centralized" })); } return Ok(await db.ExperimentBookings.AsNoTracking() .Where(x => x.ExperimentSessionId == id && x.Status == ExperimentBookingStatus.Booked) .OrderBy(x => x.Student!.StudentNumber) .Select(x => new { x.StudentId, x.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, ParticipationType = "Booked", x.BookedAt }) .ToListAsync(cancellationToken)); } [HttpPost("sessions/{id:guid}/book")] [Authorize(Roles = SystemRoles.Student)] public async Task Book( Guid id, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ConflictProblem("当前账号未关联有效学生档案。"); var session = await db.ExperimentSessions .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (session is null || session.Status != ExperimentSessionStatus.Scheduled || session.ExperimentProject!.Status != ExperimentProjectStatus.Published) return NotFound(); if (session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled) return ConflictProblem("集中安排实验无需学生预约。"); if (!await TeachingTaskRosterQuery .TaskIdsForStudent(db, student.Id) .ContainsAsync( session.ExperimentProject.TeachingTaskId, cancellationToken)) return Forbid(); if (session.SessionDate < DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8))) return ConflictProblem("该实验场次已经结束,不能预约。"); var existing = await db.ExperimentBookings .FirstOrDefaultAsync(x => x.ExperimentProjectId == session.ExperimentProjectId && x.StudentId == student.Id, cancellationToken); if (existing?.Status == ExperimentBookingStatus.Booked) { return existing.ExperimentSessionId == session.Id ? NoContent() : ConflictProblem("该实验项目已有预约,请先取消原预约。"); } var scheduleProblem = await StudentConflictAsync( student.Id, session, cancellationToken); if (scheduleProblem is not null) return ConflictProblem(scheduleProblem); var reservedCount = await db.ExperimentBookings .CountAsync(x => x.ExperimentSessionId == session.Id && x.Status == ExperimentBookingStatus.Booked, cancellationToken); if (reservedCount >= session.Capacity) return ConflictProblem("该实验场次名额已满,请选择其他时间。"); if (existing is null) { db.ExperimentBookings.Add(new ExperimentBooking { ExperimentProjectId = session.ExperimentProjectId, ExperimentSessionId = session.Id, StudentId = student.Id }); } else { existing.ExperimentSessionId = session.Id; existing.Status = ExperimentBookingStatus.Booked; existing.BookedAt = DateTime.UtcNow; existing.CancelledAt = null; } session.ReservedCount = reservedCount + 1; await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return NoContent(); }, cancellationToken, IsolationLevel.Serializable); } [HttpDelete("bookings/{id:guid}")] [Authorize(Roles = SystemRoles.Student)] public async Task CancelBooking( Guid id, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ConflictProblem("当前账号未关联有效学生档案。"); var booking = await db.ExperimentBookings .Include(x => x.ExperimentSession) .FirstOrDefaultAsync(x => x.Id == id && x.StudentId == student.Id, cancellationToken); if (booking is null) return NotFound(); if (booking.Status == ExperimentBookingStatus.Cancelled) return NoContent(); booking.Status = ExperimentBookingStatus.Cancelled; booking.CancelledAt = DateTime.UtcNow; booking.ExperimentSession!.ReservedCount = Math.Max( 0, await db.ExperimentBookings.CountAsync(x => x.ExperimentSessionId == booking.ExperimentSessionId && x.Status == ExperimentBookingStatus.Booked && x.Id != booking.Id, cancellationToken)); await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return NoContent(); }, cancellationToken, IsolationLevel.Serializable); } private async Task ValidateSessionAsync( ExperimentProject project, ExperimentSessionRequest request, CancellationToken cancellationToken) { if (request.SessionDate < project.StartDate || request.SessionDate > project.EndDate) return "实验场次日期必须在项目开放日期范围内。"; var configuredPeriodCount = await db.ScheduleTimeSlots.AsNoTracking() .CountAsync(x => x.AcademicTermId == project.TeachingTask!.AcademicTermId && x.IsEnabled, cancellationToken); var validPeriodCount = await db.ScheduleTimeSlots.AsNoTracking() .CountAsync(x => x.AcademicTermId == project.TeachingTask!.AcademicTermId && x.IsEnabled && x.PeriodNumber >= request.StartPeriod && x.PeriodNumber < request.StartPeriod + request.PeriodCount, cancellationToken); var usesValidDefaultPeriods = configuredPeriodCount == 0 && request.StartPeriod + request.PeriodCount - 1 <= 12; if (!usesValidDefaultPeriods && validPeriodCount != request.PeriodCount) return "所选实验节次不在该学期启用节次范围内。"; var classroom = await db.Classrooms.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken); if (classroom is null) return "实验教室不存在或已停用。"; if (request.Capacity > classroom.Capacity) return $"场次容量不能超过教室容量 {classroom.Capacity} 人。"; if (project.ArrangementMode == ExperimentArrangementMode.Centralized) { var rosterCount = await TeachingTaskRosterQuery .ForTask(db, project.TeachingTaskId) .CountAsync(cancellationToken); if (rosterCount > classroom.Capacity) return $"集中实验应到 {rosterCount} 人,超过教室容量 {classroom.Capacity} 人。"; } var occupiedRooms = await classroomAvailability.GetOccupiedClassroomIdsAsync( project.TeachingTask!.AcademicTerm!, request.SessionDate, request.StartPeriod, request.PeriodCount, null, cancellationToken); if (occupiedRooms.Contains(request.ClassroomId)) return "所选教室与已发布课程、考试或已批准借用安排冲突。"; var experimentRoomConflict = await db.ExperimentSessions.AsNoTracking() .AnyAsync(x => x.ClassroomId == request.ClassroomId && x.SessionDate == request.SessionDate && x.Status == ExperimentSessionStatus.Scheduled && x.StartPeriod < request.StartPeriod + request.PeriodCount && request.StartPeriod < x.StartPeriod + x.PeriodCount, cancellationToken); if (experimentRoomConflict) return "所选教室与其他实验场次冲突。"; if (project.ArrangementMode == ExperimentArrangementMode.Centralized) { return await CentralizedTaskConflictAsync( project.TeachingTaskId, project.TeachingTask.AcademicTerm!, request.SessionDate, request.StartPeriod, request.PeriodCount, cancellationToken); } return null; } private async Task CentralizedTaskConflictAsync( Guid teachingTaskId, AcademicTerm term, DateOnly date, int startPeriod, int periodCount, CancellationToken cancellationToken) { var teacherIds = await db.TeachingTaskTeachers.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId) .Select(x => x.TeacherId) .ToListAsync(cancellationToken); var classIds = await db.TeachingTaskClasses.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId) .Select(x => x.AdministrativeClassId) .ToListAsync(cancellationToken); var (week, dayOfWeek) = ClassroomReservationAvailabilityService.ResolveTeachingWeek( term, date); var relatedTaskIds = (await CentralizedExperimentConflictQuery .TaskIdsForTeachers(db, teacherIds) .ToListAsync(cancellationToken)) .Concat(await CentralizedExperimentConflictQuery .TaskIdsForClasses(db, classIds) .ToListAsync(cancellationToken)) .Distinct() .ToArray(); var scheduleConflicts = await CentralizedExperimentConflictQuery .ScheduleEntries( db, relatedTaskIds, term.Id, dayOfWeek, week, startPeriod, periodCount) .Select(x => new { x.TeachingTaskId, x.WeekPattern }) .ToListAsync(cancellationToken); if (scheduleConflicts.Any(x => FreeClassroomRules.MatchesWeek(x.WeekPattern, week))) return "集中实验与相关教师或行政班的已发布课表冲突。"; var experimentConflict = await CentralizedExperimentConflictQuery .ExperimentSessions( db, relatedTaskIds, date, startPeriod, periodCount) .AnyAsync(cancellationToken); return experimentConflict ? "集中实验与相关教师或行政班的其他实验安排冲突。" : null; } private async Task StudentConflictAsync( Guid studentId, ExperimentSession target, CancellationToken cancellationToken) { var project = target.ExperimentProject!; var term = project.TeachingTask!.AcademicTerm!; var taskIds = await TeachingTaskRosterQuery .TaskIdsForStudent(db, studentId) .ToListAsync(cancellationToken); var (week, dayOfWeek) = ClassroomReservationAvailabilityService.ResolveTeachingWeek( term, target.SessionDate); var scheduleEntries = await db.ScheduleEntries.AsNoTracking() .Where(x => taskIds.Contains(x.TeachingTaskId) && x.SchedulePlan!.Status == SchedulePlanStatus.Published && x.SchedulePlan.AcademicTermId == term.Id && x.DayOfWeek == dayOfWeek && x.StartWeek <= week && x.EndWeek >= week && x.StartPeriod < target.StartPeriod + target.PeriodCount && target.StartPeriod < x.StartPeriod + x.PeriodCount) .Select(x => x.WeekPattern) .ToListAsync(cancellationToken); if (scheduleEntries.Any(pattern => FreeClassroomRules.MatchesWeek(pattern, week))) return "该时间与您的已发布课表冲突。"; var bookingConflict = await db.ExperimentBookings.AsNoTracking() .AnyAsync(x => x.StudentId == studentId && x.Status == ExperimentBookingStatus.Booked && x.ExperimentSessionId != target.Id && x.ExperimentSession!.Status == ExperimentSessionStatus.Scheduled && x.ExperimentSession.SessionDate == target.SessionDate && x.ExperimentSession.StartPeriod < target.StartPeriod + target.PeriodCount && target.StartPeriod < x.ExperimentSession.StartPeriod + x.ExperimentSession.PeriodCount, cancellationToken); if (bookingConflict) return "该时间与您已预约的其他实验冲突。"; var centralizedConflict = await db.ExperimentSessions.AsNoTracking() .AnyAsync(x => x.Id != target.Id && x.Status == ExperimentSessionStatus.Scheduled && x.SessionDate == target.SessionDate && x.StartPeriod < target.StartPeriod + target.PeriodCount && target.StartPeriod < x.StartPeriod + x.PeriodCount && x.ExperimentProject!.Status == ExperimentProjectStatus.Published && x.ExperimentProject.ArrangementMode == ExperimentArrangementMode.Centralized && taskIds.Contains(x.ExperimentProject.TeachingTaskId), cancellationToken); return centralizedConflict ? "该时间与您的集中实验安排冲突。" : null; } private IQueryable AccessibleTeachingTasks() { var source = db.TeachingTasks.AsQueryable(); var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.All) return source; if (scope.Scope == DataScope.College) return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId); if (scope.IsInRole(SystemRoles.Teacher)) return source.Where(x => x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); return source.Where(_ => false); } private IQueryable ScopedProjects() { var taskIds = AccessibleTeachingTasks().Select(x => x.Id); return db.ExperimentProjects.Where(x => taskIds.Contains(x.TeachingTaskId)); } private Task CurrentStudentAsync( CancellationToken cancellationToken) => db.Students.FirstOrDefaultAsync(x => x.UserId == currentUserDataScope.Current.UserId && x.Status == StudentStatus.Active, cancellationToken); private async Task ResolveSessionCapacityAsync( ExperimentProject project, int requestedCapacity, CancellationToken cancellationToken) { if (project.ArrangementMode != ExperimentArrangementMode.Centralized) return requestedCapacity; var rosterCount = await TeachingTaskRosterQuery .ForTask(db, project.TeachingTaskId) .CountAsync(cancellationToken); return rosterCount > 0 ? rosterCount : requestedCapacity; } private Task> RosterUserIdsAsync( Guid teachingTaskId, CancellationToken cancellationToken) => TeachingTaskRosterQuery.ForTask(db, teachingTaskId) .Where(x => x.UserId.HasValue) .Select(x => x.UserId!.Value) .Distinct() .ToListAsync(cancellationToken); private static string? ValidateProjectRequest( ExperimentProjectRequest request, AcademicTerm term) { if (string.IsNullOrWhiteSpace(request.Code)) return "请填写实验项目编码。"; if (string.IsNullOrWhiteSpace(request.Name)) return "请填写实验项目名称。"; if (!Enum.IsDefined(request.ArrangementMode)) return "实验安排方式无效。"; if (request.StartDate > request.EndDate) return "项目开始日期不能晚于结束日期。"; if (request.StartDate < term.StartDate || request.EndDate > term.EndDate) return "实验项目日期必须在所属学期起止日期内。"; return null; } private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict }); } public sealed record ExperimentProjectRequest( Guid TeachingTaskId, [Required, MaxLength(40)] string Code, [Required, MaxLength(120)] string Name, ExperimentArrangementMode ArrangementMode, [MaxLength(1000)] string? Description, [MaxLength(1000)] string? Requirements, DateOnly StartDate, DateOnly EndDate); public sealed record ExperimentSessionRequest( Guid ClassroomId, DateOnly SessionDate, [Range(1, 30)] int StartPeriod, [Range(1, 30)] int PeriodCount, [Range(1, 10000)] int Capacity, [MaxLength(500)] string? Notes); public sealed record ExperimentPeriodOption( Guid AcademicTermId, int PeriodNumber, string Name, string StartsAt, string EndsAt);