This commit is contained in:
2026-07-24 20:37:42 +08:00 Unverified
parent 5da1a8a403
commit 20ca205080
9 changed files with 1115 additions and 7 deletions
@@ -179,6 +179,7 @@ public sealed class CourseSelectionsController(
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CourseNature = x.TeachingTask.Course.Nature,
CollegeName = x.TeachingTask.Course.College!.Name,
x.TeachingTask.Course.Credits,
TeacherNames = x.TeachingTask.Teachers
@@ -279,10 +280,14 @@ public sealed class CourseSelectionsController(
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course!.Name,
CourseNature = x.TeachingTask.Course.Nature,
CollegeId = x.TeachingTask.Course.CollegeId,
TeacherUserIds = x.TeachingTask.Teachers
.Select(item => item.Teacher!.UserId),
x.CourseSelectionRoundId,
RoundStatus = x.CourseSelectionRound!.Status,
x.Capacity
})
.FirstOrDefaultAsync(cancellationToken);
@@ -316,13 +321,281 @@ public sealed class CourseSelectionsController(
offering.TeachingTaskId,
offering.TaskNumber,
offering.TaskName,
offering.CourseCode,
offering.CourseName,
offering.CourseNature,
offering.Capacity,
EnrolledCount = students.Count,
Students = students
Students = students,
CanProxyEnroll =
CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) &&
offering.RoundStatus != CourseSelectionRoundStatus.Draft &&
(currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin))
});
}
[HttpGet("offerings/{id:guid}/eligible-students")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> GetEligibleStudents(
Guid id,
string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.IsOpenToAll,
CourseNature = x.TeachingTask!.Course!.Nature,
RoundStatus = x.CourseSelectionRound!.Status,
ClassIds = x.TeachingTask.Classes
.Select(item => item.AdministrativeClassId)
})
.FirstOrDefaultAsync(cancellationToken);
if (offering is null) return NotFound();
if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
return ConflictProblem("管理员代选仅适用于公共必修课。");
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
return ConflictProblem("选课批次开放后才能办理管理员代选。");
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 (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.StudentNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.AdministrativeClass!.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderBy(x => x.StudentNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.StudentNumber,
x.Name,
ClassName = x.AdministrativeClass!.Name,
MajorName = x.AdministrativeClass.Major!.Name,
CollegeName = x.AdministrativeClass.Major.College!.Name
})
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpPost("offerings/{id:guid}/admin-enrollments")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> AdminEnroll(
Guid id,
AdminEnrollmentRequest request,
CancellationToken cancellationToken)
{
var studentIds = request.StudentIds.Distinct().ToArray();
if (studentIds.Length == 0)
return ValidationProblem("请至少选择一名学生。");
if (studentIds.Length > 100)
return ValidationProblem("单次最多可为 100 名学生代选。");
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
var offering = await db.CourseSelectionOfferings
.Include(x => x.CourseSelectionRound)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.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 (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 });
}
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
[Authorize(Roles = RoundManagers)]
public async Task<ActionResult> AdminWithdraw(
Guid offeringId,
Guid enrollmentId,
CancellationToken cancellationToken)
{
var enrollment = await db.CourseEnrollments
.Include(x => x.CourseSelectionOffering)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(
x =>
x.Id == enrollmentId &&
x.CourseSelectionOfferingId == offeringId,
cancellationToken);
if (enrollment is null) return NotFound();
var offering = enrollment.CourseSelectionOffering!;
if (!CourseSelectionRules.SupportsProxyEnrollment(
offering.TeachingTask!.Course!.Nature))
return ConflictProblem("管理员名单调整仅适用于公共必修课。");
if (offering.TeachingTask.Status != TeachingTaskStatus.Published)
return ConflictProblem("该教学班当前不可调整名单。");
if (enrollment.Status != CourseEnrollmentStatus.Enrolled)
return ConflictProblem("该学生已不在教学班名单中。");
enrollment.Status = CourseEnrollmentStatus.Withdrawn;
enrollment.WithdrawnAt = DateTime.UtcNow;
return await SaveAsync(enrollmentId, false, cancellationToken);
}
[HttpGet("student/options")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentOptions(
@@ -722,6 +995,9 @@ public sealed record CourseSelectionOfferingRequest(
public sealed record StudentEnrollmentRequest(Guid OfferingId);
public sealed record AdminEnrollmentRequest(
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
public sealed record StudentOfferingDto(
Guid Id,
Guid TeachingTaskId,
@@ -14,6 +14,9 @@ public static class CourseSelectionRules
round.Status == CourseSelectionRoundStatus.Open &&
nowUtc <= round.WithdrawalEndsAt;
public static bool SupportsProxyEnrollment(CourseNature nature) =>
nature == CourseNature.GeneralRequired;
public static bool HasScheduleConflict(
IEnumerable<ScheduleEntry> candidateEntries,
IEnumerable<ScheduleEntry> selectedEntries) =>
@@ -10,6 +10,7 @@ public sealed class DatabaseInitializer(
RoleManager<ApplicationRole> roleManager,
UserManager<ApplicationUser> userManager,
DevelopmentSqliteMigrator sqliteMigrator,
DevelopmentDemoDataSeeder developmentDemoDataSeeder,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<DatabaseInitializer> logger)
@@ -479,6 +480,7 @@ public sealed class DatabaseInitializer(
await SeedDevelopmentCourseSelectionAsync();
await SeedDevelopmentGradesAsync();
await SeedDevelopmentExamsAsync();
await developmentDemoDataSeeder.SeedAsync();
}
private async Task SeedDevelopmentCourseSelectionAsync()
@@ -0,0 +1,620 @@
using Jiaowu.Api.Domain.Academic;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Persistence;
public sealed class DevelopmentDemoDataSeeder(
AppDbContext db,
ILogger<DevelopmentDemoDataSeeder> logger)
{
private const int Grade = 2026;
private const int ClassesPerMajor = 2;
private const int StudentsPerClass = 35;
private const int TeachersPerCollege = 8;
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
var campus = await db.Campuses
.OrderBy(x => x.Code == "MAIN" ? 0 : 1)
.ThenBy(x => x.Code)
.FirstOrDefaultAsync(cancellationToken);
if (campus is null)
{
campus = new Campus
{
Code = "MAIN",
Name = "主校区",
Address = "大学路 1 号"
};
db.Campuses.Add(campus);
await db.SaveChangesAsync(cancellationToken);
}
await SeedCollegesAsync(campus.Id, cancellationToken);
await SeedMajorsAsync(cancellationToken);
await SeedClassesAsync(cancellationToken);
await SeedTeachersAsync(cancellationToken);
await SeedStudentsAsync(cancellationToken);
await SeedCoursesAsync(cancellationToken);
await SeedTeacherCourseApplicationsAsync(cancellationToken);
await LogSummaryAsync(cancellationToken);
}
private async Task SeedCollegesAsync(Guid campusId, CancellationToken cancellationToken)
{
var existingCodes = (await db.Colleges
.Select(x => x.Code)
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var additions = CollegeDefinitions
.Where(x => !existingCodes.Contains(x.Code))
.Select((x, index) => new College
{
Code = x.Code,
Name = x.Name,
ShortName = x.ShortName,
CampusId = campusId,
SortOrder = (index + 1) * 10
})
.ToList();
if (additions.Count == 0) return;
db.Colleges.AddRange(additions);
await db.SaveChangesAsync(cancellationToken);
}
private async Task SeedMajorsAsync(CancellationToken cancellationToken)
{
var colleges = await db.Colleges
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var existingCodes = (await db.Majors
.Select(x => x.Code)
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var additions = new List<Major>();
foreach (var collegeDefinition in CollegeDefinitions)
{
if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue;
for (var index = 0; index < collegeDefinition.Majors.Count; index++)
{
var definition = collegeDefinition.Majors[index];
if (existingCodes.Contains(definition.Code)) continue;
additions.Add(new Major
{
Code = definition.Code,
Name = definition.Name,
CollegeId = college.Id,
DegreeType = definition.DegreeType,
SchoolingYears = definition.SchoolingYears,
SortOrder = (index + 1) * 10
});
}
}
if (additions.Count == 0) return;
db.Majors.AddRange(additions);
await db.SaveChangesAsync(cancellationToken);
}
private async Task SeedClassesAsync(CancellationToken cancellationToken)
{
var targetMajorCodes = CollegeDefinitions
.SelectMany(x => x.Majors)
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var majors = await db.Majors
.Where(x => targetMajorCodes.Contains(x.Code))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var existingClasses = await db.AdministrativeClasses
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var existingCodes = (await db.AdministrativeClasses
.Select(x => x.Code)
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var additions = new List<AdministrativeClass>();
foreach (var major in majors)
{
var currentCount = existingClasses.Count(x => x.MajorId == major.Id);
for (var section = currentCount + 1; section <= ClassesPerMajor; section++)
{
var code = CreateAvailableCode(
$"{major.Code}-{Grade}-{section:D2}",
existingCodes);
additions.Add(new AdministrativeClass
{
Code = code,
Name = $"{major.Name}{Grade}级{section}班",
MajorId = major.Id,
Grade = Grade,
CounselorName = $"{CounselorSurnames[(section + major.Code.Length) % CounselorSurnames.Length]}老师",
SortOrder = section * 10
});
}
}
if (additions.Count == 0) return;
db.AdministrativeClasses.AddRange(additions);
await db.SaveChangesAsync(cancellationToken);
}
private async Task SeedTeachersAsync(CancellationToken cancellationToken)
{
var collegeCodes = CollegeDefinitions
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var colleges = await db.Colleges
.Where(x => collegeCodes.Contains(x.Code))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var existingTeachers = await db.Teachers
.Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId))
.ToListAsync(cancellationToken);
var existingNumbers = (await db.Teachers
.Select(x => x.TeacherNumber)
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var additions = new List<Teacher>();
for (var collegeIndex = 0; collegeIndex < colleges.Count; collegeIndex++)
{
var college = colleges[collegeIndex];
var currentCount = existingTeachers.Count(x => x.CollegeId == college.Id);
for (var position = currentCount + 1; position <= TeachersPerCollege; position++)
{
var teacherNumber = CreateAvailableCode(
$"T26{collegeIndex + 1:D2}{position:D3}",
existingNumbers);
additions.Add(new Teacher
{
TeacherNumber = teacherNumber,
Name = BuildPersonName(collegeIndex, position),
Gender = position % 2 == 0 ? Gender.Female : Gender.Male,
CollegeId = college.Id,
Title = TeacherTitles[(position - 1) % TeacherTitles.Length],
Status = TeacherStatus.Active,
HireDate = new DateOnly(2012 + position, 7, 1),
Email = $"{teacherNumber.ToLowerInvariant()}@example.edu.cn",
Notes = "Development 环境批量测试教师。"
});
}
}
if (additions.Count == 0) return;
db.Teachers.AddRange(additions);
await db.SaveChangesAsync(cancellationToken);
}
private async Task SeedStudentsAsync(CancellationToken cancellationToken)
{
var targetMajorCodes = CollegeDefinitions
.SelectMany(x => x.Majors)
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var classes = await db.AdministrativeClasses
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var classIds = classes.Select(x => x.Id).ToHashSet();
var existingStudents = await db.Students
.Where(x => classIds.Contains(x.AdministrativeClassId))
.ToListAsync(cancellationToken);
var existingNumbers = (await db.Students
.Select(x => x.StudentNumber)
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var additions = new List<Student>();
for (var classIndex = 0; classIndex < classes.Count; classIndex++)
{
var administrativeClass = classes[classIndex];
var existingCount = existingStudents.Count(x =>
x.AdministrativeClassId == administrativeClass.Id);
var candidate = 1;
while (existingCount + additions.Count(x =>
x.AdministrativeClassId == administrativeClass.Id) < StudentsPerClass)
{
var studentNumber = $"{Grade}{classIndex + 1:D3}{candidate:D3}";
candidate++;
if (!existingNumbers.Add(studentNumber)) continue;
additions.Add(new Student
{
StudentNumber = studentNumber,
Name = BuildPersonName(classIndex, candidate + 5),
Gender = candidate % 2 == 0 ? Gender.Male : Gender.Female,
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = Grade,
EnrollmentDate = new DateOnly(Grade, 9, 7),
DateOfBirth = new DateOnly(2007 + candidate % 2, candidate % 12 + 1,
candidate % 27 + 1),
Status = StudentStatus.Active,
Email = $"{studentNumber}@student.example.edu.cn",
Notes = "Development 环境批量测试学生。"
});
}
}
if (additions.Count == 0) return;
db.Students.AddRange(additions);
await db.SaveChangesAsync(cancellationToken);
}
private async Task SeedCoursesAsync(CancellationToken cancellationToken)
{
var colleges = await db.Colleges
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var categories = await db.CourseCategories
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
var existingCodes = (await db.Courses
.Select(x => x.Code)
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var additions = new List<Course>();
foreach (var definition in PublicCourseDefinitions)
{
if (existingCodes.Contains(definition.Code) ||
!colleges.TryGetValue(definition.CollegeCode, out var college) ||
!categories.TryGetValue(definition.CategoryCode, out var category))
continue;
additions.Add(new Course
{
Code = definition.Code,
Name = definition.Name,
CollegeId = college.Id,
CourseCategoryId = category.Id,
Credits = definition.Credits,
TotalHours = definition.TotalHours,
LectureHours = definition.LectureHours,
PracticeHours = definition.TotalHours - definition.LectureHours,
Nature = definition.Nature,
AssessmentMethod = definition.AssessmentMethod,
Description = "Development 环境公共课程测试数据。"
});
}
foreach (var collegeDefinition in CollegeDefinitions)
{
if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue;
for (var index = 0; index < collegeDefinition.ProfessionalCourses.Count; index++)
{
var code = $"{collegeDefinition.Code}D{index + 1:D2}";
if (existingCodes.Contains(code)) continue;
var nature = index switch
{
< 4 => CourseNature.MajorRequired,
< 6 => CourseNature.MajorElective,
_ => CourseNature.Practice
};
var categoryCode = nature == CourseNature.Practice ? "PRACTICE" : "MAJOR";
additions.Add(new Course
{
Code = code,
Name = collegeDefinition.ProfessionalCourses[index],
CollegeId = college.Id,
CourseCategoryId = categories[categoryCode].Id,
Credits = nature switch
{
CourseNature.MajorRequired => index % 2 == 0 ? 3m : 3.5m,
_ => 2m
},
TotalHours = nature switch
{
CourseNature.MajorRequired => index % 2 == 0 ? 48 : 56,
CourseNature.MajorElective => 32,
_ => 48
},
LectureHours = nature == CourseNature.Practice ? 8 :
nature == CourseNature.MajorElective ? 24 :
index % 2 == 0 ? 40 : 48,
PracticeHours = nature == CourseNature.Practice ? 40 :
nature == CourseNature.MajorElective ? 8 : 8,
Nature = nature,
AssessmentMethod = nature == CourseNature.Practice
? AssessmentMethod.Assessment
: AssessmentMethod.Examination,
Description = $"由{collegeDefinition.Name}开设的专业课程测试数据。"
});
}
}
if (additions.Count == 0) return;
db.Courses.AddRange(additions);
await db.SaveChangesAsync(cancellationToken);
}
private async Task SeedTeacherCourseApplicationsAsync(CancellationToken cancellationToken)
{
var term = await db.AcademicTerms
.OrderByDescending(x => x.IsCurrent)
.ThenByDescending(x => x.StartDate)
.FirstOrDefaultAsync(cancellationToken);
if (term is null) return;
var collegeCodes = CollegeDefinitions
.Select(x => x.Code)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var colleges = await db.Colleges
.Where(x => collegeCodes.Contains(x.Code))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var collegeIds = colleges.Select(x => x.Id).ToHashSet();
var teachers = await db.Teachers
.Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active)
.OrderBy(x => x.TeacherNumber)
.ToListAsync(cancellationToken);
var courses = await db.Courses
.Where(x => collegeIds.Contains(x.CollegeId))
.OrderBy(x => x.Code)
.ToListAsync(cancellationToken);
var publicCourses = courses
.Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective)
.ToList();
if (publicCourses.Count == 0) return;
var existing = (await db.TeacherCourseApplications
.Where(x => x.AcademicTermId == term.Id)
.Select(x => new { x.TeacherId, x.CourseId })
.ToListAsync(cancellationToken))
.Select(x => (x.TeacherId, x.CourseId))
.ToHashSet();
var additions = new List<TeacherCourseApplication>();
for (var teacherIndex = 0; teacherIndex < teachers.Count; teacherIndex++)
{
var teacher = teachers[teacherIndex];
var professionalCourses = courses
.Where(x => x.CollegeId == teacher.CollegeId &&
x.Nature is CourseNature.MajorRequired or
CourseNature.MajorElective or CourseNature.Practice)
.Take(4);
var selectedPublicCourses = Enumerable.Range(0, 2)
.Select(offset => publicCourses[(teacherIndex + offset) % publicCourses.Count]);
foreach (var course in professionalCourses
.Concat(selectedPublicCourses)
.DistinctBy(x => x.Id))
{
if (!existing.Add((teacher.Id, course.Id))) continue;
additions.Add(new TeacherCourseApplication
{
AcademicTermId = term.Id,
TeacherId = teacher.Id,
CourseId = course.Id,
Status = TeacherCourseApplicationStatus.Approved,
Statement = "Development 环境批量生成的授课意向。",
ReviewComment = "测试数据自动审核通过。",
SubmittedAt = DateTime.UtcNow.AddDays(-7),
ReviewedAt = DateTime.UtcNow.AddDays(-6)
});
}
}
if (additions.Count == 0) return;
db.TeacherCourseApplications.AddRange(additions);
await db.SaveChangesAsync(cancellationToken);
}
private async Task LogSummaryAsync(CancellationToken cancellationToken)
{
var courseCounts = await db.Courses
.GroupBy(x => x.Nature)
.Select(x => new { Nature = x.Key, Count = x.Count() })
.ToDictionaryAsync(x => x.Nature, x => x.Count, cancellationToken);
logger.LogInformation(
"Development 测试数据已就绪:学院/教学单位 {CollegeCount},专业 {MajorCount}" +
"行政班 {ClassCount},教师 {TeacherCount},学生 {StudentCount},课程 {CourseCount}" +
"公共必修 {GeneralRequiredCount},公共选修 {GeneralElectiveCount}" +
"专业必修 {MajorRequiredCount},专业选修 {MajorElectiveCount},实践课程 {PracticeCount}" +
"已审核授课资格 {ApplicationCount}。",
await db.Colleges.CountAsync(cancellationToken),
await db.Majors.CountAsync(cancellationToken),
await db.AdministrativeClasses.CountAsync(cancellationToken),
await db.Teachers.CountAsync(cancellationToken),
await db.Students.CountAsync(cancellationToken),
await db.Courses.CountAsync(cancellationToken),
courseCounts.GetValueOrDefault(CourseNature.GeneralRequired),
courseCounts.GetValueOrDefault(CourseNature.GeneralElective),
courseCounts.GetValueOrDefault(CourseNature.MajorRequired),
courseCounts.GetValueOrDefault(CourseNature.MajorElective),
courseCounts.GetValueOrDefault(CourseNature.Practice),
await db.TeacherCourseApplications
.CountAsync(x => x.Status == TeacherCourseApplicationStatus.Approved,
cancellationToken));
}
private static string CreateAvailableCode(string preferredCode, ISet<string> existingCodes)
{
var candidate = preferredCode;
var suffix = 1;
while (!existingCodes.Add(candidate))
{
candidate = $"{preferredCode}-{suffix++}";
}
return candidate;
}
private static string BuildPersonName(int groupIndex, int position)
{
var surname = Surnames[(groupIndex + position) % Surnames.Length];
var givenName = GivenNames[
(groupIndex * 7 + position * 3) % GivenNames.Length];
return surname + givenName;
}
private static readonly string[] Surnames =
["王", "李", "张", "刘", "陈", "杨", "黄", "赵", "吴", "周", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "林", "罗"];
private static readonly string[] GivenNames =
["明远", "知夏", "嘉树", "雨桐", "思源", "若溪", "景行", "书雅", "子涵", "浩然", "清越", "语晨", "承宇", "欣怡", "博文", "婉宁", "俊逸", "安然", "泽楷", "可心"];
private static readonly string[] CounselorSurnames =
["陈", "林", "周", "王", "李", "张", "刘", "赵"];
private static readonly string[] TeacherTitles =
["教授", "副教授", "讲师", "讲师", "副教授", "实验师", "讲师", "教授"];
private static readonly CollegeSeed[] CollegeDefinitions =
[
new("CS", "计算机学院", "计算机学院",
[
new("080901", "计算机科学与技术", "工学学士"),
new("080902", "软件工程", "工学学士"),
new("080903", "网络工程", "工学学士"),
new("080910T", "数据科学与大数据技术", "工学学士"),
new("080717T", "人工智能", "工学学士")
],
["程序设计基础", "离散数学", "数据结构", "计算机组成原理", "操作系统", "数据库系统原理", "软件工程课程设计", "人工智能项目实践"]),
new("EIA", "电子信息与自动化学院", "电子信息学院",
[
new("080701", "电子信息工程", "工学学士"),
new("080703", "通信工程", "工学学士"),
new("080801", "自动化", "工学学士"),
new("080803T", "机器人工程", "工学学士")
],
["电路分析", "模拟电子技术", "数字电子技术", "信号与系统", "通信原理", "嵌入式系统", "电子系统设计", "综合电子实训"]),
new("ME", "机械与车辆工程学院", "机械学院",
[
new("080202", "机械设计制造及其自动化", "工学学士"),
new("080207", "车辆工程", "工学学士"),
new("080205", "工业设计", "工学学士"),
new("080213T", "智能制造工程", "工学学士")
],
["工程制图", "理论力学", "材料力学", "机械原理", "机械设计", "智能制造技术", "机械创新设计", "工程训练"]),
new("EE", "电气工程学院", "电气学院",
[
new("080601", "电气工程及其自动化", "工学学士"),
new("080604T", "电气工程与智能控制", "工学学士"),
new("080605T", "电机电器智能化", "工学学士")
],
["电路原理", "电机学", "电力电子技术", "自动控制原理", "电力系统分析", "继电保护", "电气控制实训", "电力系统综合设计"]),
new("CIVIL", "土木建筑工程学院", "土建学院",
[
new("081001", "土木工程", "工学学士"),
new("082801", "建筑学", "建筑学学士", 5),
new("120103", "工程管理", "管理学学士"),
new("081006T", "道路桥梁与渡河工程", "工学学士")
],
["工程制图与识图", "工程力学", "结构力学", "混凝土结构", "土力学与地基基础", "工程项目管理", "建筑设计基础", "工程测量实习"]),
new("ECON", "经济与管理学院", "经管学院",
[
new("120201K", "工商管理", "管理学学士"),
new("120203K", "会计学", "管理学学士"),
new("020301K", "金融学", "经济学学士"),
new("020401", "国际经济与贸易", "经济学学士"),
new("120202", "市场营销", "管理学学士")
],
["微观经济学", "宏观经济学", "管理学原理", "会计学原理", "统计学", "财务管理", "企业经营沙盘", "商务数据分析实践"]),
new("FOREIGN", "外国语学院", "外国语学院",
[
new("050201", "英语", "文学学士"),
new("050207", "日语", "文学学士"),
new("050262", "商务英语", "文学学士")
],
["综合英语", "英语听力", "英语口语", "英语写作", "翻译理论与实践", "跨文化交际", "商务英语实训", "口译实践"]),
new("MATH", "数学与统计学院", "数统学院",
[
new("070101", "数学与应用数学", "理学学士"),
new("071201", "统计学", "理学学士"),
new("020102", "经济统计学", "经济学学士")
],
["数学分析", "高等代数", "解析几何", "常微分方程", "实变函数", "数值分析", "数学建模", "统计软件实践"]),
new("PHYSICS", "物理与光电工程学院", "物电学院",
[
new("070201", "物理学", "理学学士"),
new("070202", "应用物理学", "理学学士"),
new("080705", "光电信息科学与工程", "工学学士")
],
["力学", "热学", "电磁学", "光学", "量子力学", "固体物理", "近代物理实验", "光电技术综合实验"]),
new("CHEM", "化学与环境工程学院", "化环学院",
[
new("070301", "化学", "理学学士"),
new("070302", "应用化学", "理学学士"),
new("081301", "化学工程与工艺", "工学学士"),
new("082502", "环境工程", "工学学士")
],
["无机化学", "有机化学", "分析化学", "物理化学", "化工原理", "仪器分析", "基础化学实验", "化工设计实践"]),
new("HUMANITIES", "人文与法学院", "人文法学院",
[
new("050101", "汉语言文学", "文学学士"),
new("030101K", "法学", "法学学士"),
new("120402", "行政管理", "管理学学士")
],
["中国古代文学", "中国现当代文学", "现代汉语", "古代汉语", "文学概论", "行政管理学", "新闻写作实训", "社会调查实践"]),
new("EDU", "教育科学学院", "教育学院",
[
new("040101", "教育学", "教育学学士"),
new("040107", "小学教育", "教育学学士"),
new("040106", "学前教育", "教育学学士")
],
["教育学原理", "普通心理学", "教育心理学", "课程与教学论", "教育研究方法", "班级管理", "微格教学", "教育见习"]),
new("ART", "艺术设计学院", "艺术学院",
[
new("130502", "视觉传达设计", "艺术学学士"),
new("130503", "环境设计", "艺术学学士"),
new("130202", "音乐学", "艺术学学士")
],
["设计素描", "色彩基础", "构成基础", "艺术概论", "数字媒体设计", "品牌视觉设计", "专业采风", "毕业创作实践"]),
new("PE", "体育学院", "体育学院",
[
new("040201", "体育教育", "教育学学士"),
new("040203", "社会体育指导与管理", "教育学学士")
],
["运动解剖学", "运动生理学", "学校体育学", "体育心理学", "运动训练学", "体育社会学", "田径专项训练", "球类专项训练"]),
new("LIFE", "生命科学与食品工程学院", "生食学院",
[
new("071001", "生物科学", "理学学士"),
new("071002", "生物技术", "理学学士"),
new("082701", "食品科学与工程", "工学学士")
],
["普通生物学", "生物化学", "细胞生物学", "遗传学", "微生物学", "食品化学", "分子生物学实验", "生物工程综合实践"]),
new("MARXISM", "马克思主义学院", "马克思主义学院", [], [])
];
private static readonly PublicCourseSeed[] PublicCourseDefinitions =
[
new("PUB001", "思想道德与法治", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
new("PUB002", "中国近现代史纲要", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
new("PUB003", "马克思主义基本原理", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
new("PUB004", "毛泽东思想和中国特色社会主义理论体系概论", "MARXISM", "MORAL", 5, 80, 64, CourseNature.GeneralRequired),
new("PUB005", "习近平新时代中国特色社会主义思想概论", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
new("PUB006", "形势与政策", "MARXISM", "MORAL", 2, 32, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB007", "大学英语 I", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired),
new("PUB008", "大学英语 II", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired),
new("PUB009", "高等数学 A(上)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired),
new("PUB010", "高等数学 A(下)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired),
new("PUB011", "线性代数", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired),
new("PUB012", "概率论与数理统计", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired),
new("PUB013", "大学计算机基础", "CS", "BASIC", 2, 32, 16, CourseNature.GeneralRequired),
new("PUB014", "Python 程序设计", "CS", "BASIC", 3, 48, 24, CourseNature.GeneralRequired),
new("PUB015", "大学体育 I", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB016", "大学体育 II", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB017", "军事理论", "HUMANITIES", "MILITARY", 2, 36, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB018", "大学生心理健康教育", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB019", "大学生职业发展与就业指导", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB020", "创新创业基础", "ECON", "INNOVATION", 2, 32, 20, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB021", "劳动教育", "EDU", "LABOR", 1, 32, 8, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB022", "国家安全教育", "HUMANITIES", "MORAL", 1, 16, 16, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB023", "文献检索与学术规范", "HUMANITIES", "BASIC", 1, 16, 12, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
new("PUB024", "艺术鉴赏", "ART", "AESTHETIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment),
new("PUB025", "中国传统文化", "HUMANITIES", "AESTHETIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment),
new("PUB026", "生态文明导论", "LIFE", "BASIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment),
new("PUB027", "人工智能导论", "CS", "INNOVATION", 2, 32, 20, CourseNature.GeneralElective, AssessmentMethod.Assessment),
new("PUB028", "经济学通识", "ECON", "BASIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment)
];
private sealed record CollegeSeed(
string Code,
string Name,
string ShortName,
IReadOnlyList<MajorSeed> Majors,
IReadOnlyList<string> ProfessionalCourses);
private sealed record MajorSeed(
string Code,
string Name,
string DegreeType,
int SchoolingYears = 4);
private sealed record PublicCourseSeed(
string Code,
string Name,
string CollegeCode,
string CategoryCode,
decimal Credits,
int TotalHours,
int LectureHours,
CourseNature Nature,
AssessmentMethod AssessmentMethod = AssessmentMethod.Examination);
}
+6
View File
@@ -87,6 +87,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services
@@ -248,6 +249,11 @@ using (var scope = app.Services.CreateScope())
.InitializeAsync();
}
if (args.Contains("--seed-only", StringComparer.OrdinalIgnoreCase))
{
return;
}
app.Run();
public partial class Program;