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.Excel; 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 = OfferingManagers)] public async Task GetEligibleStudents( Guid id, string? keyword = null, int page = 1, int pageSize = 20, bool forceMode = false, 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 (!forceMode && !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 (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && 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 }); } [HttpPost("offerings/{offeringId:guid}/force-enroll")] [Authorize(Roles = OfferingManagers)] public async Task ForceEnroll( Guid offeringId, ForceEnrollmentRequest 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) .FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken); if (offering is null) return NotFound(); var round = offering.CourseSelectionRound!; var task = offering.TeachingTask!; 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 existingEnrollments = await db.CourseEnrollments .Where(x => x.CourseSelectionOfferingId == offeringId && studentIds.Contains(x.StudentId)) .ToListAsync(cancellationToken); var alreadyEnrolled = existingEnrollments .FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled); if (alreadyEnrolled is not null) { var dup = students.First(x => x.Id == alreadyEnrolled.StudentId); return ConflictProblem( $"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。"); } var now = DateTime.UtcNow; var enrolled = 0; foreach (var student in students) { var enrollment = existingEnrollments .FirstOrDefault(x => x.StudentId == student.Id); if (enrollment is null) { db.CourseEnrollments.Add(new CourseEnrollment { CourseSelectionOfferingId = offeringId, StudentId = student.Id, EnrollmentType = EnrollmentType.Retake, EnrolledAt = now }); } else { enrollment.Status = CourseEnrollmentStatus.Enrolled; enrollment.EnrolledAt = now; enrollment.WithdrawnAt = null; enrollment.EnrollmentType = EnrollmentType.Retake; } enrolled++; } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Ok(new { EnrolledCount = enrolled }); } [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(), x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible, db.CourseEnrollments.Any(e => e.StudentId == student.Id && e.CourseSelectionOffering!.TeachingTask!.CourseId == x.TeachingTask.CourseId && e.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != round.AcademicTermId), 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.EnrollmentType, 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(); // Detect retake: student previously took the same course in any term var isRetake = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != round.AcademicTermId, cancellationToken); 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); var effectiveCapacity = isRetake ? CourseSelectionRules.RetakeCapacity(offering.Capacity) : offering.Capacity; if (enrolledCount >= effectiveCapacity) return ConflictProblem("该教学班名额已满。"); // Normal enrollment: no duplicate course in same term if (!isRetake) { 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("同一学期不能重复选择相同课程。"); } // Credit limit check 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.#} 学分上限。"); } // Schedule conflict check var candidateEntries = await PublishedScheduleEntries( round.AcademicTermId, [task.Id], cancellationToken); if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && 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)) { if (!isRetake) return ConflictProblem("该教学班与已选课程的上课时间冲突。"); // Retake: allow ≤50% overlap var overlap = CourseSelectionRules.CalculateScheduleOverlap( candidateEntries, selectedEntries); if (overlap > 50) return ConflictProblem( $"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。"); } if (existing is null) { existing = new CourseEnrollment { CourseSelectionOfferingId = offering.Id, StudentId = student.Id, EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal }; db.CourseEnrollments.Add(existing); } else { existing.Status = CourseEnrollmentStatus.Enrolled; existing.EnrolledAt = now; existing.WithdrawnAt = null; existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal; } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Created(string.Empty, new { existing.Id, IsRetake = isRetake }); } [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); } [HttpGet("my-offerings")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetMyOfferings( Guid? academicTermId, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var offeringsQuery = db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId) && x.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft); if (academicTermId.HasValue) offeringsQuery = offeringsQuery.Where(x => x.CourseSelectionRound!.AcademicTermId == academicTermId); var offerings = await offeringsQuery .OrderByDescending(x => x.CourseSelectionRound!.AcademicTerm!.StartDate) .ThenBy(x => x.TeachingTask!.Course!.Code) .Select(x => new { x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, x.TeachingTask.AcademicTermId, TermName = x.TeachingTask.AcademicTerm!.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, RoundName = x.CourseSelectionRound!.Name, x.Capacity, EnrolledCount = x.Enrollments.Count(e => e.Status == CourseEnrollmentStatus.Enrolled), RoundStatus = x.CourseSelectionRound.Status }) .ToListAsync(cancellationToken); return Ok(offerings); } [HttpGet("offerings/{id:guid}/roster/export.xlsx")] [Authorize(Roles = RosterReaders)] public async Task ExportRoster( Guid id, CancellationToken cancellationToken) { var offering = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, RoundName = x.CourseSelectionRound!.Name, CollegeId = x.TeachingTask.Course.CollegeId, TeacherUserIds = x.TeachingTask.Teachers .Select(item => item.Teacher!.UserId) }) .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.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name, x.EnrolledAt }) .ToListAsync(cancellationToken); var bytes = ExcelWorkbookHelper.Create( "选课名单", ["学号", "姓名", "班级", "专业", "选课时间"], students.Select(s => new List { s.StudentNumber, s.Name, s.ClassName, s.MajorName, s.EnrolledAt.ToString("yyyy-MM-dd HH:mm") }).ToList>()); return File(bytes, ExcelWorkbookHelper.ContentType, $"选课名单-{offering.TaskNumber}.xlsx"); } 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 ForceEnrollmentRequest( [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, bool IsFlexible, bool IsRetake, IEnumerable Schedules); public sealed record StudentScheduleDto( int DayOfWeek, int StartPeriod, int PeriodCount, int StartWeek, int EndWeek, WeekPattern WeekPattern, string ClassroomName);