修复Mysql

This commit is contained in:
2026-07-26 10:19:46 +08:00 Unverified
parent 634ef69a27
commit 72297aa23f
31 changed files with 1642 additions and 1192 deletions
@@ -178,13 +178,25 @@ public sealed class AttendanceController(
{ {
Sheet = new Sheet = new
{ {
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate, sheet.Id,
sheet.Status, sheet.Notes, sheet.SubmittedAt, sheet.TeachingTaskId,
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName, sheet.Name,
sheet.AttendanceDate,
sheet.Status,
sheet.Notes,
sheet.SubmittedAt,
sheet.TaskNumber,
sheet.TaskName,
sheet.CourseCode,
sheet.CourseName,
Records = sheet.Records.Select(r => new Records = sheet.Records.Select(r => new
{ {
r.StudentId, r.StudentNumber, r.Name, r.ClassName, r.StudentId,
r.Status, r.Notes, r.StudentNumber,
r.Name,
r.ClassName,
r.Status,
r.Notes,
IsExempt = exemptStudentIds.Contains(r.StudentId), IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId) IsDeferred = deferredStudentIds.Contains(r.StudentId)
}) })
@@ -504,8 +516,8 @@ public sealed class AttendanceController(
var source = db.AttendanceRecords.AsNoTracking() var source = db.AttendanceRecords.AsNoTracking()
.Where(r => .Where(r =>
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted)
targetClassIds.Contains(r.Student!.AdministrativeClassId)); .WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId);
if (academicTermId.HasValue) if (academicTermId.HasValue)
source = source.Where(r => source = source.Where(r =>
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
@@ -77,45 +77,57 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
} }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
var errors = new List<string>(); return await db.ExecuteInRetriableTransactionAsync<
ExcelImportResult result; ActionResult<ExcelImportResult>>(
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); async transaction =>
try
{
result = kind.ToLowerInvariant() switch
{ {
"campuses" => await ImportCampusesAsync(rows, errors, cancellationToken), var errors = new List<string>();
"colleges" => await ImportCollegesAsync(rows, errors, cancellationToken), ExcelImportResult result;
"majors" => await ImportMajorsAsync(rows, errors, cancellationToken), try
"classes" => await ImportClassesAsync(rows, errors, cancellationToken), {
"terms" => await ImportTermsAsync(rows, errors, cancellationToken), result = kind.ToLowerInvariant() switch
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken), {
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken), "campuses" => await ImportCampusesAsync(
"course-categories" => await ImportCourseCategoriesAsync( rows, errors, cancellationToken),
rows, errors, cancellationToken), "colleges" => await ImportCollegesAsync(
_ => throw new InvalidOperationException() rows, errors, cancellationToken),
}; "majors" => await ImportMajorsAsync(
rows, errors, cancellationToken),
"classes" => await ImportClassesAsync(
rows, errors, cancellationToken),
"terms" => await ImportTermsAsync(
rows, errors, cancellationToken),
"buildings" => await ImportBuildingsAsync(
rows, errors, cancellationToken),
"classrooms" => await ImportClassroomsAsync(
rows, errors, cancellationToken),
"course-categories" => await ImportCourseCategoriesAsync(
rows, errors, cancellationToken),
_ => throw new InvalidOperationException()
};
if (errors.Count > 0) if (errors.Count > 0)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors); return ImportValidationProblem(errors);
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails return Conflict(new ProblemDetails
{ {
Title = "导入失败", Title = "导入失败",
Detail = "存在重复编码或无效关联,未写入任何数据。", Detail = "存在重复编码或无效关联,未写入任何数据。",
Status = StatusCodes.Status409Conflict Status = StatusCodes.Status409Conflict
}); });
} }
},
cancellationToken);
} }
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync( private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
@@ -256,7 +268,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new Major entity = new Major
{ {
Code = code, Name = name, CollegeId = college.Id, Code = code,
Name = name,
CollegeId = college.Id,
DegreeType = degreeType DegreeType = degreeType
}; };
db.Majors.Add(entity); db.Majors.Add(entity);
@@ -316,7 +330,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new AdministrativeClass entity = new AdministrativeClass
{ {
Code = code, Name = name, MajorId = major.Id, Grade = grade.Value Code = code,
Name = name,
MajorId = major.Id,
Grade = grade.Value
}; };
db.AdministrativeClasses.Add(entity); db.AdministrativeClasses.Add(entity);
existing[code] = entity; existing[code] = entity;
@@ -362,8 +379,11 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new AcademicTerm entity = new AcademicTerm
{ {
Code = code, Name = name, AcademicYear = academicYear, Code = code,
Season = season.Value, StartDate = startDate.Value, Name = name,
AcademicYear = academicYear,
Season = season.Value,
StartDate = startDate.Value,
EndDate = endDate.Value EndDate = endDate.Value
}; };
db.AcademicTerms.Add(entity); db.AcademicTerms.Add(entity);
@@ -461,7 +481,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
{ {
entity = new Classroom entity = new Classroom
{ {
Code = code, Name = name, BuildingId = building.Id, Code = code,
Name = name,
BuildingId = building.Id,
RoomType = roomType RoomType = roomType
}; };
db.Classrooms.Add(entity); db.Classrooms.Add(entity);
@@ -369,12 +369,12 @@ public sealed class CourseSelectionsController(
var source = db.Students.AsNoTracking() var source = db.Students.AsNoTracking()
.Where(x => .Where(x =>
x.Status == StudentStatus.Active && x.Status == StudentStatus.Active &&
(offering.IsOpenToAll ||
offering.ClassIds.Contains(x.AdministrativeClassId)) &&
!db.CourseEnrollments.Any(enrollment => !db.CourseEnrollments.Any(enrollment =>
enrollment.CourseSelectionOfferingId == id && enrollment.CourseSelectionOfferingId == id &&
enrollment.StudentId == x.Id && enrollment.StudentId == x.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled)); enrollment.Status == CourseEnrollmentStatus.Enrolled));
if (!offering.IsOpenToAll)
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
if (!string.IsNullOrWhiteSpace(keyword)) if (!string.IsNullOrWhiteSpace(keyword))
{ {
keyword = keyword.Trim(); keyword = keyword.Trim();
@@ -415,157 +415,160 @@ public sealed class CourseSelectionsController(
if (studentIds.Length > 100) if (studentIds.Length > 100)
return ValidationProblem("单次最多可为 100 名学生代选。"); return ValidationProblem("单次最多可为 100 名学生代选。");
await using var transaction = await db.Database.BeginTransactionAsync( return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
IsolationLevel.Serializable, async transaction =>
cancellationToken);
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 == id, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
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 inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
if (inactive is not null)
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
var outOfScope = students.FirstOrDefault(student =>
!offering.IsOpenToAll &&
!task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId));
if (outOfScope is not null)
{
return ConflictProblem(
$"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
}
var existingEnrollments = await db.CourseEnrollments
.Where(x =>
x.CourseSelectionOfferingId == id &&
studentIds.Contains(x.StudentId))
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{
var student = students.First(x => x.Id == alreadyEnrolled.StudentId);
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。");
}
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount + students.Count > offering.Capacity)
{
return ConflictProblem(
$"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。");
}
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
studentIds.Contains(x.StudentId) &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (duplicateStudentIds.Count > 0)
{
var student = students.First(x => duplicateStudentIds.Contains(x.Id));
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。");
}
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
foreach (var student in students)
{
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 ConflictProblem( db.ChangeTracker.Clear();
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。"); 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 == id, cancellationToken);
if (offering is null) return NotFound();
var round = offering.CourseSelectionRound!;
var task = offering.TeachingTask!;
if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
if (round.Status == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理管理员代选。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可办理代选。");
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking() var students = await db.Students
.Where(x => .Include(x => x.AdministrativeClass)
x.StudentId == student.Id && .WhereIn(studentIds, x => x.Id)
x.Status == CourseEnrollmentStatus.Enrolled && .OrderBy(x => x.StudentNumber)
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == .ToListAsync(cancellationToken);
round.AcademicTermId) if (students.Count != studentIds.Length)
.Select(x => x.CourseSelectionOffering!.TeachingTaskId) return ValidationProblem("存在无效的学生档案。");
.Distinct() var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
.ToArrayAsync(cancellationToken); if (inactive is not null)
var selectedEntries = await PublishedScheduleEntries( return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
round.AcademicTermId, var outOfScope = students.FirstOrDefault(student =>
selectedTaskIds, !offering.IsOpenToAll &&
cancellationToken); !task.Classes.Any(item =>
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) item.AdministrativeClassId == student.AdministrativeClassId));
{ if (outOfScope is not null)
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。");
}
}
var now = DateTime.UtcNow;
foreach (var student in students)
{
var enrollment = existingEnrollments.FirstOrDefault(x =>
x.StudentId == student.Id);
if (enrollment is null)
{
db.CourseEnrollments.Add(new CourseEnrollment
{ {
CourseSelectionOfferingId = id, return ConflictProblem(
StudentId = student.Id, $"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
EnrolledAt = now }
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
}
}
await db.SaveChangesAsync(cancellationToken); var existingEnrollments = await db.CourseEnrollments
await transaction.CommitAsync(cancellationToken); .Where(x => x.CourseSelectionOfferingId == id)
return Ok(new { EnrolledCount = students.Count }); .WhereIn(studentIds, x => x.StudentId)
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{
var student = students.First(x => x.Id == alreadyEnrolled.StudentId);
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。");
}
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
if (enrolledCount + students.Count > offering.Capacity)
{
return ConflictProblem(
$"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。");
}
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
round.AcademicTermId)
.WhereIn(studentIds, x => x.StudentId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (duplicateStudentIds.Count > 0)
{
var student = students.First(x => duplicateStudentIds.Contains(x.Id));
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。");
}
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId,
[task.Id],
cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
foreach (var student in students)
{
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 ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
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))
{
return ConflictProblem(
$"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。");
}
}
var now = DateTime.UtcNow;
foreach (var student in students)
{
var enrollment = existingEnrollments.FirstOrDefault(x =>
x.StudentId == student.Id);
if (enrollment is null)
{
db.CourseEnrollments.Add(new CourseEnrollment
{
CourseSelectionOfferingId = id,
StudentId = student.Id,
EnrolledAt = now
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
}
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Ok(new { EnrolledCount = students.Count });
},
cancellationToken,
IsolationLevel.Serializable);
} }
[HttpPost("offerings/{offeringId:guid}/force-enroll")] [HttpPost("offerings/{offeringId:guid}/force-enroll")]
@@ -581,73 +584,77 @@ public sealed class CourseSelectionsController(
if (studentIds.Length > 100) if (studentIds.Length > 100)
return ValidationProblem("单次最多可为 100 名学生强制选课。"); return ValidationProblem("单次最多可为 100 名学生强制选课。");
await using var transaction = await db.Database.BeginTransactionAsync( return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
IsolationLevel.Serializable, cancellationToken); async transaction =>
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 db.ChangeTracker.Clear();
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)
.WhereIn(studentIds, x => 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)
.WhereIn(studentIds, x => x.StudentId)
.ToListAsync(cancellationToken);
var alreadyEnrolled = existingEnrollments
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
if (alreadyEnrolled is not null)
{ {
CourseSelectionOfferingId = offeringId, var dup = students.First(x => x.Id == alreadyEnrolled.StudentId);
StudentId = student.Id, return ConflictProblem(
EnrollmentType = EnrollmentType.Retake, $"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。");
EnrolledAt = now }
});
}
else
{
enrollment.Status = CourseEnrollmentStatus.Enrolled;
enrollment.EnrolledAt = now;
enrollment.WithdrawnAt = null;
enrollment.EnrollmentType = EnrollmentType.Retake;
}
enrolled++;
}
await db.SaveChangesAsync(cancellationToken); var now = DateTime.UtcNow;
await transaction.CommitAsync(cancellationToken); var enrolled = 0;
return Ok(new { EnrolledCount = enrolled }); 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 });
},
cancellationToken,
IsolationLevel.Serializable);
} }
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")] [HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
@@ -824,145 +831,149 @@ public sealed class CourseSelectionsController(
StudentEnrollmentRequest request, StudentEnrollmentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var student = await CurrentStudentAsync(cancellationToken); return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
if (student is null) return ProfileNotFound(); async transaction =>
if (student.Status != StudentStatus.Active)
return ConflictProblem("只有在籍学生可以选课。");
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)
.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 task = offering.TeachingTask!;
var now = DateTime.UtcNow;
if (!CourseSelectionRules.IsSelectionOpen(round, now))
return ConflictProblem("当前不在该选课批次的开放时间内。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
if (!offering.IsOpenToAll &&
!task.Classes.Any(x =>
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 &&
x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
return ConflictProblem("你已经选择了该教学班。");
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
var effectiveCapacity = isRetake
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
: offering.Capacity;
if (enrolledCount >= effectiveCapacity)
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 &&
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 ConflictProblem(
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
// Schedule conflict check
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId, [task.Id], cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.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 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, db.ChangeTracker.Clear();
StudentId = student.Id, var student = await CurrentStudentAsync(cancellationToken);
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal if (student is null) return ProfileNotFound();
}; if (student.Status != StudentStatus.Active)
db.CourseEnrollments.Add(existing); return ConflictProblem("只有在籍学生可以选课。");
}
else var offering = await db.CourseSelectionOfferings
{ .Include(x => x.CourseSelectionRound)
existing.Status = CourseEnrollmentStatus.Enrolled; .Include(x => x.TeachingTask)
existing.EnrolledAt = now; .ThenInclude(x => x!.Course)
existing.WithdrawnAt = null; .Include(x => x.TeachingTask)
existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal; .ThenInclude(x => x!.Classes)
} .FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken);
await db.SaveChangesAsync(cancellationToken); if (offering is null) return NotFound();
await transaction.CommitAsync(cancellationToken); var round = offering.CourseSelectionRound!;
return Created(string.Empty, new { existing.Id, IsRetake = isRetake }); var task = offering.TeachingTask!;
var now = DateTime.UtcNow;
if (!CourseSelectionRules.IsSelectionOpen(round, now))
return ConflictProblem("当前不在该选课批次的开放时间内。");
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可选。");
if (!offering.IsOpenToAll &&
!task.Classes.Any(x =>
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 &&
x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == CourseEnrollmentStatus.Enrolled)
return ConflictProblem("你已经选择了该教学班。");
var enrolledCount = await db.CourseEnrollments.CountAsync(
x =>
x.CourseSelectionOfferingId == offering.Id &&
x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken);
var effectiveCapacity = isRetake
? CourseSelectionRules.RetakeCapacity(offering.Capacity)
: offering.Capacity;
if (enrolledCount >= effectiveCapacity)
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 &&
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 ConflictProblem(
$"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," +
$"超过本轮 {round.MaxCredits:0.#} 学分上限。");
}
// Schedule conflict check
var candidateEntries = await PublishedScheduleEntries(
round.AcademicTermId, [task.Id], cancellationToken);
if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) &&
candidateEntries.Count == 0)
return ConflictProblem("该教学班尚未发布课表,暂时不能选课。");
var selectedTaskIds = await db.CourseEnrollments
.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 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,
EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal
};
db.CourseEnrollments.Add(existing);
}
else
{
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, IsRetake = isRetake });
},
cancellationToken,
IsolationLevel.Serializable);
} }
[HttpDelete("student/enrollments/{id:guid}")] [HttpDelete("student/enrollments/{id:guid}")]
@@ -1159,9 +1170,9 @@ public sealed class CourseSelectionsController(
if (taskIds.Count == 0) return []; if (taskIds.Count == 0) return [];
return await db.ScheduleEntries.AsNoTracking() return await db.ScheduleEntries.AsNoTracking()
.Where(x => .Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.SchedulePlan!.AcademicTermId == academicTermId && x.SchedulePlan!.AcademicTermId == academicTermId &&
x.SchedulePlan.Status == SchedulePlanStatus.Published) x.SchedulePlan.Status == SchedulePlanStatus.Published)
.WhereIn(taskIds, x => x.TeachingTaskId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -122,32 +122,39 @@ public sealed class CoursesExcelController(
if (rows.Count == 0) if (rows.Count == 0)
return ValidationProblem("Excel 中没有可导入的课程数据。"); return ValidationProblem("Excel 中没有可导入的课程数据。");
var errors = new List<string>(); return await db.ExecuteInRetriableTransactionAsync<
await using var transaction = ActionResult<ExcelImportResult>>(
await db.Database.BeginTransactionAsync(cancellationToken); async transaction =>
try
{
var result = await ImportRowsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
{ {
await transaction.RollbackAsync(cancellationToken); var errors = new List<string>();
return ImportValidationProblem(errors); try
} {
var result = await ImportRowsAsync(
rows,
errors,
cancellationToken);
if (errors.Count > 0)
{
await transaction.RollbackAsync(cancellationToken);
return ImportValidationProblem(errors);
}
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(result); return Ok(result);
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails return Conflict(new ProblemDetails
{ {
Title = "导入失败", Title = "导入失败",
Detail = "存在重复课程编码或无效关联,未写入任何课程。", Detail = "存在重复课程编码或无效关联,未写入任何课程。",
Status = StatusCodes.Status409Conflict Status = StatusCodes.Status409Conflict
}); });
} }
},
cancellationToken);
} }
private async Task<ExcelImportResult> ImportRowsAsync( private async Task<ExcelImportResult> ImportRowsAsync(
@@ -103,12 +103,14 @@ public sealed class CurriculumPlansController(
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var accessibleCollegeIds = majorOptions var accessibleMajors = db.Majors.AsNoTracking().Where(x => x.IsEnabled);
.Select(x => x.CollegeId) if (collegeId.HasValue)
.Distinct() accessibleMajors = accessibleMajors.Where(
.ToArray(); x => x.CollegeId == collegeId.Value);
var colleges = await db.Colleges.AsNoTracking() var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled && accessibleCollegeIds.Contains(x.Id)) .Where(x =>
x.IsEnabled &&
accessibleMajors.Any(major => major.CollegeId == x.Id))
.OrderBy(x => x.SortOrder) .OrderBy(x => x.SortOrder)
.ThenBy(x => x.Code) .ThenBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name }) .Select(x => new { x.Id, x.Code, x.Name })
@@ -288,46 +290,57 @@ public sealed class CurriculumPlansController(
[HttpPost("{id:guid}/publish")] [HttpPost("{id:guid}/publish")]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{ {
var plan = await ScopedPlans() return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
.Include(x => x.Major) async transaction =>
.Include(x => x.Modules) {
.ThenInclude(x => x.Courses) db.ChangeTracker.Clear();
.ThenInclude(x => x.Course) var plan = await ScopedPlans()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .Include(x => x.Major)
if (plan is null) return NotFound(); .Include(x => x.Modules)
if (plan.Status != CurriculumPlanStatus.Draft) .ThenInclude(x => x.Courses)
return ConflictProblem("只有草稿方案可以发布。"); .ThenInclude(x => x.Course)
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0)) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return ConflictProblem("发布前每个课程模块都必须配置课程。"); if (plan is null) return NotFound();
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits) if (plan.Status != CurriculumPlanStatus.Draft)
return ConflictProblem("各模块最低学分之和必须等于方案总学分。"); return ConflictProblem("只有草稿方案可以发布。");
if (plan.Modules.Any(module => if (plan.Modules.Count == 0 ||
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits)) plan.Modules.Any(x => x.Courses.Count == 0))
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。"); return ConflictProblem("发布前每个课程模块都必须配置课程。");
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1)) if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
return ConflictProblem("同一门课程不能重复加入多个模块。"); return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
if (plan.Modules.SelectMany(x => x.Courses).Any(x => if (plan.Modules.Any(module =>
x.RecommendedSemester > plan.Major!.SchoolingYears * 2)) module.Courses.Sum(x => x.Course!.Credits) <
return ConflictProblem("建议学期超出了该专业学制。"); module.RequiredCredits))
return ConflictProblem(
"存在课程学分合计低于最低学分要求的模块。");
if (plan.Modules.SelectMany(x => x.Courses)
.GroupBy(x => x.CourseId)
.Any(x => x.Count() > 1))
return ConflictProblem("同一门课程不能重复加入多个模块。");
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
x.RecommendedSemester >
plan.Major!.SchoolingYears * 2))
return ConflictProblem("建议学期超出了该专业学制。");
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var previousPlans = await ScopedPlans()
var previousPlans = await ScopedPlans() .Where(x =>
.Where(x => x.Id != plan.Id &&
x.Id != plan.Id && x.MajorId == plan.MajorId &&
x.MajorId == plan.MajorId && x.EffectiveGrade == plan.EffectiveGrade &&
x.EffectiveGrade == plan.EffectiveGrade && x.Status == CurriculumPlanStatus.Published)
x.Status == CurriculumPlanStatus.Published) .ToListAsync(cancellationToken);
.ToListAsync(cancellationToken); foreach (var previous in previousPlans)
foreach (var previous in previousPlans) {
{ previous.Status = CurriculumPlanStatus.Archived;
previous.Status = CurriculumPlanStatus.Archived; }
}
plan.Status = CurriculumPlanStatus.Published; plan.Status = CurriculumPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow; plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return NoContent(); return NoContent();
},
cancellationToken);
} }
[HttpPost("{planId:guid}/modules")] [HttpPost("{planId:guid}/modules")]
@@ -31,9 +31,15 @@ public sealed class DegreeAwardsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.GraduationYear, x.DegreeName, x.Id,
x.MinimumGradePoint, x.Status, x.Notes, x.Name,
x.CalculatedAt, x.PublishedAt, x.GraduationYear,
x.DegreeName,
x.MinimumGradePoint,
x.Status,
x.Notes,
x.CalculatedAt,
x.PublishedAt,
ResultCount = x.Results.Count(result => ResultCount = x.Results.Count(result =>
!collegeId.HasValue || !collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId), result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
@@ -67,19 +73,32 @@ public sealed class DegreeAwardsController(
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId); x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new return Ok(new
{ {
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName, batch.Id,
batch.MinimumGradePoint, batch.Status, batch.Notes, batch.Name,
batch.CalculatedAt, batch.PublishedAt, batch.GraduationYear,
batch.DegreeName,
batch.MinimumGradePoint,
batch.Status,
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
Results = await source.OrderBy(x => x.Student!.StudentNumber) Results = await source.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new .Select(x => new
{ {
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name, ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name, CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion, x.AverageGradePoint,
x.ExceptionReason, x.IsOverridden, x.CalculatedConclusion,
x.ReviewComment, x.ReviewedAt x.Conclusion,
x.ExceptionReason,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token) }).ToListAsync(token)
}); });
} }
@@ -130,9 +149,9 @@ public sealed class DegreeAwardsController(
.ToList(); .ToList();
var studentIds = audits.Select(x => x.StudentId).ToArray(); var studentIds = audits.Select(x => x.StudentId).ToArray();
var gradePoints = await db.GradeRecords.AsNoTracking() var gradePoints = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) && .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue) x.GradePoint.HasValue)
.WhereIn(studentIds, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -241,10 +260,14 @@ public sealed class DegreeAwardsController(
x.DegreeAwardBatch.GraduationYear, x.DegreeAwardBatch.GraduationYear,
x.DegreeAwardBatch.DegreeName, x.DegreeAwardBatch.DegreeName,
x.DegreeAwardBatch.MinimumGradePoint, x.DegreeAwardBatch.MinimumGradePoint,
x.Student!.StudentNumber, x.Student.Name, x.Student!.StudentNumber,
x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name, MajorName = x.Student.AdministrativeClass!.Major!.Name,
x.AverageGradePoint, x.Conclusion, x.ExceptionReason, x.AverageGradePoint,
x.IsOverridden, x.ReviewComment, x.Conclusion,
x.ExceptionReason,
x.IsOverridden,
x.ReviewComment,
x.DegreeAwardBatch.PublishedAt x.DegreeAwardBatch.PublishedAt
}).FirstOrDefaultAsync(token); }).FirstOrDefaultAsync(token);
return Ok(result); return Ok(result);
@@ -39,12 +39,19 @@ public sealed class EvaluationsController(
.OrderByDescending(x => x.AcademicTerm!.StartDate) .OrderByDescending(x => x.AcademicTerm!.StartDate)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.Status, x.Id,
x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Name,
x.StartsAt, x.EndsAt, x.Status,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.StartsAt,
x.EndsAt,
Dimensions = x.Dimensions.Select(d => new Dimensions = x.Dimensions.Select(d => new
{ {
d.Id, d.Name, d.MaxScore, d.SortOrder d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}), }),
RecordCount = x.Records.Count, RecordCount = x.Records.Count,
x.CreatedAt x.CreatedAt
@@ -194,8 +201,10 @@ public sealed class EvaluationsController(
var enrollments = await db.CourseEnrollments.AsNoTracking() var enrollments = await db.CourseEnrollments.AsNoTracking()
.Where(e => .Where(e =>
e.Status == CourseEnrollmentStatus.Enrolled && e.Status == CourseEnrollmentStatus.Enrolled &&
e.StudentId == studentId.Value && e.StudentId == studentId.Value)
termIds.Contains(e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)) .WhereIn(
termIds,
e => e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)
.Select(e => new .Select(e => new
{ {
e.CourseSelectionOffering!.TeachingTaskId, e.CourseSelectionOffering!.TeachingTaskId,
@@ -211,9 +220,10 @@ public sealed class EvaluationsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// Check which tasks have already been evaluated // Check which tasks have already been evaluated
var setupIds = openSetups.Select(s => s.Id).ToArray();
var evaluatedIds = await db.EvaluationRecords.AsNoTracking() var evaluatedIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.StudentId == studentId.Value && .Where(r => r.StudentId == studentId.Value)
openSetups.Select(s => s.Id).Contains(r.EvaluationSetupId)) .WhereIn(setupIds, r => r.EvaluationSetupId)
.Select(r => r.TeachingTaskId) .Select(r => r.TeachingTaskId)
.ToHashSetAsync(cancellationToken); .ToHashSetAsync(cancellationToken);
@@ -251,7 +261,9 @@ public sealed class EvaluationsController(
.Where(x => x.Id == teachingTaskId) .Where(x => x.Id == teachingTaskId)
.Select(x => new .Select(x => new
{ {
x.Id, x.TaskNumber, x.Name, x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId, x.AcademicTermId,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course.Name, CourseName = x.Course.Name,
@@ -283,7 +295,10 @@ public sealed class EvaluationsController(
setup.Name, setup.Name,
Dimensions = setup.Dimensions.Select(d => new Dimensions = setup.Dimensions.Select(d => new
{ {
d.Id, d.Name, d.MaxScore, d.SortOrder d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}), }),
Task = task Task = task
}); });
@@ -384,8 +399,8 @@ public sealed class EvaluationsController(
foreach (var setup in setups) foreach (var setup in setups)
{ {
var setupTaskIds = await db.EvaluationRecords.AsNoTracking() var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
.Where(r => r.EvaluationSetupId == setup.Id && .Where(r => r.EvaluationSetupId == setup.Id)
taskIds.Contains(r.TeachingTaskId)) .WhereIn(taskIds, r => r.TeachingTaskId)
.Select(r => r.TeachingTaskId) .Select(r => r.TeachingTaskId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -396,7 +411,9 @@ public sealed class EvaluationsController(
.Where(x => x.Id == taskId) .Where(x => x.Id == taskId)
.Select(x => new .Select(x => new
{ {
x.Id, x.TaskNumber, x.Name, x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId, x.AcademicTermId,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course.Name CourseName = x.Course.Name
@@ -525,12 +542,18 @@ public sealed class EvaluationsController(
{ {
Setup = new Setup = new
{ {
setup.Id, setup.Name, setup.Status, setup.Id,
setup.Name,
setup.Status,
TermName = setup.AcademicTerm!.Name, TermName = setup.AcademicTerm!.Name,
setup.StartsAt, setup.EndsAt, setup.StartsAt,
setup.EndsAt,
Dimensions = setup.Dimensions.Select(d => new Dimensions = setup.Dimensions.Select(d => new
{ {
d.Id, d.Name, d.MaxScore, d.SortOrder d.Id,
d.Name,
d.MaxScore,
d.SortOrder
}) })
}, },
ByTask = byTask, ByTask = byTask,
+52 -38
View File
@@ -39,8 +39,14 @@ public sealed class ExamsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
SessionCount = x.Sessions.Count,
x.Notes,
x.PublishedAt
}).ToListAsync(cancellationToken)); }).ToListAsync(cancellationToken));
} }
@@ -72,37 +78,42 @@ public sealed class ExamsController(
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published)) .Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, x.Notes, x.PublishedAt, x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.ExamDate) Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new .ThenBy(item => item.StartPeriod).Select(item => new
{ {
item.Id, item.Id,
item.TeachingTaskId, item.TeachingTaskId,
item.TeachingTask!.TaskNumber, item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name, TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code, CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name, CourseName = item.TeachingTask.Course.Name,
item.ClassroomId, item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null, ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null, BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null, ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate, item.ExamDate,
item.StartPeriod, item.StartPeriod,
item.PeriodCount, item.PeriodCount,
item.StartsAt, item.StartsAt,
item.EndsAt, item.EndsAt,
item.RequiredBuildingId, item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null, ? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount, item.RequiredInvigilatorCount,
item.Notes, item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
StudentCount = db.CourseEnrollments.Count(e => StudentCount = db.CourseEnrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled && e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId) e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId)
}) })
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan); return plan is null ? NotFound() : Ok(plan);
} }
@@ -270,7 +281,7 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (occupiedIds.Count > 0) if (occupiedIds.Count > 0)
query = query.Where(x => !occupiedIds.Contains(x.Id)); query = query.WhereNotIn(occupiedIds, x => x.Id);
return Ok(await query.OrderBy(x => x.Building!.Name) return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity) .ThenBy(x => x.Capacity)
@@ -313,8 +324,8 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking() return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyIds.Contains(x.Id)) .WhereNotIn(busyIds, x => x.Id)
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.Select(x => new .Select(x => new
{ {
@@ -547,8 +558,10 @@ public sealed class ExamsController(
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && if (await db.Teachers
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) .Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。"); return ValidationProblem("存在无效监考教师。");
} }
@@ -564,9 +577,10 @@ public sealed class ExamsController(
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await overlaps.AnyAsync(x => if (await db.ExamSessionInvigilators
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), .Where(i => overlaps.Any(x => x.Id == i.ExamSessionId))
cancellationToken)) .WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有考试任务。"); return ConflictProblem("监考教师在该时段已有考试任务。");
} }
@@ -32,8 +32,15 @@ public sealed class GraduationAuditsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status, x.Id,
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt, x.Name,
x.GraduationYear,
x.EnrollmentYear,
x.Status,
x.Notes,
x.CalculatedAt,
x.PublishedAt,
x.CreatedAt,
ResultCount = x.Results.Count(result => ResultCount = x.Results.Count(result =>
!collegeId.HasValue || !collegeId.HasValue ||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId), result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
@@ -68,23 +75,38 @@ public sealed class GraduationAuditsController(
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId); x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
return Ok(new return Ok(new
{ {
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear, batch.Id,
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt, batch.Name,
batch.GraduationYear,
batch.EnrollmentYear,
batch.Status,
batch.Notes,
batch.CalculatedAt,
batch.PublishedAt,
Results = await results Results = await results
.OrderBy(x => x.Student!.StudentNumber) .OrderBy(x => x.Student!.StudentNumber)
.Select(x => new .Select(x => new
{ {
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name, ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name,
CollegeName = x.Student.AdministrativeClass.Major.College!.Name, CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
x.StudentStatusSnapshot, x.StudentStatusSnapshot,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null, PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits, x.RequiredCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount, x.EarnedCredits,
x.FailedCourseCount, x.MissingCourseNames, x.RequiredCourseCount,
x.CalculatedConclusion, x.Conclusion, x.IsOverridden, x.PassedRequiredCourseCount,
x.ReviewComment, x.ReviewedAt x.FailedCourseCount,
x.MissingCourseNames,
x.CalculatedConclusion,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.ReviewedAt
}).ToListAsync(token) }).ToListAsync(token)
}); });
} }
@@ -136,8 +158,8 @@ public sealed class GraduationAuditsController(
.ToListAsync(token); .ToListAsync(token);
var studentIds = students.Select(x => x.Id).ToArray(); var studentIds = students.Select(x => x.Id).ToArray();
var grades = await db.GradeRecords.AsNoTracking() var grades = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) && .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
x.GradeSheet!.Status == GradeSheetStatus.Published) .WhereIn(studentIds, x => x.StudentId)
.Select(x => new GradeSnapshot( .Select(x => new GradeSnapshot(
x.StudentId, x.StudentId,
x.GradeSheet!.TeachingTask!.CourseId, x.GradeSheet!.TeachingTask!.CourseId,
@@ -275,13 +297,19 @@ public sealed class GraduationAuditsController(
x.Id, x.Id,
BatchName = x.GraduationAuditBatch!.Name, BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear, x.GraduationAuditBatch.GraduationYear,
x.Student!.StudentNumber, x.Student.Name, x.Student!.StudentNumber,
x.Student.Name,
MajorName = x.Student.AdministrativeClass!.Major!.Name, MajorName = x.Student.AdministrativeClass!.Major!.Name,
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null, PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
x.RequiredCredits, x.EarnedCredits, x.RequiredCredits,
x.RequiredCourseCount, x.PassedRequiredCourseCount, x.EarnedCredits,
x.FailedCourseCount, x.MissingCourseNames, x.RequiredCourseCount,
x.Conclusion, x.IsOverridden, x.ReviewComment, x.PassedRequiredCourseCount,
x.FailedCourseCount,
x.MissingCourseNames,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.GraduationAuditBatch.PublishedAt x.GraduationAuditBatch.PublishedAt
}).FirstOrDefaultAsync(token); }).FirstOrDefaultAsync(token);
return Ok(result); return Ok(result);
@@ -44,8 +44,14 @@ public sealed class MakeupExamsController(
.ThenByDescending(x => x.CreatedAt) .ThenByDescending(x => x.CreatedAt)
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
SessionCount = x.Sessions.Count,
x.Notes,
x.PublishedAt
}).ToListAsync(cancellationToken)); }).ToListAsync(cancellationToken));
} }
@@ -77,46 +83,51 @@ public sealed class MakeupExamsController(
.Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published)) .Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
.Select(x => new .Select(x => new
{ {
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Id,
x.Status, x.Notes, x.PublishedAt, x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.Status,
x.Notes,
x.PublishedAt,
Sessions = x.Sessions.OrderBy(item => item.ExamDate) Sessions = x.Sessions.OrderBy(item => item.ExamDate)
.ThenBy(item => item.StartPeriod).Select(item => new .ThenBy(item => item.StartPeriod).Select(item => new
{
item.Id,
item.TeachingTaskId,
item.TeachingTask!.TaskNumber,
TaskName = item.TeachingTask.Name,
CourseCode = item.TeachingTask.Course!.Code,
CourseName = item.TeachingTask.Course.Name,
item.ClassroomId,
ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = item.Enrollments.Count,
Enrollments = item.Enrollments.Select(e => new
{ {
e.StudentId, item.Id,
e.Student!.StudentNumber, item.TeachingTaskId,
e.Student.Name, item.TeachingTask!.TaskNumber,
ClassName = e.Student.AdministrativeClass!.Name, TaskName = item.TeachingTask.Name,
e.Reason, CourseCode = item.TeachingTask.Course!.Code,
e.SourceGradeRecordId, CourseName = item.TeachingTask.Course.Name,
e.SourceDeferredExamId, item.ClassroomId,
e.MakeupScore ClassroomName = item.Classroom != null ? item.Classroom.Name : null,
BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null,
ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null,
item.ExamDate,
item.StartPeriod,
item.PeriodCount,
item.StartsAt,
item.EndsAt,
item.RequiredBuildingId,
RequiredBuildingName = item.RequiredBuilding != null
? item.RequiredBuilding.Name : null,
item.RequiredInvigilatorCount,
item.Notes,
InvigilatorIds = item.Invigilators.Select(i => i.TeacherId),
InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name),
EnrolledCount = item.Enrollments.Count,
Enrollments = item.Enrollments.Select(e => new
{
e.StudentId,
e.Student!.StudentNumber,
e.Student.Name,
ClassName = e.Student.AdministrativeClass!.Name,
e.Reason,
e.SourceGradeRecordId,
e.SourceDeferredExamId,
e.MakeupScore
})
}) })
})
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan); return plan is null ? NotFound() : Ok(plan);
} }
@@ -311,8 +322,12 @@ public sealed class MakeupExamsController(
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (existing is not null) if (existing is not null)
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(), return Ok(new
message = "该计划已有正在执行的任务。" }); {
jobId = existing.Id,
status = existing.Status.ToString(),
message = "该计划已有正在执行的任务。"
});
var job = new MakeupExamAutoJob var job = new MakeupExamAutoJob
{ {
@@ -404,9 +419,9 @@ public sealed class MakeupExamsController(
// Check for time conflicts with other makeup sessions // Check for time conflicts with other makeup sessions
var conflictIds = await db.MakeupExamEnrollments.AsNoTracking() var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
.Where(x => validIds.Contains(x.StudentId) && .Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt &&
x.MakeupExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.MakeupExamSession.EndsAt) session.StartsAt < x.MakeupExamSession.EndsAt)
.WhereIn(validIds, x => x.StudentId)
.Select(x => x.StudentId) .Select(x => x.StudentId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -414,7 +429,7 @@ public sealed class MakeupExamsController(
if (conflictIds.Count > 0) if (conflictIds.Count > 0)
{ {
var conflictNumbers = await db.Students var conflictNumbers = await db.Students
.Where(s => conflictIds.Contains(s.Id)) .WhereIn(conflictIds, s => s.Id)
.Select(s => s.StudentNumber) .Select(s => s.StudentNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return ConflictProblem( return ConflictProblem(
@@ -590,7 +605,7 @@ public sealed class MakeupExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (occupiedIds.Count > 0) if (occupiedIds.Count > 0)
query = query.Where(x => !occupiedIds.Contains(x.Id)); query = query.WhereNotIn(occupiedIds, x => x.Id);
return Ok(await query.OrderBy(x => x.Building!.Name) return Ok(await query.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Capacity) .ThenBy(x => x.Capacity)
@@ -634,8 +649,8 @@ public sealed class MakeupExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(await db.Teachers.AsNoTracking() return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyIds.Contains(x.Id)) .WhereNotIn(busyIds, x => x.Id)
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.Select(x => new .Select(x => new
{ {
@@ -845,8 +860,10 @@ public sealed class MakeupExamsController(
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && if (await db.Teachers
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) .Where(x => x.Status == TeacherStatus.Active)
.WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效监考教师。"); return ValidationProblem("存在无效监考教师。");
} }
@@ -862,9 +879,10 @@ public sealed class MakeupExamsController(
if (teacherIds.Length > 0) if (teacherIds.Length > 0)
{ {
if (await overlaps.AnyAsync(x => if (await db.MakeupExamSessionInvigilators
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), .Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId))
cancellationToken)) .WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有补考任务。"); return ConflictProblem("监考教师在该时段已有补考任务。");
} }
@@ -158,52 +158,60 @@ public sealed class PersonnelController(
TeacherAccountActivationRequest request, TeacherAccountActivationRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var teacher = await db.Teachers.FindAsync([id], cancellationToken); return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
if (teacher is null) return NotFound(); async transaction =>
if (!CanAccessCollege(teacher.CollegeId)) return Forbid(); {
if (teacher.Status != TeacherStatus.Active) db.ChangeTracker.Clear();
{ var teacher = await db.Teachers.FindAsync([id], cancellationToken);
return ConflictProblem("仅在职教师可以激活登录账号。"); if (teacher is null) return NotFound();
} if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
if (teacher.UserId.HasValue) if (teacher.Status != TeacherStatus.Active)
{ {
return ConflictProblem("该教师档案已经关联登录账号。"); return ConflictProblem("仅在职教师可以激活登录账号。");
} }
if (teacher.UserId.HasValue)
{
return ConflictProblem("该教师档案已经关联登录账号。");
}
var userName = teacher.TeacherNumber.Trim(); var userName = teacher.TeacherNumber.Trim();
if (await userManager.FindByNameAsync(userName) is not null) if (await userManager.FindByNameAsync(userName) is not null)
{ {
return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。"); return ConflictProblem(
} "该工号已有登录账号但未正确关联,请到账号管理中核对。");
}
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); var user = new ApplicationUser
var user = new ApplicationUser {
{ UserName = userName,
UserName = userName, DisplayName = teacher.Name,
DisplayName = teacher.Name, StaffNumber = userName,
StaffNumber = userName, CollegeId = teacher.CollegeId,
CollegeId = teacher.CollegeId, IsEnabled = true,
IsEnabled = true, LockoutEnabled = true
LockoutEnabled = true };
}; var result = await userManager.CreateAsync(user, request.Password);
var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded)
if (!result.Succeeded) {
{ await transaction.RollbackAsync(cancellationToken);
await transaction.RollbackAsync(cancellationToken); return IdentityValidationProblem(result);
return IdentityValidationProblem(result); }
}
result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher); result = await userManager.AddToRoleAsync(
if (!result.Succeeded) user,
{ SystemRoles.Teacher);
await transaction.RollbackAsync(cancellationToken); if (!result.Succeeded)
return IdentityValidationProblem(result); {
} await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(result);
}
teacher.UserId = user.Id; teacher.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(new { user.Id, UserName = userName }); return Ok(new { user.Id, UserName = userName });
},
cancellationToken);
} }
[HttpGet("students")] [HttpGet("students")]
@@ -123,32 +123,39 @@ public sealed class PersonnelExcelController(
} }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
var errors = new List<string>(); return await db.ExecuteInRetriableTransactionAsync<
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); ActionResult<ExcelImportResult>>(
try async transaction =>
{
var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)
? await ImportTeachersAsync(rows, errors, cancellationToken)
: await ImportStudentsAsync(rows, errors, cancellationToken);
if (errors.Count > 0)
{ {
await transaction.RollbackAsync(cancellationToken); var errors = new List<string>();
return ImportValidationProblem(errors); try
} {
await db.SaveChangesAsync(cancellationToken); var result = kind.Equals(
await transaction.CommitAsync(cancellationToken); "teachers",
return Ok(result); StringComparison.OrdinalIgnoreCase)
} ? await ImportTeachersAsync(rows, errors, cancellationToken)
catch (DbUpdateException) : await ImportStudentsAsync(rows, errors, cancellationToken);
{ if (errors.Count > 0)
await transaction.RollbackAsync(cancellationToken); {
return Conflict(new ProblemDetails await transaction.RollbackAsync(cancellationToken);
{ return ImportValidationProblem(errors);
Title = "导入失败", }
Detail = "存在重复编号或无效关联,未写入任何档案。", await db.SaveChangesAsync(cancellationToken);
Status = StatusCodes.Status409Conflict await transaction.CommitAsync(cancellationToken);
}); return Ok(result);
} }
catch (DbUpdateException)
{
await transaction.RollbackAsync(cancellationToken);
return Conflict(new ProblemDetails
{
Title = "导入失败",
Detail = "存在重复编号或无效关联,未写入任何档案。",
Status = StatusCodes.Status409Conflict
});
}
},
cancellationToken);
} }
private async Task<ExcelImportResult> ImportTeachersAsync( private async Task<ExcelImportResult> ImportTeachersAsync(
@@ -82,24 +82,24 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{ {
x.Id, x.Id,
x.TaskNumber, x.TaskNumber,
x.Name, x.Name,
CourseCode = x.Course!.Code, CourseCode = x.Course!.Code,
CourseName = x.Course!.Name, CourseName = x.Course!.Name,
CollegeId = x.Course.CollegeId, CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
x.Capacity, x.Capacity,
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode x.SchedulingMode
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var taskIds = tasks.Select(x => x.Id).ToList(); var taskIds = tasks.Select(x => x.Id).ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task => return Ok(tasks.Select(task =>
@@ -109,19 +109,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{ {
task.Id, task.Id,
task.TaskNumber, task.TaskNumber,
task.Name, task.Name,
task.CourseCode, task.CourseCode,
task.CourseName, task.CourseName,
task.CollegeId, task.CollegeId,
task.CollegeName, task.CollegeName,
task.TeacherNames, task.TeacherNames,
task.Capacity, task.Capacity,
task.StartWeek, task.StartWeek,
task.EndWeek, task.EndWeek,
task.WeeklyHours, task.WeeklyHours,
task.SchedulingMode, task.SchedulingMode,
HasCustomConstraint = constraint is not null, HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
? false ? false
: constraint?.RequiresClassroom ?? true, : constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId, constraint?.RequiredCampusId,
@@ -143,34 +143,34 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{ {
if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7)) if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7))
return ValidationProblem("允许上课日必须位于星期一至星期日。"); return ValidationProblem("允许上课日必须位于星期一至星期日。");
if (request.EarliestPeriod.HasValue && if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue && request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod) request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。"); return ValidationProblem("最早节次不能晚于最晚节次。");
if (!Enum.IsDefined(request.SchedulingMode)) if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。"); return ValidationProblem("授课方式无效。");
var task = await db.TeachingTasks var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound(); if (task is null) return NotFound();
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
await HasScheduleEntriesAsync([teachingTaskId], cancellationToken)) await HasScheduleEntriesAsync([teachingTaskId], cancellationToken))
return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。"); return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。");
task.SchedulingMode = request.SchedulingMode; task.SchedulingMode = request.SchedulingMode;
if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible) if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
{ {
var flexibleConstraint = await db.TeachingTaskScheduleConstraints var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null) if (flexibleConstraint is not null)
{ {
ClearConstraint(flexibleConstraint); ClearConstraint(flexibleConstraint);
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return NoContent(); return NoContent();
} }
Building? building = null; Building? building = null;
if (request.RequiredBuildingId.HasValue) if (request.RequiredBuildingId.HasValue)
{ {
building = await db.Buildings.AsNoTracking() building = await db.Buildings.AsNoTracking()
@@ -189,7 +189,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
return ValidationProblem("指定校区不存在或已停用。"); return ValidationProblem("指定校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking() var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(request.AllowedClassroomIds, x => x.Id)
.Include(x => x.Building) .Include(x => x.Building)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count()) if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count())
@@ -225,9 +226,9 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
? request.AllowedClassroomIds.Distinct().Select(classroomId => ? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: []; : [];
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return NoContent(); return NoContent();
} }
[HttpPut("constraints/batch")] [HttpPut("constraints/batch")]
public async Task<ActionResult> SaveConstraintsBatch( public async Task<ActionResult> SaveConstraintsBatch(
@@ -246,22 +247,22 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
if (request.SchedulingMode.HasValue && if (request.SchedulingMode.HasValue &&
!Enum.IsDefined(request.SchedulingMode.Value)) !Enum.IsDefined(request.SchedulingMode.Value))
return ValidationProblem("授课方式无效。"); return ValidationProblem("授课方式无效。");
if (!request.SchedulingMode.HasValue && if (!request.SchedulingMode.HasValue &&
!request.RequiresClassroom.HasValue && !request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null && request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope && !request.UpdateClassroomScope &&
!request.UpdatePeriodRange && !request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue && !request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue) !request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。"); return ValidationProblem("请至少选择一项需要批量修改的设置。");
if (request.UpdateClassroomScope && request.RequiresClassroom == false) if (request.UpdateClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。"); return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
var tasks = await db.TeachingTasks var tasks = await db.TeachingTasks
.Where(x => .Where(x =>
taskIds.Contains(x.Id) &&
x.AcademicTermId == request.AcademicTermId && x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published) x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length) if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。"); return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
@@ -297,7 +298,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? []; var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? [];
allowedRooms = await db.Classrooms.AsNoTracking() allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => roomIds.Contains(x.Id) && x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(roomIds, x => x.Id)
.Include(x => x.Building) .Include(x => x.Building)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (allowedRooms.Count != roomIds.Length) if (allowedRooms.Count != roomIds.Length)
@@ -311,12 +313,12 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
return ValidationProblem("指定教室必须位于所选校区。"); return ValidationProblem("指定教室必须位于所选校区。");
} }
var constraints = await db.TeachingTaskScheduleConstraints var constraints = await db.TeachingTaskScheduleConstraints
.Where(x => taskIds.Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks) foreach (var task in tasks)
{ {
if (request.SchedulingMode.HasValue) if (request.SchedulingMode.HasValue)
task.SchedulingMode = request.SchedulingMode.Value; task.SchedulingMode = request.SchedulingMode.Value;
if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
@@ -384,7 +386,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
IReadOnlyCollection<Guid> taskIds, IReadOnlyCollection<Guid> taskIds,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
db.ScheduleEntries.AsNoTracking() db.ScheduleEntries.AsNoTracking()
.AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken); .WhereIn(taskIds, x => x.TeachingTaskId)
.AnyAsync(cancellationToken);
private void ClearConstraint(TeachingTaskScheduleConstraint constraint) private void ClearConstraint(TeachingTaskScheduleConstraint constraint)
{ {
@@ -95,7 +95,12 @@ public sealed class StatisticsController(
return new return new
{ {
byCollege, byMajor, byClass, byGrade, byStatus, byGender, byCollege,
byMajor,
byClass,
byGrade,
byStatus,
byGender,
enrollmentTrend, enrollmentTrend,
totals = new { totalStudents } totals = new { totalStudents }
}; };
@@ -255,7 +260,8 @@ public sealed class StatisticsController(
var passRateByCollegeResult = passRateByCollege.Select(x => new var passRateByCollegeResult = passRateByCollege.Select(x => new
{ {
x.collegeName, x.totalRecords, x.collegeName,
x.totalRecords,
passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0, passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0,
averageScore = Math.Round(x.averageScore, 1) averageScore = Math.Round(x.averageScore, 1)
}).ToList(); }).ToList();
@@ -336,7 +342,8 @@ public sealed class StatisticsController(
var byCollegeResult = byCollege.Select(x => new var byCollegeResult = byCollege.Select(x => new
{ {
x.collegeName, x.total, x.collegeName,
x.total,
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
}).OrderByDescending(x => x.passRate).ToList(); }).OrderByDescending(x => x.passRate).ToList();
@@ -353,7 +360,9 @@ public sealed class StatisticsController(
var byCourseResult = byCourse.Select(x => new var byCourseResult = byCourse.Select(x => new
{ {
x.courseCode, x.courseName, x.total, x.courseCode,
x.courseName,
x.total,
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0 passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
}).OrderByDescending(x => x.passRate).ToList(); }).OrderByDescending(x => x.passRate).ToList();
@@ -567,7 +576,8 @@ public sealed class StatisticsController(
// get entries // get entries
var entries = await db.ScheduleEntries.AsNoTracking() var entries = await db.ScheduleEntries.AsNoTracking()
.Where(e => e.SchedulePlanId == plan.Id) .Where(e => e.SchedulePlanId == plan.Id)
.Where(e => e.ClassroomId != null && classroomIds.Contains(e.ClassroomId.Value)) .Where(e => e.ClassroomId != null)
.WhereIn(classroomIds, e => e.ClassroomId!.Value)
.Select(e => new .Select(e => new
{ {
e.ClassroomId, e.ClassroomId,
@@ -633,7 +643,8 @@ public sealed class StatisticsController(
var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount); var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount);
return new return new
{ {
day, dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}", day,
dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0 utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0
}; };
}).ToList(); }).ToList();
@@ -74,8 +74,8 @@ public sealed class StudentCurriculumController(
var gradeAttempts = await db.GradeRecords.AsNoTracking() var gradeAttempts = await db.GradeRecords.AsNoTracking()
.Where(x => .Where(x =>
x.StudentId == student.Id && x.StudentId == student.Id &&
planCourseIds.Contains(x.GradeSheet!.TeachingTask!.CourseId) && x.GradeSheet!.Status == GradeSheetStatus.Published)
x.GradeSheet.Status == GradeSheetStatus.Published) .WhereIn(planCourseIds, x => x.GradeSheet!.TeachingTask!.CourseId)
.Select(x => new StudentGradeAttempt( .Select(x => new StudentGradeAttempt(
x.GradeSheet!.TeachingTask!.CourseId, x.GradeSheet!.TeachingTask!.CourseId,
x.TotalScore, x.TotalScore,
@@ -88,15 +88,15 @@ public sealed class StudentCurriculumController(
var currentCourseIds = await db.TeachingTasks.AsNoTracking() var currentCourseIds = await db.TeachingTasks.AsNoTracking()
.Where(task => .Where(task =>
planCourseIds.Contains(task.CourseId) &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.AcademicTerm!.IsCurrent && task.AcademicTerm!.IsCurrent &&
(task.Classes.Any(item => (task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId) || item.AdministrativeClassId == student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment => db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id && enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id))) enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
.WhereIn(planCourseIds, task => task.CourseId)
.Select(x => x.CourseId) .Select(x => x.CourseId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -76,11 +76,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
x.Capacity, x.Capacity,
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
@@ -157,11 +157,11 @@ public sealed class TeachingTasksController(
CollegeName = x.Course.College!.Name, CollegeName = x.Course.College!.Name,
x.Capacity, x.Capacity,
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
x.Notes, x.Notes,
x.PublishedAt, x.PublishedAt,
@@ -215,10 +215,10 @@ public sealed class TeachingTasksController(
CourseId = request.CourseId, CourseId = request.CourseId,
Capacity = request.Capacity, Capacity = request.Capacity,
StartWeek = request.StartWeek, StartWeek = request.StartWeek,
EndWeek = request.EndWeek, EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours, WeeklyHours = request.WeeklyHours,
SchedulingMode = request.SchedulingMode, SchedulingMode = request.SchedulingMode,
Notes = Normalize(request.Notes) Notes = Normalize(request.Notes)
}; };
SetAssignments(task, request); SetAssignments(task, request);
db.TeachingTasks.Add(task); db.TeachingTasks.Add(task);
@@ -249,10 +249,10 @@ public sealed class TeachingTasksController(
task.CourseId = request.CourseId; task.CourseId = request.CourseId;
task.Capacity = request.Capacity; task.Capacity = request.Capacity;
task.StartWeek = request.StartWeek; task.StartWeek = request.StartWeek;
task.EndWeek = request.EndWeek; task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours; task.WeeklyHours = request.WeeklyHours;
task.SchedulingMode = request.SchedulingMode; task.SchedulingMode = request.SchedulingMode;
task.Notes = Normalize(request.Notes); task.Notes = Normalize(request.Notes);
db.TeachingTaskTeachers.RemoveRange(task.Teachers); db.TeachingTaskTeachers.RemoveRange(task.Teachers);
db.TeachingTaskClasses.RemoveRange(task.Classes); db.TeachingTaskClasses.RemoveRange(task.Classes);
task.Teachers = []; task.Teachers = [];
@@ -342,7 +342,7 @@ public sealed class TeachingTasksController(
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.Where(x => ids.Contains(x.Id)) .WhereIn(ids, x => x.Id)
.OrderBy(x => x.TaskNumber) .OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (tasks.Count != ids.Length) if (tasks.Count != ids.Length)
@@ -352,17 +352,17 @@ public sealed class TeachingTasksController(
switch (request.Operation) switch (request.Operation)
{ {
case TeachingTaskBatchOperation.Publish: case TeachingTaskBatchOperation.Publish:
{
var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
if (validation is not null) return ConflictProblem(validation);
var publishedAt = DateTime.UtcNow;
foreach (var task in tasks)
{ {
task.Status = TeachingTaskStatus.Published; var validation = await ValidatePublishingTasksAsync(tasks, cancellationToken);
task.PublishedAt = publishedAt; if (validation is not null) return ConflictProblem(validation);
var publishedAt = DateTime.UtcNow;
foreach (var task in tasks)
{
task.Status = TeachingTaskStatus.Published;
task.PublishedAt = publishedAt;
}
break;
} }
break;
}
case TeachingTaskBatchOperation.Delete: case TeachingTaskBatchOperation.Delete:
if (tasks.Any(x => if (tasks.Any(x =>
x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed))) x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed)))
@@ -414,14 +414,14 @@ public sealed class TeachingTasksController(
.FirstOrDefaultAsync( .FirstOrDefaultAsync(
x => x.Id == request.CourseId && x.IsEnabled, x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken); cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid(); if (!CanManage(course)) return Forbid();
var hoursProblem = TeachingTaskHours.Validate( var hoursProblem = TeachingTaskHours.Validate(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
var scopedCollegeId = ScopedCollegeId(); var scopedCollegeId = ScopedCollegeId();
@@ -431,7 +431,8 @@ public sealed class TeachingTasksController(
var classIds = request.ClassIds.Distinct().ToArray(); var classIds = request.ClassIds.Distinct().ToArray();
if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。"); if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。");
var classesQuery = db.AdministrativeClasses var classesQuery = db.AdministrativeClasses
.Where(x => classIds.Contains(x.Id) && x.IsEnabled) .Where(x => x.IsEnabled)
.WhereIn(classIds, x => x.Id)
.Include(x => x.Major) .Include(x => x.Major)
.Include(x => x.Students) .Include(x => x.Students)
.AsQueryable(); .AsQueryable();
@@ -443,123 +444,127 @@ public sealed class TeachingTasksController(
if (classes.Count != classIds.Length) if (classes.Count != classIds.Length)
return ValidationProblem("存在无效或不在当前数据范围内的行政班。"); return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
await using var transaction = await db.Database.BeginTransactionAsync( return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
IsolationLevel.Serializable, async transaction =>
cancellationToken);
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
.Where(x =>
classIds.Contains(x.AdministrativeClassId) &&
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.CourseId == request.CourseId)
.Select(x => x.AdministrativeClassId)
.Distinct()
.ToListAsync(cancellationToken);
if (assignedClassIds.Count > 0)
{
var names = classes
.Where(x => assignedClassIds.Contains(x.Id))
.Select(x => x.Name);
return ConflictProblem(
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
}
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId &&
x.Status == TeacherCourseApplicationStatus.Approved &&
x.Teacher!.Status == TeacherStatus.Active)
.Where(x => !scopedCollegeId.HasValue ||
x.Teacher!.CollegeId == scopedCollegeId.Value)
.Select(x => x.Teacher!)
.ToListAsync(cancellationToken);
if (eligibleTeachers.Count == 0)
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x =>
eligibleTeacherIds.Contains(x.TeacherId) &&
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.Status != TeachingTaskStatus.Closed)
.GroupBy(x => x.TeacherId)
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
var existingNumbers = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == request.AcademicTermId)
.Select(x => x.TaskNumber)
.ToHashSetAsync(cancellationToken);
var batchCode =
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
var groups = classes.Chunk(request.ClassesPerTask).ToList();
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
eligibleTeacherIds,
existingLoads,
groups.Count);
var created = new List<TeachingTask>();
for (var index = 0; index < groups.Count; index++)
{
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
var group = groups[index];
var taskNumber = NextTaskNumber(
term.Code,
course.Code,
index + 1,
existingNumbers);
existingNumbers.Add(taskNumber);
var studentCount = group.Sum(administrativeClass =>
administrativeClass.Students.Count(student =>
student.Status == StudentStatus.Active));
var task = new TeachingTask
{ {
TaskNumber = taskNumber, db.ChangeTracker.Clear();
Name = $"{course.Name}教学班 {index + 1:D2}", var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
AcademicTermId = term.Id, .Where(x =>
CourseId = course.Id, x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
Capacity = Math.Max(1, studentCount), x.TeachingTask.CourseId == request.CourseId)
StartWeek = request.StartWeek, .WhereIn(classIds, x => x.AdministrativeClassId)
EndWeek = request.EndWeek, .Select(x => x.AdministrativeClassId)
WeeklyHours = request.WeeklyHours, .Distinct()
GenerationBatchCode = batchCode, .ToListAsync(cancellationToken);
Notes = "公共课合班自动生成,发布前可继续调整。", if (assignedClassIds.Count > 0)
Teachers = {
[ var names = classes
new TeachingTaskTeacher .Where(x => assignedClassIds.Contains(x.Id))
.Select(x => x.Name);
return ConflictProblem(
$"以下行政班已生成该课程教学任务:{string.Join('、', names)}。");
}
var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId &&
x.Status == TeacherCourseApplicationStatus.Approved &&
x.Teacher!.Status == TeacherStatus.Active)
.Where(x => !scopedCollegeId.HasValue ||
x.Teacher!.CollegeId == scopedCollegeId.Value)
.Select(x => x.Teacher!)
.ToListAsync(cancellationToken);
if (eligibleTeachers.Count == 0)
return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。");
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
.Where(x =>
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
x.TeachingTask.Status != TeachingTaskStatus.Closed)
.WhereIn(eligibleTeacherIds, x => x.TeacherId)
.GroupBy(x => x.TeacherId)
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
var existingNumbers = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == request.AcademicTermId)
.Select(x => x.TaskNumber)
.ToHashSetAsync(cancellationToken);
var batchCode =
$"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31];
var groups = classes.Chunk(request.ClassesPerTask).ToList();
var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers(
eligibleTeacherIds,
existingLoads,
groups.Count);
var created = new List<TeachingTask>();
for (var index = 0; index < groups.Count; index++)
{
var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]);
var group = groups[index];
var taskNumber = NextTaskNumber(
term.Code,
course.Code,
index + 1,
existingNumbers);
existingNumbers.Add(taskNumber);
var studentCount = group.Sum(administrativeClass =>
administrativeClass.Students.Count(student =>
student.Status == StudentStatus.Active));
var task = new TeachingTask
{
TaskNumber = taskNumber,
Name = $"{course.Name}教学班 {index + 1:D2}",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = Math.Max(1, studentCount),
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
GenerationBatchCode = batchCode,
Notes = "公共课合班自动生成,发布前可继续调整。",
Teachers =
[
new TeachingTaskTeacher
{ {
TeacherId = teacher.Id, TeacherId = teacher.Id,
IsPrimary = true IsPrimary = true
} }
], ],
Classes = group.Select(administrativeClass => Classes = group.Select(administrativeClass =>
new TeachingTaskClass new TeachingTaskClass
{ {
AdministrativeClassId = administrativeClass.Id AdministrativeClassId = administrativeClass.Id
}).ToList() }).ToList()
}; };
created.Add(task); created.Add(task);
} }
db.TeachingTasks.AddRange(created); db.TeachingTasks.AddRange(created);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return Ok(new return Ok(new
{ {
BatchCode = batchCode, BatchCode = batchCode,
CreatedCount = created.Count, CreatedCount = created.Count,
Tasks = created.Select(task => new Tasks = created.Select(task => new
{ {
task.Id, task.Id,
task.TaskNumber, task.TaskNumber,
task.Name, task.Name,
TeacherName = eligibleTeachers TeacherName = eligibleTeachers
.First(x => x.Id == task.Teachers.Single().TeacherId).Name, .First(x => x.Id == task.Teachers.Single().TeacherId).Name,
ClassNames = classes ClassNames = classes
.Where(x => task.Classes.Any(item => .Where(x => task.Classes.Any(item =>
item.AdministrativeClassId == x.Id)) item.AdministrativeClassId == x.Id))
.Select(x => x.Name), .Select(x => x.Name),
task.Capacity task.Capacity
}) })
}); });
},
cancellationToken,
IsolationLevel.Serializable);
} }
private IQueryable<TeachingTask> ScopedTasks() private IQueryable<TeachingTask> ScopedTasks()
@@ -579,8 +584,8 @@ public sealed class TeachingTasksController(
return ValidationProblem("开始周不能晚于结束周。"); return ValidationProblem("开始周不能晚于结束周。");
var course = await db.Courses.AsNoTracking() var course = await db.Courses.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。"); if (course is null) return ValidationProblem("所选课程不存在或已停用。");
if (!CanManage(course)) return Forbid(); if (!CanManage(course)) return Forbid();
if (!Enum.IsDefined(request.SchedulingMode)) if (!Enum.IsDefined(request.SchedulingMode))
return ValidationProblem("授课方式无效。"); return ValidationProblem("授课方式无效。");
var hoursProblem = TeachingTaskHours.Validate( var hoursProblem = TeachingTaskHours.Validate(
@@ -589,7 +594,7 @@ public sealed class TeachingTasksController(
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId(); var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled, x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken)) cancellationToken))
@@ -599,25 +604,26 @@ public sealed class TeachingTasksController(
if (request.PrimaryTeacherId.HasValue && if (request.PrimaryTeacherId.HasValue &&
!teacherIds.Contains(request.PrimaryTeacherId.Value)) !teacherIds.Contains(request.PrimaryTeacherId.Value))
return ValidationProblem("主讲教师必须包含在授课教师中。"); return ValidationProblem("主讲教师必须包含在授课教师中。");
if (await db.Teachers.CountAsync( if (await db.Teachers
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active, .Where(x => x.Status == TeacherStatus.Active)
cancellationToken) != teacherIds.Length) .WhereIn(teacherIds, x => x.Id)
.CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效或非在职授课教师。"); return ValidationProblem("存在无效或非在职授课教师。");
if (teacherIds.Length > 0 && if (teacherIds.Length > 0 &&
await db.TeacherCourseApplications.CountAsync( await db.TeacherCourseApplications
x => .Where(x =>
x.AcademicTermId == request.AcademicTermId && x.AcademicTermId == request.AcademicTermId &&
x.CourseId == request.CourseId && x.CourseId == request.CourseId &&
teacherIds.Contains(x.TeacherId) && x.Status == TeacherCourseApplicationStatus.Approved)
x.Status == TeacherCourseApplicationStatus.Approved, .WhereIn(teacherIds, x => x.TeacherId)
cancellationToken) != teacherIds.Length) .CountAsync(cancellationToken) != teacherIds.Length)
return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。"); return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。");
var classIds = request.ClassIds.Distinct().ToArray(); var classIds = request.ClassIds.Distinct().ToArray();
if (classIds.Length > 0) if (classIds.Length > 0)
{ {
var classes = db.AdministrativeClasses.AsNoTracking() var classes = db.AdministrativeClasses.AsNoTracking()
.Where(x => classIds.Contains(x.Id)); .WhereIn(classIds, x => x.Id);
if (collegeId.HasValue) if (collegeId.HasValue)
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value); classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
if (await classes.CountAsync(cancellationToken) != classIds.Length) if (await classes.CountAsync(cancellationToken) != classIds.Length)
@@ -673,11 +679,10 @@ public sealed class TeachingTasksController(
.ToArray(); .ToArray();
var approvedApplications = await db.TeacherCourseApplications var approvedApplications = await db.TeacherCourseApplications
.AsNoTracking() .AsNoTracking()
.Where(x => .Where(x => x.Status == TeacherCourseApplicationStatus.Approved)
termIds.Contains(x.AcademicTermId) && .WhereIn(termIds, x => x.AcademicTermId)
courseIds.Contains(x.CourseId) && .WhereIn(courseIds, x => x.CourseId)
teacherIds.Contains(x.TeacherId) && .WhereIn(teacherIds, x => x.TeacherId)
x.Status == TeacherCourseApplicationStatus.Approved)
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId }) .Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var approvedAssignments = approvedApplications var approvedAssignments = approvedApplications
@@ -317,11 +317,12 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken); .CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken);
var activePeriodCount = configuredPeriodCount == 0 var activePeriodCount = configuredPeriodCount == 0
? requestedPeriods.Length ? requestedPeriods.Length
: await db.ScheduleTimeSlots.AsNoTracking().CountAsync(x => : await db.ScheduleTimeSlots.AsNoTracking()
x.AcademicTermId == academicTermId && .Where(x =>
x.IsEnabled && x.AcademicTermId == academicTermId &&
requestedPeriods.Contains(x.PeriodNumber), x.IsEnabled)
cancellationToken); .WhereIn(requestedPeriods, x => x.PeriodNumber)
.CountAsync(cancellationToken);
if (activePeriodCount != requestedPeriods.Length) if (activePeriodCount != requestedPeriods.Length)
return ValidationProblem("查询范围包含不存在或未启用的节次。"); return ValidationProblem("查询范围包含不存在或未启用的节次。");
@@ -357,8 +358,8 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
.Where(x => .Where(x =>
x.IsEnabled && x.IsEnabled &&
x.Building!.IsEnabled && x.Building!.IsEnabled &&
x.Building.Campus!.IsEnabled && x.Building.Campus!.IsEnabled)
!occupiedIds.Contains(x.Id)); .WhereNotIn(occupiedIds, x => x.Id);
if (campusId.HasValue) if (campusId.HasValue)
rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value); rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value);
if (buildingId.HasValue) if (buildingId.HasValue)
+117 -65
View File
@@ -25,8 +25,14 @@ public sealed class UsersController(
.OrderBy(x => x.UserName) .OrderBy(x => x.UserName)
.Select(x => new .Select(x => new
{ {
x.Id, x.UserName, x.DisplayName, x.StaffNumber, x.Id,
x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt x.UserName,
x.DisplayName,
x.StaffNumber,
x.CollegeId,
x.IsEnabled,
x.LastLoginAt,
x.CreatedAt
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -36,8 +42,14 @@ public sealed class UsersController(
var identityUser = await userManager.FindByIdAsync(user.Id.ToString()); var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
result.Add(new result.Add(new
{ {
user.Id, user.UserName, user.DisplayName, user.StaffNumber, user.Id,
user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt, user.UserName,
user.DisplayName,
user.StaffNumber,
user.CollegeId,
user.IsEnabled,
user.LastLoginAt,
user.CreatedAt,
Roles = identityUser is null Roles = identityUser is null
? [] ? []
: await userManager.GetRolesAsync(identityUser) : await userManager.GetRolesAsync(identityUser)
@@ -54,38 +66,50 @@ public sealed class UsersController(
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
[HttpPost] [HttpPost]
public async Task<ActionResult> Create(CreateUserRequest request) public async Task<ActionResult> Create(
CreateUserRequest request,
CancellationToken cancellationToken)
{ {
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles); var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles; if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber); var staffNumber = Normalize(request.StaffNumber);
var profiles = await ResolveProfilesAsync(staffNumber, roles); return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
if (profiles.Error is not null) return profiles.Error; async transaction =>
{
db.ChangeTracker.Clear();
var profiles = await ResolveProfilesAsync(staffNumber, roles);
if (profiles.Error is not null) return profiles.Error;
var user = new ApplicationUser var user = new ApplicationUser
{ {
UserName = request.UserName.Trim(), UserName = request.UserName.Trim(),
DisplayName = request.DisplayName.Trim(), DisplayName = request.DisplayName.Trim(),
StaffNumber = staffNumber, StaffNumber = staffNumber,
CollegeId = request.CollegeId, CollegeId = request.CollegeId,
LockoutEnabled = true, LockoutEnabled = true,
IsEnabled = true IsEnabled = true
}; };
await using var transaction = await db.Database.BeginTransactionAsync(); var result = await userManager.CreateAsync(user, request.Password);
var result = await userManager.CreateAsync(user, request.Password); if (!result.Succeeded)
if (!result.Succeeded) return IdentityValidationProblem(result);
return IdentityValidationProblem(result);
result = await userManager.AddToRolesAsync(user, roles); result = await userManager.AddToRolesAsync(user, roles);
if (!result.Succeeded) if (!result.Succeeded)
return IdentityValidationProblem(result); return IdentityValidationProblem(result);
if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id; if (profiles.Teacher is not null)
if (profiles.Student is not null) profiles.Student.UserId = user.Id; profiles.Teacher.UserId = user.Id;
await db.SaveChangesAsync(); if (profiles.Student is not null)
await transaction.CommitAsync(); profiles.Student.UserId = user.Id;
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id }); await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return CreatedAtAction(
nameof(GetUsers),
new { id = user.Id },
new { user.Id });
},
cancellationToken);
} }
[HttpPut("{id:guid}/status")] [HttpPut("{id:guid}/status")]
@@ -103,51 +127,79 @@ public sealed class UsersController(
} }
[HttpPut("{id:guid}/roles")] [HttpPut("{id:guid}/roles")]
public async Task<IActionResult> SetRoles(Guid id, SetRolesRequest request) public async Task<IActionResult> SetRoles(
Guid id,
SetRolesRequest request,
CancellationToken cancellationToken)
{ {
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var invalidRoles = ValidateRoles(roles); var invalidRoles = ValidateRoles(roles);
if (invalidRoles is not null) return invalidRoles; if (invalidRoles is not null) return invalidRoles;
var staffNumber = Normalize(request.StaffNumber); var staffNumber = Normalize(request.StaffNumber);
var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id); return await db.ExecuteInRetriableTransactionAsync<IActionResult>(
if (profiles.Error is not null) return profiles.Error; async transaction =>
{
db.ChangeTracker.Clear();
var user = await userManager.FindByIdAsync(id.ToString());
if (user is null) return NotFound();
var profiles = await ResolveProfilesAsync(
staffNumber,
roles,
user.Id);
if (profiles.Error is not null) return profiles.Error;
var existing = await userManager.GetRolesAsync(user); var existing = await userManager.GetRolesAsync(user);
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() && if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
existing.Contains(SystemRoles.SuperAdmin) && existing.Contains(SystemRoles.SuperAdmin) &&
!roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase)) !roles.Contains(
{ SystemRoles.SuperAdmin,
return ValidationProblem("不能移除当前账号的超级管理员角色。"); StringComparer.OrdinalIgnoreCase))
} {
return ValidationProblem(
"不能移除当前账号的超级管理员角色。");
}
await using var transaction = await db.Database.BeginTransactionAsync(); user.StaffNumber = staffNumber;
user.StaffNumber = staffNumber; user.CollegeId = request.CollegeId;
user.CollegeId = request.CollegeId; var updateResult = await userManager.UpdateAsync(user);
var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded)
if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult); return IdentityValidationProblem(updateResult);
var removeResult = await userManager.RemoveFromRolesAsync( var removeResult = await userManager.RemoveFromRolesAsync(
user, user,
existing.Except(roles, StringComparer.OrdinalIgnoreCase)); existing.Except(roles, StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult); if (!removeResult.Succeeded)
var addResult = await userManager.AddToRolesAsync( return IdentityValidationProblem(removeResult);
user, var addResult = await userManager.AddToRolesAsync(
roles.Except(existing, StringComparer.OrdinalIgnoreCase)); user,
if (!addResult.Succeeded) return IdentityValidationProblem(addResult); roles.Except(existing, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
return IdentityValidationProblem(addResult);
var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync(); var linkedTeachers = await db.Teachers
var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync(); .Where(x => x.UserId == id)
if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase)) .ToListAsync();
foreach (var teacher in linkedTeachers) teacher.UserId = null; var linkedStudents = await db.Students
if (!roles.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase)) .Where(x => x.UserId == id)
foreach (var student in linkedStudents) student.UserId = null; .ToListAsync();
if (profiles.Teacher is not null) profiles.Teacher.UserId = id; if (!roles.Contains(
if (profiles.Student is not null) profiles.Student.UserId = id; SystemRoles.Teacher,
await db.SaveChangesAsync(); StringComparer.OrdinalIgnoreCase))
await transaction.CommitAsync(); foreach (var teacher in linkedTeachers)
return NoContent(); teacher.UserId = null;
if (!roles.Contains(
SystemRoles.Student,
StringComparer.OrdinalIgnoreCase))
foreach (var student in linkedStudents)
student.UserId = null;
if (profiles.Teacher is not null)
profiles.Teacher.UserId = id;
if (profiles.Student is not null)
profiles.Student.UserId = id;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken);
} }
private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles) private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles)
+196 -153
View File
@@ -83,169 +83,212 @@ public sealed class UsersExcelController(
} }
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。"); if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。");
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync( return await db.ExecuteInRetriableTransactionAsync<
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken); ActionResult<ExcelImportResult>>(
var users = await userManager.Users.ToDictionaryAsync( async transaction =>
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
var teachers = await db.Teachers.ToDictionaryAsync(
x => x.TeacherNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var students = await db.Students.ToDictionaryAsync(
x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var errors = new List<string>();
var plans = new List<UserImportPlan>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
var userName = Required(row, "登录账号", errors);
var displayName = Required(row, "姓名", errors);
if (userName is null || displayName is null) continue;
if (!seenNames.Add(userName))
{ {
errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。"); db.ChangeTracker.Clear();
continue; var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
} x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var users = await userManager.Users.ToDictionaryAsync(
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
var teachers = await db.Teachers.ToDictionaryAsync(
x => x.TeacherNumber,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var students = await db.Students.ToDictionaryAsync(
x => x.StudentNumber,
StringComparer.OrdinalIgnoreCase,
cancellationToken);
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var errors = new List<string>();
var plans = new List<UserImportPlan>();
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedTeachers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var plannedStudents = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = users.GetValueOrDefault(userName); foreach (var row in rows)
var password = Optional(row, "初始密码");
if (existing is null && (password?.Length ?? 0) < 8)
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
if (password is { Length: > 0 and < 8 })
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
var roleNames = ParseRoles(row, errors);
var enabled = ParseEnabled(row, errors);
var staffNumber = Optional(row, "工号/学号");
Guid? collegeId = null;
var collegeCode = Optional(row, "学院编码");
if (collegeCode is not null)
{
if (!colleges.TryGetValue(collegeCode, out var college))
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
else collegeId = college.Id;
}
Teacher? teacher = null;
Student? student = null;
if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher))
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
else if (teacher.UserId.HasValue && teacher.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
else if (!plannedTeachers.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
}
if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null || !students.TryGetValue(staffNumber, out student))
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
else if (student.UserId.HasValue && student.UserId != existing?.Id)
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
else if (!plannedStudents.Add(staffNumber))
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
}
if (existing?.Id.ToString() == currentUserId)
{
if (!enabled)
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
if (!roleNames.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
errors.Add($"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
}
plans.Add(new UserImportPlan(
row.RowNumber, existing, userName, displayName, password,
staffNumber, collegeId, roleNames, enabled, teacher, student));
}
if (errors.Count > 0) return ImportValidationProblem(errors);
var created = 0;
var updated = 0;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
foreach (var plan in plans)
{
var user = plan.Existing;
if (user is null)
{
user = new ApplicationUser
{ {
UserName = plan.UserName, var userName = Required(row, "登录账号", errors);
DisplayName = plan.DisplayName, var displayName = Required(row, "姓名", errors);
StaffNumber = plan.StaffNumber, if (userName is null || displayName is null) continue;
CollegeId = plan.CollegeId, if (!seenNames.Add(userName))
IsEnabled = plan.IsEnabled, {
LockoutEnabled = true errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。");
}; continue;
var createResult = await userManager.CreateAsync(user, plan.Password!); }
if (!createResult.Succeeded)
{ var existing = users.GetValueOrDefault(userName);
await transaction.RollbackAsync(cancellationToken); var password = Optional(row, "初始密码");
return IdentityValidationProblem(createResult, plan.RowNumber); if (existing is null && (password?.Length ?? 0) < 8)
errors.Add($"第 {row.RowNumber} 行:新账号的初始密码至少需要 8 位。");
if (password is { Length: > 0 and < 8 })
errors.Add($"第 {row.RowNumber} 行:密码至少需要 8 位。");
var roleNames = ParseRoles(row, errors);
var enabled = ParseEnabled(row, errors);
var staffNumber = Optional(row, "工号/学号");
Guid? collegeId = null;
var collegeCode = Optional(row, "学院编码");
if (collegeCode is not null)
{
if (!colleges.TryGetValue(collegeCode, out var college))
errors.Add(
$"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
else collegeId = college.Id;
}
Teacher? teacher = null;
Student? student = null;
if (roleNames.Contains(
SystemRoles.Teacher,
StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null ||
!teachers.TryGetValue(staffNumber, out teacher))
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
else if (teacher.UserId.HasValue &&
teacher.UserId != existing?.Id)
errors.Add(
$"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
else if (!plannedTeachers.Add(staffNumber))
errors.Add(
$"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
}
if (roleNames.Contains(
SystemRoles.Student,
StringComparer.OrdinalIgnoreCase))
{
if (staffNumber is null ||
!students.TryGetValue(staffNumber, out student))
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
else if (student.UserId.HasValue &&
student.UserId != existing?.Id)
errors.Add(
$"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
else if (!plannedStudents.Add(staffNumber))
errors.Add(
$"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
}
if (existing?.Id.ToString() == currentUserId)
{
if (!enabled)
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
if (!roleNames.Contains(
SystemRoles.SuperAdmin,
StringComparer.OrdinalIgnoreCase))
errors.Add(
$"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
}
plans.Add(new UserImportPlan(
row.RowNumber, existing, userName, displayName, password,
staffNumber, collegeId, roleNames, enabled, teacher, student));
} }
created++;
} if (errors.Count > 0) return ImportValidationProblem(errors);
else
{ var created = 0;
user.DisplayName = plan.DisplayName; var updated = 0;
user.StaffNumber = plan.StaffNumber; foreach (var plan in plans)
user.CollegeId = plan.CollegeId;
user.IsEnabled = plan.IsEnabled;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{ {
await transaction.RollbackAsync(cancellationToken); var user = plan.Existing;
return IdentityValidationProblem(updateResult, plan.RowNumber); if (user is null)
} {
if (plan.Password is not null) user = new ApplicationUser
{ {
var token = await userManager.GeneratePasswordResetTokenAsync(user); UserName = plan.UserName,
var passwordResult = await userManager.ResetPasswordAsync( DisplayName = plan.DisplayName,
user, token, plan.Password); StaffNumber = plan.StaffNumber,
if (!passwordResult.Succeeded) CollegeId = plan.CollegeId,
IsEnabled = plan.IsEnabled,
LockoutEnabled = true
};
var createResult = await userManager.CreateAsync(
user,
plan.Password!);
if (!createResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
createResult,
plan.RowNumber);
}
created++;
}
else
{
user.DisplayName = plan.DisplayName;
user.StaffNumber = plan.StaffNumber;
user.CollegeId = plan.CollegeId;
user.IsEnabled = plan.IsEnabled;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
updateResult,
plan.RowNumber);
}
if (plan.Password is not null)
{
var token = await userManager
.GeneratePasswordResetTokenAsync(user);
var passwordResult = await userManager.ResetPasswordAsync(
user,
token,
plan.Password);
if (!passwordResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(
passwordResult,
plan.RowNumber);
}
}
updated++;
}
var existingRoles = await userManager.GetRolesAsync(user);
var removeResult = await userManager.RemoveFromRolesAsync(
user,
existingRoles.Except(
plan.Roles,
StringComparer.OrdinalIgnoreCase));
if (!removeResult.Succeeded)
{ {
await transaction.RollbackAsync(cancellationToken); await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(passwordResult, plan.RowNumber); return IdentityValidationProblem(removeResult, plan.RowNumber);
} }
var addResult = await userManager.AddToRolesAsync(
user,
plan.Roles.Except(
existingRoles,
StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(addResult, plan.RowNumber);
}
foreach (var linked in await db.Teachers
.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Teacher) linked.UserId = null;
foreach (var linked in await db.Students
.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Student) linked.UserId = null;
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
if (plan.Student is not null) plan.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
} }
updated++;
}
var existingRoles = await userManager.GetRolesAsync(user); await transaction.CommitAsync(cancellationToken);
var removeResult = await userManager.RemoveFromRolesAsync( return Ok(new ExcelImportResult(created, updated, plans.Count));
user, },
existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase)); cancellationToken);
if (!removeResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(removeResult, plan.RowNumber);
}
var addResult = await userManager.AddToRolesAsync(
user,
plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase));
if (!addResult.Succeeded)
{
await transaction.RollbackAsync(cancellationToken);
return IdentityValidationProblem(addResult, plan.RowNumber);
}
foreach (var linked in await db.Teachers.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Teacher) linked.UserId = null;
foreach (var linked in await db.Students.Where(x => x.UserId == user.Id)
.ToListAsync(cancellationToken))
if (linked != plan.Student) linked.UserId = null;
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
if (plan.Student is not null) plan.Student.UserId = user.Id;
await db.SaveChangesAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return Ok(new ExcelImportResult(created, updated, plans.Count));
} }
[HttpPut("{id:guid}/password")] [HttpPut("{id:guid}/password")]
@@ -34,9 +34,13 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
{ {
db.WarningRules.Add(new WarningRule db.WarningRules.Add(new WarningRule
{ {
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(), AcademicTermId = academicTermId,
Threshold = r.Threshold, IsEnabled = r.IsEnabled, Type = r.Type,
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor, Name = r.Name.Trim(),
Threshold = r.Threshold,
IsEnabled = r.IsEnabled,
NotifyStudent = r.NotifyStudent,
NotifyCounselor = r.NotifyCounselor,
Description = r.Description?.Trim(), Description = r.Description?.Trim(),
AutoCheckEnabled = r.AutoCheckEnabled, AutoCheckEnabled = r.AutoCheckEnabled,
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek, CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
@@ -110,7 +114,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
{ {
var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct); var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct);
if (classIds.Count > 0) if (classIds.Count > 0)
q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId)); q = q.WhereIn(classIds, x => x.Student!.AdministrativeClassId);
else if (scope.Current.RestrictedCollegeId.HasValue) else if (scope.Current.RestrictedCollegeId.HasValue)
q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId); q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId);
} }
@@ -168,7 +168,7 @@ public sealed class ExamArrangementService(AppDbContext db)
.ToHashSet(); .ToHashSet();
if (occupiedRoomIds.Count > 0) if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id)); query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
// Exclude classrooms occupied by DB sessions not yet tracked in memory // Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking() var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
@@ -181,7 +181,7 @@ public sealed class ExamArrangementService(AppDbContext db)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0) if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id)); query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query return await query
.OrderBy(x => x.Capacity) .OrderBy(x => x.Capacity)
@@ -213,8 +213,8 @@ public sealed class ExamArrangementService(AppDbContext db)
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking() return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyTeacherIds.Contains(x.Id)) .WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid()) .OrderBy(x => Guid.NewGuid())
.Take(needed) .Take(needed)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -164,7 +164,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.ToHashSet(); .ToHashSet();
if (occupiedRoomIds.Count > 0) if (occupiedRoomIds.Count > 0)
query = query.Where(x => !occupiedRoomIds.Contains(x.Id)); query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking() var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking()
.Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId && .Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId &&
@@ -176,7 +176,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0) if (dbOccupiedRooms.Count > 0)
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id)); query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query return await query
.OrderBy(x => x.Capacity) .OrderBy(x => x.Capacity)
@@ -208,8 +208,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
return await db.Teachers.AsNoTracking() return await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && .Where(x => x.Status == TeacherStatus.Active)
!busyTeacherIds.Contains(x.Id)) .WhereNotIn(busyTeacherIds, x => x.Id)
.OrderBy(x => Guid.NewGuid()) .OrderBy(x => Guid.NewGuid())
.Take(needed) .Take(needed)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -32,8 +32,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
// 1. Approved deferred exams (highest priority) // 1. Approved deferred exams (highest priority)
var deferredStudents = await db.DeferredExams.AsNoTracking() var deferredStudents = await db.DeferredExams.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId && .Where(x => x.TeachingTaskId == teachingTaskId &&
x.Status == ApprovalStatus.Approved && x.Status == ApprovalStatus.Approved)
!enrolledSet.Contains(x.StudentId)) .WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -58,8 +58,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
// 2. Absent students // 2. Absent students
var absentStudents = await db.GradeRecords.AsNoTracking() var absentStudents = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == gradeSheet.Id && .Where(x => x.GradeSheetId == gradeSheet.Id &&
x.ExamStatus == GradeExamStatus.Absent && x.ExamStatus == GradeExamStatus.Absent)
!enrolledSet.Contains(x.StudentId)) .WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -85,8 +85,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
var failedStudents = await db.GradeRecords.AsNoTracking() var failedStudents = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheetId == gradeSheet.Id && .Where(x => x.GradeSheetId == gradeSheet.Id &&
x.ExamStatus == GradeExamStatus.Normal && x.ExamStatus == GradeExamStatus.Normal &&
x.TotalScore < 60 && x.TotalScore < 60)
!enrolledSet.Contains(x.StudentId)) .WhereNotIn(enrolledSet, x => x.StudentId)
.Select(x => new .Select(x => new
{ {
x.StudentId, x.StudentId,
@@ -0,0 +1,47 @@
using System.Linq.Expressions;
namespace Jiaowu.Api.Infrastructure.Persistence;
public static class QueryableCollectionExtensions
{
public static IQueryable<TEntity> WhereIn<TEntity, TValue>(
this IQueryable<TEntity> source,
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector)
{
var predicate = BuildPredicate(values, valueSelector, Expression.OrElse, false);
return source.Where(predicate);
}
public static IQueryable<TEntity> WhereNotIn<TEntity, TValue>(
this IQueryable<TEntity> source,
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector)
{
var predicate = BuildPredicate(values, valueSelector, Expression.AndAlso, true);
return source.Where(predicate);
}
private static Expression<Func<TEntity, bool>> BuildPredicate<TEntity, TValue>(
IEnumerable<TValue> values,
Expression<Func<TEntity, TValue>> valueSelector,
Func<Expression, Expression, BinaryExpression> combine,
bool negate)
{
Expression? body = null;
foreach (var value in values.Distinct())
{
Expression comparison = Expression.Equal(
valueSelector.Body,
Expression.Constant(value, typeof(TValue)));
if (negate)
comparison = Expression.Not(comparison);
body = body is null ? comparison : combine(body, comparison);
}
body ??= Expression.Constant(negate);
return Expression.Lambda<Func<TEntity, bool>>(
body,
valueSelector.Parameters);
}
}
@@ -0,0 +1,44 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace Jiaowu.Api.Infrastructure.Persistence;
public static class RetriableTransactionExtensions
{
public static Task<TResult> ExecuteInRetriableTransactionAsync<TResult>(
this DbContext db,
Func<IDbContextTransaction, Task<TResult>> operation,
CancellationToken cancellationToken,
IsolationLevel? isolationLevel = null)
{
var strategy = db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var transaction = isolationLevel.HasValue
? await db.Database.BeginTransactionAsync(
isolationLevel.Value,
cancellationToken)
: await db.Database.BeginTransactionAsync(cancellationToken);
return await operation(transaction);
});
}
public static Task ExecuteInRetriableTransactionAsync(
this DbContext db,
Func<IDbContextTransaction, Task> operation,
CancellationToken cancellationToken,
IsolationLevel? isolationLevel = null)
{
var strategy = db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var transaction = isolationLevel.HasValue
? await db.Database.BeginTransactionAsync(
isolationLevel.Value,
cancellationToken)
: await db.Database.BeginTransactionAsync(cancellationToken);
await operation(transaction);
});
}
}
@@ -38,8 +38,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
.ThenByDescending(x => x.Capacity) .ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber) .ThenBy(x => x.TaskNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var taskIds = tasks.Select(task => task.Id).ToArray();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking() var classrooms = await db.Classrooms.AsNoTracking()
@@ -169,10 +169,7 @@ public sealed class AutomaticScheduleJobProcessor(
job.CompletedTasks = result.CompletedTasks; job.CompletedTasks = result.CompletedTasks;
job.MessagesJson = JsonSerializer.Serialize(result.Messages); job.MessagesJson = JsonSerializer.Serialize(result.Messages);
job.CompletedAt = DateTime.UtcNow; job.CompletedAt = DateTime.UtcNow;
await using var transaction =
await db.Database.BeginTransactionAsync(stoppingToken);
await db.SaveChangesAsync(stoppingToken); await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
logger.LogInformation( logger.LogInformation(
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.", "Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
@@ -141,35 +141,46 @@ public sealed class SchedulePublishJobProcessor(
ReportProgress, ReportProgress,
stoppingToken); stoppingToken);
await using var transaction = var publishedPlanId = plan.Id;
await db.Database.BeginTransactionAsync(stoppingToken); await db.ExecuteInRetriableTransactionAsync(
if (plan.Status != SchedulePlanStatus.Draft) async transaction =>
throw new SchedulePublishValidationException( {
"排课草稿状态已发生变化,请刷新后重试。"); db.ChangeTracker.Clear();
var publishJob = await db.SchedulePublishJobs
.FirstAsync(x => x.Id == jobId, stoppingToken);
var publishPlan = await db.SchedulePlans
.FirstAsync(x => x.Id == publishedPlanId, stoppingToken);
if (publishPlan.Status != SchedulePlanStatus.Draft)
{
throw new SchedulePublishValidationException(
"排课草稿状态已发生变化,请刷新后重试。");
}
var previous = await db.SchedulePlans var previous = await db.SchedulePlans
.Where(x => .Where(x =>
x.Id != plan.Id && x.Id != publishPlan.Id &&
x.AcademicTermId == plan.AcademicTermId && x.AcademicTermId == publishPlan.AcademicTermId &&
x.Status == SchedulePlanStatus.Published) x.Status == SchedulePlanStatus.Published)
.ToListAsync(stoppingToken); .ToListAsync(stoppingToken);
foreach (var oldPlan in previous) foreach (var oldPlan in previous)
oldPlan.Status = SchedulePlanStatus.Archived; oldPlan.Status = SchedulePlanStatus.Archived;
plan.Status = SchedulePlanStatus.Published; publishPlan.Status = SchedulePlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow; publishPlan.PublishedAt = DateTime.UtcNow;
job.Status = SchedulePublishJobStatus.Succeeded; publishJob.Status = SchedulePublishJobStatus.Succeeded;
job.ActiveAcademicTermId = null; publishJob.ActiveAcademicTermId = null;
job.CompletedSteps = job.TotalSteps; publishJob.CompletedSteps = publishJob.TotalSteps;
job.CurrentStep = "课表已发布"; publishJob.CurrentStep = "课表已发布";
job.CompletedAt = DateTime.UtcNow; publishJob.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(stoppingToken); await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken); await transaction.CommitAsync(stoppingToken);
},
stoppingToken);
logger.LogInformation( logger.LogInformation(
"Schedule publish job {JobId} published plan {SchedulePlanId}.", "Schedule publish job {JobId} published plan {SchedulePlanId}.",
job.Id, jobId,
plan.Id); publishedPlanId);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@@ -245,7 +256,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList(); var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId)) .WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
@@ -14,6 +14,31 @@ namespace Jiaowu.Api.Tests;
public sealed class AuthControllerTests public sealed class AuthControllerTests
{ {
[Fact]
public async Task RetriableTransaction_WorksWithRetryingExecutionStrategy()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>()
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
await db.ExecuteInRetriableTransactionAsync(
async transaction =>
{
db.Colleges.Add(new College { Code = "TX", Name = "事务测试学院" });
await db.SaveChangesAsync();
await transaction.CommitAsync();
},
CancellationToken.None);
Assert.Equal(1, await db.Colleges.CountAsync());
}
[Fact] [Fact]
public async Task ActivateStudent_WorksWithRetryingExecutionStrategy() public async Task ActivateStudent_WorksWithRetryingExecutionStrategy()
{ {
@@ -52,6 +52,25 @@ public sealed class MySqlMigrationTests
}); });
} }
[Fact]
public void MySql_guid_collections_use_provider_safe_predicates()
{
using var db = new AppDbContext(CreateMySqlOptions());
var ids = new[]
{
Guid.Parse("11111111-1111-1111-1111-111111111111"),
Guid.Parse("22222222-2222-2222-2222-222222222222")
};
var sql = db.Colleges
.WhereIn(ids, x => x.Id)
.ToQueryString();
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(ids[0].ToString(), sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
}
private static DbContextOptions<AppDbContext> CreateMySqlOptions() => private static DbContextOptions<AppDbContext> CreateMySqlOptions() =>
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseMySQL( .UseMySQL(
@@ -74,8 +74,9 @@ public sealed class PersonnelControllerTests
Assert.Equal(teacher.Name, user.DisplayName); Assert.Equal(teacher.Name, user.DisplayName);
Assert.Equal(teacher.CollegeId, user.CollegeId); Assert.Equal(teacher.CollegeId, user.CollegeId);
Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher)); Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher));
await db.Entry(teacher).ReloadAsync(); db.ChangeTracker.Clear();
Assert.Equal(user.Id, teacher.UserId); var linkedTeacher = await db.Teachers.SingleAsync(x => x.Id == teacher.Id);
Assert.Equal(user.Id, linkedTeacher.UserId);
} }
private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope