修复Mysql
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -77,20 +77,30 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
var errors = new List<string>();
|
||||
ExcelImportResult result;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
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),
|
||||
"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()
|
||||
@@ -116,6 +126,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<IReadOnlyList<object?>>> 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);
|
||||
|
||||
@@ -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,9 +415,10 @@ public sealed class CourseSelectionsController(
|
||||
if (studentIds.Length > 100)
|
||||
return ValidationProblem("单次最多可为 100 名学生代选。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -437,7 +438,7 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
var students = await db.Students
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.Where(x => studentIds.Contains(x.Id))
|
||||
.WhereIn(studentIds, x => x.Id)
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (students.Count != studentIds.Length)
|
||||
@@ -456,9 +457,8 @@ public sealed class CourseSelectionsController(
|
||||
}
|
||||
|
||||
var existingEnrollments = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOfferingId == id &&
|
||||
studentIds.Contains(x.StudentId))
|
||||
.Where(x => x.CourseSelectionOfferingId == id)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled);
|
||||
@@ -482,11 +482,11 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
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)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -566,6 +566,9 @@ public sealed class CourseSelectionsController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { EnrolledCount = students.Count });
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
[HttpPost("offerings/{offeringId:guid}/force-enroll")]
|
||||
@@ -581,8 +584,10 @@ public sealed class CourseSelectionsController(
|
||||
if (studentIds.Length > 100)
|
||||
return ValidationProblem("单次最多可为 100 名学生强制选课。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable, cancellationToken);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -599,16 +604,15 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
var students = await db.Students
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.Where(x => studentIds.Contains(x.Id))
|
||||
.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 &&
|
||||
studentIds.Contains(x.StudentId))
|
||||
.Where(x => x.CourseSelectionOfferingId == offeringId)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var alreadyEnrolled = existingEnrollments
|
||||
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
|
||||
@@ -648,6 +652,9 @@ public sealed class CourseSelectionsController(
|
||||
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,14 +831,15 @@ public sealed class CourseSelectionsController(
|
||||
StudentEnrollmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
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)
|
||||
@@ -963,6 +971,9 @@ public sealed class CourseSelectionsController(
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,12 +122,17 @@ public sealed class CoursesExcelController(
|
||||
if (rows.Count == 0)
|
||||
return ValidationProblem("Excel 中没有可导入的课程数据。");
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
var errors = new List<string>();
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var result = await ImportRowsAsync(rows, errors, cancellationToken);
|
||||
var result = await ImportRowsAsync(
|
||||
rows,
|
||||
errors,
|
||||
cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
@@ -148,6 +153,8 @@ public sealed class CoursesExcelController(
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ExcelImportResult> ImportRowsAsync(
|
||||
|
||||
@@ -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,6 +290,10 @@ public sealed class CurriculumPlansController(
|
||||
[HttpPost("{id:guid}/publish")]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var plan = await ScopedPlans()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
@@ -297,20 +303,25 @@ public sealed class CurriculumPlansController(
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
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 &&
|
||||
@@ -328,6 +339,8 @@ public sealed class CurriculumPlansController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{planId:guid}/modules")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +78,13 @@ 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
|
||||
{
|
||||
@@ -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("监考教师在该时段已有考试任务。");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,8 +83,13 @@ 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
|
||||
{
|
||||
@@ -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("监考教师在该时段已有补考任务。");
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,10 @@ public sealed class PersonnelController(
|
||||
TeacherAccountActivationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
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();
|
||||
@@ -173,10 +177,10 @@ public sealed class PersonnelController(
|
||||
var userName = teacher.TeacherNumber.Trim();
|
||||
if (await userManager.FindByNameAsync(userName) is not null)
|
||||
{
|
||||
return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。");
|
||||
return ConflictProblem(
|
||||
"该工号已有登录账号但未正确关联,请到账号管理中核对。");
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = userName,
|
||||
@@ -193,7 +197,9 @@ public sealed class PersonnelController(
|
||||
return IdentityValidationProblem(result);
|
||||
}
|
||||
|
||||
result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher);
|
||||
result = await userManager.AddToRoleAsync(
|
||||
user,
|
||||
SystemRoles.Teacher);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
@@ -204,6 +210,8 @@ public sealed class PersonnelController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { user.Id, UserName = userName });
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("students")]
|
||||
|
||||
@@ -123,11 +123,16 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
var errors = new List<string>();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)
|
||||
var result = kind.Equals(
|
||||
"teachers",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? await ImportTeachersAsync(rows, errors, cancellationToken)
|
||||
: await ImportStudentsAsync(rows, errors, cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
@@ -149,6 +154,8 @@ public sealed class PersonnelExcelController(
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ExcelImportResult> ImportTeachersAsync(
|
||||
|
||||
@@ -99,7 +99,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
.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 =>
|
||||
@@ -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())
|
||||
@@ -259,9 +260,9 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
|
||||
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)
|
||||
@@ -312,7 +314,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
var constraints = await db.TeachingTaskScheduleConstraints
|
||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
foreach (var task in tasks)
|
||||
@@ -384,7 +386,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
IReadOnlyCollection<Guid> 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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,7 +88,6 @@ 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 =>
|
||||
@@ -97,6 +96,7 @@ public sealed class StudentCurriculumController(
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
|
||||
.WhereIn(planCourseIds, task => task.CourseId)
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -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)
|
||||
@@ -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,14 +444,15 @@ public sealed class TeachingTasksController(
|
||||
if (classes.Count != classIds.Length)
|
||||
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
classIds.Contains(x.AdministrativeClassId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.CourseId == request.CourseId)
|
||||
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||
.Select(x => x.AdministrativeClassId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -479,9 +481,9 @@ public sealed class TeachingTasksController(
|
||||
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)
|
||||
.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);
|
||||
@@ -560,6 +562,9 @@ public sealed class TeachingTasksController(
|
||||
task.Capacity
|
||||
})
|
||||
});
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> ScopedTasks()
|
||||
@@ -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
|
||||
|
||||
@@ -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 =>
|
||||
: await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == academicTermId &&
|
||||
x.IsEnabled &&
|
||||
requestedPeriods.Contains(x.PeriodNumber),
|
||||
cancellationToken);
|
||||
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)
|
||||
|
||||
@@ -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,12 +66,18 @@ public sealed class UsersController(
|
||||
.ToListAsync(cancellationToken));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(CreateUserRequest request)
|
||||
public async Task<ActionResult> Create(
|
||||
CreateUserRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var invalidRoles = ValidateRoles(roles);
|
||||
if (invalidRoles is not null) return invalidRoles;
|
||||
var staffNumber = Normalize(request.StaffNumber);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var profiles = await ResolveProfilesAsync(staffNumber, roles);
|
||||
if (profiles.Error is not null) return profiles.Error;
|
||||
|
||||
@@ -72,7 +90,6 @@ public sealed class UsersController(
|
||||
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);
|
||||
@@ -81,11 +98,18 @@ public sealed class UsersController(
|
||||
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<IActionResult> SetRoles(Guid id, SetRolesRequest request)
|
||||
public async Task<IActionResult> SetRoles(
|
||||
Guid id,
|
||||
SetRolesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(id.ToString());
|
||||
if (user is null) return NotFound();
|
||||
|
||||
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var invalidRoles = ValidateRoles(roles);
|
||||
if (invalidRoles is not null) return invalidRoles;
|
||||
var staffNumber = Normalize(request.StaffNumber);
|
||||
var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id);
|
||||
return await db.ExecuteInRetriableTransactionAsync<IActionResult>(
|
||||
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))
|
||||
!roles.Contains(
|
||||
SystemRoles.SuperAdmin,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValidationProblem("不能移除当前账号的超级管理员角色。");
|
||||
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);
|
||||
if (!updateResult.Succeeded)
|
||||
return IdentityValidationProblem(updateResult);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
|
||||
if (!removeResult.Succeeded)
|
||||
return IdentityValidationProblem(removeResult);
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
roles.Except(existing, StringComparer.OrdinalIgnoreCase));
|
||||
if (!addResult.Succeeded) return IdentityValidationProblem(addResult);
|
||||
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();
|
||||
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<string> roles)
|
||||
|
||||
@@ -83,14 +83,23 @@ public sealed class UsersExcelController(
|
||||
}
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。");
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
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);
|
||||
x => x.TeacherNumber,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
var students = await db.Students.ToDictionaryAsync(
|
||||
x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
x => x.StudentNumber,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var errors = new List<string>();
|
||||
var plans = new List<UserImportPlan>();
|
||||
@@ -124,37 +133,53 @@ public sealed class UsersExcelController(
|
||||
if (collegeCode is not null)
|
||||
{
|
||||
if (!colleges.TryGetValue(collegeCode, out var college))
|
||||
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
|
||||
else collegeId = college.Id;
|
||||
}
|
||||
|
||||
Teacher? teacher = null;
|
||||
Student? student = null;
|
||||
if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
|
||||
if (roleNames.Contains(
|
||||
SystemRoles.Teacher,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher))
|
||||
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 (teacher.UserId.HasValue &&
|
||||
teacher.UserId != existing?.Id)
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedTeachers.Add(staffNumber))
|
||||
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
|
||||
}
|
||||
if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
|
||||
if (roleNames.Contains(
|
||||
SystemRoles.Student,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null || !students.TryGetValue(staffNumber, out student))
|
||||
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 (student.UserId.HasValue &&
|
||||
student.UserId != existing?.Id)
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedStudents.Add(staffNumber))
|
||||
errors.Add($"第 {row.RowNumber} 行:学号“{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} 行:不能移除当前账号的超级管理员角色。");
|
||||
if (!roleNames.Contains(
|
||||
SystemRoles.SuperAdmin,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
|
||||
}
|
||||
|
||||
plans.Add(new UserImportPlan(
|
||||
@@ -166,7 +191,6 @@ public sealed class UsersExcelController(
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var user = plan.Existing;
|
||||
@@ -181,11 +205,15 @@ public sealed class UsersExcelController(
|
||||
IsEnabled = plan.IsEnabled,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
var createResult = await userManager.CreateAsync(user, plan.Password!);
|
||||
var createResult = await userManager.CreateAsync(
|
||||
user,
|
||||
plan.Password!);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(createResult, plan.RowNumber);
|
||||
return IdentityValidationProblem(
|
||||
createResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
@@ -199,17 +227,24 @@ public sealed class UsersExcelController(
|
||||
if (!updateResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(updateResult, plan.RowNumber);
|
||||
return IdentityValidationProblem(
|
||||
updateResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
if (plan.Password is not null)
|
||||
{
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var token = await userManager
|
||||
.GeneratePasswordResetTokenAsync(user);
|
||||
var passwordResult = await userManager.ResetPasswordAsync(
|
||||
user, token, plan.Password);
|
||||
user,
|
||||
token,
|
||||
plan.Password);
|
||||
if (!passwordResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(passwordResult, plan.RowNumber);
|
||||
return IdentityValidationProblem(
|
||||
passwordResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
}
|
||||
updated++;
|
||||
@@ -218,7 +253,9 @@ public sealed class UsersExcelController(
|
||||
var existingRoles = await userManager.GetRolesAsync(user);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase));
|
||||
existingRoles.Except(
|
||||
plan.Roles,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
@@ -226,17 +263,21 @@ public sealed class UsersExcelController(
|
||||
}
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase));
|
||||
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)
|
||||
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)
|
||||
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;
|
||||
@@ -246,6 +287,8 @@ public sealed class UsersExcelController(
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new ExcelImportResult(created, updated, plans.Count));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/password")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public static class QueryableCollectionExtensions
|
||||
{
|
||||
public static IQueryable<TEntity> WhereIn<TEntity, TValue>(
|
||||
this IQueryable<TEntity> source,
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector)
|
||||
{
|
||||
var predicate = BuildPredicate(values, valueSelector, Expression.OrElse, false);
|
||||
return source.Where(predicate);
|
||||
}
|
||||
|
||||
public static IQueryable<TEntity> WhereNotIn<TEntity, TValue>(
|
||||
this IQueryable<TEntity> source,
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector)
|
||||
{
|
||||
var predicate = BuildPredicate(values, valueSelector, Expression.AndAlso, true);
|
||||
return source.Where(predicate);
|
||||
}
|
||||
|
||||
private static Expression<Func<TEntity, bool>> BuildPredicate<TEntity, TValue>(
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector,
|
||||
Func<Expression, Expression, BinaryExpression> combine,
|
||||
bool negate)
|
||||
{
|
||||
Expression? body = null;
|
||||
foreach (var value in values.Distinct())
|
||||
{
|
||||
Expression comparison = Expression.Equal(
|
||||
valueSelector.Body,
|
||||
Expression.Constant(value, typeof(TValue)));
|
||||
if (negate)
|
||||
comparison = Expression.Not(comparison);
|
||||
body = body is null ? comparison : combine(body, comparison);
|
||||
}
|
||||
|
||||
body ??= Expression.Constant(negate);
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
body,
|
||||
valueSelector.Parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public static class RetriableTransactionExtensions
|
||||
{
|
||||
public static Task<TResult> ExecuteInRetriableTransactionAsync<TResult>(
|
||||
this DbContext db,
|
||||
Func<IDbContextTransaction, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken,
|
||||
IsolationLevel? isolationLevel = null)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = isolationLevel.HasValue
|
||||
? await db.Database.BeginTransactionAsync(
|
||||
isolationLevel.Value,
|
||||
cancellationToken)
|
||||
: await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
return await operation(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
public static Task ExecuteInRetriableTransactionAsync(
|
||||
this DbContext db,
|
||||
Func<IDbContextTransaction, Task> operation,
|
||||
CancellationToken cancellationToken,
|
||||
IsolationLevel? isolationLevel = null)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = isolationLevel.HasValue
|
||||
? await db.Database.BeginTransactionAsync(
|
||||
isolationLevel.Value,
|
||||
cancellationToken)
|
||||
: await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
await operation(transaction);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
.ThenByDescending(x => x.Capacity)
|
||||
.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()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -141,35 +141,46 @@ public sealed class SchedulePublishJobProcessor(
|
||||
ReportProgress,
|
||||
stoppingToken);
|
||||
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||
if (plan.Status != SchedulePlanStatus.Draft)
|
||||
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.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;
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>()
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
||||
await db.ExecuteInRetriableTransactionAsync(
|
||||
async transaction =>
|
||||
{
|
||||
db.Colleges.Add(new College { Code = "TX", Name = "事务测试学院" });
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, await db.Colleges.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateStudent_WorksWithRetryingExecutionStrategy()
|
||||
{
|
||||
|
||||
@@ -52,6 +52,25 @@ public sealed class MySqlMigrationTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MySql_guid_collections_use_provider_safe_predicates()
|
||||
{
|
||||
using var db = new AppDbContext(CreateMySqlOptions());
|
||||
var ids = new[]
|
||||
{
|
||||
Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
Guid.Parse("22222222-2222-2222-2222-222222222222")
|
||||
};
|
||||
|
||||
var sql = db.Colleges
|
||||
.WhereIn(ids, x => x.Id)
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(ids[0].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DbContextOptions<AppDbContext> CreateMySqlOptions() =>
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseMySQL(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user