diff --git a/README.md b/README.md index 5cfa35b..151f3bd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 -当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 +当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。 diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 07c9366..4415bc7 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -153,12 +153,51 @@ public sealed class CourseSelectionsController( [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); + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var round = await db.CourseSelectionRounds + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (round is null) return NotFound(); + if (round.Status != CourseSelectionRoundStatus.Open) + return ConflictProblem("只有开放中的选课批次可以关闭。"); + + var waitlisted = await db.CourseEnrollments + .Include(x => x.Student) + .Include(x => x.CourseSelectionOffering) + .ThenInclude(x => x!.TeachingTask) + .ThenInclude(x => x!.Course) + .Where(x => + x.CourseSelectionOffering!.CourseSelectionRoundId == id && + x.Status == CourseEnrollmentStatus.Waitlisted) + .ToListAsync(cancellationToken); + var now = DateTime.UtcNow; + foreach (var enrollment in waitlisted) + { + enrollment.Status = CourseEnrollmentStatus.Expired; + enrollment.WithdrawnAt = now; + if (enrollment.Student!.UserId is Guid userId) + { + db.Notifications.Add(new Notification + { + UserId = userId, + Title = "课程候补已结束", + Content = + $"“{enrollment.CourseSelectionOffering!.TeachingTask!.Course!.Name}”" + + "选课批次已关闭,本次候补未获得名额。", + LinkUrl = "/course-selections" + }); + } + } + + round.Status = CourseSelectionRoundStatus.Closed; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { ExpiredWaitlistCount = waitlisted.Count }); + }, + cancellationToken, + IsolationLevel.Serializable); } [HttpGet("rounds/{roundId:guid}/offerings")] @@ -193,6 +232,8 @@ public sealed class CourseSelectionsController( x.Capacity, EnrolledCount = x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Enrolled), + WaitlistedCount = x.Enrollments.Count(item => + item.Status == CourseEnrollmentStatus.Waitlisted), x.IsOpenToAll, x.Notes, x.UpdatedAt @@ -318,6 +359,36 @@ public sealed class CourseSelectionsController( x.EnrolledAt }) .ToListAsync(cancellationToken); + var waitlistedRows = await db.CourseEnrollments.AsNoTracking() + .Where(x => + x.CourseSelectionOfferingId == id && + x.Status == CourseEnrollmentStatus.Waitlisted) + .OrderBy(x => x.WaitlistedAt) + .ThenBy(x => x.CreatedAt) + .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.WaitlistedAt + }) + .ToListAsync(cancellationToken); + var waitlist = waitlistedRows + .Select((item, index) => new + { + item.Id, + item.StudentId, + item.StudentNumber, + item.Name, + item.ClassName, + item.MajorName, + item.WaitlistedAt, + Position = index + 1 + }) + .ToList(); return Ok(new { offering.Id, @@ -330,6 +401,13 @@ public sealed class CourseSelectionsController( offering.Capacity, EnrolledCount = students.Count, Students = students, + WaitlistedCount = waitlist.Count, + Waitlist = waitlist, + CanManageWaitlist = + offering.RoundStatus == CourseSelectionRoundStatus.Open && + (scope.IsInRole(SystemRoles.SuperAdmin) || + scope.IsInRole(SystemRoles.AcademicAdmin) || + scope.IsInRole(SystemRoles.CollegeAdmin)), CanProxyEnroll = CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) && offering.RoundStatus != CourseSelectionRoundStatus.Draft && @@ -550,19 +628,28 @@ public sealed class CourseSelectionsController( x.StudentId == student.Id); if (enrollment is null) { - db.CourseEnrollments.Add(new CourseEnrollment + enrollment = new CourseEnrollment { CourseSelectionOfferingId = id, StudentId = student.Id, EnrolledAt = now - }); + }; + db.CourseEnrollments.Add(enrollment); } else { enrollment.Status = CourseEnrollmentStatus.Enrolled; enrollment.EnrolledAt = now; + enrollment.WaitlistedAt = null; enrollment.WithdrawnAt = null; } + await ExpireOtherWaitlistsForCourseAsync( + student.Id, + task.CourseId, + round.AcademicTermId, + enrollment.Id, + now, + cancellationToken); } await db.SaveChangesAsync(cancellationToken); @@ -633,21 +720,30 @@ public sealed class CourseSelectionsController( .FirstOrDefault(x => x.StudentId == student.Id); if (enrollment is null) { - db.CourseEnrollments.Add(new CourseEnrollment + enrollment = new CourseEnrollment { CourseSelectionOfferingId = offeringId, StudentId = student.Id, EnrollmentType = EnrollmentType.Retake, EnrolledAt = now - }); + }; + db.CourseEnrollments.Add(enrollment); } else { enrollment.Status = CourseEnrollmentStatus.Enrolled; enrollment.EnrolledAt = now; + enrollment.WaitlistedAt = null; enrollment.WithdrawnAt = null; enrollment.EnrollmentType = EnrollmentType.Retake; } + await ExpireOtherWaitlistsForCourseAsync( + student.Id, + task.CourseId, + round.AcademicTermId, + enrollment.Id, + now, + cancellationToken); enrolled++; } @@ -666,28 +762,93 @@ public sealed class CourseSelectionsController( 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("该学生已不在教学班名单中。"); + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var enrollment = await db.CourseEnrollments + .Include(x => x.CourseSelectionOffering) + .ThenInclude(x => x!.CourseSelectionRound) + .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); + enrollment.Status = CourseEnrollmentStatus.Withdrawn; + enrollment.WithdrawnAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + var promoted = await PromoteNextWaitlistedAsync( + offering, + cancellationToken); + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { PromotedStudentId = promoted?.StudentId }); + }, + cancellationToken, + IsolationLevel.Serializable); + } + + [HttpDelete("offerings/{offeringId:guid}/waitlist/{enrollmentId:guid}")] + [Authorize(Roles = OfferingManagers)] + public async Task AdminCancelWaitlist( + Guid offeringId, + Guid enrollmentId, + CancellationToken cancellationToken) + { + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + if (!await ScopedOfferings().AnyAsync( + x => x.Id == offeringId, + cancellationToken)) + return NotFound(); + var enrollment = await db.CourseEnrollments + .Include(x => x.Student) + .Include(x => x.CourseSelectionOffering) + .ThenInclude(x => x!.TeachingTask) + .ThenInclude(x => x!.Course) + .FirstOrDefaultAsync( + x => + x.Id == enrollmentId && + x.CourseSelectionOfferingId == offeringId && + x.Status == CourseEnrollmentStatus.Waitlisted, + cancellationToken); + if (enrollment is null) return NotFound(); + + enrollment.Status = CourseEnrollmentStatus.Cancelled; + enrollment.WithdrawnAt = DateTime.UtcNow; + if (enrollment.Student!.UserId is Guid userId) + { + db.Notifications.Add(new Notification + { + UserId = userId, + Title = "课程候补已取消", + Content = + $"管理员已将你移出“{enrollment.CourseSelectionOffering!.TeachingTask!.Course!.Name}”" + + "候补队列。", + LinkUrl = "/course-selections" + }); + } + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return NoContent(); + }, + cancellationToken, + IsolationLevel.Serializable); } [HttpGet("student/options")] @@ -724,6 +885,8 @@ public sealed class CourseSelectionsController( x.Capacity, x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Enrolled), + x.Enrollments.Count(item => + item.Status == CourseEnrollmentStatus.Waitlisted), x.IsOpenToAll, x.Enrollments .Where(item => item.StudentId == student.Id) @@ -732,6 +895,8 @@ public sealed class CourseSelectionsController( x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible, db.CourseEnrollments.Any(e => e.StudentId == student.Id && + (e.Status == CourseEnrollmentStatus.Enrolled || + e.Status == CourseEnrollmentStatus.Withdrawn) && e.CourseSelectionOffering!.TeachingTask!.CourseId == x.TeachingTask.CourseId && e.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != @@ -751,9 +916,48 @@ public sealed class CourseSelectionsController( entry.EndWeek, entry.WeekPattern, entry.Classroom == null ? "不占用教室" : entry.Classroom.Name)) - .ToList())) + .ToList(), + null)) .ToListAsync(cancellationToken); + var waitlistedOfferingIds = offerings + .Where(x => x.EnrollmentStatus == CourseEnrollmentStatus.Waitlisted) + .Select(x => x.Id) + .ToArray(); + if (waitlistedOfferingIds.Length > 0) + { + var queueRows = await db.CourseEnrollments.AsNoTracking() + .Where(x => x.Status == CourseEnrollmentStatus.Waitlisted) + .WhereIn(waitlistedOfferingIds, x => x.CourseSelectionOfferingId) + .Select(x => new + { + x.CourseSelectionOfferingId, + x.StudentId, + x.WaitlistedAt, + x.CreatedAt + }) + .ToListAsync(cancellationToken); + var positions = queueRows + .GroupBy(x => x.CourseSelectionOfferingId) + .SelectMany(group => group + .OrderBy(x => x.WaitlistedAt) + .ThenBy(x => x.CreatedAt) + .Select((item, index) => new + { + item.CourseSelectionOfferingId, + item.StudentId, + Position = index + 1 + })) + .Where(x => x.StudentId == student.Id) + .ToDictionary(x => x.CourseSelectionOfferingId, x => x.Position); + offerings = offerings + .Select(x => x with + { + WaitlistPosition = positions.GetValueOrDefault(x.Id) + }) + .ToList(); + } + return Ok(new { Round = new @@ -816,9 +1020,11 @@ public sealed class CourseSelectionsController( x.Status, x.EnrollmentType, x.EnrolledAt, + x.WaitlistedAt, x.WithdrawnAt, CanWithdraw = - x.Status == CourseEnrollmentStatus.Enrolled && + (x.Status == CourseEnrollmentStatus.Enrolled || + x.Status == CourseEnrollmentStatus.Waitlisted) && x.CourseSelectionOffering.CourseSelectionRound.Status == CourseSelectionRoundStatus.Open && DateTime.UtcNow <= @@ -866,6 +1072,8 @@ public sealed class CourseSelectionsController( var isRetake = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && + (x.Status == CourseEnrollmentStatus.Enrolled || + x.Status == CourseEnrollmentStatus.Withdrawn) && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != round.AcademicTermId, @@ -884,9 +1092,9 @@ public sealed class CourseSelectionsController( x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); - var effectiveCapacity = isRetake - ? CourseSelectionRules.RetakeCapacity(offering.Capacity) - : offering.Capacity; + var effectiveCapacity = CourseSelectionRules.EffectiveCapacity( + offering.Capacity, + isRetake); if (enrolledCount >= effectiveCapacity) return ConflictProblem("该教学班名额已满。"); @@ -967,9 +1175,17 @@ public sealed class CourseSelectionsController( { existing.Status = CourseEnrollmentStatus.Enrolled; existing.EnrolledAt = now; + existing.WaitlistedAt = null; existing.WithdrawnAt = null; existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal; } + await ExpireOtherWaitlistsForCourseAsync( + student.Id, + task.CourseId, + round.AcademicTermId, + existing.Id, + now, + cancellationToken); await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Created(string.Empty, new { existing.Id, IsRetake = isRetake }); @@ -978,28 +1194,168 @@ public sealed class CourseSelectionsController( IsolationLevel.Serializable); } + [HttpPost("student/waitlist")] + [Authorize(Roles = SystemRoles.Student)] + public async Task JoinWaitlist( + StudentEnrollmentRequest request, + CancellationToken cancellationToken) + { + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var student = await CurrentStudentAsync(cancellationToken); + if (student is null) return ProfileNotFound(); + + 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 now = DateTime.UtcNow; + if (!CourseSelectionRules.IsSelectionOpen(round, now)) + return ConflictProblem("当前不在该选课批次的开放时间内。"); + + var existing = await db.CourseEnrollments.FirstOrDefaultAsync( + x => + x.CourseSelectionOfferingId == offering.Id && + x.StudentId == student.Id, + cancellationToken); + if (existing?.Status == CourseEnrollmentStatus.Enrolled) + return ConflictProblem("你已经选择了该教学班。"); + if (existing?.Status == CourseEnrollmentStatus.Waitlisted) + return ConflictProblem("你已经在该教学班的候补队列中。"); + + var eligibility = await EvaluateEnrollmentEligibilityAsync( + student, + offering, + cancellationToken); + if (eligibility.Error is not null) + return ConflictProblem(eligibility.Error); + + var sameCourseWaitlistExists = await db.CourseEnrollments.AnyAsync( + x => + x.StudentId == student.Id && + x.Status == CourseEnrollmentStatus.Waitlisted && + x.CourseSelectionOffering!.TeachingTask!.CourseId == + offering.TeachingTask!.CourseId && + x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == + round.AcademicTermId, + cancellationToken); + if (sameCourseWaitlistExists) + return ConflictProblem("你已在本学期同一课程的其他教学班候补。"); + + var enrolledCount = await db.CourseEnrollments.CountAsync( + x => + x.CourseSelectionOfferingId == offering.Id && + x.Status == CourseEnrollmentStatus.Enrolled, + cancellationToken); + var effectiveCapacity = CourseSelectionRules.EffectiveCapacity( + offering.Capacity, + eligibility.IsRetake); + if (enrolledCount < effectiveCapacity) + return ConflictProblem("该教学班当前仍有名额,请直接选择课程。"); + + if (existing is null) + { + existing = new CourseEnrollment + { + CourseSelectionOfferingId = offering.Id, + StudentId = student.Id, + Status = CourseEnrollmentStatus.Waitlisted, + EnrollmentType = eligibility.IsRetake + ? EnrollmentType.Retake + : EnrollmentType.Normal, + WaitlistedAt = now + }; + db.CourseEnrollments.Add(existing); + } + else + { + existing.Status = CourseEnrollmentStatus.Waitlisted; + existing.EnrollmentType = eligibility.IsRetake + ? EnrollmentType.Retake + : EnrollmentType.Normal; + existing.WaitlistedAt = now; + existing.WithdrawnAt = null; + } + + await db.SaveChangesAsync(cancellationToken); + var position = await db.CourseEnrollments.CountAsync( + x => + x.CourseSelectionOfferingId == offering.Id && + x.Status == CourseEnrollmentStatus.Waitlisted && + x.WaitlistedAt <= now, + cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Accepted(new + { + existing.Id, + Status = CourseEnrollmentStatus.Waitlisted, + Position = position, + IsRetake = eligibility.IsRetake + }); + }, + cancellationToken, + IsolationLevel.Serializable); + } + [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); + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var student = await CurrentStudentAsync(cancellationToken); + if (student is null) return ProfileNotFound(); + var enrollment = await db.CourseEnrollments + .Include(x => x.CourseSelectionOffering) + .ThenInclude(x => x!.CourseSelectionRound) + .Include(x => x.CourseSelectionOffering) + .ThenInclude(x => x!.TeachingTask) + .ThenInclude(x => x!.Course) + .FirstOrDefaultAsync( + x => x.Id == id && x.StudentId == student.Id, + cancellationToken); + if (enrollment is null) return NotFound(); + var wasEnrolled = enrollment.Status == CourseEnrollmentStatus.Enrolled; + var wasWaitlisted = + enrollment.Status == CourseEnrollmentStatus.Waitlisted; + if (!wasEnrolled && !wasWaitlisted) + return ConflictProblem("该课程已经退选或候补已经结束。"); + if (!CourseSelectionRules.CanWithdraw( + enrollment.CourseSelectionOffering!.CourseSelectionRound!, + DateTime.UtcNow)) + return ConflictProblem("当前批次已停止退课和候补调整。"); + + enrollment.Status = wasWaitlisted + ? CourseEnrollmentStatus.Cancelled + : CourseEnrollmentStatus.Withdrawn; + enrollment.WithdrawnAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + CourseEnrollment? promoted = null; + if (wasEnrolled) + { + promoted = await PromoteNextWaitlistedAsync( + enrollment.CourseSelectionOffering, + cancellationToken); + await db.SaveChangesAsync(cancellationToken); + } + await transaction.CommitAsync(cancellationToken); + return Ok(new + { + CancelledWaitlist = wasWaitlisted, + PromotedStudentId = promoted?.StudentId + }); + }, + cancellationToken, + IsolationLevel.Serializable); } [HttpGet("my-offerings")] @@ -1178,6 +1534,214 @@ public sealed class CourseSelectionsController( .ToListAsync(cancellationToken); } + private async Task EvaluateEnrollmentEligibilityAsync( + Student student, + CourseSelectionOffering offering, + CancellationToken cancellationToken) + { + var round = offering.CourseSelectionRound!; + var task = offering.TeachingTask!; + if (student.Status != StudentStatus.Active) + return new(false, "只有在籍学生可以选课或候补。"); + if (task.Status != TeachingTaskStatus.Published) + return new(false, "该教学班当前不可选。"); + if (!offering.IsOpenToAll && + !await db.TeachingTaskClasses.AnyAsync( + x => + x.TeachingTaskId == task.Id && + x.AdministrativeClassId == student.AdministrativeClassId, + cancellationToken)) + return new(false, "你不属于该教学班的选课对象。"); + + var isRetake = await db.CourseEnrollments.AnyAsync( + x => + x.StudentId == student.Id && + (x.Status == CourseEnrollmentStatus.Enrolled || + x.Status == CourseEnrollmentStatus.Withdrawn) && + x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && + x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != + round.AcademicTermId, + cancellationToken); + 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 new(false, "同一学期不能重复选择相同课程。"); + } + + 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 new( + isRetake, + $"获得名额后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," + + $"超过本轮 {round.MaxCredits:0.#} 学分上限。"); + } + + var candidateEntries = await PublishedScheduleEntries( + round.AcademicTermId, + [task.Id], + cancellationToken); + if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && + candidateEntries.Count == 0) + return new(isRetake, "该教学班尚未发布课表,暂时不能选课或候补。"); + + 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)) + { + if (!isRetake) + return new(false, "该教学班与已选课程的上课时间冲突。"); + var overlap = CourseSelectionRules.CalculateScheduleOverlap( + candidateEntries, + selectedEntries); + if (overlap > 50) + { + return new( + true, + $"重修课程时间冲突 {overlap:F0}%,超过 50% 上限。"); + } + } + + return new(isRetake, null); + } + + private async Task PromoteNextWaitlistedAsync( + CourseSelectionOffering offering, + CancellationToken cancellationToken) + { + var round = offering.CourseSelectionRound!; + if (!CourseSelectionRules.CanPromoteWaitlist(round, DateTime.UtcNow)) + return null; + + var waitlist = await db.CourseEnrollments + .Include(x => x.Student) + .ThenInclude(x => x!.AdministrativeClass) + .Where(x => + x.CourseSelectionOfferingId == offering.Id && + x.Status == CourseEnrollmentStatus.Waitlisted) + .OrderBy(x => x.WaitlistedAt) + .ThenBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + if (waitlist.Count == 0) return null; + + var enrolledCount = await db.CourseEnrollments.CountAsync( + x => + x.CourseSelectionOfferingId == offering.Id && + x.Status == CourseEnrollmentStatus.Enrolled, + cancellationToken); + var now = DateTime.UtcNow; + foreach (var candidate in waitlist) + { + var eligibility = await EvaluateEnrollmentEligibilityAsync( + candidate.Student!, + offering, + cancellationToken); + if (eligibility.Error is not null) + { + candidate.Status = CourseEnrollmentStatus.Expired; + candidate.WithdrawnAt = now; + if (candidate.Student!.UserId is Guid invalidUserId) + { + db.Notifications.Add(new Notification + { + UserId = invalidUserId, + Title = "课程候补已失效", + Content = + $"“{offering.TeachingTask!.Course!.Name}”候补未能递补:" + + eligibility.Error, + LinkUrl = "/course-selections" + }); + } + continue; + } + + var effectiveCapacity = CourseSelectionRules.EffectiveCapacity( + offering.Capacity, + eligibility.IsRetake); + if (enrolledCount >= effectiveCapacity) + continue; + + candidate.Status = CourseEnrollmentStatus.Enrolled; + candidate.EnrollmentType = eligibility.IsRetake + ? EnrollmentType.Retake + : EnrollmentType.Normal; + candidate.EnrolledAt = now; + candidate.WithdrawnAt = null; + await ExpireOtherWaitlistsForCourseAsync( + candidate.StudentId, + offering.TeachingTask!.CourseId, + round.AcademicTermId, + candidate.Id, + now, + cancellationToken); + if (candidate.Student!.UserId is Guid userId) + { + db.Notifications.Add(new Notification + { + UserId = userId, + Title = "课程候补递补成功", + Content = + $"“{offering.TeachingTask.Course!.Name}”已释放名额," + + "你已自动进入正式选课名单。", + LinkUrl = "/course-selections" + }); + } + return candidate; + } + + return null; + } + + private async Task ExpireOtherWaitlistsForCourseAsync( + Guid studentId, + Guid courseId, + Guid academicTermId, + Guid retainedEnrollmentId, + DateTime now, + CancellationToken cancellationToken) + { + var otherWaitlists = await db.CourseEnrollments + .Where(x => + x.Id != retainedEnrollmentId && + x.StudentId == studentId && + x.Status == CourseEnrollmentStatus.Waitlisted && + x.CourseSelectionOffering!.TeachingTask!.CourseId == courseId && + x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == + academicTermId) + .ToListAsync(cancellationToken); + foreach (var waitlist in otherWaitlists) + { + waitlist.Status = CourseEnrollmentStatus.Expired; + waitlist.WithdrawnAt = now; + } + } + private async Task SaveAsync( Guid id, bool created, @@ -1207,6 +1771,8 @@ public sealed class CourseSelectionsController( private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed record EnrollmentEligibility(bool IsRetake, string? Error); } public sealed record CourseSelectionRoundRequest( @@ -1242,11 +1808,13 @@ public sealed record StudentOfferingDto( IEnumerable TeacherNames, int Capacity, int EnrolledCount, + int WaitlistedCount, bool IsOpenToAll, CourseEnrollmentStatus? EnrollmentStatus, bool IsFlexible, bool IsRetake, - IEnumerable Schedules); + IEnumerable Schedules, + int? WaitlistPosition); public sealed record StudentScheduleDto( int DayOfWeek, diff --git a/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs b/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs index 867e3c5..e0fc215 100644 --- a/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs @@ -38,6 +38,7 @@ public sealed class CourseEnrollment : EntityBase public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled; public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal; public DateTime EnrolledAt { get; set; } = DateTime.UtcNow; + public DateTime? WaitlistedAt { get; set; } public DateTime? WithdrawnAt { get; set; } } @@ -51,7 +52,10 @@ public enum CourseSelectionRoundStatus public enum CourseEnrollmentStatus { Enrolled = 1, - Withdrawn = 2 + Withdrawn = 2, + Waitlisted = 3, + Cancelled = 4, + Expired = 5 } public enum EnrollmentType diff --git a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs index e816ed4..39d700f 100644 --- a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs +++ b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs @@ -14,6 +14,10 @@ public static class CourseSelectionRules round.Status == CourseSelectionRoundStatus.Open && nowUtc <= round.WithdrawalEndsAt; + public static bool CanPromoteWaitlist(CourseSelectionRound round, DateTime nowUtc) => + round.Status == CourseSelectionRoundStatus.Open && + nowUtc <= round.WithdrawalEndsAt; + public static bool SupportsProxyEnrollment(CourseNature nature) => nature == CourseNature.GeneralRequired; @@ -68,4 +72,7 @@ public static class CourseSelectionRules /// Retake expanded capacity: ceiling(original * 1.15). public static int RetakeCapacity(int originalCapacity) => (int)Math.Ceiling(originalCapacity * 1.15); + + public static int EffectiveCapacity(int originalCapacity, bool isRetake) => + isRetake ? RetakeCapacity(originalCapacity) : originalCapacity; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 791bcd7..850e6da 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -500,6 +500,12 @@ public sealed class AppDbContext(DbContextOptions options) entity.HasIndex(x => new { x.CourseSelectionOfferingId, x.StudentId }) .IsUnique(); entity.HasIndex(x => new { x.StudentId, x.Status }); + entity.HasIndex(x => new + { + x.CourseSelectionOfferingId, + x.Status, + x.WaitlistedAt + }); entity.HasOne(x => x.CourseSelectionOffering) .WithMany(x => x.Enrollments) .HasForeignKey(x => x.CourseSelectionOfferingId) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 87d2e3a..df4200c 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -48,6 +48,8 @@ public sealed class DevelopmentSqliteMigrator( "20260725_25_academic_warnings"; private const string AcademicTermArchivingMigration = "20260726_27_academic_term_archiving"; + private const string CourseSelectionWaitlistMigration = + "20260726_28_course_selection_waitlist"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -344,6 +346,19 @@ public sealed class DevelopmentSqliteMigrator( AcademicTermArchivingMigration, academicTermArchivingExists ? [] : AcademicTermArchivingStatements, cancellationToken); + + var courseSelectionWaitlistExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('CourseEnrollments') + WHERE name = 'WaitlistedAt' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + CourseSelectionWaitlistMigration, + courseSelectionWaitlistExists ? [] : CourseSelectionWaitlistStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1589,6 +1604,18 @@ public sealed class DevelopmentSqliteMigrator( """ ]; + private static readonly string[] CourseSelectionWaitlistStatements = + [ + """ + ALTER TABLE "CourseEnrollments" + ADD COLUMN "WaitlistedAt" TEXT NULL; + """, + """ + CREATE INDEX IF NOT EXISTS "IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt" + ON "CourseEnrollments" ("CourseSelectionOfferingId", "Status", "WaitlistedAt"); + """ + ]; + private static readonly string[] CourseAdjustmentsStatements = [ """ diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726170000_CourseSelectionWaitlist.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726170000_CourseSelectionWaitlist.cs new file mode 100644 index 0000000..448cf49 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726170000_CourseSelectionWaitlist.cs @@ -0,0 +1,37 @@ +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +[DbContext(typeof(AppDbContext))] +[Migration("20260726170000_CourseSelectionWaitlist")] +public partial class CourseSelectionWaitlist : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "WaitlistedAt", + table: "CourseEnrollments", + type: "datetime(6)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt", + table: "CourseEnrollments", + columns: new[] { "CourseSelectionOfferingId", "Status", "WaitlistedAt" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt", + table: "CourseEnrollments"); + + migrationBuilder.DropColumn( + name: "WaitlistedAt", + table: "CourseEnrollments"); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 1492e59..718d8bb 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -732,6 +732,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("UpdatedAt") .HasColumnType("datetime(6)"); + b.Property("WaitlistedAt") + .HasColumnType("datetime(6)"); + b.Property("WithdrawnAt") .HasColumnType("datetime(6)"); @@ -740,6 +743,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasIndex("CourseSelectionOfferingId", "StudentId") .IsUnique(); + b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt"); + b.HasIndex("StudentId", "Status"); b.ToTable("CourseEnrollments"); diff --git a/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs index a4f774c..b989cf1 100644 --- a/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs +++ b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs @@ -31,6 +31,21 @@ public sealed class CourseSelectionRulesTests Assert.False(CourseSelectionRules.CanWithdraw(round, now.AddHours(1))); } + [Fact] + public void Waitlist_promotion_uses_withdrawal_window_and_retake_capacity() + { + var now = new DateTime(2026, 7, 24, 8, 0, 0, DateTimeKind.Utc); + var round = CreateRound(now.AddHours(-1), now.AddHours(1)); + round.WithdrawalEndsAt = now.AddHours(2); + + Assert.True(CourseSelectionRules.CanPromoteWaitlist(round, now)); + Assert.Equal(60, CourseSelectionRules.EffectiveCapacity(60, false)); + Assert.Equal(69, CourseSelectionRules.EffectiveCapacity(60, true)); + + round.Status = CourseSelectionRoundStatus.Closed; + Assert.False(CourseSelectionRules.CanPromoteWaitlist(round, now)); + } + [Fact] public void Schedule_conflict_detects_overlapping_weeks_and_periods() { diff --git a/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs b/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs new file mode 100644 index 0000000..0591cb9 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/CourseSelectionsControllerTests.cs @@ -0,0 +1,303 @@ +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class CourseSelectionsControllerTests +{ + [Fact] + public async Task Withdrawal_skips_invalid_waiter_and_promotes_next_student() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + var data = await SeedFullOfferingAsync(db); + + var firstController = new CourseSelectionsController( + db, + new StudentDataScope(data.FirstWaiterUserId)); + var firstResult = await firstController.JoinWaitlist( + new StudentEnrollmentRequest(data.OfferingId), + CancellationToken.None); + Assert.IsType(firstResult); + + var secondController = new CourseSelectionsController( + db, + new StudentDataScope(data.SecondWaiterUserId)); + var secondResult = await secondController.JoinWaitlist( + new StudentEnrollmentRequest(data.OfferingId), + CancellationToken.None); + Assert.IsType(secondResult); + + db.ChangeTracker.Clear(); + var firstWaiter = await db.Students.SingleAsync( + x => x.UserId == data.FirstWaiterUserId); + firstWaiter.Status = StudentStatus.Suspended; + await db.SaveChangesAsync(); + + var enrolledController = new CourseSelectionsController( + db, + new StudentDataScope(data.EnrolledUserId)); + var withdrawResult = await enrolledController.Withdraw( + data.EnrollmentId, + CancellationToken.None); + Assert.IsType(withdrawResult); + + db.ChangeTracker.Clear(); + var enrollments = await db.CourseEnrollments + .OrderBy(x => x.StudentId) + .ToListAsync(); + Assert.Equal( + CourseEnrollmentStatus.Withdrawn, + enrollments.Single(x => x.StudentId == data.EnrolledStudentId).Status); + Assert.Equal( + CourseEnrollmentStatus.Expired, + enrollments.Single(x => x.StudentId == data.FirstWaiterStudentId).Status); + Assert.Equal( + CourseEnrollmentStatus.Enrolled, + enrollments.Single(x => x.StudentId == data.SecondWaiterStudentId).Status); + Assert.Equal( + 2, + await db.Notifications.CountAsync(x => + x.Title == "课程候补已失效" || + x.Title == "课程候补递补成功")); + } + + [Fact] + public async Task Closing_round_expires_remaining_waitlist_and_notifies_student() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + var data = await SeedFullOfferingAsync(db); + + var studentController = new CourseSelectionsController( + db, + new StudentDataScope(data.FirstWaiterUserId)); + Assert.IsType(await studentController.JoinWaitlist( + new StudentEnrollmentRequest(data.OfferingId), + CancellationToken.None)); + + var adminController = new CourseSelectionsController( + db, + new AdminDataScope()); + Assert.IsType(await adminController.CloseRound( + data.RoundId, + CancellationToken.None)); + + db.ChangeTracker.Clear(); + var waitlist = await db.CourseEnrollments.SingleAsync( + x => x.StudentId == data.FirstWaiterStudentId); + Assert.Equal(CourseEnrollmentStatus.Expired, waitlist.Status); + Assert.NotNull(waitlist.WithdrawnAt); + Assert.True(await db.Notifications.AnyAsync(x => + x.UserId == data.FirstWaiterUserId && + x.Title == "课程候补已结束")); + } + + private static async Task SeedFullOfferingAsync(AppDbContext db) + { + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major + { + Code = "080901", + Name = "计算机科学与技术", + CollegeId = college.Id, + DegreeType = "工学学士" + }; + var administrativeClass = new AdministrativeClass + { + Code = "CS2026-01", + Name = "计科 2026-1 班", + MajorId = major.Id, + Grade = 2026 + }; + var term = new AcademicTerm + { + Code = "2026-1", + Name = "2026—2027 学年第一学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 20) + }; + var course = new Course + { + Code = "CS101", + Name = "程序设计基础", + CollegeId = college.Id, + Credits = 4, + TotalHours = 64, + LectureHours = 48, + PracticeHours = 16, + Nature = CourseNature.MajorRequired, + AssessmentMethod = AssessmentMethod.Examination + }; + var task = new TeachingTask + { + TaskNumber = "2026-1-CS101-01", + Name = "程序设计基础教学班", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 1, + SchedulingMode = TeachingTaskSchedulingMode.Flexible, + Status = TeachingTaskStatus.Published, + Classes = + [ + new TeachingTaskClass + { + AdministrativeClassId = administrativeClass.Id + } + ] + }; + var round = new CourseSelectionRound + { + AcademicTermId = term.Id, + Name = "第一轮选课", + StartsAt = DateTime.UtcNow.AddHours(-1), + EndsAt = DateTime.UtcNow.AddHours(1), + WithdrawalEndsAt = DateTime.UtcNow.AddHours(2), + MaxCredits = 30, + Status = CourseSelectionRoundStatus.Open + }; + var offering = new CourseSelectionOffering + { + CourseSelectionRoundId = round.Id, + TeachingTaskId = task.Id, + Capacity = 1 + }; + var enrolledUser = CreateUser("2026001", "已选学生"); + var firstWaiterUser = CreateUser("2026002", "候补甲"); + var secondWaiterUser = CreateUser("2026003", "候补乙"); + var enrolledStudent = CreateStudent( + "2026001", + "已选学生", + enrolledUser.Id, + administrativeClass.Id); + var firstWaiter = CreateStudent( + "2026002", + "候补甲", + firstWaiterUser.Id, + administrativeClass.Id); + var secondWaiter = CreateStudent( + "2026003", + "候补乙", + secondWaiterUser.Id, + administrativeClass.Id); + var enrollment = new CourseEnrollment + { + CourseSelectionOfferingId = offering.Id, + StudentId = enrolledStudent.Id + }; + + db.Add(college); + await db.SaveChangesAsync(); + + db.AddRange( + major, + term, + course, + enrolledUser, + firstWaiterUser, + secondWaiterUser); + await db.SaveChangesAsync(); + + db.Add(administrativeClass); + await db.SaveChangesAsync(); + + db.AddRange( + enrolledStudent, + firstWaiter, + secondWaiter); + await db.SaveChangesAsync(); + + db.AddRange(task, round); + await db.SaveChangesAsync(); + + db.Add(offering); + await db.SaveChangesAsync(); + + db.Add(enrollment); + await db.SaveChangesAsync(); + return new SeededSelection( + round.Id, + offering.Id, + enrollment.Id, + enrolledStudent.Id, + firstWaiter.Id, + secondWaiter.Id, + enrolledUser.Id, + firstWaiterUser.Id, + secondWaiterUser.Id); + } + + private static ApplicationUser CreateUser(string userName, string displayName) => + new() + { + Id = Guid.NewGuid(), + UserName = userName, + NormalizedUserName = userName, + DisplayName = displayName + }; + + private static Student CreateStudent( + string number, + string name, + Guid userId, + Guid classId) => + new() + { + StudentNumber = number, + Name = name, + UserId = userId, + AdministrativeClassId = classId, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1), + Status = StudentStatus.Active + }; + + private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + userId, + "测试学生", + null, + DataScope.Self, + new HashSet([SystemRoles.Student])); + } + + private sealed class AdminDataScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "测试管理员", + null, + DataScope.All, + new HashSet([SystemRoles.SuperAdmin])); + } + + private sealed record SeededSelection( + Guid RoundId, + Guid OfferingId, + Guid EnrollmentId, + Guid EnrolledStudentId, + Guid FirstWaiterStudentId, + Guid SecondWaiterStudentId, + Guid EnrolledUserId, + Guid FirstWaiterUserId, + Guid SecondWaiterUserId); +} diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 168578f..62d5e18 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -8,7 +8,7 @@ namespace Jiaowu.Api.Tests; public sealed class MySqlMigrationTests { private const string LatestMigration = - "20260726022634_AttendanceCheckIn"; + "20260726170000_CourseSelectionWaitlist"; [Fact] public void Production_migration_is_discoverable_and_generates_mysql_sql() @@ -27,6 +27,10 @@ public sealed class MySqlMigrationTests Assert.Contains("CREATE TABLE `WarningRules`", script); Assert.Contains("ADD `CheckInMethod` int NOT NULL DEFAULT 1", script); Assert.Contains("CREATE UNIQUE INDEX `IX_AttendanceSheets_CheckInToken`", script); + Assert.Contains("ADD `WaitlistedAt` datetime(6) NULL", script); + Assert.Contains( + "CREATE INDEX `IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt`", + script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("DEFAULT 1", script); Assert.DoesNotContain("0001-01-01", script); diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue index f250733..b358229 100644 --- a/web/src/views/CourseSelectionView.vue +++ b/web/src/views/CourseSelectionView.vue @@ -114,8 +114,8 @@ function conflictingSelectedCourses(offering: any) { return selectedOfferings.value .filter((selected) => selected.id !== offering.id - && selected.schedules.some((existing: any) => - offering.schedules.some((candidate: any) => + && (selected.schedules ?? []).some((existing: any) => + (offering.schedules ?? []).some((candidate: any) => schedulesOverlap(candidate, existing), ), ), @@ -123,14 +123,10 @@ function conflictingSelectedCourses(offering: any) { .map((item) => item.courseName) } -function offeringBlockReason(offering: any) { - if (offering.enrollmentStatus === 'Enrolled') return '' +function offeringEligibilityReason(offering: any) { + if (['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus)) return '' if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内' - const effectiveCap = offering.isRetake - ? Math.ceil(offering.capacity * 1.15) - : offering.capacity - if (offering.enrolledCount >= effectiveCap) return '教学班名额已满' - if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布' + if (!offering.isFlexible && !(offering.schedules ?? []).length) return '正式课表尚未发布' if (!offering.isRetake && selectedOfferings.value.some((item) => item.id !== offering.id && item.courseCode === offering.courseCode, )) return '本学期已选择同一课程' @@ -147,9 +143,26 @@ function offeringBlockReason(offering: any) { return '' } +function effectiveCapacity(offering: any) { + return offering.isRetake + ? Math.ceil(offering.capacity * 1.15) + : offering.capacity +} + +function isOfferingFull(offering: any) { + return offering.enrolledCount >= effectiveCapacity(offering) +} + +function offeringBlockReason(offering: any) { + const eligibilityReason = offeringEligibilityReason(offering) + if (eligibilityReason) return eligibilityReason + if (isOfferingFull(offering)) return '教学班名额已满,可加入候补' + return '' +} + function calcRetakeOverlapOk(offering: any): boolean { let totalPeriods = 0, overlapPeriods = 0 - for (const candidate of offering.schedules) { + for (const candidate of (offering.schedules ?? [])) { totalPeriods += candidate.periodCount for (const selected of selectedOfferings.value) { for (const existing of (selected.schedules ?? [])) { @@ -179,12 +192,15 @@ const filteredOfferings = computed(() => { if (offeringStatus.value === 'Selected') { return offering.enrollmentStatus === 'Enrolled' } - const blocked = Boolean(offeringBlockReason(offering)) + if (offeringStatus.value === 'Waitlisted') { + return offering.enrollmentStatus === 'Waitlisted' + } + const blocked = Boolean(offeringEligibilityReason(offering)) if (offeringStatus.value === 'Selectable') { - return offering.enrollmentStatus !== 'Enrolled' && !blocked + return !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && !blocked } if (offeringStatus.value === 'Blocked') { - return offering.enrollmentStatus !== 'Enrolled' && blocked + return !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) && blocked } return true }) @@ -328,6 +344,8 @@ async function loadRounds(keepSelection = true) { async function selectRound(round: any) { selectedRound.value = round + offerings.value = [] + enrollments.value = [] previewOfferingId.value = '' detailLoading.value = true try { @@ -652,6 +670,25 @@ async function removeFromRoster(student: any) { } } +async function removeFromWaitlist(student: any) { + if (!roster.value) return + try { + await ElMessageBox.confirm( + `确认将 ${student.studentNumber} ${student.name} 移出“${roster.value.courseName}”候补队列?`, + '调整候补队列', + { type: 'warning', confirmButtonText: '确认移出', cancelButtonText: '取消' }, + ) + await http.delete( + `/course-selections/offerings/${roster.value.id}/waitlist/${student.id}`, + ) + ElMessage.success('已移出候补队列') + await loadRoster(roster.value.id) + if (selectedRound.value) await selectRound(selectedRound.value) + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) + } +} + async function enroll(offering: any) { try { await http.post('/course-selections/student/enrollments', { @@ -664,6 +701,18 @@ async function enroll(offering: any) { } } +async function joinWaitlist(offering: any) { + try { + const { data } = await http.post('/course-selections/student/waitlist', { + offeringId: offering.id, + }) + ElMessage.success(`已加入“${offering.courseName}”候补,当前第 ${data.position} 位`) + await selectRound(selectedRound.value) + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + function togglePreview(offering: any) { previewOfferingId.value = previewOfferingId.value === offering.id ? '' @@ -691,6 +740,27 @@ async function withdraw(offering: any) { } } +async function cancelWaitlist(offering: any) { + const enrollment = enrollments.value.find( + (item) => + item.courseSelectionOfferingId === offering.id && + item.status === 'Waitlisted', + ) + if (!enrollment) return + try { + await ElMessageBox.confirm( + `确定取消“${offering.courseName}”候补吗?取消后将失去当前顺位。`, + '取消候补', + { type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '继续候补' }, + ) + await http.delete(`/course-selections/student/enrollments/${enrollment.id}`) + ElMessage.success('已取消候补') + await selectRound(selectedRound.value) + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) + } +} + onMounted(async () => { try { if (isManager.value) terms.value = (await http.get('/base-data/terms')).data @@ -814,9 +884,12 @@ onMounted(async () => { - + @@ -962,6 +1035,7 @@ onMounted(async () => { :options="[ { label: '全部', value: 'All' }, { label: '可选', value: 'Selectable' }, + { label: '候补', value: 'Waitlisted' }, { label: '已选', value: 'Selected' }, { label: '受限', value: 'Blocked' }, ]" @@ -977,8 +1051,10 @@ onMounted(async () => { 'offering-ticket', { selected: offering.enrollmentStatus === 'Enrolled', + waitlisted: offering.enrollmentStatus === 'Waitlisted', previewing: offering.id === previewOfferingId, - blocked: offering.enrollmentStatus !== 'Enrolled' && offeringBlockReason(offering), + blocked: !['Enrolled', 'Waitlisted'].includes(offering.enrollmentStatus) + && offeringEligibilityReason(offering), }, ]" > @@ -994,19 +1070,24 @@ onMounted(async () => { 已选 + 候补第 {{ offering.waitlistPosition }} 位
-
+
{{ formatSchedule(schedule) }}
非排时课程 · 不占正常时间与场地 - 课表尚未发布 + 课表尚未发布
{{ offeringBlockReason(offering) }} @@ -1021,9 +1102,12 @@ onMounted(async () => { 剩余 {{ Math.max(0, offering.capacity - offering.enrolledCount) }} / {{ offering.capacity }} 席
+ + 当前候补 {{ offering.waitlistedCount }} 人 +
{ :disabled="selectedRound.status !== 'Open'" @click="withdraw(offering)" >退选 + 取消候补 + 加入候补 {{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }} @@ -1144,7 +1240,10 @@ onMounted(async () => {
{{ roster.courseCode }} · {{ roster.taskNumber }} {{ roster.taskName }} - {{ roster.enrolledCount }} / {{ roster.capacity }} 人 + + 正式 {{ roster.enrolledCount }} / {{ roster.capacity }} 人 + +
{ +
+
+
+ WAITLIST + 候补队列 +
+ 退课释放名额后,系统按顺位重新校验并自动递补 +
+ + + + + + + + + + + + + +
@@ -1313,4 +1441,24 @@ onMounted(async () => {