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.CourseSelection; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/course-selections")] public sealed class CourseSelectionsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string RoundManagers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; private const string OfferingManagers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; private const string SelectionUsers = OfferingManagers + "," + SystemRoles.Student; private const string RosterReaders = OfferingManagers + "," + SystemRoles.Teacher; [HttpGet("rounds")] [Authorize(Roles = SelectionUsers)] public async Task GetRounds( Guid? academicTermId, CancellationToken cancellationToken) { var source = db.CourseSelectionRounds.AsNoTracking().AsQueryable(); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); if (currentUserDataScope.Current.IsInRole(SystemRoles.Student)) source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft); var now = DateTime.UtcNow; return Ok(await source .OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenByDescending(x => x.StartsAt) .Select(x => new { x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.StartsAt, x.EndsAt, x.WithdrawalEndsAt, x.MaxCredits, x.Status, IsAvailableNow = x.Status == CourseSelectionRoundStatus.Open && now >= x.StartsAt && now <= x.EndsAt, OfferingCount = x.Offerings.Count, x.Notes, x.UpdatedAt }) .ToListAsync(cancellationToken)); } [HttpPost("rounds")] [Authorize(Roles = RoundManagers)] public async Task CreateRound( CourseSelectionRoundRequest request, CancellationToken cancellationToken) { var validation = await ValidateRoundAsync(request, cancellationToken); if (validation is not null) return validation; var round = new CourseSelectionRound { AcademicTermId = request.AcademicTermId, Name = request.Name.Trim(), StartsAt = request.StartsAt.ToUniversalTime(), EndsAt = request.EndsAt.ToUniversalTime(), WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(), MaxCredits = request.MaxCredits, Notes = Normalize(request.Notes) }; db.CourseSelectionRounds.Add(round); return await SaveAsync(round.Id, true, cancellationToken); } [HttpPut("rounds/{id:guid}")] [Authorize(Roles = RoundManagers)] public async Task UpdateRound( Guid id, CourseSelectionRoundRequest request, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("只有草稿选课批次可以修改。"); var validation = await ValidateRoundAsync(request, cancellationToken); if (validation is not null) return validation; round.AcademicTermId = request.AcademicTermId; round.Name = request.Name.Trim(); round.StartsAt = request.StartsAt.ToUniversalTime(); round.EndsAt = request.EndsAt.ToUniversalTime(); round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(); round.MaxCredits = request.MaxCredits; round.Notes = Normalize(request.Notes); return await SaveAsync(id, false, cancellationToken); } [HttpDelete("rounds/{id:guid}")] [Authorize(Roles = RoundManagers)] public async Task DeleteRound(Guid id, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("只有草稿选课批次可以删除。"); db.CourseSelectionRounds.Remove(round); return await SaveAsync(id, false, cancellationToken); } [HttpPost("rounds/{id:guid}/open")] [Authorize(Roles = RoundManagers)] public async Task OpenRound(Guid id, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds .Include(x => x.Offerings) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("只有草稿选课批次可以开放。"); if (round.Offerings.Count == 0) return ConflictProblem("至少配置一个可选教学班后才能开放。"); round.Status = CourseSelectionRoundStatus.Open; return await SaveAsync(id, false, cancellationToken); } [HttpPost("rounds/{id:guid}/close")] [Authorize(Roles = RoundManagers)] public async Task CloseRound(Guid id, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Open) return ConflictProblem("只有开放中的选课批次可以关闭。"); round.Status = CourseSelectionRoundStatus.Closed; return await SaveAsync(id, false, cancellationToken); } [HttpGet("rounds/{roundId:guid}/offerings")] [Authorize(Roles = OfferingManagers)] public async Task GetOfferings( Guid roundId, CancellationToken cancellationToken) { var source = ScopedOfferings() .AsNoTracking() .Where(x => x.CourseSelectionRoundId == roundId); return Ok(await source .OrderBy(x => x.TeachingTask!.Course!.Code) .ThenBy(x => x.TeachingTask!.TaskNumber) .Select(x => new { x.Id, x.CourseSelectionRoundId, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, CourseNature = x.TeachingTask.Course.Nature, CollegeName = x.TeachingTask.Course.College!.Name, x.TeachingTask.Course.Credits, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), ClassNames = x.TeachingTask.Classes .Select(item => item.AdministrativeClass!.Name), x.Capacity, EnrolledCount = x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Enrolled), x.IsOpenToAll, x.Notes, x.UpdatedAt }) .ToListAsync(cancellationToken)); } [HttpPost("rounds/{roundId:guid}/offerings")] [Authorize(Roles = OfferingManagers)] public async Task CreateOffering( Guid roundId, CourseSelectionOfferingRequest request, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds.FindAsync([roundId], cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后不能调整教学班。"); var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken); var validation = ValidateOffering(round, task, request); if (validation is not null) return validation; var offering = new CourseSelectionOffering { CourseSelectionRoundId = roundId, TeachingTaskId = request.TeachingTaskId, Capacity = request.Capacity, IsOpenToAll = request.IsOpenToAll, Notes = Normalize(request.Notes) }; db.CourseSelectionOfferings.Add(offering); return await SaveAsync(offering.Id, true, cancellationToken); } [HttpPut("rounds/{roundId:guid}/offerings/{id:guid}")] [Authorize(Roles = OfferingManagers)] public async Task UpdateOffering( Guid roundId, Guid id, CourseSelectionOfferingRequest request, CancellationToken cancellationToken) { var offering = await ScopedOfferings() .Include(x => x.CourseSelectionRound) .FirstOrDefaultAsync( x => x.Id == id && x.CourseSelectionRoundId == roundId, cancellationToken); if (offering is null) return NotFound(); if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后不能调整教学班。"); var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken); var validation = ValidateOffering(offering.CourseSelectionRound, task, request); if (validation is not null) return validation; offering.TeachingTaskId = request.TeachingTaskId; offering.Capacity = request.Capacity; offering.IsOpenToAll = request.IsOpenToAll; offering.Notes = Normalize(request.Notes); return await SaveAsync(id, false, cancellationToken); } [HttpDelete("rounds/{roundId:guid}/offerings/{id:guid}")] [Authorize(Roles = OfferingManagers)] public async Task DeleteOffering( Guid roundId, Guid id, CancellationToken cancellationToken) { var offering = await ScopedOfferings() .Include(x => x.CourseSelectionRound) .FirstOrDefaultAsync( x => x.Id == id && x.CourseSelectionRoundId == roundId, cancellationToken); if (offering is null) return NotFound(); if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后不能调整教学班。"); db.CourseSelectionOfferings.Remove(offering); return await SaveAsync(id, false, cancellationToken); } [HttpGet("offerings/{id:guid}/roster")] [Authorize(Roles = RosterReaders)] public async Task GetRoster(Guid id, CancellationToken cancellationToken) { var offering = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course!.Name, CourseNature = x.TeachingTask.Course.Nature, CollegeId = x.TeachingTask.Course.CollegeId, TeacherUserIds = x.TeachingTask.Teachers .Select(item => item.Teacher!.UserId), x.CourseSelectionRoundId, RoundStatus = x.CourseSelectionRound!.Status, x.Capacity }) .FirstOrDefaultAsync(cancellationToken); if (offering is null) return NotFound(); var scope = currentUserDataScope.Current; var isAssignedTeacher = scope.IsInRole(SystemRoles.Teacher) && offering.TeacherUserIds.Contains(scope.UserId); if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) return Forbid(); var students = await db.CourseEnrollments.AsNoTracking() .Where(x => x.CourseSelectionOfferingId == id && x.Status == CourseEnrollmentStatus.Enrolled) .OrderBy(x => x.Student!.StudentNumber) .Select(x => new { x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name, x.EnrolledAt }) .ToListAsync(cancellationToken); return Ok(new { offering.Id, offering.TeachingTaskId, offering.TaskNumber, offering.TaskName, offering.CourseCode, offering.CourseName, offering.CourseNature, offering.Capacity, EnrolledCount = students.Count, Students = students, CanProxyEnroll = CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) && offering.RoundStatus != CourseSelectionRoundStatus.Draft && (currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin)) }); } [HttpGet("offerings/{id:guid}/eligible-students")] [Authorize(Roles = RoundManagers)] public async Task GetEligibleStudents( Guid id, string? keyword = null, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); var offering = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.IsOpenToAll, CourseNature = x.TeachingTask!.Course!.Nature, RoundStatus = x.CourseSelectionRound!.Status, ClassIds = x.TeachingTask.Classes .Select(item => item.AdministrativeClassId) }) .FirstOrDefaultAsync(cancellationToken); if (offering is null) return NotFound(); if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature)) return ConflictProblem("管理员代选仅适用于公共必修课。"); if (offering.RoundStatus == CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后才能办理管理员代选。"); var source = db.Students.AsNoTracking() .Where(x => x.Status == StudentStatus.Active && (offering.IsOpenToAll || offering.ClassIds.Contains(x.AdministrativeClassId)) && !db.CourseEnrollments.Any(enrollment => enrollment.CourseSelectionOfferingId == id && enrollment.StudentId == x.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled)); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); source = source.Where(x => x.StudentNumber.Contains(keyword) || x.Name.Contains(keyword) || x.AdministrativeClass!.Name.Contains(keyword)); } var total = await source.CountAsync(cancellationToken); var items = await source .OrderBy(x => x.StudentNumber) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.Id, x.StudentNumber, x.Name, ClassName = x.AdministrativeClass!.Name, MajorName = x.AdministrativeClass.Major!.Name, CollegeName = x.AdministrativeClass.Major.College!.Name }) .ToListAsync(cancellationToken); return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize }); } [HttpPost("offerings/{id:guid}/admin-enrollments")] [Authorize(Roles = RoundManagers)] public async Task AdminEnroll( Guid id, AdminEnrollmentRequest request, CancellationToken cancellationToken) { var studentIds = request.StudentIds.Distinct().ToArray(); if (studentIds.Length == 0) return ValidationProblem("请至少选择一名学生。"); if (studentIds.Length > 100) return ValidationProblem("单次最多可为 100 名学生代选。"); await using var transaction = await db.Database.BeginTransactionAsync( IsolationLevel.Serializable, cancellationToken); var offering = await db.CourseSelectionOfferings .Include(x => x.CourseSelectionRound) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Classes) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (offering is null) return NotFound(); var round = offering.CourseSelectionRound!; var task = offering.TeachingTask!; if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature)) return ConflictProblem("管理员代选仅适用于公共必修课。"); if (round.Status == CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后才能办理管理员代选。"); if (task.Status != TeachingTaskStatus.Published) return ConflictProblem("该教学班当前不可办理代选。"); var students = await db.Students .Include(x => x.AdministrativeClass) .Where(x => studentIds.Contains(x.Id)) .OrderBy(x => x.StudentNumber) .ToListAsync(cancellationToken); if (students.Count != studentIds.Length) return ValidationProblem("存在无效的学生档案。"); var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active); if (inactive is not null) return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。"); var outOfScope = students.FirstOrDefault(student => !offering.IsOpenToAll && !task.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId)); if (outOfScope is not null) { return ConflictProblem( $"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。"); } var existingEnrollments = await db.CourseEnrollments .Where(x => x.CourseSelectionOfferingId == id && studentIds.Contains(x.StudentId)) .ToListAsync(cancellationToken); var alreadyEnrolled = existingEnrollments.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled); if (alreadyEnrolled is not null) { var student = students.First(x => x.Id == alreadyEnrolled.StudentId); return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。"); } var enrolledCount = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); if (enrolledCount + students.Count > offering.Capacity) { return ConflictProblem( $"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。"); } var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking() .Where(x => studentIds.Contains(x.StudentId) && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == round.AcademicTermId) .Select(x => x.StudentId) .Distinct() .ToListAsync(cancellationToken); if (duplicateStudentIds.Count > 0) { var student = students.First(x => duplicateStudentIds.Contains(x.Id)); return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。"); } var candidateEntries = await PublishedScheduleEntries( round.AcademicTermId, [task.Id], cancellationToken); if (candidateEntries.Count == 0) return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。"); foreach (var student in students) { var selectedCredits = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id) .SumAsync( x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits, cancellationToken) ?? 0; if (selectedCredits + task.Course.Credits > round.MaxCredits) { return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。"); } var selectedTaskIds = await db.CourseEnrollments.AsNoTracking() .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == round.AcademicTermId) .Select(x => x.CourseSelectionOffering!.TeachingTaskId) .Distinct() .ToArrayAsync(cancellationToken); var selectedEntries = await PublishedScheduleEntries( round.AcademicTermId, selectedTaskIds, cancellationToken); if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) { return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。"); } } var now = DateTime.UtcNow; foreach (var student in students) { var enrollment = existingEnrollments.FirstOrDefault(x => x.StudentId == student.Id); if (enrollment is null) { db.CourseEnrollments.Add(new CourseEnrollment { CourseSelectionOfferingId = id, StudentId = student.Id, EnrolledAt = now }); } else { enrollment.Status = CourseEnrollmentStatus.Enrolled; enrollment.EnrolledAt = now; enrollment.WithdrawnAt = null; } } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Ok(new { EnrolledCount = students.Count }); } [HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")] [Authorize(Roles = RoundManagers)] public async Task AdminWithdraw( Guid offeringId, Guid enrollmentId, CancellationToken cancellationToken) { var enrollment = await db.CourseEnrollments .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync( x => x.Id == enrollmentId && x.CourseSelectionOfferingId == offeringId, cancellationToken); if (enrollment is null) return NotFound(); var offering = enrollment.CourseSelectionOffering!; if (!CourseSelectionRules.SupportsProxyEnrollment( offering.TeachingTask!.Course!.Nature)) return ConflictProblem("管理员名单调整仅适用于公共必修课。"); if (offering.TeachingTask.Status != TeachingTaskStatus.Published) return ConflictProblem("该教学班当前不可调整名单。"); if (enrollment.Status != CourseEnrollmentStatus.Enrolled) return ConflictProblem("该学生已不在教学班名单中。"); enrollment.Status = CourseEnrollmentStatus.Withdrawn; enrollment.WithdrawnAt = DateTime.UtcNow; return await SaveAsync(enrollmentId, false, cancellationToken); } [HttpGet("student/options")] [Authorize(Roles = SystemRoles.Student)] public async Task GetStudentOptions( Guid roundId, CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); var round = await db.CourseSelectionRounds.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken); if (round is null || round.Status == CourseSelectionRoundStatus.Draft) return NotFound(); var offerings = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.CourseSelectionRoundId == roundId && x.TeachingTask!.Status == TeachingTaskStatus.Published && (x.IsOpenToAll || x.TeachingTask.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId))) .OrderBy(x => x.TeachingTask!.Course!.Code) .Select(x => new StudentOfferingDto( x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, x.TeachingTask.Course!.Code, x.TeachingTask.Course.Name, x.TeachingTask.Course.Credits, x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.Capacity, x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Enrolled), x.IsOpenToAll, x.Enrollments .Where(item => item.StudentId == student.Id) .Select(item => (CourseEnrollmentStatus?)item.Status) .FirstOrDefault(), db.ScheduleEntries .Where(entry => entry.TeachingTaskId == x.TeachingTaskId && entry.SchedulePlan!.AcademicTermId == round.AcademicTermId && entry.SchedulePlan.Status == SchedulePlanStatus.Published) .OrderBy(entry => entry.DayOfWeek) .ThenBy(entry => entry.StartPeriod) .Select(entry => new StudentScheduleDto( entry.DayOfWeek, entry.StartPeriod, entry.PeriodCount, entry.StartWeek, entry.EndWeek, entry.WeekPattern, entry.Classroom == null ? "不占用教室" : entry.Classroom.Name)) .ToList())) .ToListAsync(cancellationToken); return Ok(new { Round = new { round.Id, round.Name, round.StartsAt, round.EndsAt, round.WithdrawalEndsAt, round.MaxCredits, round.Status, IsAvailableNow = CourseSelectionRules.IsSelectionOpen( round, DateTime.UtcNow) }, Student = new { student.Id, student.StudentNumber, student.Name, ClassName = student.AdministrativeClass!.Name }, Offerings = offerings }); } [HttpGet("student/enrollments")] [Authorize(Roles = SystemRoles.Student)] public async Task GetStudentEnrollments( Guid? academicTermId, CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); var source = db.CourseEnrollments.AsNoTracking() .Where(x => x.StudentId == student.Id); if (academicTermId.HasValue) { source = source.Where(x => x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == academicTermId); } return Ok(await source .OrderByDescending(x => x.EnrolledAt) .Select(x => new { x.Id, x.CourseSelectionOfferingId, RoundId = x.CourseSelectionOffering!.CourseSelectionRoundId, RoundName = x.CourseSelectionOffering.CourseSelectionRound!.Name, TermName = x.CourseSelectionOffering.CourseSelectionRound.AcademicTerm!.Name, x.CourseSelectionOffering.TeachingTaskId, TaskNumber = x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name, x.CourseSelectionOffering.TeachingTask.Course.Credits, TeacherNames = x.CourseSelectionOffering.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.Status, x.EnrolledAt, x.WithdrawnAt, CanWithdraw = x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering.CourseSelectionRound.Status == CourseSelectionRoundStatus.Open && DateTime.UtcNow <= x.CourseSelectionOffering.CourseSelectionRound.WithdrawalEndsAt }) .ToListAsync(cancellationToken)); } [HttpPost("student/enrollments")] [Authorize(Roles = SystemRoles.Student)] public async Task Enroll( StudentEnrollmentRequest request, CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); if (student.Status != StudentStatus.Active) return ConflictProblem("只有在籍学生可以选课。"); await using var transaction = await db.Database.BeginTransactionAsync( IsolationLevel.Serializable, cancellationToken); var offering = await db.CourseSelectionOfferings .Include(x => x.CourseSelectionRound) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Classes) .FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken); if (offering is null) return NotFound(); var round = offering.CourseSelectionRound!; var task = offering.TeachingTask!; var now = DateTime.UtcNow; if (!CourseSelectionRules.IsSelectionOpen(round, now)) return ConflictProblem("当前不在该选课批次的开放时间内。"); if (task.Status != TeachingTaskStatus.Published) return ConflictProblem("该教学班当前不可选。"); if (!offering.IsOpenToAll && !task.Classes.Any(x => x.AdministrativeClassId == student.AdministrativeClassId)) return Forbid(); var existing = await db.CourseEnrollments.FirstOrDefaultAsync( x => x.CourseSelectionOfferingId == offering.Id && x.StudentId == student.Id, cancellationToken); if (existing?.Status == CourseEnrollmentStatus.Enrolled) return ConflictProblem("你已经选择了该教学班。"); var enrolledCount = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); if (enrolledCount >= offering.Capacity) return ConflictProblem("该教学班名额已满。"); var duplicateCourse = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == round.AcademicTermId, cancellationToken); if (duplicateCourse) return ConflictProblem("同一学期不能重复选择相同课程。"); var selectedCredits = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id) .SumAsync( x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits, cancellationToken) ?? 0; if (selectedCredits + task.Course!.Credits > round.MaxCredits) { return ConflictProblem( $"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。"); } var candidateEntries = await PublishedScheduleEntries( round.AcademicTermId, [task.Id], cancellationToken); if (candidateEntries.Count == 0) return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); var selectedTaskIds = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == round.AcademicTermId) .Select(x => x.CourseSelectionOffering!.TeachingTaskId) .Distinct() .ToArrayAsync(cancellationToken); var selectedEntries = await PublishedScheduleEntries( round.AcademicTermId, selectedTaskIds, cancellationToken); if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) return ConflictProblem("该教学班与已选课程的上课时间冲突。"); if (existing is null) { existing = new CourseEnrollment { CourseSelectionOfferingId = offering.Id, StudentId = student.Id }; db.CourseEnrollments.Add(existing); } else { existing.Status = CourseEnrollmentStatus.Enrolled; existing.EnrolledAt = now; existing.WithdrawnAt = null; } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Created(string.Empty, new { existing.Id }); } [HttpDelete("student/enrollments/{id:guid}")] [Authorize(Roles = SystemRoles.Student)] public async Task Withdraw(Guid id, CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); var enrollment = await db.CourseEnrollments .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.CourseSelectionRound) .FirstOrDefaultAsync( x => x.Id == id && x.StudentId == student.Id, cancellationToken); if (enrollment is null) return NotFound(); if (enrollment.Status != CourseEnrollmentStatus.Enrolled) return ConflictProblem("该课程已经退选。"); if (!CourseSelectionRules.CanWithdraw( enrollment.CourseSelectionOffering!.CourseSelectionRound!, DateTime.UtcNow)) return ConflictProblem("当前批次已停止退课。"); enrollment.Status = CourseEnrollmentStatus.Withdrawn; enrollment.WithdrawnAt = DateTime.UtcNow; return await SaveAsync(id, false, cancellationToken); } private IQueryable ScopedOfferings() { var source = db.CourseSelectionOfferings.AsQueryable(); var scope = currentUserDataScope.Current; return scope.Scope == DataScope.All ? source : source.Where(x => x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId); } private async Task FindAccessibleTaskAsync( Guid id, CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; var source = db.TeachingTasks.AsNoTracking().AsQueryable(); if (scope.Scope != DataScope.All) { source = source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId); } return await source .Include(x => x.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); } private ActionResult? ValidateOffering( CourseSelectionRound round, TeachingTask? task, CourseSelectionOfferingRequest request) { if (task is null) return ValidationProblem("所选教学班不存在或超出数据范围。"); if (task.AcademicTermId != round.AcademicTermId) return ValidationProblem("教学班与选课批次必须属于同一学期。"); if (task.Status != TeachingTaskStatus.Published) return ValidationProblem("只有已发布的教学班可以进入选课。"); if (request.Capacity > task.Capacity) return ValidationProblem($"选课容量不能超过教学班容量 {task.Capacity}。"); return null; } private async Task ValidateRoundAsync( CourseSelectionRoundRequest request, CancellationToken cancellationToken) { var startsAt = request.StartsAt.ToUniversalTime(); var endsAt = request.EndsAt.ToUniversalTime(); var withdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(); if (startsAt >= endsAt) return ValidationProblem("选课开始时间必须早于结束时间。"); if (withdrawalEndsAt < endsAt) return ValidationProblem("退课截止时间不能早于选课结束时间。"); if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选学期不存在或已停用。"); return null; } private async Task CurrentStudentAsync( CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; return await db.Students .Include(x => x.AdministrativeClass) .FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken); } private async Task> PublishedScheduleEntries( Guid academicTermId, IReadOnlyCollection taskIds, CancellationToken cancellationToken) { if (taskIds.Count == 0) return []; return await db.ScheduleEntries.AsNoTracking() .Where(x => taskIds.Contains(x.TeachingTaskId) && x.SchedulePlan!.AcademicTermId == academicTermId && x.SchedulePlan.Status == SchedulePlanStatus.Published) .ToListAsync(cancellationToken); } private async Task SaveAsync( Guid id, bool created, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) { return ConflictProblem("记录重复、容量已变化,或关联数据已失效。"); } } private ActionResult ProfileNotFound() => 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(); } public sealed record CourseSelectionRoundRequest( Guid AcademicTermId, [Required, MaxLength(120)] string Name, DateTime StartsAt, DateTime EndsAt, DateTime WithdrawalEndsAt, [Range(typeof(decimal), "0.5", "99")] decimal MaxCredits, [MaxLength(500)] string? Notes); public sealed record CourseSelectionOfferingRequest( Guid TeachingTaskId, [Range(1, 10000)] int Capacity, bool IsOpenToAll, [MaxLength(500)] string? Notes); public sealed record StudentEnrollmentRequest(Guid OfferingId); public sealed record AdminEnrollmentRequest( [MinLength(1)] IReadOnlyCollection StudentIds); public sealed record StudentOfferingDto( Guid Id, Guid TeachingTaskId, string TaskNumber, string CourseCode, string CourseName, decimal Credits, IEnumerable TeacherNames, int Capacity, int EnrolledCount, bool IsOpenToAll, CourseEnrollmentStatus? EnrollmentStatus, IEnumerable Schedules); public sealed record StudentScheduleDto( int DayOfWeek, int StartPeriod, int PeriodCount, int StartWeek, int EndWeek, WeekPattern WeekPattern, string ClassroomName);