已把选课的“年级划分”和限制规则补成完整闭环:
选课批次可指定多个适用年级;留空兼容原有“全部年级”。 新增可选的“最多课程门数”,与原有学分上限并行。 年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。 普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。 管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。 新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
This commit is contained in:
@@ -46,7 +46,24 @@ public sealed class CourseSelectionsController(
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (currentUserDataScope.Current.IsInRole(SystemRoles.Student))
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var studentGrade = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => (int?)x.AdministrativeClass!.Grade)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft);
|
||||
if (studentGrade.HasValue)
|
||||
{
|
||||
source = source.Where(x =>
|
||||
!x.EligibleGrades.Any() ||
|
||||
x.EligibleGrades.Any(item => item.Grade == studentGrade.Value));
|
||||
}
|
||||
else
|
||||
{
|
||||
source = source.Where(_ => false);
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(await source
|
||||
@@ -64,6 +81,10 @@ public sealed class CourseSelectionsController(
|
||||
x.EndsAt,
|
||||
x.WithdrawalEndsAt,
|
||||
x.MaxCredits,
|
||||
x.MaxCourseCount,
|
||||
EligibleGrades = x.EligibleGrades
|
||||
.OrderBy(item => item.Grade)
|
||||
.Select(item => item.Grade),
|
||||
x.Status,
|
||||
IsAvailableNow =
|
||||
x.Status == CourseSelectionRoundStatus.Open &&
|
||||
@@ -76,6 +97,20 @@ public sealed class CourseSelectionsController(
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("configuration-options")]
|
||||
[Authorize(Roles = RoundManagers)]
|
||||
public async Task<ActionResult> GetConfigurationOptions(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var grades = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.Select(x => x.Grade)
|
||||
.Distinct()
|
||||
.OrderByDescending(x => x)
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Grades = grades });
|
||||
}
|
||||
|
||||
[HttpPost("rounds")]
|
||||
[Authorize(Roles = RoundManagers)]
|
||||
public async Task<ActionResult> CreateRound(
|
||||
@@ -92,6 +127,10 @@ public sealed class CourseSelectionsController(
|
||||
EndsAt = request.EndsAt.ToUniversalTime(),
|
||||
WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(),
|
||||
MaxCredits = request.MaxCredits,
|
||||
MaxCourseCount = request.MaxCourseCount,
|
||||
EligibleGrades = NormalizeGrades(request.EligibleGrades)
|
||||
.Select(grade => new CourseSelectionRoundGrade { Grade = grade })
|
||||
.ToList(),
|
||||
Notes = Normalize(request.Notes)
|
||||
};
|
||||
db.CourseSelectionRounds.Add(round);
|
||||
@@ -105,7 +144,9 @@ public sealed class CourseSelectionsController(
|
||||
CourseSelectionRoundRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken);
|
||||
var round = await db.CourseSelectionRounds
|
||||
.Include(x => x.EligibleGrades)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (round is null) return NotFound();
|
||||
if (round.Status != CourseSelectionRoundStatus.Draft)
|
||||
return ConflictProblem("只有草稿选课批次可以修改。");
|
||||
@@ -117,6 +158,20 @@ public sealed class CourseSelectionsController(
|
||||
round.EndsAt = request.EndsAt.ToUniversalTime();
|
||||
round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
|
||||
round.MaxCredits = request.MaxCredits;
|
||||
round.MaxCourseCount = request.MaxCourseCount;
|
||||
var requestedGrades = NormalizeGrades(request.EligibleGrades);
|
||||
var requestedGradeSet = requestedGrades.ToHashSet();
|
||||
db.CourseSelectionRoundGrades.RemoveRange(
|
||||
round.EligibleGrades.Where(x => !requestedGradeSet.Contains(x.Grade)));
|
||||
foreach (var grade in requestedGrades.Where(grade =>
|
||||
round.EligibleGrades.All(x => x.Grade != grade)))
|
||||
{
|
||||
round.EligibleGrades.Add(new CourseSelectionRoundGrade
|
||||
{
|
||||
CourseSelectionRoundId = round.Id,
|
||||
Grade = grade
|
||||
});
|
||||
}
|
||||
round.Notes = Normalize(request.Notes);
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
@@ -356,6 +411,7 @@ public sealed class CourseSelectionsController(
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
Grade = x.Student.AdministrativeClass.Grade,
|
||||
x.EnrolledAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -373,6 +429,7 @@ public sealed class CourseSelectionsController(
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
Grade = x.Student.AdministrativeClass.Grade,
|
||||
x.WaitlistedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -385,6 +442,7 @@ public sealed class CourseSelectionsController(
|
||||
item.Name,
|
||||
item.ClassName,
|
||||
item.MajorName,
|
||||
item.Grade,
|
||||
item.WaitlistedAt,
|
||||
Position = index + 1
|
||||
})
|
||||
@@ -435,12 +493,17 @@ public sealed class CourseSelectionsController(
|
||||
x.Id,
|
||||
x.IsOpenToAll,
|
||||
CourseNature = x.TeachingTask!.Course!.Nature,
|
||||
CollegeId = x.TeachingTask.Course.CollegeId,
|
||||
RoundStatus = x.CourseSelectionRound!.Status,
|
||||
EligibleGrades = x.CourseSelectionRound.EligibleGrades
|
||||
.Select(item => item.Grade),
|
||||
ClassIds = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClassId)
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (offering is null) return NotFound();
|
||||
if (!currentUserDataScope.Current.CanAccessCollege(offering.CollegeId))
|
||||
return Forbid();
|
||||
if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
||||
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
||||
@@ -453,7 +516,13 @@ public sealed class CourseSelectionsController(
|
||||
enrollment.CourseSelectionOfferingId == id &&
|
||||
enrollment.StudentId == x.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
||||
if (!offering.IsOpenToAll)
|
||||
if (!forceMode && offering.EligibleGrades.Any())
|
||||
{
|
||||
source = source.WhereIn(
|
||||
offering.EligibleGrades,
|
||||
x => x.AdministrativeClass!.Grade);
|
||||
}
|
||||
if (!forceMode && !offering.IsOpenToAll)
|
||||
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
@@ -475,6 +544,7 @@ public sealed class CourseSelectionsController(
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
ClassName = x.AdministrativeClass!.Name,
|
||||
Grade = x.AdministrativeClass.Grade,
|
||||
MajorName = x.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||
})
|
||||
@@ -501,6 +571,7 @@ public sealed class CourseSelectionsController(
|
||||
db.ChangeTracker.Clear();
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.ThenInclude(x => x!.EligibleGrades)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -526,6 +597,18 @@ public sealed class CourseSelectionsController(
|
||||
var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
|
||||
if (inactive is not null)
|
||||
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
|
||||
var eligibleGrades = round.EligibleGrades
|
||||
.Select(x => x.Grade)
|
||||
.ToArray();
|
||||
var wrongGrade = students.FirstOrDefault(student =>
|
||||
!CourseSelectionRules.IsGradeEligible(
|
||||
eligibleGrades,
|
||||
student.AdministrativeClass!.Grade));
|
||||
if (wrongGrade is not null)
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"学生 {wrongGrade.StudentNumber} {wrongGrade.Name} 所在年级不属于本轮选课对象。");
|
||||
}
|
||||
var outOfScope = students.FirstOrDefault(student =>
|
||||
!offering.IsOpenToAll &&
|
||||
!task.Classes.Any(item =>
|
||||
@@ -600,6 +683,20 @@ public sealed class CourseSelectionsController(
|
||||
return ConflictProblem(
|
||||
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||
}
|
||||
var selectedCourseCount = await db.CourseEnrollments.CountAsync(
|
||||
x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id,
|
||||
cancellationToken);
|
||||
if (CourseSelectionRules.HasReachedCourseLimit(
|
||||
round.MaxCourseCount,
|
||||
selectedCourseCount))
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"学生 {student.StudentNumber} {student.Name} 已达到本轮最多 " +
|
||||
$"{round.MaxCourseCount} 门课程限制。");
|
||||
}
|
||||
|
||||
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -685,6 +782,8 @@ public sealed class CourseSelectionsController(
|
||||
if (offering is null) return NotFound();
|
||||
var round = offering.CourseSelectionRound!;
|
||||
var task = offering.TeachingTask!;
|
||||
if (!currentUserDataScope.Current.CanAccessCollege(task.Course!.CollegeId))
|
||||
return Forbid();
|
||||
|
||||
if (round.Status == CourseSelectionRoundStatus.Draft)
|
||||
return ConflictProblem("选课批次开放后才能办理强制选课。");
|
||||
@@ -769,6 +868,7 @@ public sealed class CourseSelectionsController(
|
||||
var enrollment = await db.CourseEnrollments
|
||||
.Include(x => x.CourseSelectionOffering)
|
||||
.ThenInclude(x => x!.CourseSelectionRound)
|
||||
.ThenInclude(x => x!.EligibleGrades)
|
||||
.Include(x => x.CourseSelectionOffering)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
@@ -860,9 +960,19 @@ public sealed class CourseSelectionsController(
|
||||
var student = await CurrentStudentAsync(cancellationToken);
|
||||
if (student is null) return ProfileNotFound();
|
||||
var round = await db.CourseSelectionRounds.AsNoTracking()
|
||||
.Include(x => x.EligibleGrades)
|
||||
.FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken);
|
||||
if (round is null || round.Status == CourseSelectionRoundStatus.Draft)
|
||||
return NotFound();
|
||||
var eligibleGrades = round.EligibleGrades
|
||||
.Select(x => x.Grade)
|
||||
.ToArray();
|
||||
if (!CourseSelectionRules.IsGradeEligible(
|
||||
eligibleGrades,
|
||||
student.AdministrativeClass!.Grade))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var offerings = await db.CourseSelectionOfferings.AsNoTracking()
|
||||
.Where(x =>
|
||||
@@ -968,6 +1078,8 @@ public sealed class CourseSelectionsController(
|
||||
round.EndsAt,
|
||||
round.WithdrawalEndsAt,
|
||||
round.MaxCredits,
|
||||
round.MaxCourseCount,
|
||||
EligibleGrades = eligibleGrades,
|
||||
round.Status,
|
||||
IsAvailableNow = CourseSelectionRules.IsSelectionOpen(
|
||||
round,
|
||||
@@ -978,7 +1090,8 @@ public sealed class CourseSelectionsController(
|
||||
student.Id,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
ClassName = student.AdministrativeClass!.Name
|
||||
ClassName = student.AdministrativeClass!.Name,
|
||||
Grade = student.AdministrativeClass.Grade
|
||||
},
|
||||
Offerings = offerings
|
||||
});
|
||||
@@ -1050,6 +1163,7 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.ThenInclude(x => x!.EligibleGrades)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -1063,6 +1177,12 @@ public sealed class CourseSelectionsController(
|
||||
return ConflictProblem("当前不在该选课批次的开放时间内。");
|
||||
if (task.Status != TeachingTaskStatus.Published)
|
||||
return ConflictProblem("该教学班当前不可选。");
|
||||
if (!CourseSelectionRules.IsGradeEligible(
|
||||
round.EligibleGrades.Select(x => x.Grade),
|
||||
student.AdministrativeClass!.Grade))
|
||||
{
|
||||
return ConflictProblem("你所在的年级不属于本轮选课对象。");
|
||||
}
|
||||
if (!offering.IsOpenToAll &&
|
||||
!task.Classes.Any(x =>
|
||||
x.AdministrativeClassId == student.AdministrativeClassId))
|
||||
@@ -1128,6 +1248,19 @@ public sealed class CourseSelectionsController(
|
||||
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
||||
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||
}
|
||||
var selectedCourseCount = await db.CourseEnrollments.CountAsync(
|
||||
x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id,
|
||||
cancellationToken);
|
||||
if (CourseSelectionRules.HasReachedCourseLimit(
|
||||
round.MaxCourseCount,
|
||||
selectedCourseCount))
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"你已达到本轮最多 {round.MaxCourseCount} 门课程限制。");
|
||||
}
|
||||
|
||||
// Schedule conflict check
|
||||
var candidateEntries = await PublishedScheduleEntries(
|
||||
@@ -1209,6 +1342,7 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.ThenInclude(x => x!.EligibleGrades)
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -1317,6 +1451,7 @@ public sealed class CourseSelectionsController(
|
||||
var enrollment = await db.CourseEnrollments
|
||||
.Include(x => x.CourseSelectionOffering)
|
||||
.ThenInclude(x => x!.CourseSelectionRound)
|
||||
.ThenInclude(x => x!.EligibleGrades)
|
||||
.Include(x => x.CourseSelectionOffering)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.ThenInclude(x => x!.Course)
|
||||
@@ -1504,6 +1639,11 @@ public sealed class CourseSelectionsController(
|
||||
return ValidationProblem("选课开始时间必须早于结束时间。");
|
||||
if (withdrawalEndsAt < endsAt)
|
||||
return ValidationProblem("退课截止时间不能早于选课结束时间。");
|
||||
var eligibleGrades = NormalizeGrades(request.EligibleGrades);
|
||||
if (eligibleGrades.Length > 20)
|
||||
return ValidationProblem("单个选课批次最多配置 20 个适用年级。");
|
||||
if (eligibleGrades.Any(grade => grade is < 2000 or > 2200))
|
||||
return ValidationProblem("适用年级必须在 2000—2200 之间。");
|
||||
if (!await db.AcademicTerms.AnyAsync(
|
||||
x => x.Id == request.AcademicTermId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
@@ -1545,6 +1685,12 @@ public sealed class CourseSelectionsController(
|
||||
return new(false, "只有在籍学生可以选课或候补。");
|
||||
if (task.Status != TeachingTaskStatus.Published)
|
||||
return new(false, "该教学班当前不可选。");
|
||||
if (!CourseSelectionRules.IsGradeEligible(
|
||||
round.EligibleGrades.Select(x => x.Grade),
|
||||
student.AdministrativeClass!.Grade))
|
||||
{
|
||||
return new(false, "你所在的年级不属于本轮选课对象。");
|
||||
}
|
||||
if (!offering.IsOpenToAll &&
|
||||
!await db.TeachingTaskClasses.AnyAsync(
|
||||
x =>
|
||||
@@ -1591,6 +1737,20 @@ public sealed class CourseSelectionsController(
|
||||
$"获得名额后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
|
||||
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||
}
|
||||
var selectedCourseCount = await db.CourseEnrollments.CountAsync(
|
||||
x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id,
|
||||
cancellationToken);
|
||||
if (CourseSelectionRules.HasReachedCourseLimit(
|
||||
round.MaxCourseCount,
|
||||
selectedCourseCount))
|
||||
{
|
||||
return new(
|
||||
isRetake,
|
||||
$"你已达到本轮最多 {round.MaxCourseCount} 门课程限制。");
|
||||
}
|
||||
|
||||
var candidateEntries = await PublishedScheduleEntries(
|
||||
round.AcademicTermId,
|
||||
@@ -1772,6 +1932,9 @@ public sealed class CourseSelectionsController(
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static int[] NormalizeGrades(IEnumerable<int>? values) =>
|
||||
values?.Distinct().OrderBy(x => x).ToArray() ?? [];
|
||||
|
||||
private sealed record EnrollmentEligibility(bool IsRetake, string? Error);
|
||||
}
|
||||
|
||||
@@ -1782,6 +1945,8 @@ public sealed record CourseSelectionRoundRequest(
|
||||
DateTime EndsAt,
|
||||
DateTime WithdrawalEndsAt,
|
||||
[Range(typeof(decimal), "0.5", "99")] decimal MaxCredits,
|
||||
[Range(1, 100)] int? MaxCourseCount,
|
||||
IReadOnlyCollection<int>? EligibleGrades,
|
||||
[MaxLength(500)] string? Notes);
|
||||
|
||||
public sealed record CourseSelectionOfferingRequest(
|
||||
|
||||
Reference in New Issue
Block a user