生产环境

This commit is contained in:
2026-07-25 20:21:42 +08:00 Unverified
parent 1da6543a38
commit a576cc815d
30 changed files with 3325 additions and 4129 deletions
@@ -295,7 +295,8 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
{
origRecord.TotalScore = passedRecord.TotalScore;
origRecord.GradePoint = passedRecord.GradePoint;
origRecord.Notes = $"课程替代:{cs.SubstituteCourse.Code} {cs.SubstituteCourse.Name}";
origRecord.Notes =
$"课程替代:{cs.SubstituteCourse!.Code} {cs.SubstituteCourse.Name}";
}
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Jiaowu.Api.Infrastructure.Persistence;
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("ConnectionStrings__MySql")
?? "Server=localhost;Port=3306;Database=jiaowu;User=__design_time__;Password=__not_used__;";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySQL(connectionString)
.Options;
return new AppDbContext(options);
}
}
@@ -10,31 +10,49 @@ public sealed class DatabaseInitializer(
RoleManager<ApplicationRole> roleManager,
UserManager<ApplicationUser> userManager,
DevelopmentSqliteMigrator sqliteMigrator,
DevelopmentDemoDataSeeder developmentDemoDataSeeder,
DatabaseOptions databaseOptions,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<DatabaseInitializer> logger)
{
public async Task InitializeAsync()
public async Task InitializeAsync(bool migrateOnly = false)
{
if (environment.IsDevelopment())
{
if (migrateOnly)
{
throw new InvalidOperationException(
"--migrate-only 仅用于非 Development 环境的 MySQL 数据库。");
}
await db.Database.EnsureCreatedAsync();
await sqliteMigrator.MigrateAsync();
}
else
else if (migrateOnly || databaseOptions.ApplyMigrationsOnStartup)
{
await db.Database.MigrateAsync();
}
else
{
var pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToArray();
if (pendingMigrations.Length > 0)
{
throw new InvalidOperationException(
$"数据库还有 {pendingMigrations.Length} 个待执行迁移。请在发布前运行 " +
"'Jiaowu.Api --migrate-only',或显式配置 " +
"Database:ApplyMigrationsOnStartup=true。");
}
}
if (migrateOnly)
{
logger.LogInformation("数据库迁移已完成。");
return;
}
await SeedRolesAsync();
await SeedAdministratorAsync();
await SeedCourseCategoriesAsync();
if (environment.IsDevelopment())
{
await SeedDevelopmentDataAsync();
}
}
private async Task SeedRolesAsync()
@@ -131,7 +149,11 @@ public sealed class DatabaseInitializer(
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (code, name, sortOrder) in defaults)
{
if (existingCodes.Contains(code)) continue;
if (existingCodes.Contains(code))
{
continue;
}
db.CourseCategories.Add(new CourseCategory
{
Code = code,
@@ -159,523 +181,6 @@ public sealed class DatabaseInitializer(
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentDataAsync()
{
if (!await db.Campuses.AnyAsync())
{
var campus = new Campus
{
Code = "MAIN",
Name = "主校区",
Address = "大学路 1 号"
};
var college = new College
{
Code = "CS",
Name = "计算机学院",
ShortName = "计算机学院",
CampusId = campus.Id
};
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士",
SchoolingYears = 4
};
var building = new Building
{
Code = "J1",
Name = "第一教学楼",
CampusId = campus.Id
};
db.AddRange(
campus,
college,
major,
new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026,
CounselorName = "陈老师"
},
building,
new Classroom
{
Code = "J1-201",
Name = "J1-201",
BuildingId = building.Id,
Capacity = 60,
RoomType = "多媒体教室",
Equipment = "投影、扩声、录播"
},
new AcademicTerm
{
Code = "2026-2027-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17),
IsCurrent = true
});
await db.SaveChangesAsync();
}
var computerCollege = await db.Colleges.SingleAsync(x => x.Code == "CS");
var computerClass = await db.AdministrativeClasses
.SingleAsync(x => x.Code == "CS2026-01");
if (!await db.Teachers.AnyAsync())
{
db.Teachers.AddRange(
new Teacher
{
TeacherNumber = "T2026001",
Name = "陈明远",
Gender = Gender.Male,
CollegeId = computerCollege.Id,
Title = "副教授",
Status = TeacherStatus.Active,
HireDate = new DateOnly(2018, 7, 1),
Email = "chenmy@example.edu.cn"
},
new Teacher
{
TeacherNumber = "T2026002",
Name = "林书雅",
Gender = Gender.Female,
CollegeId = computerCollege.Id,
Title = "讲师",
Status = TeacherStatus.Active,
HireDate = new DateOnly(2022, 9, 1),
Email = "linsy@example.edu.cn"
});
}
if (!await db.Students.AnyAsync())
{
db.Students.AddRange(
new Student
{
StudentNumber = "202601001",
Name = "周启航",
Gender = Gender.Male,
AdministrativeClassId = computerClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active
},
new Student
{
StudentNumber = "202601002",
Name = "许知夏",
Gender = Gender.Female,
AdministrativeClassId = computerClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active
},
new Student
{
StudentNumber = "202601003",
Name = "方嘉树",
Gender = Gender.Male,
AdministrativeClassId = computerClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active
});
}
var courseCategories = await db.CourseCategories
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
if (!await db.Courses.AnyAsync())
{
db.Courses.AddRange(
new Course
{
Code = "CS101",
Name = "程序设计基础",
EnglishName = "Fundamentals of Programming",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["BASIC"].Id,
Credits = 4,
TotalHours = 64,
LectureHours = 40,
PracticeHours = 24,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination,
Description = "面向一年级学生的程序设计入门课程。"
},
new Course
{
Code = "CS201",
Name = "数据结构",
EnglishName = "Data Structures",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["BASIC"].Id,
Credits = 3.5m,
TotalHours = 56,
LectureHours = 40,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
},
new Course
{
Code = "CS305",
Name = "软件工程实践",
EnglishName = "Software Engineering Practice",
CollegeId = computerCollege.Id,
CourseCategoryId = courseCategories["PRACTICE"].Id,
Credits = 2,
TotalHours = 48,
LectureHours = 8,
PracticeHours = 40,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
});
}
await db.SaveChangesAsync();
if (!await db.CurriculumPlans.AnyAsync())
{
var seededCourses = await db.Courses
.Where(x => x.Code == "CS101" || x.Code == "CS201" || x.Code == "CS305")
.ToDictionaryAsync(x => x.Code);
if (seededCourses.Count == 3)
{
db.CurriculumPlans.Add(new CurriculumPlan
{
MajorId = (await db.Majors.SingleAsync(x => x.Code == "080901")).Id,
Name = "计算机科学与技术专业培养方案",
Version = "2026版",
EffectiveGrade = 2026,
TotalCredits = 9.5m,
Status = CurriculumPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Description = "用于本地开发的精简培养方案示例。",
Modules =
[
new CurriculumModule
{
Code = "BASIC",
Name = "专业基础课程",
RequiredCredits = 7.5m,
SortOrder = 10,
Courses =
[
new CurriculumCourse
{
CourseId = seededCourses["CS101"].Id,
RecommendedSemester = 1,
Type = CurriculumCourseType.Required
},
new CurriculumCourse
{
CourseId = seededCourses["CS201"].Id,
RecommendedSemester = 3,
Type = CurriculumCourseType.Required
}
]
},
new CurriculumModule
{
Code = "PRACTICE",
Name = "实践教学",
RequiredCredits = 2m,
SortOrder = 20,
Courses =
[
new CurriculumCourse
{
CourseId = seededCourses["CS305"].Id,
RecommendedSemester = 5,
Type = CurriculumCourseType.Required
}
]
}
]
});
await db.SaveChangesAsync();
}
}
if (!await db.TeachingTasks.AnyAsync())
{
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var course = await db.Courses.SingleAsync(x => x.Code == "CS101");
var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001");
var administrativeClass = await db.AdministrativeClasses
.SingleAsync(x => x.Code == "CS2026-01");
db.TeachingTasks.Add(new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班 01",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
StartWeek = 1,
EndWeek = 16,
WeeklyHours = 4,
Status = TeachingTaskStatus.Published,
PublishedAt = DateTime.UtcNow,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
});
await db.SaveChangesAsync();
}
if (!await db.SchedulePlans.AnyAsync())
{
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
var classroom = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = task.Id,
ClassroomId = classroom.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
}
]
});
await db.SaveChangesAsync();
}
await SeedDevelopmentUsersAsync(computerCollege.Id);
await SeedDevelopmentCourseSelectionAsync();
await SeedDevelopmentGradesAsync();
await SeedDevelopmentExamsAsync();
await developmentDemoDataSeeder.SeedAsync();
}
private async Task SeedDevelopmentCourseSelectionAsync()
{
if (!await db.CourseSelectionRounds.AnyAsync())
{
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var now = DateTime.UtcNow;
db.CourseSelectionRounds.Add(new CourseSelectionRound
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期第一轮选课",
StartsAt = now.AddDays(-2),
EndsAt = now.AddDays(14),
WithdrawalEndsAt = now.AddDays(21),
MaxCredits = 30,
Status = CourseSelectionRoundStatus.Open,
Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。",
Offerings =
[
new CourseSelectionOffering
{
TeachingTaskId = task.Id,
Capacity = 60,
IsOpenToAll = false,
Notes = "面向计科 2026-1 班开放。"
}
]
});
await db.SaveChangesAsync();
}
var seededTask = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var offering = await db.CourseSelectionOfferings.FirstOrDefaultAsync(
x => x.TeachingTaskId == seededTask.Id);
var student = await db.Students.SingleAsync(x =>
x.StudentNumber == "202601001");
if (offering is not null &&
!await db.CourseEnrollments.AnyAsync(x =>
x.CourseSelectionOfferingId == offering.Id &&
x.StudentId == student.Id))
{
db.CourseEnrollments.Add(new CourseEnrollment
{
CourseSelectionOfferingId = offering.Id,
StudentId = student.Id
});
await db.SaveChangesAsync();
}
}
private async Task SeedDevelopmentGradesAsync()
{
if (await db.GradeSheets.AnyAsync()) return;
var task = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var student = await db.Students.SingleAsync(
x => x.StudentNumber == "202601001");
db.GradeSheets.Add(new GradeSheet
{
TeachingTaskId = task.Id,
RegularWeight = 30,
FinalWeight = 70,
Status = GradeSheetStatus.Draft,
Records =
[
new GradeRecord
{
StudentId = student.Id,
RegularScore = 88,
FinalScore = 92,
TotalScore = 90.8m,
GradePoint = 4.0m
}
]
});
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentExamsAsync()
{
if (await db.ExamPlans.AnyAsync()) return;
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
var room = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001");
db.ExamPlans.Add(new ExamPlan
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期期末考试",
Status = ExamPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Notes = "本地开发演示考试计划。",
Sessions =
[
new ExamSession
{
TeachingTaskId = task.Id,
ClassroomId = room.Id,
ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 2,
Invigilators =
[
new ExamSessionInvigilator { TeacherId = teacher.Id }
]
}
]
});
await db.SaveChangesAsync();
}
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
{
var definitions = new[]
{
new DevelopmentUser(
"academic", "校级教务员", "Academic@123456",
null, null, SystemRoles.AcademicAdmin),
new DevelopmentUser(
"college", "计算机学院教务员", "College@123456",
"A2026001", collegeId, SystemRoles.CollegeAdmin),
new DevelopmentUser(
"counselor", "陈老师", "Counselor@123456",
"C2026001", collegeId, SystemRoles.Counselor),
new DevelopmentUser(
"teacher", "陈明远", "Teacher@123456",
"T2026001", collegeId, SystemRoles.Teacher),
new DevelopmentUser(
"student", "周启航", "Student@123456",
"202601001", collegeId, SystemRoles.Student),
new DevelopmentUser(
"leader", "教学分管领导", "Leader@123456",
null, null, SystemRoles.Leader)
};
foreach (var definition in definitions)
{
var user = await userManager.FindByNameAsync(definition.UserName);
if (user is null)
{
user = new ApplicationUser
{
UserName = definition.UserName,
DisplayName = definition.DisplayName,
StaffNumber = definition.StaffNumber,
CollegeId = definition.CollegeId,
LockoutEnabled = true,
IsEnabled = true
};
EnsureSucceeded(
await userManager.CreateAsync(user, definition.Password),
$"创建开发账号 {definition.UserName}");
}
if (!await userManager.IsInRoleAsync(user, definition.Role))
{
EnsureSucceeded(
await userManager.AddToRoleAsync(user, definition.Role),
$"授予开发账号 {definition.UserName} 角色");
}
if (definition.Role == SystemRoles.Teacher)
{
var teacher = await db.Teachers.SingleAsync(
x => x.TeacherNumber == definition.StaffNumber);
if (!teacher.UserId.HasValue) teacher.UserId = user.Id;
}
if (definition.Role == SystemRoles.Student)
{
var student = await db.Students.SingleAsync(
x => x.StudentNumber == definition.StaffNumber);
if (!student.UserId.HasValue) student.UserId = user.Id;
}
if (definition.Role == SystemRoles.Counselor)
{
var classes = await db.AdministrativeClasses
.Where(x =>
x.CounselorUserId == null &&
x.CounselorName == definition.DisplayName)
.ToListAsync();
foreach (var administrativeClass in classes)
administrativeClass.CounselorUserId = user.Id;
}
}
await db.SaveChangesAsync();
}
private static void EnsureSucceeded(IdentityResult result, string action)
{
if (result.Succeeded)
@@ -686,12 +191,4 @@ public sealed class DatabaseInitializer(
throw new InvalidOperationException(
$"{action}失败:{string.Join("", result.Errors.Select(x => x.Description))}");
}
private sealed record DevelopmentUser(
string UserName,
string DisplayName,
string Password,
string? StaffNumber,
Guid? CollegeId,
string Role);
}
@@ -4,4 +4,6 @@ public sealed class DatabaseOptions
{
public const string SectionName = "Database";
public string Provider { get; set; } = "MySql";
public bool ApplyMigrationsOnStartup { get; set; }
public int CommandTimeoutSeconds { get; set; } = 30;
}
@@ -1,620 +0,0 @@
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);
}
@@ -1,184 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class FlexibleGradesAndAttendance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GradeItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false),
Weight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeItems", x => x.Id);
table.ForeignKey(
name: "FK_GradeItems_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GradeItemScores",
columns: table => new
{
GradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
GradeItemId = table.Column<Guid>(type: "char(36)", nullable: false),
Score = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeItemScores", x => new { x.GradeRecordId, x.GradeItemId });
table.ForeignKey(
name: "FK_GradeItemScores_GradeItems_GradeItemId",
column: x => x.GradeItemId,
principalTable: "GradeItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_GradeItemScores_GradeRecords_GradeRecordId",
column: x => x.GradeRecordId,
principalTable: "GradeRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GradeItems_GradeSheetId_SortOrder",
table: "GradeItems",
columns: new[] { "GradeSheetId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_GradeItemScores_GradeRecordId_GradeItemId",
table: "GradeItemScores",
columns: new[] { "GradeRecordId", "GradeItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GradeItemScores_GradeItemId",
table: "GradeItemScores",
column: "GradeItemId");
migrationBuilder.DropColumn(
name: "MidtermWeight",
table: "GradeSheets");
migrationBuilder.DropColumn(
name: "MidtermScore",
table: "GradeRecords");
migrationBuilder.CreateTable(
name: "AttendanceSheets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
AttendanceDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceSheets", x => x.Id);
table.ForeignKey(
name: "FK_AttendanceSheets_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AttendanceRecords",
columns: table => new
{
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceRecords", x => new { x.AttendanceSheetId, x.StudentId });
table.ForeignKey(
name: "FK_AttendanceRecords_AttendanceSheets_AttendanceSheetId",
column: x => x.AttendanceSheetId,
principalTable: "AttendanceSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AttendanceRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AttendanceSheets_TeachingTaskId_AttendanceDate",
table: "AttendanceSheets",
columns: new[] { "TeachingTaskId", "AttendanceDate" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceRecords_AttendanceSheetId_StudentId",
table: "AttendanceRecords",
columns: new[] { "AttendanceSheetId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AttendanceRecords_StudentId",
table: "AttendanceRecords",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AttendanceRecords");
migrationBuilder.DropTable(name: "AttendanceSheets");
migrationBuilder.DropTable(name: "GradeItemScores");
migrationBuilder.DropTable(name: "GradeItems");
migrationBuilder.AddColumn<decimal>(
name: "MidtermWeight",
table: "GradeSheets",
type: "decimal(5,1)",
precision: 5,
scale: 1,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "MidtermScore",
table: "GradeRecords",
type: "decimal(5,1)",
precision: 5,
scale: 1,
nullable: true);
}
}
}
@@ -1,158 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamSchedulingOptimization : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Drop old FK/index on ClassroomId
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions");
// Make ClassroomId nullable
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ExamSessions",
type: "char(36)",
nullable: true,
oldClrType: typeof(Guid),
oldType: "char(36)");
// Add new columns
migrationBuilder.AddColumn<DateOnly>(
name: "ExamDate",
table: "ExamSessions",
type: "date",
nullable: false,
defaultValue: new DateOnly(2027, 1, 1));
migrationBuilder.AddColumn<int>(
name: "StartPeriod",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "PeriodCount",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 2);
migrationBuilder.AddColumn<Guid>(
name: "RequiredBuildingId",
table: "ExamSessions",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RequiredInvigilatorCount",
table: "ExamSessions",
type: "int",
nullable: false,
defaultValue: 2);
// Re-add FK/index on ClassroomId (nullable, SetNull)
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
// New indices
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ExamPlanId_ExamDate",
table: "ExamSessions",
columns: new[] { "ExamPlanId", "ExamDate" });
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_RequiredBuildingId",
table: "ExamSessions",
column: "RequiredBuildingId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Buildings_RequiredBuildingId",
table: "ExamSessions",
column: "RequiredBuildingId",
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Remove new FK/index
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Buildings_RequiredBuildingId",
table: "ExamSessions");
migrationBuilder.DropForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_ExamPlanId_ExamDate",
table: "ExamSessions");
migrationBuilder.DropIndex(
name: "IX_ExamSessions_RequiredBuildingId",
table: "ExamSessions");
// Drop new columns
migrationBuilder.DropColumn(name: "RequiredInvigilatorCount", table: "ExamSessions");
migrationBuilder.DropColumn(name: "RequiredBuildingId", table: "ExamSessions");
migrationBuilder.DropColumn(name: "PeriodCount", table: "ExamSessions");
migrationBuilder.DropColumn(name: "StartPeriod", table: "ExamSessions");
migrationBuilder.DropColumn(name: "ExamDate", table: "ExamSessions");
// Restore ClassroomId to non-nullable
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ExamSessions",
type: "char(36)",
nullable: false,
defaultValue: Guid.Empty,
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true);
// Restore original FK/index
migrationBuilder.CreateIndex(
name: "IX_ExamSessions_ClassroomId",
table: "ExamSessions",
column: "ClassroomId");
migrationBuilder.AddForeignKey(
name: "FK_ExamSessions_Classrooms_ClassroomId",
table: "ExamSessions",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class TeachingEvaluation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class RetakeEnrollment : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EnrollmentType",
table: "CourseEnrollments",
type: "int",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EnrollmentType",
table: "CourseEnrollments");
}
}
}
@@ -1,125 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseAdjustments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseAdjustments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
TargetDate = table.Column<DateOnly>(type: "date", nullable: true),
DayOfWeek = table.Column<int>(type: "int", nullable: true),
StartPeriod = table.Column<int>(type: "int", nullable: true),
PeriodCount = table.Column<int>(type: "int", nullable: true),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: true),
SubstituteTeacherId = table.Column<Guid>(type: "char(36)", nullable: true),
CancelWeek = table.Column<int>(type: "int", nullable: true),
CancelDate = table.Column<DateOnly>(type: "date", nullable: true),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseAdjustments", x => x.Id);
table.ForeignKey(
name: "FK_CourseAdjustments_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_CourseAdjustments_Teachers_SubstituteTeacherId",
column: x => x.SubstituteTeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_CourseAdjustments_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
LinkUrl = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseAdjustments_ApplicantUserId",
table: "CourseAdjustments",
column: "ApplicantUserId");
migrationBuilder.CreateIndex(
name: "IX_CourseAdjustments_ClassroomId",
table: "CourseAdjustments",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_CourseAdjustments_Status_CreatedAt",
table: "CourseAdjustments",
columns: new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_CourseAdjustments_SubstituteTeacherId",
table: "CourseAdjustments",
column: "SubstituteTeacherId");
migrationBuilder.CreateIndex(
name: "IX_CourseAdjustments_TeachingTaskId_Status",
table: "CourseAdjustments",
columns: new[] { "TeachingTaskId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_CreatedAt",
table: "Notifications",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_IsRead",
table: "Notifications",
columns: new[] { "UserId", "IsRead" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Notifications");
migrationBuilder.DropTable(name: "CourseAdjustments");
}
}
}
@@ -1,66 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AttendanceAppeal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AppealStatus",
table: "AttendanceRecords",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "AppealReason",
table: "AttendanceRecords",
type: "varchar(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "AppealSubmittedAt",
table: "AttendanceRecords",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AppealReviewComment",
table: "AttendanceRecords",
type: "varchar(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "AppealReviewedAt",
table: "AttendanceRecords",
type: "datetime(6)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_AttendanceRecords_AppealStatus",
table: "AttendanceRecords",
column: "AppealStatus");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_AttendanceRecords_AppealStatus",
table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "AppealReviewedAt", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "AppealReviewComment", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "AppealSubmittedAt", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "AppealReason", table: "AttendanceRecords");
migrationBuilder.DropColumn(name: "AppealStatus", table: "AttendanceRecords");
}
}
}
@@ -1,117 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
public partial class ApprovalRequests : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// CourseExemption
migrationBuilder.CreateTable("CourseExemptions", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
}, constraints: table =>
{
table.PrimaryKey("PK_CourseExemptions", x => x.Id);
table.ForeignKey("FK_CourseExemptions_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseExemptions_TeachingTasks", x => x.TeachingTaskId, "TeachingTasks", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_CourseExemptions_Status_CreatedAt", "CourseExemptions", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_CourseExemptions_StudentId_TeachingTaskId", "CourseExemptions", new[] { "StudentId", "TeachingTaskId" }, unique: true);
// DeferredExam
migrationBuilder.CreateTable("DeferredExams", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
}, constraints: table =>
{
table.PrimaryKey("PK_DeferredExams", x => x.Id);
table.ForeignKey("FK_DeferredExams_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_DeferredExams_TeachingTasks", x => x.TeachingTaskId, "TeachingTasks", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_DeferredExams_Status_CreatedAt", "DeferredExams", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_DeferredExams_StudentId_TeachingTaskId", "DeferredExams", new[] { "StudentId", "TeachingTaskId" }, unique: true);
// GradeModification
migrationBuilder.CreateTable("GradeModifications", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
CurrentScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
RequestedScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CollegeReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CollegeReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
FinalReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
FinalReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
}, constraints: table =>
{
table.PrimaryKey("PK_GradeModifications", x => x.Id);
table.ForeignKey("FK_GradeModifications_GradeRecords", x => x.GradeRecordId, "GradeRecords", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_GradeModifications_Status_CreatedAt", "GradeModifications", new[] { "Status", "CreatedAt" });
// CourseSubstitution
migrationBuilder.CreateTable("CourseSubstitutions", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
OriginalCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
SubstituteCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
}, constraints: table =>
{
table.PrimaryKey("PK_CourseSubstitutions", x => x.Id);
table.ForeignKey("FK_CourseSubstitutions_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseSubstitutions_OriginalCourse", x => x.OriginalCourseId, "Courses", "Id", onDelete: ReferentialAction.Restrict);
table.ForeignKey("FK_CourseSubstitutions_SubstituteCourse", x => x.SubstituteCourseId, "Courses", "Id", onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex("IX_CourseSubstitutions_Status_CreatedAt", "CourseSubstitutions", new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex("IX_CourseSubstitutions_StudentId_OriginalCourseId", "CourseSubstitutions", new[] { "StudentId", "OriginalCourseId" }, unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable("CourseSubstitutions");
migrationBuilder.DropTable("GradeModifications");
migrationBuilder.DropTable("DeferredExams");
migrationBuilder.DropTable("CourseExemptions");
}
}
}
@@ -1,46 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
public partial class AcademicWarnings : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable("WarningRules", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
Threshold = table.Column<decimal>(type: "decimal(7,2)", precision: 7, scale: 2, nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
NotifyStudent = table.Column<bool>(type: "tinyint(1)", nullable: false),
NotifyCounselor = table.Column<bool>(type: "tinyint(1)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Description = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
}, constraints: t => { t.PrimaryKey("PK_WarningRules", x => x.Id); t.ForeignKey("FK_WarningRules_AcademicTerms", x => x.AcademicTermId, "AcademicTerms", "Id", onDelete: ReferentialAction.Cascade); });
migrationBuilder.CreateIndex("IX_WarningRules_AcademicTermId_Type", "WarningRules", new[] { "AcademicTermId", "Type" }, unique: true);
migrationBuilder.CreateTable("WarningRecords", table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
TriggerValue = table.Column<decimal>(type: "decimal(7,2)", precision: 7, scale: 2, nullable: false),
Detail = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
AcknowledgedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
AcknowledgeComment = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
}, constraints: t => { t.PrimaryKey("PK_WarningRecords", x => x.Id); t.ForeignKey("FK_WarningRecords_Students", x => x.StudentId, "Students", "Id", onDelete: ReferentialAction.Restrict); });
migrationBuilder.CreateIndex("IX_WarningRecords_StudentId_AcademicTermId_Type", "WarningRecords", new[] { "StudentId", "AcademicTermId", "Type" }, unique: true);
migrationBuilder.CreateIndex("IX_WarningRecords_Status", "WarningRecords", "Status");
}
protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable("WarningRecords"); migrationBuilder.DropTable("WarningRules"); }
}
}
@@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
public partial class WarningAutoCheck : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable("WarningSchedules");
migrationBuilder.AddColumn<bool>("AutoCheckEnabled", "WarningRules", type: "tinyint(1)", nullable: false, defaultValue: false);
migrationBuilder.AddColumn<int?>("CheckDayOfWeek", "WarningRules", type: "int", nullable: true);
migrationBuilder.AddColumn<int>("CheckHour", "WarningRules", type: "int", nullable: false, defaultValue: 8);
migrationBuilder.AddColumn<int>("CheckMinute", "WarningRules", type: "int", nullable: false, defaultValue: 0);
migrationBuilder.AddColumn<DateTime?>("LastCheckAt", "WarningRules", type: "datetime(6)", nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn("LastCheckAt", "WarningRules");
migrationBuilder.DropColumn("CheckMinute", "WarningRules");
migrationBuilder.DropColumn("CheckHour", "WarningRules");
migrationBuilder.DropColumn("CheckDayOfWeek", "WarningRules");
migrationBuilder.DropColumn("AutoCheckEnabled", "WarningRules");
// WarningSchedules table recreation omitted for brevity
}
}
}
+16 -4
View File
@@ -6,10 +6,13 @@
<ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
<BuildFrontendOnPublish Condition="'$(BuildFrontendOnPublish)' == ''">true</BuildFrontendOnPublish>
<NpmCommand Condition="'$(OS)' == 'Windows_NT'">call npm.cmd</NpmCommand>
<NpmCommand Condition="'$(NpmCommand)' == ''">npm</NpmCommand>
</PropertyGroup>
<ItemGroup>
<Content Remove="wwwroot\**\*" />
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
</ItemGroup>
<ItemGroup>
@@ -29,10 +32,19 @@
<Target
Name="BuildVueFrontendForPublish"
AfterTargets="ComputeFilesToPublish"
Condition="'$(BuildFrontendOnPublish)' == 'true' and Exists('$(SpaRoot)/package.json')">
<Message Importance="high" Text="Building Vue frontend for ASP.NET Core publish..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm ci" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
Condition="Exists('$(SpaRoot)/package.json')">
<Message
Condition="'$(BuildFrontendOnPublish)' == 'true'"
Importance="high"
Text="Building Vue frontend for ASP.NET Core publish..." />
<Exec
Condition="'$(BuildFrontendOnPublish)' == 'true'"
WorkingDirectory="$(SpaRoot)"
Command="$(NpmCommand) ci" />
<Exec
Condition="'$(BuildFrontendOnPublish)' == 'true'"
WorkingDirectory="$(SpaRoot)"
Command="$(NpmCommand) run build" />
<ItemGroup>
<FrontendFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" />
-14
View File
@@ -1,14 +0,0 @@
@Host = http://localhost:5255
GET {{Host}}/health
Accept: application/json
###
POST {{Host}}/api/auth/login
Content-Type: application/json
{
"userName": "admin",
"password": "Admin@123456"
}
+64 -18
View File
@@ -27,7 +27,22 @@ if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase
throw new InvalidOperationException("SQLite 仅允许在 Development 环境使用。生产环境请配置 MySql。");
}
builder.Services.AddDbContext<AppDbContext>(options =>
var allowedHosts = builder.Configuration["AllowedHosts"];
if (!builder.Environment.IsDevelopment() &&
(string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts == "*"))
{
throw new InvalidOperationException(
"生产环境必须通过 AllowedHosts 配置明确的访问域名,不能使用通配符。");
}
if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
{
throw new InvalidOperationException(
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
}
builder.Services.AddSingleton(databaseOptions);
builder.Services.AddDbContextPool<AppDbContext>(options =>
{
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
{
@@ -59,7 +74,14 @@ builder.Services.AddDbContext<AppDbContext>(options =>
throw new InvalidOperationException(
"缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。");
}
options.UseMySQL(connectionString);
options.UseMySQL(connectionString, mySqlOptions =>
{
mySqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds);
mySqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
});
});
builder.Services
@@ -79,9 +101,11 @@ builder.Services
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("缺少 Jwt 配置。");
if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32)
if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 ||
jwtOptions.Key.Contains("REPLACE", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Jwt:Key 至少需要 32 字节。");
throw new InvalidOperationException(
"Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。");
}
builder.Services.Configure<JwtOptions>(
@@ -92,7 +116,6 @@ builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
@@ -147,13 +170,17 @@ builder.Services.AddCors(options =>
options.AddPolicy("Web", policy =>
{
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:5173"];
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
?? [];
if (origins.Length > 0)
{
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod();
}
});
});
builder.Services.AddResponseCompression(options => options.EnableForHttps = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler(options =>
{
@@ -215,6 +242,7 @@ builder.Services.AddSwaggerGen(options =>
var app = builder.Build();
app.UseExceptionHandler();
app.UseResponseCompression();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
@@ -246,13 +274,10 @@ app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<AuditMiddleware>();
app.MapControllers();
app.MapGet("/health", () => Results.Ok(new
{
Status = "healthy",
Database = databaseOptions.Provider,
Environment = app.Environment.EnvironmentName,
Time = DateTimeOffset.UtcNow
})).AllowAnonymous();
app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
.AllowAnonymous();
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
app.MapFallback(async context =>
{
if (context.Request.Path.StartsWithSegments("/api") ||
@@ -280,14 +305,35 @@ app.MapFallback(async context =>
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
.InitializeAsync();
.InitializeAsync(
args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase));
}
if (args.Contains("--seed-only", StringComparer.OrdinalIgnoreCase))
if (args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase))
{
return;
}
app.Run();
static async Task<IResult> CheckDatabaseHealthAsync(
AppDbContext db,
CancellationToken cancellationToken)
{
try
{
return await db.Database.CanConnectAsync(cancellationToken)
? Results.Ok(new { Status = "healthy" })
: Results.Json(
new { Status = "unhealthy" },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
catch
{
return Results.Json(
new { Status = "unhealthy" },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
public partial class Program;
+6 -5
View File
@@ -6,12 +6,13 @@
"SQLite": "Data Source=data/jiaowu-dev.sqlite"
},
"Jwt": {
"Key": "jiaowu-development-secret-key-change-before-production"
"Key": "jiaowu-development-secret-key-change-before-production",
"ExpireMinutes": 480
},
"SeedAdmin": {
"UserName": "admin",
"Password": "Admin@123456",
"DisplayName": "系统管理员"
"Cors": {
"Origins": [
"http://localhost:5173"
]
},
"Logging": {
"LogLevel": {
+6 -6
View File
@@ -1,6 +1,8 @@
{
"Database": {
"Provider": "MySql"
"Provider": "MySql",
"ApplyMigrationsOnStartup": false,
"CommandTimeoutSeconds": 30
},
"ConnectionStrings": {
"MySql": ""
@@ -8,13 +10,11 @@
"Jwt": {
"Issuer": "Jiaowu.Api",
"Audience": "Jiaowu.Web",
"Key": "REPLACE_IN_PRODUCTION_WITH_A_LONG_RANDOM_SECRET",
"ExpireMinutes": 480
"Key": "",
"ExpireMinutes": 60
},
"Cors": {
"Origins": [
"http://localhost:5173"
]
"Origins": []
},
"Logging": {
"LogLevel": {