This commit is contained in:
2026-07-25 15:30:46 +08:00 Unverified
parent 4e7da6709e
commit 9685435da9
7 changed files with 451 additions and 49 deletions
@@ -337,12 +337,13 @@ public sealed class CourseSelectionsController(
}
[HttpGet("offerings/{id:guid}/eligible-students")]
[Authorize(Roles = RoundManagers)]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> GetEligibleStudents(
Guid id,
string? keyword = null,
int page = 1,
int pageSize = 20,
bool forceMode = false,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
@@ -360,10 +361,10 @@ public sealed class CourseSelectionsController(
})
.FirstOrDefaultAsync(cancellationToken);
if (offering is null) return NotFound();
if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理管理员代选。");
return ConflictProblem("选课批次开放后才能办理。");
var source = db.Students.AsNoTracking()
.Where(x =>
@@ -567,6 +568,88 @@ public sealed class CourseSelectionsController(
return Ok(new { EnrolledCount = students.Count });
}
[HttpPost("offerings/{offeringId:guid}/force-enroll")]
[Authorize(Roles = OfferingManagers)]
public async Task<ActionResult> 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<ActionResult> AdminWithdraw(
@@ -638,6 +721,12 @@ public sealed class CourseSelectionsController(
.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 &&
@@ -716,6 +805,7 @@ public sealed class CourseSelectionsController(
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Status,
x.EnrollmentType,
x.EnrolledAt,
x.WithdrawnAt,
CanWithdraw =
@@ -762,6 +852,15 @@ public sealed class CourseSelectionsController(
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 &&
@@ -769,25 +868,34 @@ public sealed class CourseSelectionsController(
cancellationToken);
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
return ConflictProblem("你已经选择了该教学班。");
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount >= offering.Capacity)
var effectiveCapacity = isRetake
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
: offering.Capacity;
if (enrolledCount >= effectiveCapacity)
return ConflictProblem("该教学班名额已满。");
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("同一学期不能重复选择相同课程。");
// 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 &&
@@ -799,16 +907,17 @@ public sealed class CourseSelectionsController(
if (selectedCredits + task.Course!.Credits > round.MaxCredits)
{
return ConflictProblem(
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。");
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
// Schedule conflict check
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
round.AcademicTermId, [task.Id], cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.Where(x =>
x.StudentId == student.Id &&
@@ -819,18 +928,28 @@ public sealed class CourseSelectionsController(
.Distinct()
.ToArrayAsync(cancellationToken);
var selectedEntries = await PublishedScheduleEntries(
round.AcademicTermId,
selectedTaskIds,
cancellationToken);
round.AcademicTermId, selectedTaskIds, cancellationToken);
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
return ConflictProblem("该教学班与已选课程的上课时间冲突。");
{
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
StudentId = student.Id,
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal
};
db.CourseEnrollments.Add(existing);
}
@@ -839,10 +958,11 @@ public sealed class CourseSelectionsController(
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 });
return Created(string.Empty, new { existing.Id, IsRetake = isRetake });
}
[HttpDelete("student/enrollments/{id:guid}")]
@@ -1096,6 +1216,9 @@ public sealed record StudentEnrollmentRequest(Guid OfferingId);
public sealed record AdminEnrollmentRequest(
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
public sealed record ForceEnrollmentRequest(
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
public sealed record StudentOfferingDto(
Guid Id,
Guid TeachingTaskId,
@@ -1109,6 +1232,7 @@ public sealed record StudentOfferingDto(
bool IsOpenToAll,
CourseEnrollmentStatus? EnrollmentStatus,
bool IsFlexible,
bool IsRetake,
IEnumerable<StudentScheduleDto> Schedules);
public sealed record StudentScheduleDto(
@@ -36,6 +36,7 @@ public sealed class CourseEnrollment : EntityBase
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled;
public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal;
public DateTime EnrolledAt { get; set; } = DateTime.UtcNow;
public DateTime? WithdrawnAt { get; set; }
}
@@ -52,3 +53,9 @@ public enum CourseEnrollmentStatus
Enrolled = 1,
Withdrawn = 2
}
public enum EnrollmentType
{
Normal = 1,
Retake = 2
}
@@ -26,4 +26,46 @@ public static class CourseSelectionRules
candidateEntries.Any(candidate =>
selectedEntries.Any(selected =>
ScheduleConflictDetector.TimeOverlaps(candidate, selected)));
/// <summary>
/// Returns the overlap percentage of candidate schedule entries with selected entries.
/// 0 = no conflict, 100 = fully overlapping.
/// Used for retake enrollment where ≤50% overlap is allowed.
/// </summary>
public static double CalculateScheduleOverlap(
IReadOnlyCollection<ScheduleEntry> candidateEntries,
IReadOnlyCollection<ScheduleEntry> selectedEntries)
{
if (candidateEntries.Count == 0 || selectedEntries.Count == 0)
return 0;
int totalCandidatePeriods = 0;
int overlappedPeriods = 0;
foreach (var candidate in candidateEntries)
{
totalCandidatePeriods += candidate.PeriodCount;
foreach (var selected in selectedEntries)
{
if (!ScheduleConflictDetector.TimeOverlaps(candidate, selected))
continue;
// Calculate overlapping periods
var overlapStart = Math.Max(
candidate.StartPeriod, selected.StartPeriod);
var overlapEnd = Math.Min(
candidate.StartPeriod + candidate.PeriodCount,
selected.StartPeriod + selected.PeriodCount);
if (overlapEnd > overlapStart)
overlappedPeriods += overlapEnd - overlapStart;
}
}
return totalCandidatePeriods == 0
? 0
: (double)overlappedPeriods / totalCandidatePeriods * 100;
}
/// <summary>Retake expanded capacity: ceiling(original * 1.15).</summary>
public static int RetakeCapacity(int originalCapacity) =>
(int)Math.Ceiling(originalCapacity * 1.15);
}
@@ -34,6 +34,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_19_flexible_grades";
private const string ExamSchedulingOptimizationMigration =
"20260725_20_exam_scheduling_optimization";
private const string RetakeEnrollmentMigration =
"20260725_21_retake_enrollment";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -225,6 +227,19 @@ public sealed class DevelopmentSqliteMigrator(
? []
: ExamSchedulingOptimizationStatements,
cancellationToken);
var retakeEnrollmentExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('CourseEnrollments')
WHERE name = 'EnrollmentType'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
RetakeEnrollmentMigration,
retakeEnrollmentExists ? [] : RetakeEnrollmentStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1461,4 +1476,12 @@ public sealed class DevelopmentSqliteMigrator(
ON "ExamSessions" ("RequiredBuildingId");
"""
];
private static readonly string[] RetakeEnrollmentStatements =
[
"""
ALTER TABLE "CourseEnrollments"
ADD COLUMN "EnrollmentType" INTEGER NOT NULL DEFAULT 1;
"""
];
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class RetakeEnrollment : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EnrollmentType",
table: "CourseEnrollments",
type: "int",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EnrollmentType",
table: "CourseEnrollments");
}
}
}