已完成“选课候补与自动递补”完整流程。

学生端:满员后可加入/取消候补,展示候补顺位和当前候补人数。
自动递补:退课释放名额后,按候补时间顺序重新校验学籍、范围、学分及时间冲突;不合格者自动失效并继续下一位。
管理端:教学班显示候补人数,名单抽屉提供候补队列及移出操作。
状态通知:递补成功、资格失效、批次关闭、管理员移出均会通知学生。
并发安全:退课和递补放在 Serializable 事务中执行。
数据库:已补充 SQLite 开发迁移和 MySQL 正式迁移。
This commit is contained in:
2026-07-26 15:39:50 +08:00 Unverified
parent 65eed820a7
commit 56b13b3adb
12 changed files with 1203 additions and 79 deletions
@@ -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<AppDbContext>()
.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<AcceptedResult>(firstResult);
var secondController = new CourseSelectionsController(
db,
new StudentDataScope(data.SecondWaiterUserId));
var secondResult = await secondController.JoinWaitlist(
new StudentEnrollmentRequest(data.OfferingId),
CancellationToken.None);
Assert.IsType<AcceptedResult>(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<OkObjectResult>(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<AppDbContext>()
.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<AcceptedResult>(await studentController.JoinWaitlist(
new StudentEnrollmentRequest(data.OfferingId),
CancellationToken.None));
var adminController = new CourseSelectionsController(
db,
new AdminDataScope());
Assert.IsType<OkObjectResult>(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<SeededSelection> 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<string>([SystemRoles.Student]));
}
private sealed class AdminDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
private sealed record SeededSelection(
Guid RoundId,
Guid OfferingId,
Guid EnrollmentId,
Guid EnrolledStudentId,
Guid FirstWaiterStudentId,
Guid SecondWaiterStudentId,
Guid EnrolledUserId,
Guid FirstWaiterUserId,
Guid SecondWaiterUserId);
}