From 72297aa23f0d3f0425c94bab214be1097f0b64a5 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 26 Jul 2026 10:19:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMysql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AttendanceController.cs | 26 +- .../Controllers/BaseDataExcelController.cs | 104 ++- .../Controllers/CourseSelectionsController.cs | 715 +++++++++--------- .../Controllers/CoursesExcelController.cs | 55 +- .../Controllers/CurriculumPlansController.cs | 99 +-- .../Controllers/DegreeAwardsController.cs | 53 +- .../Controllers/EvaluationsController.cs | 55 +- src/Jiaowu.Api/Controllers/ExamsController.cs | 90 ++- .../Controllers/GraduationAuditsController.cs | 62 +- .../Controllers/MakeupExamsController.cs | 120 +-- .../Controllers/PersonnelController.cs | 92 ++- .../Controllers/PersonnelExcelController.cs | 57 +- .../Controllers/ScheduleSettingsController.cs | 145 ++-- .../Controllers/StatisticsController.cs | 23 +- .../StudentCurriculumController.cs | 10 +- .../Controllers/TeachingTasksController.cs | 335 ++++---- .../TimetableManagementController.cs | 15 +- src/Jiaowu.Api/Controllers/UsersController.cs | 182 +++-- .../Controllers/UsersExcelController.cs | 349 +++++---- .../Controllers/WarningsController.cs | 12 +- .../Exams/ExamArrangementService.cs | 8 +- .../Exams/MakeupExamArrangementService.cs | 8 +- .../Exams/MakeupExamEligibilityService.cs | 12 +- .../QueryableCollectionExtensions.cs | 47 ++ .../RetriableTransactionExtensions.cs | 44 ++ .../Scheduling/AutomaticScheduleGenerator.cs | 3 +- .../Scheduling/AutomaticScheduleJobs.cs | 3 - .../Scheduling/SchedulePublishJobs.cs | 61 +- tests/Jiaowu.Api.Tests/AuthControllerTests.cs | 25 + tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs | 19 + .../PersonnelControllerTests.cs | 5 +- 31 files changed, 1642 insertions(+), 1192 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/QueryableCollectionExtensions.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/RetriableTransactionExtensions.cs diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index 34ffd53..75403b2 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -178,13 +178,25 @@ public sealed class AttendanceController( { Sheet = new { - sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate, - sheet.Status, sheet.Notes, sheet.SubmittedAt, - sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName, + sheet.Id, + sheet.TeachingTaskId, + sheet.Name, + sheet.AttendanceDate, + sheet.Status, + sheet.Notes, + sheet.SubmittedAt, + sheet.TaskNumber, + sheet.TaskName, + sheet.CourseCode, + sheet.CourseName, Records = sheet.Records.Select(r => new { - r.StudentId, r.StudentNumber, r.Name, r.ClassName, - r.Status, r.Notes, + r.StudentId, + r.StudentNumber, + r.Name, + r.ClassName, + r.Status, + r.Notes, IsExempt = exemptStudentIds.Contains(r.StudentId), IsDeferred = deferredStudentIds.Contains(r.StudentId) }) @@ -504,8 +516,8 @@ public sealed class AttendanceController( var source = db.AttendanceRecords.AsNoTracking() .Where(r => - r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted && - targetClassIds.Contains(r.Student!.AdministrativeClassId)); + r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted) + .WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId); if (academicTermId.HasValue) source = source.Where(r => r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId); diff --git a/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs index d2ab7fd..4ff9049 100644 --- a/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs +++ b/src/Jiaowu.Api/Controllers/BaseDataExcelController.cs @@ -77,45 +77,57 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase } if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); - var errors = new List(); - ExcelImportResult result; - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - try - { - result = kind.ToLowerInvariant() switch + return await db.ExecuteInRetriableTransactionAsync< + ActionResult>( + async transaction => { - "campuses" => await ImportCampusesAsync(rows, errors, cancellationToken), - "colleges" => await ImportCollegesAsync(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() - }; + var errors = new List(); + ExcelImportResult result; + try + { + result = kind.ToLowerInvariant() switch + { + "campuses" => await ImportCampusesAsync( + rows, errors, cancellationToken), + "colleges" => await ImportCollegesAsync( + 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) - { - await transaction.RollbackAsync(cancellationToken); - return ImportValidationProblem(errors); - } + if (errors.Count > 0) + { + await transaction.RollbackAsync(cancellationToken); + return ImportValidationProblem(errors); + } - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return Ok(result); - } - catch (DbUpdateException) - { - await transaction.RollbackAsync(cancellationToken); - return Conflict(new ProblemDetails - { - Title = "导入失败", - Detail = "存在重复编码或无效关联,未写入任何数据。", - Status = StatusCodes.Status409Conflict - }); - } + await db.SaveChangesAsync(cancellationToken); + 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>> GetExportRowsAsync( @@ -256,7 +268,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase { entity = new Major { - Code = code, Name = name, CollegeId = college.Id, + Code = code, + Name = name, + CollegeId = college.Id, DegreeType = degreeType }; db.Majors.Add(entity); @@ -316,7 +330,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase { 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); existing[code] = entity; @@ -362,8 +379,11 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase { entity = new AcademicTerm { - Code = code, Name = name, AcademicYear = academicYear, - Season = season.Value, StartDate = startDate.Value, + Code = code, + Name = name, + AcademicYear = academicYear, + Season = season.Value, + StartDate = startDate.Value, EndDate = endDate.Value }; db.AcademicTerms.Add(entity); @@ -461,7 +481,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase { entity = new Classroom { - Code = code, Name = name, BuildingId = building.Id, + Code = code, + Name = name, + BuildingId = building.Id, RoomType = roomType }; db.Classrooms.Add(entity); diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 75aedba..29cf83e 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -369,12 +369,12 @@ public sealed class CourseSelectionsController( var source = db.Students.AsNoTracking() .Where(x => x.Status == StudentStatus.Active && - (offering.IsOpenToAll || - offering.ClassIds.Contains(x.AdministrativeClassId)) && !db.CourseEnrollments.Any(enrollment => enrollment.CourseSelectionOfferingId == id && enrollment.StudentId == x.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled)); + if (!offering.IsOpenToAll) + source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); @@ -415,157 +415,160 @@ public sealed class CourseSelectionsController( if (studentIds.Length > 100) return ValidationProblem("单次最多可为 100 名学生代选。"); - await using var transaction = await db.Database.BeginTransactionAsync( - IsolationLevel.Serializable, - cancellationToken); - var offering = await db.CourseSelectionOfferings - .Include(x => x.CourseSelectionRound) - .Include(x => x.TeachingTask) - .ThenInclude(x => x!.Course) - .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 await db.ExecuteInRetriableTransactionAsync( + async transaction => { - return ConflictProblem( - $"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。"); - } + db.ChangeTracker.Clear(); + 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() - .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 + 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 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) { - CourseSelectionOfferingId = id, - StudentId = student.Id, - EnrolledAt = now - }); - } - else - { - enrollment.Status = CourseEnrollmentStatus.Enrolled; - enrollment.EnrolledAt = now; - enrollment.WithdrawnAt = null; - } - } + return ConflictProblem( + $"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。"); + } - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return Ok(new { EnrolledCount = students.Count }); + var existingEnrollments = await db.CourseEnrollments + .Where(x => x.CourseSelectionOfferingId == id) + .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")] @@ -581,73 +584,77 @@ public sealed class CourseSelectionsController( if (studentIds.Length > 100) return ValidationProblem("单次最多可为 100 名学生强制选课。"); - await using var transaction = await db.Database.BeginTransactionAsync( - IsolationLevel.Serializable, cancellationToken); - var offering = await db.CourseSelectionOfferings - .Include(x => x.CourseSelectionRound) - .Include(x => x.TeachingTask) - .ThenInclude(x => x!.Course) - .FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken); - if (offering is null) return NotFound(); - var round = offering.CourseSelectionRound!; - var task = offering.TeachingTask!; - - if (round.Status == CourseSelectionRoundStatus.Draft) - return ConflictProblem("选课批次开放后才能办理强制选课。"); - if (task.Status != TeachingTaskStatus.Published) - return ConflictProblem("该教学班当前不可选。"); - - var students = await db.Students - .Include(x => x.AdministrativeClass) - .Where(x => studentIds.Contains(x.Id)) - .OrderBy(x => x.StudentNumber) - .ToListAsync(cancellationToken); - if (students.Count != studentIds.Length) - return ValidationProblem("存在无效的学生档案。"); - - var existingEnrollments = await db.CourseEnrollments - .Where(x => - x.CourseSelectionOfferingId == offeringId && - studentIds.Contains(x.StudentId)) - .ToListAsync(cancellationToken); - var alreadyEnrolled = existingEnrollments - .FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled); - if (alreadyEnrolled is not null) - { - var dup = students.First(x => x.Id == alreadyEnrolled.StudentId); - return ConflictProblem( - $"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。"); - } - - var now = DateTime.UtcNow; - var enrolled = 0; - foreach (var student in students) - { - var enrollment = existingEnrollments - .FirstOrDefault(x => x.StudentId == student.Id); - if (enrollment is null) + return await db.ExecuteInRetriableTransactionAsync( + async transaction => { - 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, - StudentId = student.Id, - EnrollmentType = EnrollmentType.Retake, - EnrolledAt = now - }); - } - else - { - enrollment.Status = CourseEnrollmentStatus.Enrolled; - enrollment.EnrolledAt = now; - enrollment.WithdrawnAt = null; - enrollment.EnrollmentType = EnrollmentType.Retake; - } - enrolled++; - } + var dup = students.First(x => x.Id == alreadyEnrolled.StudentId); + return ConflictProblem( + $"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。"); + } - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return Ok(new { EnrolledCount = enrolled }); + var now = DateTime.UtcNow; + var enrolled = 0; + foreach (var student in students) + { + var enrollment = existingEnrollments + .FirstOrDefault(x => x.StudentId == student.Id); + if (enrollment is null) + { + db.CourseEnrollments.Add(new CourseEnrollment + { + CourseSelectionOfferingId = offeringId, + StudentId = student.Id, + EnrollmentType = EnrollmentType.Retake, + EnrolledAt = now + }); + } + else + { + enrollment.Status = CourseEnrollmentStatus.Enrolled; + enrollment.EnrolledAt = now; + enrollment.WithdrawnAt = null; + enrollment.EnrollmentType = EnrollmentType.Retake; + } + enrolled++; + } + + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { EnrolledCount = enrolled }); + }, + cancellationToken, + IsolationLevel.Serializable); } [HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")] @@ -824,145 +831,149 @@ public sealed class CourseSelectionsController( StudentEnrollmentRequest request, CancellationToken cancellationToken) { - var student = await CurrentStudentAsync(cancellationToken); - if (student is null) return ProfileNotFound(); - 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 + return await db.ExecuteInRetriableTransactionAsync( + async transaction => { - 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 }); + db.ChangeTracker.Clear(); + var student = await CurrentStudentAsync(cancellationToken); + if (student is null) return ProfileNotFound(); + if (student.Status != StudentStatus.Active) + return ConflictProblem("只有在籍学生可以选课。"); + + 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, + 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}")] @@ -1159,9 +1170,9 @@ public sealed class CourseSelectionsController( if (taskIds.Count == 0) return []; return await db.ScheduleEntries.AsNoTracking() .Where(x => - taskIds.Contains(x.TeachingTaskId) && x.SchedulePlan!.AcademicTermId == academicTermId && x.SchedulePlan.Status == SchedulePlanStatus.Published) + .WhereIn(taskIds, x => x.TeachingTaskId) .ToListAsync(cancellationToken); } diff --git a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs index d8df342..4f24307 100644 --- a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs @@ -122,32 +122,39 @@ public sealed class CoursesExcelController( if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的课程数据。"); - var errors = new List(); - await using var transaction = - await db.Database.BeginTransactionAsync(cancellationToken); - try - { - var result = await ImportRowsAsync(rows, errors, cancellationToken); - if (errors.Count > 0) + return await db.ExecuteInRetriableTransactionAsync< + ActionResult>( + async transaction => { - await transaction.RollbackAsync(cancellationToken); - return ImportValidationProblem(errors); - } + var errors = new List(); + try + { + var result = await ImportRowsAsync( + rows, + errors, + cancellationToken); + if (errors.Count > 0) + { + await transaction.RollbackAsync(cancellationToken); + return ImportValidationProblem(errors); + } - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return Ok(result); - } - catch (DbUpdateException) - { - await transaction.RollbackAsync(cancellationToken); - return Conflict(new ProblemDetails - { - Title = "导入失败", - Detail = "存在重复课程编码或无效关联,未写入任何课程。", - Status = StatusCodes.Status409Conflict - }); - } + await db.SaveChangesAsync(cancellationToken); + 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 ImportRowsAsync( diff --git a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs index 513ff80..d6cdd5d 100644 --- a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs +++ b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs @@ -103,12 +103,14 @@ public sealed class CurriculumPlansController( }) .ToListAsync(cancellationToken); - var accessibleCollegeIds = majorOptions - .Select(x => x.CollegeId) - .Distinct() - .ToArray(); + var accessibleMajors = db.Majors.AsNoTracking().Where(x => x.IsEnabled); + if (collegeId.HasValue) + accessibleMajors = accessibleMajors.Where( + x => x.CollegeId == collegeId.Value); 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) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name }) @@ -288,46 +290,57 @@ public sealed class CurriculumPlansController( [HttpPost("{id:guid}/publish")] public async Task Publish(Guid id, CancellationToken cancellationToken) { - var plan = await ScopedPlans() - .Include(x => x.Major) - .Include(x => x.Modules) - .ThenInclude(x => x.Courses) - .ThenInclude(x => x.Course) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - if (plan is null) return NotFound(); - if (plan.Status != CurriculumPlanStatus.Draft) - return ConflictProblem("只有草稿方案可以发布。"); - if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0)) - return ConflictProblem("发布前每个课程模块都必须配置课程。"); - if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits) - return ConflictProblem("各模块最低学分之和必须等于方案总学分。"); - if (plan.Modules.Any(module => - module.Courses.Sum(x => x.Course!.Credits) < 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("建议学期超出了该专业学制。"); + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var plan = await ScopedPlans() + .Include(x => x.Major) + .Include(x => x.Modules) + .ThenInclude(x => x.Courses) + .ThenInclude(x => x.Course) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != CurriculumPlanStatus.Draft) + return ConflictProblem("只有草稿方案可以发布。"); + if (plan.Modules.Count == 0 || + plan.Modules.Any(x => x.Courses.Count == 0)) + return ConflictProblem("发布前每个课程模块都必须配置课程。"); + if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits) + return ConflictProblem("各模块最低学分之和必须等于方案总学分。"); + if (plan.Modules.Any(module => + module.Courses.Sum(x => x.Course!.Credits) < + 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() - .Where(x => - x.Id != plan.Id && - x.MajorId == plan.MajorId && - x.EffectiveGrade == plan.EffectiveGrade && - x.Status == CurriculumPlanStatus.Published) - .ToListAsync(cancellationToken); - foreach (var previous in previousPlans) - { - previous.Status = CurriculumPlanStatus.Archived; - } + var previousPlans = await ScopedPlans() + .Where(x => + x.Id != plan.Id && + x.MajorId == plan.MajorId && + x.EffectiveGrade == plan.EffectiveGrade && + x.Status == CurriculumPlanStatus.Published) + .ToListAsync(cancellationToken); + foreach (var previous in previousPlans) + { + previous.Status = CurriculumPlanStatus.Archived; + } - plan.Status = CurriculumPlanStatus.Published; - plan.PublishedAt = DateTime.UtcNow; - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return NoContent(); + plan.Status = CurriculumPlanStatus.Published; + plan.PublishedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return NoContent(); + }, + cancellationToken); } [HttpPost("{planId:guid}/modules")] diff --git a/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs b/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs index 2817883..9b4bfd3 100644 --- a/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs +++ b/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs @@ -31,9 +31,15 @@ public sealed class DegreeAwardsController( .ThenByDescending(x => x.CreatedAt) .Select(x => new { - x.Id, x.Name, x.GraduationYear, x.DegreeName, - x.MinimumGradePoint, x.Status, x.Notes, - x.CalculatedAt, x.PublishedAt, + x.Id, + x.Name, + x.GraduationYear, + x.DegreeName, + x.MinimumGradePoint, + x.Status, + x.Notes, + x.CalculatedAt, + x.PublishedAt, ResultCount = x.Results.Count(result => !collegeId.HasValue || result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId), @@ -67,19 +73,32 @@ public sealed class DegreeAwardsController( x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId); return Ok(new { - batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName, - batch.MinimumGradePoint, batch.Status, batch.Notes, - batch.CalculatedAt, batch.PublishedAt, + batch.Id, + batch.Name, + batch.GraduationYear, + batch.DegreeName, + batch.MinimumGradePoint, + batch.Status, + batch.Notes, + batch.CalculatedAt, + batch.PublishedAt, Results = await source.OrderBy(x => x.Student!.StudentNumber) .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, MajorName = x.Student.AdministrativeClass.Major!.Name, CollegeName = x.Student.AdministrativeClass.Major.College!.Name, - x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion, - x.ExceptionReason, x.IsOverridden, - x.ReviewComment, x.ReviewedAt + x.AverageGradePoint, + x.CalculatedConclusion, + x.Conclusion, + x.ExceptionReason, + x.IsOverridden, + x.ReviewComment, + x.ReviewedAt }).ToListAsync(token) }); } @@ -130,9 +149,9 @@ public sealed class DegreeAwardsController( .ToList(); var studentIds = audits.Select(x => x.StudentId).ToArray(); var gradePoints = await db.GradeRecords.AsNoTracking() - .Where(x => studentIds.Contains(x.StudentId) && - x.GradeSheet!.Status == GradeSheetStatus.Published && + .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published && x.GradePoint.HasValue) + .WhereIn(studentIds, x => x.StudentId) .Select(x => new { x.StudentId, @@ -241,10 +260,14 @@ public sealed class DegreeAwardsController( x.DegreeAwardBatch.GraduationYear, x.DegreeAwardBatch.DegreeName, x.DegreeAwardBatch.MinimumGradePoint, - x.Student!.StudentNumber, x.Student.Name, + x.Student!.StudentNumber, + x.Student.Name, MajorName = x.Student.AdministrativeClass!.Major!.Name, - x.AverageGradePoint, x.Conclusion, x.ExceptionReason, - x.IsOverridden, x.ReviewComment, + x.AverageGradePoint, + x.Conclusion, + x.ExceptionReason, + x.IsOverridden, + x.ReviewComment, x.DegreeAwardBatch.PublishedAt }).FirstOrDefaultAsync(token); return Ok(result); diff --git a/src/Jiaowu.Api/Controllers/EvaluationsController.cs b/src/Jiaowu.Api/Controllers/EvaluationsController.cs index fea2860..e149d77 100644 --- a/src/Jiaowu.Api/Controllers/EvaluationsController.cs +++ b/src/Jiaowu.Api/Controllers/EvaluationsController.cs @@ -39,12 +39,19 @@ public sealed class EvaluationsController( .OrderByDescending(x => x.AcademicTerm!.StartDate) .Select(x => new { - x.Id, x.Name, x.Status, - x.AcademicTermId, TermName = x.AcademicTerm!.Name, - x.StartsAt, x.EndsAt, + x.Id, + x.Name, + x.Status, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.StartsAt, + x.EndsAt, 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, x.CreatedAt @@ -194,8 +201,10 @@ public sealed class EvaluationsController( var enrollments = await db.CourseEnrollments.AsNoTracking() .Where(e => e.Status == CourseEnrollmentStatus.Enrolled && - e.StudentId == studentId.Value && - termIds.Contains(e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)) + e.StudentId == studentId.Value) + .WhereIn( + termIds, + e => e.CourseSelectionOffering!.TeachingTask!.AcademicTermId) .Select(e => new { e.CourseSelectionOffering!.TeachingTaskId, @@ -211,9 +220,10 @@ public sealed class EvaluationsController( .ToListAsync(cancellationToken); // Check which tasks have already been evaluated + var setupIds = openSetups.Select(s => s.Id).ToArray(); var evaluatedIds = await db.EvaluationRecords.AsNoTracking() - .Where(r => r.StudentId == studentId.Value && - openSetups.Select(s => s.Id).Contains(r.EvaluationSetupId)) + .Where(r => r.StudentId == studentId.Value) + .WhereIn(setupIds, r => r.EvaluationSetupId) .Select(r => r.TeachingTaskId) .ToHashSetAsync(cancellationToken); @@ -251,7 +261,9 @@ public sealed class EvaluationsController( .Where(x => x.Id == teachingTaskId) .Select(x => new { - x.Id, x.TaskNumber, x.Name, + x.Id, + x.TaskNumber, + x.Name, x.AcademicTermId, CourseCode = x.Course!.Code, CourseName = x.Course.Name, @@ -283,7 +295,10 @@ public sealed class EvaluationsController( setup.Name, Dimensions = setup.Dimensions.Select(d => new { - d.Id, d.Name, d.MaxScore, d.SortOrder + d.Id, + d.Name, + d.MaxScore, + d.SortOrder }), Task = task }); @@ -384,8 +399,8 @@ public sealed class EvaluationsController( foreach (var setup in setups) { var setupTaskIds = await db.EvaluationRecords.AsNoTracking() - .Where(r => r.EvaluationSetupId == setup.Id && - taskIds.Contains(r.TeachingTaskId)) + .Where(r => r.EvaluationSetupId == setup.Id) + .WhereIn(taskIds, r => r.TeachingTaskId) .Select(r => r.TeachingTaskId) .Distinct() .ToListAsync(cancellationToken); @@ -396,7 +411,9 @@ public sealed class EvaluationsController( .Where(x => x.Id == taskId) .Select(x => new { - x.Id, x.TaskNumber, x.Name, + x.Id, + x.TaskNumber, + x.Name, x.AcademicTermId, CourseCode = x.Course!.Code, CourseName = x.Course.Name @@ -525,12 +542,18 @@ public sealed class EvaluationsController( { Setup = new { - setup.Id, setup.Name, setup.Status, + setup.Id, + setup.Name, + setup.Status, TermName = setup.AcademicTerm!.Name, - setup.StartsAt, setup.EndsAt, + setup.StartsAt, + setup.EndsAt, Dimensions = setup.Dimensions.Select(d => new { - d.Id, d.Name, d.MaxScore, d.SortOrder + d.Id, + d.Name, + d.MaxScore, + d.SortOrder }) }, ByTask = byTask, diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index 3c128cb..104edd3 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -39,8 +39,14 @@ public sealed class ExamsController( .ThenByDescending(x => x.CreatedAt) .Select(x => new { - x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, - x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt + x.Id, + x.Name, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.Status, + SessionCount = x.Sessions.Count, + x.Notes, + x.PublishedAt }).ToListAsync(cancellationToken)); } @@ -72,37 +78,42 @@ public sealed class ExamsController( .Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published)) .Select(x => new { - x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, - x.Status, x.Notes, x.PublishedAt, + x.Id, + x.Name, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.Status, + x.Notes, + x.PublishedAt, Sessions = x.Sessions.OrderBy(item => item.ExamDate) .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.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), - StudentCount = db.CourseEnrollments.Count(e => - e.Status == CourseEnrollmentStatus.Enrolled && - e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId) - }) + item.RequiredInvigilatorCount, + item.Notes, + InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), + InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), + StudentCount = db.CourseEnrollments.Count(e => + e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == item.TeachingTaskId) + }) }).FirstOrDefaultAsync(cancellationToken); return plan is null ? NotFound() : Ok(plan); } @@ -270,7 +281,7 @@ public sealed class ExamsController( .ToListAsync(cancellationToken); 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) .ThenBy(x => x.Capacity) @@ -313,8 +324,8 @@ public sealed class ExamsController( .ToListAsync(cancellationToken); return Ok(await db.Teachers.AsNoTracking() - .Where(x => x.Status == TeacherStatus.Active && - !busyIds.Contains(x.Id)) + .Where(x => x.Status == TeacherStatus.Active) + .WhereNotIn(busyIds, x => x.Id) .OrderBy(x => x.Name) .Select(x => new { @@ -547,8 +558,10 @@ public sealed class ExamsController( var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); if (teacherIds.Length > 0) { - if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && - x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) + if (await db.Teachers + .Where(x => x.Status == TeacherStatus.Active) + .WhereIn(teacherIds, x => x.Id) + .CountAsync(cancellationToken) != teacherIds.Length) return ValidationProblem("存在无效监考教师。"); } @@ -564,9 +577,10 @@ public sealed class ExamsController( if (teacherIds.Length > 0) { - if (await overlaps.AnyAsync(x => - x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), - cancellationToken)) + if (await db.ExamSessionInvigilators + .Where(i => overlaps.Any(x => x.Id == i.ExamSessionId)) + .WhereIn(teacherIds, i => i.TeacherId) + .AnyAsync(cancellationToken)) return ConflictProblem("监考教师在该时段已有考试任务。"); } diff --git a/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs b/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs index 06c4f9d..cc98122 100644 --- a/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs +++ b/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs @@ -32,8 +32,15 @@ public sealed class GraduationAuditsController( .ThenByDescending(x => x.CreatedAt) .Select(x => new { - x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status, - x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt, + x.Id, + x.Name, + x.GraduationYear, + x.EnrollmentYear, + x.Status, + x.Notes, + x.CalculatedAt, + x.PublishedAt, + x.CreatedAt, ResultCount = x.Results.Count(result => !collegeId.HasValue || result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId), @@ -68,23 +75,38 @@ public sealed class GraduationAuditsController( x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId); return Ok(new { - batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear, - batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt, + batch.Id, + batch.Name, + batch.GraduationYear, + batch.EnrollmentYear, + batch.Status, + batch.Notes, + batch.CalculatedAt, + batch.PublishedAt, Results = await results .OrderBy(x => x.Student!.StudentNumber) .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, MajorName = x.Student.AdministrativeClass.Major!.Name, CollegeName = x.Student.AdministrativeClass.Major.College!.Name, x.StudentStatusSnapshot, PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null, - x.RequiredCredits, x.EarnedCredits, - x.RequiredCourseCount, x.PassedRequiredCourseCount, - x.FailedCourseCount, x.MissingCourseNames, - x.CalculatedConclusion, x.Conclusion, x.IsOverridden, - x.ReviewComment, x.ReviewedAt + x.RequiredCredits, + x.EarnedCredits, + x.RequiredCourseCount, + x.PassedRequiredCourseCount, + x.FailedCourseCount, + x.MissingCourseNames, + x.CalculatedConclusion, + x.Conclusion, + x.IsOverridden, + x.ReviewComment, + x.ReviewedAt }).ToListAsync(token) }); } @@ -136,8 +158,8 @@ public sealed class GraduationAuditsController( .ToListAsync(token); var studentIds = students.Select(x => x.Id).ToArray(); var grades = await db.GradeRecords.AsNoTracking() - .Where(x => studentIds.Contains(x.StudentId) && - x.GradeSheet!.Status == GradeSheetStatus.Published) + .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published) + .WhereIn(studentIds, x => x.StudentId) .Select(x => new GradeSnapshot( x.StudentId, x.GradeSheet!.TeachingTask!.CourseId, @@ -275,13 +297,19 @@ public sealed class GraduationAuditsController( x.Id, BatchName = x.GraduationAuditBatch!.Name, x.GraduationAuditBatch.GraduationYear, - x.Student!.StudentNumber, x.Student.Name, + x.Student!.StudentNumber, + x.Student.Name, MajorName = x.Student.AdministrativeClass!.Major!.Name, PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null, - x.RequiredCredits, x.EarnedCredits, - x.RequiredCourseCount, x.PassedRequiredCourseCount, - x.FailedCourseCount, x.MissingCourseNames, - x.Conclusion, x.IsOverridden, x.ReviewComment, + x.RequiredCredits, + x.EarnedCredits, + x.RequiredCourseCount, + x.PassedRequiredCourseCount, + x.FailedCourseCount, + x.MissingCourseNames, + x.Conclusion, + x.IsOverridden, + x.ReviewComment, x.GraduationAuditBatch.PublishedAt }).FirstOrDefaultAsync(token); return Ok(result); diff --git a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs index f4fdedb..94bc1d3 100644 --- a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs +++ b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs @@ -44,8 +44,14 @@ public sealed class MakeupExamsController( .ThenByDescending(x => x.CreatedAt) .Select(x => new { - x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, - x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt + x.Id, + x.Name, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.Status, + SessionCount = x.Sessions.Count, + x.Notes, + x.PublishedAt }).ToListAsync(cancellationToken)); } @@ -77,46 +83,51 @@ public sealed class MakeupExamsController( .Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published)) .Select(x => new { - x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, - x.Status, x.Notes, x.PublishedAt, + x.Id, + x.Name, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.Status, + x.Notes, + x.PublishedAt, Sessions = x.Sessions.OrderBy(item => item.ExamDate) .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, - e.Student!.StudentNumber, - e.Student.Name, - ClassName = e.Student.AdministrativeClass!.Name, - e.Reason, - e.SourceGradeRecordId, - e.SourceDeferredExamId, - e.MakeupScore + 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, + e.Student!.StudentNumber, + e.Student.Name, + ClassName = e.Student.AdministrativeClass!.Name, + e.Reason, + e.SourceGradeRecordId, + e.SourceDeferredExamId, + e.MakeupScore + }) }) - }) }).FirstOrDefaultAsync(cancellationToken); return plan is null ? NotFound() : Ok(plan); } @@ -311,8 +322,12 @@ public sealed class MakeupExamsController( .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(cancellationToken); if (existing is not null) - return Ok(new { jobId = existing.Id, status = existing.Status.ToString(), - message = "该计划已有正在执行的任务。" }); + return Ok(new + { + jobId = existing.Id, + status = existing.Status.ToString(), + message = "该计划已有正在执行的任务。" + }); var job = new MakeupExamAutoJob { @@ -404,9 +419,9 @@ public sealed class MakeupExamsController( // Check for time conflicts with other makeup sessions var conflictIds = await db.MakeupExamEnrollments.AsNoTracking() - .Where(x => validIds.Contains(x.StudentId) && - x.MakeupExamSession!.StartsAt < session.EndsAt && + .Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt && session.StartsAt < x.MakeupExamSession.EndsAt) + .WhereIn(validIds, x => x.StudentId) .Select(x => x.StudentId) .Distinct() .ToListAsync(cancellationToken); @@ -414,7 +429,7 @@ public sealed class MakeupExamsController( if (conflictIds.Count > 0) { var conflictNumbers = await db.Students - .Where(s => conflictIds.Contains(s.Id)) + .WhereIn(conflictIds, s => s.Id) .Select(s => s.StudentNumber) .ToListAsync(cancellationToken); return ConflictProblem( @@ -590,7 +605,7 @@ public sealed class MakeupExamsController( .ToListAsync(cancellationToken); 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) .ThenBy(x => x.Capacity) @@ -634,8 +649,8 @@ public sealed class MakeupExamsController( .ToListAsync(cancellationToken); return Ok(await db.Teachers.AsNoTracking() - .Where(x => x.Status == TeacherStatus.Active && - !busyIds.Contains(x.Id)) + .Where(x => x.Status == TeacherStatus.Active) + .WhereNotIn(busyIds, x => x.Id) .OrderBy(x => x.Name) .Select(x => new { @@ -845,8 +860,10 @@ public sealed class MakeupExamsController( var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); if (teacherIds.Length > 0) { - if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && - x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) + if (await db.Teachers + .Where(x => x.Status == TeacherStatus.Active) + .WhereIn(teacherIds, x => x.Id) + .CountAsync(cancellationToken) != teacherIds.Length) return ValidationProblem("存在无效监考教师。"); } @@ -862,9 +879,10 @@ public sealed class MakeupExamsController( if (teacherIds.Length > 0) { - if (await overlaps.AnyAsync(x => - x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), - cancellationToken)) + if (await db.MakeupExamSessionInvigilators + .Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId)) + .WhereIn(teacherIds, i => i.TeacherId) + .AnyAsync(cancellationToken)) return ConflictProblem("监考教师在该时段已有补考任务。"); } diff --git a/src/Jiaowu.Api/Controllers/PersonnelController.cs b/src/Jiaowu.Api/Controllers/PersonnelController.cs index f48e211..9ee4b71 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelController.cs @@ -158,52 +158,60 @@ public sealed class PersonnelController( TeacherAccountActivationRequest request, CancellationToken cancellationToken) { - var teacher = await db.Teachers.FindAsync([id], cancellationToken); - if (teacher is null) return NotFound(); - if (!CanAccessCollege(teacher.CollegeId)) return Forbid(); - if (teacher.Status != TeacherStatus.Active) - { - return ConflictProblem("仅在职教师可以激活登录账号。"); - } - if (teacher.UserId.HasValue) - { - return ConflictProblem("该教师档案已经关联登录账号。"); - } + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var teacher = await db.Teachers.FindAsync([id], cancellationToken); + if (teacher is null) return NotFound(); + if (!CanAccessCollege(teacher.CollegeId)) return Forbid(); + if (teacher.Status != TeacherStatus.Active) + { + return ConflictProblem("仅在职教师可以激活登录账号。"); + } + if (teacher.UserId.HasValue) + { + return ConflictProblem("该教师档案已经关联登录账号。"); + } - var userName = teacher.TeacherNumber.Trim(); - if (await userManager.FindByNameAsync(userName) is not null) - { - return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。"); - } + var userName = teacher.TeacherNumber.Trim(); + if (await userManager.FindByNameAsync(userName) is not null) + { + return ConflictProblem( + "该工号已有登录账号但未正确关联,请到账号管理中核对。"); + } - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - var user = new ApplicationUser - { - UserName = userName, - DisplayName = teacher.Name, - StaffNumber = userName, - CollegeId = teacher.CollegeId, - IsEnabled = true, - LockoutEnabled = true - }; - var result = await userManager.CreateAsync(user, request.Password); - if (!result.Succeeded) - { - await transaction.RollbackAsync(cancellationToken); - return IdentityValidationProblem(result); - } + var user = new ApplicationUser + { + UserName = userName, + DisplayName = teacher.Name, + StaffNumber = userName, + CollegeId = teacher.CollegeId, + IsEnabled = true, + LockoutEnabled = true + }; + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(result); + } - result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher); - if (!result.Succeeded) - { - await transaction.RollbackAsync(cancellationToken); - return IdentityValidationProblem(result); - } + result = await userManager.AddToRoleAsync( + user, + SystemRoles.Teacher); + if (!result.Succeeded) + { + await transaction.RollbackAsync(cancellationToken); + return IdentityValidationProblem(result); + } - teacher.UserId = user.Id; - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return Ok(new { user.Id, UserName = userName }); + teacher.UserId = user.Id; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { user.Id, UserName = userName }); + }, + cancellationToken); } [HttpGet("students")] diff --git a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs index 69f8aff..41c6037 100644 --- a/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs +++ b/src/Jiaowu.Api/Controllers/PersonnelExcelController.cs @@ -123,32 +123,39 @@ public sealed class PersonnelExcelController( } if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。"); - var errors = new List(); - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - try - { - var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase) - ? await ImportTeachersAsync(rows, errors, cancellationToken) - : await ImportStudentsAsync(rows, errors, cancellationToken); - if (errors.Count > 0) + return await db.ExecuteInRetriableTransactionAsync< + ActionResult>( + async transaction => { - await transaction.RollbackAsync(cancellationToken); - return ImportValidationProblem(errors); - } - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return Ok(result); - } - catch (DbUpdateException) - { - await transaction.RollbackAsync(cancellationToken); - return Conflict(new ProblemDetails - { - Title = "导入失败", - Detail = "存在重复编号或无效关联,未写入任何档案。", - Status = StatusCodes.Status409Conflict - }); - } + var errors = new List(); + try + { + 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); + return ImportValidationProblem(errors); + } + await db.SaveChangesAsync(cancellationToken); + 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 ImportTeachersAsync( diff --git a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs index 2846050..c8a71bb 100644 --- a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs +++ b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs @@ -82,24 +82,24 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase { x.Id, x.TaskNumber, - x.Name, - CourseCode = x.Course!.Code, - CourseName = x.Course!.Name, - CollegeId = x.Course.CollegeId, - CollegeName = x.Course.College!.Name, - TeacherNames = x.Teachers + x.Name, + CourseCode = x.Course!.Code, + CourseName = x.Course!.Name, + CollegeId = x.Course.CollegeId, + CollegeName = x.Course.College!.Name, + TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), - x.Capacity, - x.StartWeek, - x.EndWeek, - x.WeeklyHours, - x.SchedulingMode + x.Capacity, + x.StartWeek, + x.EndWeek, + x.WeeklyHours, + x.SchedulingMode }) .ToListAsync(cancellationToken); var taskIds = tasks.Select(x => x.Id).ToList(); var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() - .Where(x => taskIds.Contains(x.TeachingTaskId)) + .WhereIn(taskIds, x => x.TeachingTaskId) .Include(x => x.AllowedClassrooms) .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); return Ok(tasks.Select(task => @@ -109,19 +109,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase { task.Id, task.TaskNumber, - task.Name, - task.CourseCode, - task.CourseName, - task.CollegeId, - task.CollegeName, - task.TeacherNames, - task.Capacity, - task.StartWeek, - task.EndWeek, - task.WeeklyHours, - task.SchedulingMode, - HasCustomConstraint = constraint is not null, - RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible + task.Name, + task.CourseCode, + task.CourseName, + task.CollegeId, + task.CollegeName, + task.TeacherNames, + task.Capacity, + task.StartWeek, + task.EndWeek, + task.WeeklyHours, + task.SchedulingMode, + HasCustomConstraint = constraint is not null, + RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible ? false : constraint?.RequiresClassroom ?? true, constraint?.RequiredCampusId, @@ -143,34 +143,34 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase { if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7)) return ValidationProblem("允许上课日必须位于星期一至星期日。"); - if (request.EarliestPeriod.HasValue && - request.LatestPeriod.HasValue && - request.EarliestPeriod > request.LatestPeriod) - return ValidationProblem("最早节次不能晚于最晚节次。"); - if (!Enum.IsDefined(request.SchedulingMode)) - return ValidationProblem("授课方式无效。"); - var task = await db.TeachingTasks - .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); - if (task is null) return NotFound(); - if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && - await HasScheduleEntriesAsync([teachingTaskId], cancellationToken)) - return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。"); + if (request.EarliestPeriod.HasValue && + request.LatestPeriod.HasValue && + request.EarliestPeriod > request.LatestPeriod) + return ValidationProblem("最早节次不能晚于最晚节次。"); + if (!Enum.IsDefined(request.SchedulingMode)) + return ValidationProblem("授课方式无效。"); + var task = await db.TeachingTasks + .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); + if (task is null) return NotFound(); + if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && + await HasScheduleEntriesAsync([teachingTaskId], cancellationToken)) + return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。"); - task.SchedulingMode = request.SchedulingMode; - if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible) - { - var flexibleConstraint = await db.TeachingTaskScheduleConstraints - .Include(x => x.AllowedClassrooms) - .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); - if (flexibleConstraint is not null) - { - ClearConstraint(flexibleConstraint); - } - await db.SaveChangesAsync(cancellationToken); - return NoContent(); - } + task.SchedulingMode = request.SchedulingMode; + if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible) + { + var flexibleConstraint = await db.TeachingTaskScheduleConstraints + .Include(x => x.AllowedClassrooms) + .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); + if (flexibleConstraint is not null) + { + ClearConstraint(flexibleConstraint); + } + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } - Building? building = null; + Building? building = null; if (request.RequiredBuildingId.HasValue) { building = await db.Buildings.AsNoTracking() @@ -189,7 +189,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase return ValidationProblem("指定校区不存在或已停用。"); 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) .ToListAsync(cancellationToken); if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count()) @@ -225,9 +226,9 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase ? request.AllowedClassroomIds.Distinct().Select(classroomId => new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() : []; - await db.SaveChangesAsync(cancellationToken); - return NoContent(); - } + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } [HttpPut("constraints/batch")] public async Task SaveConstraintsBatch( @@ -246,22 +247,22 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase if (request.SchedulingMode.HasValue && !Enum.IsDefined(request.SchedulingMode.Value)) return ValidationProblem("授课方式无效。"); - if (!request.SchedulingMode.HasValue && - !request.RequiresClassroom.HasValue && - request.AllowedDayOfWeeks is null && - !request.UpdateClassroomScope && - !request.UpdatePeriodRange && - !request.EarliestPeriod.HasValue && - !request.LatestPeriod.HasValue) + if (!request.SchedulingMode.HasValue && + !request.RequiresClassroom.HasValue && + request.AllowedDayOfWeeks is null && + !request.UpdateClassroomScope && + !request.UpdatePeriodRange && + !request.EarliestPeriod.HasValue && + !request.LatestPeriod.HasValue) return ValidationProblem("请至少选择一项需要批量修改的设置。"); if (request.UpdateClassroomScope && request.RequiresClassroom == false) return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。"); var tasks = await db.TeachingTasks .Where(x => - taskIds.Contains(x.Id) && x.AcademicTermId == request.AcademicTermId && x.Status == TeachingTaskStatus.Published) + .WhereIn(taskIds, x => x.Id) .ToListAsync(cancellationToken); if (tasks.Count != taskIds.Length) return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。"); @@ -297,7 +298,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? []; 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) .ToListAsync(cancellationToken); if (allowedRooms.Count != roomIds.Length) @@ -311,12 +313,12 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase return ValidationProblem("指定教室必须位于所选校区。"); } - var constraints = await db.TeachingTaskScheduleConstraints - .Where(x => taskIds.Contains(x.TeachingTaskId)) - .Include(x => x.AllowedClassrooms) - .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); - foreach (var task in tasks) - { + var constraints = await db.TeachingTaskScheduleConstraints + .WhereIn(taskIds, x => x.TeachingTaskId) + .Include(x => x.AllowedClassrooms) + .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); + foreach (var task in tasks) + { if (request.SchedulingMode.HasValue) task.SchedulingMode = request.SchedulingMode.Value; if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) @@ -384,7 +386,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase IReadOnlyCollection taskIds, CancellationToken cancellationToken) => db.ScheduleEntries.AsNoTracking() - .AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken); + .WhereIn(taskIds, x => x.TeachingTaskId) + .AnyAsync(cancellationToken); private void ClearConstraint(TeachingTaskScheduleConstraint constraint) { diff --git a/src/Jiaowu.Api/Controllers/StatisticsController.cs b/src/Jiaowu.Api/Controllers/StatisticsController.cs index a9cd79e..78f5fac 100644 --- a/src/Jiaowu.Api/Controllers/StatisticsController.cs +++ b/src/Jiaowu.Api/Controllers/StatisticsController.cs @@ -95,7 +95,12 @@ public sealed class StatisticsController( return new { - byCollege, byMajor, byClass, byGrade, byStatus, byGender, + byCollege, + byMajor, + byClass, + byGrade, + byStatus, + byGender, enrollmentTrend, totals = new { totalStudents } }; @@ -255,7 +260,8 @@ public sealed class StatisticsController( 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, averageScore = Math.Round(x.averageScore, 1) }).ToList(); @@ -336,7 +342,8 @@ public sealed class StatisticsController( 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 }).OrderByDescending(x => x.passRate).ToList(); @@ -353,7 +360,9 @@ public sealed class StatisticsController( 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 }).OrderByDescending(x => x.passRate).ToList(); @@ -567,7 +576,8 @@ public sealed class StatisticsController( // get entries var entries = await db.ScheduleEntries.AsNoTracking() .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 { e.ClassroomId, @@ -633,7 +643,8 @@ public sealed class StatisticsController( var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount); 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 }; }).ToList(); diff --git a/src/Jiaowu.Api/Controllers/StudentCurriculumController.cs b/src/Jiaowu.Api/Controllers/StudentCurriculumController.cs index 204ee22..942e6b6 100644 --- a/src/Jiaowu.Api/Controllers/StudentCurriculumController.cs +++ b/src/Jiaowu.Api/Controllers/StudentCurriculumController.cs @@ -74,8 +74,8 @@ public sealed class StudentCurriculumController( var gradeAttempts = await db.GradeRecords.AsNoTracking() .Where(x => 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( x.GradeSheet!.TeachingTask!.CourseId, x.TotalScore, @@ -88,15 +88,15 @@ public sealed class StudentCurriculumController( var currentCourseIds = await db.TeachingTasks.AsNoTracking() .Where(task => - planCourseIds.Contains(task.CourseId) && task.Status == TeachingTaskStatus.Published && task.AcademicTerm!.IsCurrent && (task.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId) || db.CourseEnrollments.Any(enrollment => enrollment.StudentId == student.Id && - enrollment.Status == CourseEnrollmentStatus.Enrolled && - enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id))) + enrollment.Status == CourseEnrollmentStatus.Enrolled && + enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id))) + .WhereIn(planCourseIds, task => task.CourseId) .Select(x => x.CourseId) .Distinct() .ToListAsync(cancellationToken); diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index c6149ff..09e583b 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -76,11 +76,11 @@ public sealed class TeachingTasksController( CollegeName = x.Course.College!.Name, x.Capacity, x.StartWeek, - x.EndWeek, - x.WeeklyHours, - x.SchedulingMode, - CourseTotalHours = x.Course.TotalHours, - x.GenerationBatchCode, + x.EndWeek, + x.WeeklyHours, + x.SchedulingMode, + CourseTotalHours = x.Course.TotalHours, + x.GenerationBatchCode, x.Status, TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) @@ -157,11 +157,11 @@ public sealed class TeachingTasksController( CollegeName = x.Course.College!.Name, x.Capacity, x.StartWeek, - x.EndWeek, - x.WeeklyHours, - x.SchedulingMode, - CourseTotalHours = x.Course.TotalHours, - x.GenerationBatchCode, + x.EndWeek, + x.WeeklyHours, + x.SchedulingMode, + CourseTotalHours = x.Course.TotalHours, + x.GenerationBatchCode, x.Status, x.Notes, x.PublishedAt, @@ -215,10 +215,10 @@ public sealed class TeachingTasksController( CourseId = request.CourseId, Capacity = request.Capacity, StartWeek = request.StartWeek, - EndWeek = request.EndWeek, - WeeklyHours = request.WeeklyHours, - SchedulingMode = request.SchedulingMode, - Notes = Normalize(request.Notes) + EndWeek = request.EndWeek, + WeeklyHours = request.WeeklyHours, + SchedulingMode = request.SchedulingMode, + Notes = Normalize(request.Notes) }; SetAssignments(task, request); db.TeachingTasks.Add(task); @@ -249,10 +249,10 @@ public sealed class TeachingTasksController( task.CourseId = request.CourseId; task.Capacity = request.Capacity; task.StartWeek = request.StartWeek; - task.EndWeek = request.EndWeek; - task.WeeklyHours = request.WeeklyHours; - task.SchedulingMode = request.SchedulingMode; - task.Notes = Normalize(request.Notes); + task.EndWeek = request.EndWeek; + task.WeeklyHours = request.WeeklyHours; + task.SchedulingMode = request.SchedulingMode; + task.Notes = Normalize(request.Notes); db.TeachingTaskTeachers.RemoveRange(task.Teachers); db.TeachingTaskClasses.RemoveRange(task.Classes); task.Teachers = []; @@ -342,7 +342,7 @@ public sealed class TeachingTasksController( .Include(x => x.Classes) .ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x!.Students) - .Where(x => ids.Contains(x.Id)) + .WhereIn(ids, x => x.Id) .OrderBy(x => x.TaskNumber) .ToListAsync(cancellationToken); if (tasks.Count != ids.Length) @@ -352,17 +352,17 @@ public sealed class TeachingTasksController( switch (request.Operation) { 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; - task.PublishedAt = publishedAt; + 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; + task.PublishedAt = publishedAt; + } + break; } - break; - } case TeachingTaskBatchOperation.Delete: if (tasks.Any(x => x.Status is not (TeachingTaskStatus.Draft or TeachingTaskStatus.Closed))) @@ -414,14 +414,14 @@ public sealed class TeachingTasksController( .FirstOrDefaultAsync( x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); - if (course is null) return ValidationProblem("所选课程不存在或已停用。"); - if (!CanManage(course)) return Forbid(); - var hoursProblem = TeachingTaskHours.Validate( - course, - request.StartWeek, - request.EndWeek, - request.WeeklyHours); - if (hoursProblem is not null) return ValidationProblem(hoursProblem); + if (course is null) return ValidationProblem("所选课程不存在或已停用。"); + if (!CanManage(course)) return Forbid(); + var hoursProblem = TeachingTaskHours.Validate( + course, + request.StartWeek, + request.EndWeek, + request.WeeklyHours); + if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); var scopedCollegeId = ScopedCollegeId(); @@ -431,7 +431,8 @@ public sealed class TeachingTasksController( var classIds = request.ClassIds.Distinct().ToArray(); if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。"); 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.Students) .AsQueryable(); @@ -443,123 +444,127 @@ public sealed class TeachingTasksController( if (classes.Count != classIds.Length) return ValidationProblem("存在无效或不在当前数据范围内的行政班。"); - await using var transaction = await db.Database.BeginTransactionAsync( - IsolationLevel.Serializable, - 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(); - 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 + return await db.ExecuteInRetriableTransactionAsync( + async transaction => { - 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 + db.ChangeTracker.Clear(); + var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking() + .Where(x => + x.TeachingTask!.AcademicTermId == request.AcademicTermId && + x.TeachingTask.CourseId == request.CourseId) + .WhereIn(classIds, x => x.AdministrativeClassId) + .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 => + 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(); + 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, IsPrimary = true } - ], - Classes = group.Select(administrativeClass => - new TeachingTaskClass - { - AdministrativeClassId = administrativeClass.Id - }).ToList() - }; - created.Add(task); - } + ], + Classes = group.Select(administrativeClass => + new TeachingTaskClass + { + AdministrativeClassId = administrativeClass.Id + }).ToList() + }; + created.Add(task); + } - db.TeachingTasks.AddRange(created); - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return Ok(new - { - BatchCode = batchCode, - CreatedCount = created.Count, - Tasks = created.Select(task => new - { - task.Id, - task.TaskNumber, - task.Name, - TeacherName = eligibleTeachers - .First(x => x.Id == task.Teachers.Single().TeacherId).Name, - ClassNames = classes - .Where(x => task.Classes.Any(item => - item.AdministrativeClassId == x.Id)) - .Select(x => x.Name), - task.Capacity - }) - }); + db.TeachingTasks.AddRange(created); + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new + { + BatchCode = batchCode, + CreatedCount = created.Count, + Tasks = created.Select(task => new + { + task.Id, + task.TaskNumber, + task.Name, + TeacherName = eligibleTeachers + .First(x => x.Id == task.Teachers.Single().TeacherId).Name, + ClassNames = classes + .Where(x => task.Classes.Any(item => + item.AdministrativeClassId == x.Id)) + .Select(x => x.Name), + task.Capacity + }) + }); + }, + cancellationToken, + IsolationLevel.Serializable); } private IQueryable ScopedTasks() @@ -579,8 +584,8 @@ public sealed class TeachingTasksController( return ValidationProblem("开始周不能晚于结束周。"); var course = await db.Courses.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); - if (course is null) return ValidationProblem("所选课程不存在或已停用。"); - if (!CanManage(course)) return Forbid(); + if (course is null) return ValidationProblem("所选课程不存在或已停用。"); + if (!CanManage(course)) return Forbid(); if (!Enum.IsDefined(request.SchedulingMode)) return ValidationProblem("授课方式无效。"); var hoursProblem = TeachingTaskHours.Validate( @@ -589,7 +594,7 @@ public sealed class TeachingTasksController( request.EndWeek, request.WeeklyHours); if (hoursProblem is not null) return ValidationProblem(hoursProblem); - var collegeId = ScopedCollegeId(); + var collegeId = ScopedCollegeId(); if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) @@ -599,25 +604,26 @@ public sealed class TeachingTasksController( if (request.PrimaryTeacherId.HasValue && !teacherIds.Contains(request.PrimaryTeacherId.Value)) return ValidationProblem("主讲教师必须包含在授课教师中。"); - if (await db.Teachers.CountAsync( - x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active, - cancellationToken) != teacherIds.Length) + if (await db.Teachers + .Where(x => x.Status == TeacherStatus.Active) + .WhereIn(teacherIds, x => x.Id) + .CountAsync(cancellationToken) != teacherIds.Length) return ValidationProblem("存在无效或非在职授课教师。"); if (teacherIds.Length > 0 && - await db.TeacherCourseApplications.CountAsync( - x => + await db.TeacherCourseApplications + .Where(x => x.AcademicTermId == request.AcademicTermId && x.CourseId == request.CourseId && - teacherIds.Contains(x.TeacherId) && - x.Status == TeacherCourseApplicationStatus.Approved, - cancellationToken) != teacherIds.Length) + x.Status == TeacherCourseApplicationStatus.Approved) + .WhereIn(teacherIds, x => x.TeacherId) + .CountAsync(cancellationToken) != teacherIds.Length) return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。"); var classIds = request.ClassIds.Distinct().ToArray(); if (classIds.Length > 0) { var classes = db.AdministrativeClasses.AsNoTracking() - .Where(x => classIds.Contains(x.Id)); + .WhereIn(classIds, x => x.Id); if (collegeId.HasValue) classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value); if (await classes.CountAsync(cancellationToken) != classIds.Length) @@ -673,11 +679,10 @@ public sealed class TeachingTasksController( .ToArray(); var approvedApplications = await db.TeacherCourseApplications .AsNoTracking() - .Where(x => - termIds.Contains(x.AcademicTermId) && - courseIds.Contains(x.CourseId) && - teacherIds.Contains(x.TeacherId) && - x.Status == TeacherCourseApplicationStatus.Approved) + .Where(x => x.Status == TeacherCourseApplicationStatus.Approved) + .WhereIn(termIds, x => x.AcademicTermId) + .WhereIn(courseIds, x => x.CourseId) + .WhereIn(teacherIds, x => x.TeacherId) .Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId }) .ToListAsync(cancellationToken); var approvedAssignments = approvedApplications diff --git a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs index 128cc05..9f0690e 100644 --- a/src/Jiaowu.Api/Controllers/TimetableManagementController.cs +++ b/src/Jiaowu.Api/Controllers/TimetableManagementController.cs @@ -317,11 +317,12 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase .CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken); var activePeriodCount = configuredPeriodCount == 0 ? requestedPeriods.Length - : await db.ScheduleTimeSlots.AsNoTracking().CountAsync(x => - x.AcademicTermId == academicTermId && - x.IsEnabled && - requestedPeriods.Contains(x.PeriodNumber), - cancellationToken); + : await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => + x.AcademicTermId == academicTermId && + x.IsEnabled) + .WhereIn(requestedPeriods, x => x.PeriodNumber) + .CountAsync(cancellationToken); if (activePeriodCount != requestedPeriods.Length) return ValidationProblem("查询范围包含不存在或未启用的节次。"); @@ -357,8 +358,8 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase .Where(x => x.IsEnabled && x.Building!.IsEnabled && - x.Building.Campus!.IsEnabled && - !occupiedIds.Contains(x.Id)); + x.Building.Campus!.IsEnabled) + .WhereNotIn(occupiedIds, x => x.Id); if (campusId.HasValue) rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value); if (buildingId.HasValue) diff --git a/src/Jiaowu.Api/Controllers/UsersController.cs b/src/Jiaowu.Api/Controllers/UsersController.cs index 55be428..82464c5 100644 --- a/src/Jiaowu.Api/Controllers/UsersController.cs +++ b/src/Jiaowu.Api/Controllers/UsersController.cs @@ -25,8 +25,14 @@ public sealed class UsersController( .OrderBy(x => x.UserName) .Select(x => new { - x.Id, x.UserName, x.DisplayName, x.StaffNumber, - x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt + x.Id, + x.UserName, + x.DisplayName, + x.StaffNumber, + x.CollegeId, + x.IsEnabled, + x.LastLoginAt, + x.CreatedAt }) .ToListAsync(cancellationToken); @@ -36,8 +42,14 @@ public sealed class UsersController( var identityUser = await userManager.FindByIdAsync(user.Id.ToString()); result.Add(new { - user.Id, user.UserName, user.DisplayName, user.StaffNumber, - user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt, + user.Id, + user.UserName, + user.DisplayName, + user.StaffNumber, + user.CollegeId, + user.IsEnabled, + user.LastLoginAt, + user.CreatedAt, Roles = identityUser is null ? [] : await userManager.GetRolesAsync(identityUser) @@ -54,38 +66,50 @@ public sealed class UsersController( .ToListAsync(cancellationToken)); [HttpPost] - public async Task Create(CreateUserRequest request) + public async Task Create( + CreateUserRequest request, + CancellationToken cancellationToken) { var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); var invalidRoles = ValidateRoles(roles); if (invalidRoles is not null) return invalidRoles; var staffNumber = Normalize(request.StaffNumber); - var profiles = await ResolveProfilesAsync(staffNumber, roles); - if (profiles.Error is not null) return profiles.Error; + return await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + db.ChangeTracker.Clear(); + var profiles = await ResolveProfilesAsync(staffNumber, roles); + if (profiles.Error is not null) return profiles.Error; - var user = new ApplicationUser - { - UserName = request.UserName.Trim(), - DisplayName = request.DisplayName.Trim(), - StaffNumber = staffNumber, - CollegeId = request.CollegeId, - LockoutEnabled = true, - IsEnabled = true - }; - await using var transaction = await db.Database.BeginTransactionAsync(); - var result = await userManager.CreateAsync(user, request.Password); - if (!result.Succeeded) - return IdentityValidationProblem(result); + var user = new ApplicationUser + { + UserName = request.UserName.Trim(), + DisplayName = request.DisplayName.Trim(), + StaffNumber = staffNumber, + CollegeId = request.CollegeId, + LockoutEnabled = true, + IsEnabled = true + }; + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + return IdentityValidationProblem(result); - result = await userManager.AddToRolesAsync(user, roles); - if (!result.Succeeded) - return IdentityValidationProblem(result); + result = await userManager.AddToRolesAsync(user, roles); + if (!result.Succeeded) + return IdentityValidationProblem(result); - if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id; - if (profiles.Student is not null) profiles.Student.UserId = user.Id; - await db.SaveChangesAsync(); - await transaction.CommitAsync(); - return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id }); + if (profiles.Teacher is not null) + profiles.Teacher.UserId = user.Id; + if (profiles.Student is not null) + profiles.Student.UserId = 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")] @@ -103,51 +127,79 @@ public sealed class UsersController( } [HttpPut("{id:guid}/roles")] - public async Task SetRoles(Guid id, SetRolesRequest request) + public async Task 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 invalidRoles = ValidateRoles(roles); if (invalidRoles is not null) return invalidRoles; var staffNumber = Normalize(request.StaffNumber); - var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id); - if (profiles.Error is not null) return profiles.Error; + return await db.ExecuteInRetriableTransactionAsync( + 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); - if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() && - existing.Contains(SystemRoles.SuperAdmin) && - !roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase)) - { - return ValidationProblem("不能移除当前账号的超级管理员角色。"); - } + var existing = await userManager.GetRolesAsync(user); + if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() && + existing.Contains(SystemRoles.SuperAdmin) && + !roles.Contains( + SystemRoles.SuperAdmin, + StringComparer.OrdinalIgnoreCase)) + { + return ValidationProblem( + "不能移除当前账号的超级管理员角色。"); + } - await using var transaction = await db.Database.BeginTransactionAsync(); - user.StaffNumber = staffNumber; - user.CollegeId = request.CollegeId; - var updateResult = await userManager.UpdateAsync(user); - if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult); - var removeResult = await userManager.RemoveFromRolesAsync( - user, - existing.Except(roles, StringComparer.OrdinalIgnoreCase)); - if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult); - var addResult = await userManager.AddToRolesAsync( - user, - roles.Except(existing, StringComparer.OrdinalIgnoreCase)); - if (!addResult.Succeeded) return IdentityValidationProblem(addResult); + user.StaffNumber = staffNumber; + user.CollegeId = request.CollegeId; + var updateResult = await userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + return IdentityValidationProblem(updateResult); + var removeResult = await userManager.RemoveFromRolesAsync( + user, + existing.Except(roles, StringComparer.OrdinalIgnoreCase)); + if (!removeResult.Succeeded) + return IdentityValidationProblem(removeResult); + var addResult = await userManager.AddToRolesAsync( + user, + roles.Except(existing, StringComparer.OrdinalIgnoreCase)); + if (!addResult.Succeeded) + return IdentityValidationProblem(addResult); - var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync(); - var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync(); - if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase)) - foreach (var teacher in linkedTeachers) 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(); - await transaction.CommitAsync(); - return NoContent(); + var linkedTeachers = await db.Teachers + .Where(x => x.UserId == id) + .ToListAsync(); + var linkedStudents = await db.Students + .Where(x => x.UserId == id) + .ToListAsync(); + if (!roles.Contains( + SystemRoles.Teacher, + StringComparer.OrdinalIgnoreCase)) + foreach (var teacher in linkedTeachers) + 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 roles) diff --git a/src/Jiaowu.Api/Controllers/UsersExcelController.cs b/src/Jiaowu.Api/Controllers/UsersExcelController.cs index ec85e5c..c8f401b 100644 --- a/src/Jiaowu.Api/Controllers/UsersExcelController.cs +++ b/src/Jiaowu.Api/Controllers/UsersExcelController.cs @@ -83,169 +83,212 @@ public sealed class UsersExcelController( } if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。"); - 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(); - var plans = new List(); - var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); - var plannedTeachers = new HashSet(StringComparer.OrdinalIgnoreCase); - var plannedStudents = new HashSet(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)) + return await db.ExecuteInRetriableTransactionAsync< + ActionResult>( + async transaction => { - errors.Add($"第 {row.RowNumber} 行:登录账号“{userName}”在文件中重复。"); - continue; - } + db.ChangeTracker.Clear(); + 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(); + var plans = new List(); + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var plannedTeachers = new HashSet(StringComparer.OrdinalIgnoreCase); + var plannedStudents = new HashSet(StringComparer.OrdinalIgnoreCase); - var existing = users.GetValueOrDefault(userName); - 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 + foreach (var row in rows) { - UserName = plan.UserName, - DisplayName = plan.DisplayName, - StaffNumber = plan.StaffNumber, - 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); + 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}”在文件中重复。"); + continue; + } + + var existing = users.GetValueOrDefault(userName); + 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)); } - 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) + + if (errors.Count > 0) return ImportValidationProblem(errors); + + var created = 0; + var updated = 0; + foreach (var plan in plans) { - 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) + var user = plan.Existing; + if (user is null) + { + user = new ApplicationUser + { + UserName = plan.UserName, + DisplayName = plan.DisplayName, + StaffNumber = plan.StaffNumber, + 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); - 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); - var removeResult = await userManager.RemoveFromRolesAsync( - user, - existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase)); - 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)); + await transaction.CommitAsync(cancellationToken); + return Ok(new ExcelImportResult(created, updated, plans.Count)); + }, + cancellationToken); } [HttpPut("{id:guid}/password")] diff --git a/src/Jiaowu.Api/Controllers/WarningsController.cs b/src/Jiaowu.Api/Controllers/WarningsController.cs index ea4f1d1..a8ff740 100644 --- a/src/Jiaowu.Api/Controllers/WarningsController.cs +++ b/src/Jiaowu.Api/Controllers/WarningsController.cs @@ -34,9 +34,13 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc { db.WarningRules.Add(new WarningRule { - AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(), - Threshold = r.Threshold, IsEnabled = r.IsEnabled, - NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor, + AcademicTermId = academicTermId, + Type = r.Type, + Name = r.Name.Trim(), + Threshold = r.Threshold, + IsEnabled = r.IsEnabled, + NotifyStudent = r.NotifyStudent, + NotifyCounselor = r.NotifyCounselor, Description = r.Description?.Trim(), AutoCheckEnabled = r.AutoCheckEnabled, 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); 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) q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId); } diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs index bb98eb0..5095d2e 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs @@ -168,7 +168,7 @@ public sealed class ExamArrangementService(AppDbContext db) .ToHashSet(); 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 var dbOccupiedRooms = await db.ExamSessions.AsNoTracking() @@ -181,7 +181,7 @@ public sealed class ExamArrangementService(AppDbContext db) .ToListAsync(cancellationToken); if (dbOccupiedRooms.Count > 0) - query = query.Where(x => !dbOccupiedRooms.Contains(x.Id)); + query = query.WhereNotIn(dbOccupiedRooms, x => x.Id); return await query .OrderBy(x => x.Capacity) @@ -213,8 +213,8 @@ public sealed class ExamArrangementService(AppDbContext db) foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); return await db.Teachers.AsNoTracking() - .Where(x => x.Status == TeacherStatus.Active && - !busyTeacherIds.Contains(x.Id)) + .Where(x => x.Status == TeacherStatus.Active) + .WhereNotIn(busyTeacherIds, x => x.Id) .OrderBy(x => Guid.NewGuid()) .Take(needed) .ToListAsync(cancellationToken); diff --git a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs index 1efbca4..36cce7c 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs @@ -164,7 +164,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db) .ToHashSet(); 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() .Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId && @@ -176,7 +176,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db) .ToListAsync(cancellationToken); if (dbOccupiedRooms.Count > 0) - query = query.Where(x => !dbOccupiedRooms.Contains(x.Id)); + query = query.WhereNotIn(dbOccupiedRooms, x => x.Id); return await query .OrderBy(x => x.Capacity) @@ -208,8 +208,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db) foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); return await db.Teachers.AsNoTracking() - .Where(x => x.Status == TeacherStatus.Active && - !busyTeacherIds.Contains(x.Id)) + .Where(x => x.Status == TeacherStatus.Active) + .WhereNotIn(busyTeacherIds, x => x.Id) .OrderBy(x => Guid.NewGuid()) .Take(needed) .ToListAsync(cancellationToken); diff --git a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs index 71ce755..d036758 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamEligibilityService.cs @@ -32,8 +32,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db) // 1. Approved deferred exams (highest priority) var deferredStudents = await db.DeferredExams.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId && - x.Status == ApprovalStatus.Approved && - !enrolledSet.Contains(x.StudentId)) + x.Status == ApprovalStatus.Approved) + .WhereNotIn(enrolledSet, x => x.StudentId) .Select(x => new { x.StudentId, @@ -58,8 +58,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db) // 2. Absent students var absentStudents = await db.GradeRecords.AsNoTracking() .Where(x => x.GradeSheetId == gradeSheet.Id && - x.ExamStatus == GradeExamStatus.Absent && - !enrolledSet.Contains(x.StudentId)) + x.ExamStatus == GradeExamStatus.Absent) + .WhereNotIn(enrolledSet, x => x.StudentId) .Select(x => new { x.StudentId, @@ -85,8 +85,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db) var failedStudents = await db.GradeRecords.AsNoTracking() .Where(x => x.GradeSheetId == gradeSheet.Id && x.ExamStatus == GradeExamStatus.Normal && - x.TotalScore < 60 && - !enrolledSet.Contains(x.StudentId)) + x.TotalScore < 60) + .WhereNotIn(enrolledSet, x => x.StudentId) .Select(x => new { x.StudentId, diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/QueryableCollectionExtensions.cs b/src/Jiaowu.Api/Infrastructure/Persistence/QueryableCollectionExtensions.cs new file mode 100644 index 0000000..feba239 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/QueryableCollectionExtensions.cs @@ -0,0 +1,47 @@ +using System.Linq.Expressions; + +namespace Jiaowu.Api.Infrastructure.Persistence; + +public static class QueryableCollectionExtensions +{ + public static IQueryable WhereIn( + this IQueryable source, + IEnumerable values, + Expression> valueSelector) + { + var predicate = BuildPredicate(values, valueSelector, Expression.OrElse, false); + return source.Where(predicate); + } + + public static IQueryable WhereNotIn( + this IQueryable source, + IEnumerable values, + Expression> valueSelector) + { + var predicate = BuildPredicate(values, valueSelector, Expression.AndAlso, true); + return source.Where(predicate); + } + + private static Expression> BuildPredicate( + IEnumerable values, + Expression> valueSelector, + Func 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>( + body, + valueSelector.Parameters); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/RetriableTransactionExtensions.cs b/src/Jiaowu.Api/Infrastructure/Persistence/RetriableTransactionExtensions.cs new file mode 100644 index 0000000..ba5bf30 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/RetriableTransactionExtensions.cs @@ -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 ExecuteInRetriableTransactionAsync( + this DbContext db, + Func> 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 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); + }); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs index fc15dbc..0a48a23 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs @@ -38,8 +38,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) .ThenByDescending(x => x.Capacity) .ThenBy(x => x.TaskNumber) .ToListAsync(cancellationToken); + var taskIds = tasks.Select(task => task.Id).ToArray(); 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) .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); var classrooms = await db.Classrooms.AsNoTracking() diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs index f7c6a3a..43056de 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs @@ -169,10 +169,7 @@ public sealed class AutomaticScheduleJobProcessor( job.CompletedTasks = result.CompletedTasks; job.MessagesJson = JsonSerializer.Serialize(result.Messages); job.CompletedAt = DateTime.UtcNow; - await using var transaction = - await db.Database.BeginTransactionAsync(stoppingToken); await db.SaveChangesAsync(stoppingToken); - await transaction.CommitAsync(stoppingToken); logger.LogInformation( "Automatic schedule job {JobId} completed with {CreatedEntries} entries.", diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs index 00e0538..36c02cf 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs @@ -141,35 +141,46 @@ public sealed class SchedulePublishJobProcessor( ReportProgress, stoppingToken); - await using var transaction = - await db.Database.BeginTransactionAsync(stoppingToken); - if (plan.Status != SchedulePlanStatus.Draft) - throw new SchedulePublishValidationException( - "排课草稿状态已发生变化,请刷新后重试。"); + var publishedPlanId = plan.Id; + await db.ExecuteInRetriableTransactionAsync( + async transaction => + { + 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 - .Where(x => - x.Id != plan.Id && - x.AcademicTermId == plan.AcademicTermId && - x.Status == SchedulePlanStatus.Published) - .ToListAsync(stoppingToken); - foreach (var oldPlan in previous) - oldPlan.Status = SchedulePlanStatus.Archived; + var previous = await db.SchedulePlans + .Where(x => + x.Id != publishPlan.Id && + x.AcademicTermId == publishPlan.AcademicTermId && + x.Status == SchedulePlanStatus.Published) + .ToListAsync(stoppingToken); + foreach (var oldPlan in previous) + oldPlan.Status = SchedulePlanStatus.Archived; - plan.Status = SchedulePlanStatus.Published; - plan.PublishedAt = DateTime.UtcNow; - job.Status = SchedulePublishJobStatus.Succeeded; - job.ActiveAcademicTermId = null; - job.CompletedSteps = job.TotalSteps; - job.CurrentStep = "课表已发布"; - job.CompletedAt = DateTime.UtcNow; - await db.SaveChangesAsync(stoppingToken); - await transaction.CommitAsync(stoppingToken); + publishPlan.Status = SchedulePlanStatus.Published; + publishPlan.PublishedAt = DateTime.UtcNow; + publishJob.Status = SchedulePublishJobStatus.Succeeded; + publishJob.ActiveAcademicTermId = null; + publishJob.CompletedSteps = publishJob.TotalSteps; + publishJob.CurrentStep = "课表已发布"; + publishJob.CompletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(stoppingToken); + await transaction.CommitAsync(stoppingToken); + }, + stoppingToken); logger.LogInformation( "Schedule publish job {JobId} published plan {SchedulePlanId}.", - job.Id, - plan.Id); + jobId, + publishedPlanId); } 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 constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() - .Where(x => taskIds.Contains(x.TeachingTaskId)) + .WhereIn(taskIds, x => x.TeachingTaskId) .Include(x => x.AllowedClassrooms) .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); diff --git a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs index 8360f0f..11903e0 100644 --- a/tests/Jiaowu.Api.Tests/AuthControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/AuthControllerTests.cs @@ -14,6 +14,31 @@ namespace Jiaowu.Api.Tests; 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() + .UseSqlite(connection) + .ReplaceService() + .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] public async Task ActivateStudent_WorksWithRetryingExecutionStrategy() { diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 3209050..98d9a1a 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -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 CreateMySqlOptions() => new DbContextOptionsBuilder() .UseMySQL( diff --git a/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs index 777e619..fb3ea6f 100644 --- a/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/PersonnelControllerTests.cs @@ -74,8 +74,9 @@ public sealed class PersonnelControllerTests Assert.Equal(teacher.Name, user.DisplayName); Assert.Equal(teacher.CollegeId, user.CollegeId); Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher)); - await db.Entry(teacher).ReloadAsync(); - Assert.Equal(user.Id, teacher.UserId); + db.ChangeTracker.Clear(); + var linkedTeacher = await db.Teachers.SingleAsync(x => x.Id == teacher.Id); + Assert.Equal(user.Id, linkedTeacher.UserId); } private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope