已把选课的“年级划分”和限制规则补成完整闭环:

选课批次可指定多个适用年级;留空兼容原有“全部年级”。
新增可选的“最多课程门数”,与原有学分上限并行。
年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。
普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。
管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。
新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
This commit is contained in:
2026-07-26 16:12:21 +08:00 Unverified
parent 56b13b3adb
commit f62cd21319
11 changed files with 4920 additions and 25 deletions
@@ -109,6 +109,89 @@ public sealed class CourseSelectionsControllerTests
x.Title == "课程候补已结束"));
}
[Fact]
public async Task Student_cannot_join_waitlist_outside_the_round_grade_scope()
{
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);
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
{
CourseSelectionRoundId = data.RoundId,
Grade = 2025
});
await db.SaveChangesAsync();
var controller = new CourseSelectionsController(
db,
new StudentDataScope(data.FirstWaiterUserId));
var result = await controller.JoinWaitlist(
new StudentEnrollmentRequest(data.OfferingId),
CancellationToken.None);
var conflict = Assert.IsType<ConflictObjectResult>(result);
var problem = Assert.IsType<ProblemDetails>(conflict.Value);
Assert.Equal("你所在的年级不属于本轮选课对象。", problem.Detail);
Assert.False(await db.CourseEnrollments.AnyAsync(x =>
x.StudentId == data.FirstWaiterStudentId));
}
[Fact]
public async Task Updating_round_preserves_an_unchanged_grade_without_duplicate_rows()
{
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 round = await db.CourseSelectionRounds.FindAsync(data.RoundId);
round!.Status = CourseSelectionRoundStatus.Draft;
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
{
CourseSelectionRoundId = data.RoundId,
Grade = 2026
});
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var request = new CourseSelectionRoundRequest(
round.AcademicTermId,
"2026 级第一轮选课",
DateTime.UtcNow.AddHours(-1),
DateTime.UtcNow.AddDays(7),
DateTime.UtcNow.AddDays(14),
30,
6,
[2026],
null);
var controller = new CourseSelectionsController(db, new AdminDataScope());
Assert.IsType<NoContentResult>(await controller.UpdateRound(
data.RoundId,
request,
CancellationToken.None));
db.ChangeTracker.Clear();
Assert.IsType<NoContentResult>(await controller.UpdateRound(
data.RoundId,
request,
CancellationToken.None));
Assert.Equal(
1,
await db.CourseSelectionRoundGrades.CountAsync(x =>
x.CourseSelectionRoundId == data.RoundId &&
x.Grade == 2026));
}
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
{
var college = new College { Code = "CS", Name = "计算机学院" };