diff --git a/README.md b/README.md index 6db5cf0..6f01c13 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,37 @@ $env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jia `SeedAdmin__UserName`、`SeedAdmin__Password` 和 `SeedAdmin__DisplayName`, 账号创建后立即移除这些配置。 +### 独立的生产演示环境 + +如需验证 Production 配置和 MySQL 8.4 部署链路,请新建专用的空数据库(例如 +`jiaowu_demo`),不要向准备承载真实业务的数据库插入演示数据。先按前述步骤执行 +`--migrate-only`,停止该环境的应用实例,再改用应用运行账号执行: + +```powershell +$env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jiaowu_demo;User=APP_USER;Password=APP_PASSWORD;SslMode=VerifyFull;SslCa=C:\certs\mysql-ca.pem;' +$env:SeedAdmin__UserName = 'demo-admin' +$env:SeedAdmin__Password = '请替换为演示环境专用的强密码' +$env:SeedAdmin__DisplayName = '演示环境管理员' + +& '.artifacts\publish\Jiaowu.Api.exe' --seed-demo-data --confirm-production-demo-data + +Remove-Item -LiteralPath 'Env:SeedAdmin__UserName' +Remove-Item -LiteralPath 'Env:SeedAdmin__Password' +Remove-Item -LiteralPath 'Env:SeedAdmin__DisplayName' +``` + +该命令仅允许在非 Development 环境运行,且必须同时提供确认参数。它会再次检查迁移 +状态,只在没有校区、学院、专业、班级、师生、课程、学期、教室和教学任务等业务数据 +的空库中写入;检测到任何已有业务数据都会直接终止。写入完成后命令退出,不会启动 +Web 服务,也不会在以后启动时自动补写或重复写入。 + +演示数据包含校区、学期、教学楼与教室、16 个学院/教学单位、52 个专业、104 个行政班、 +128 名教师、3,640 名学生、148 门课程和授课资格,不包含内置通用密码或学生/教师登录 +账号。演示数据 DML 在一个事务中提交,失败会回滚;迁移 DDL 必须保持为前置独立步骤, +因为 MySQL 8.4 的 DDL 会触发隐式提交。参见 +[MySQL 8.4 事务说明](https://dev.mysql.com/doc/refman/8.4/en/commit.html)和 +[隐式提交语句清单](https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html)。 + 生产数据库使用 MySQL 专用 EF Core 迁移。部署前先恢复仓库工具并检查迁移: ```powershell diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DemoDataSeeder.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DemoDataSeeder.cs new file mode 100644 index 0000000..f3502d8 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DemoDataSeeder.cs @@ -0,0 +1,696 @@ +using System.Data; +using Jiaowu.Api.Domain.Academic; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Persistence; + +public sealed class DemoDataSeeder( + AppDbContext db, + ILogger 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 executionStrategy = db.Database.CreateExecutionStrategy(); + await executionStrategy.ExecuteAsync(async () => + { + db.ChangeTracker.Clear(); + await using var transaction = await db.Database.BeginTransactionAsync( + IsolationLevel.Serializable, + cancellationToken); + await EnsureDatabaseIsEmptyAsync(cancellationToken); + + var campus = new Campus + { + Code = "MAIN", + Name = "主校区", + Address = "大学路 1 号" + }; + db.Campuses.Add(campus); + await db.SaveChangesAsync(cancellationToken); + + await SeedAcademicTermAsync(cancellationToken); + await SeedClassroomsAsync(campus.Id, 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); + await transaction.CommitAsync(cancellationToken); + }); + } + + private async Task EnsureDatabaseIsEmptyAsync(CancellationToken cancellationToken) + { + var occupiedAreas = new List(); + if (await db.Campuses.AnyAsync(cancellationToken)) occupiedAreas.Add("校区"); + if (await db.Colleges.AnyAsync(cancellationToken)) occupiedAreas.Add("学院"); + if (await db.Majors.AnyAsync(cancellationToken)) occupiedAreas.Add("专业"); + if (await db.AdministrativeClasses.AnyAsync(cancellationToken)) occupiedAreas.Add("行政班"); + if (await db.Teachers.AnyAsync(cancellationToken)) occupiedAreas.Add("教师"); + if (await db.Students.AnyAsync(cancellationToken)) occupiedAreas.Add("学生"); + if (await db.Courses.AnyAsync(cancellationToken)) occupiedAreas.Add("课程"); + if (await db.AcademicTerms.AnyAsync(cancellationToken)) occupiedAreas.Add("学期"); + if (await db.Buildings.AnyAsync(cancellationToken)) occupiedAreas.Add("教学楼"); + if (await db.Classrooms.AnyAsync(cancellationToken)) occupiedAreas.Add("教室"); + if (await db.TeachingTasks.AnyAsync(cancellationToken)) occupiedAreas.Add("教学任务"); + + if (occupiedAreas.Count > 0) + { + throw new InvalidOperationException( + "演示数据只允许写入没有业务数据的新数据库。检测到已有:" + + string.Join("、", occupiedAreas) + + "。请创建独立空库,不要清理或覆盖现有生产数据。"); + } + } + + private async Task SeedAcademicTermAsync(CancellationToken cancellationToken) + { + db.AcademicTerms.Add(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(cancellationToken); + } + + private async Task SeedClassroomsAsync( + Guid campusId, + CancellationToken cancellationToken) + { + var building = new Building + { + Code = "J1", + Name = "第一教学楼", + CampusId = campusId + }; + db.Buildings.Add(building); + for (var floor = 1; floor <= 5; floor++) + { + for (var room = 1; room <= 6; room++) + { + var roomNumber = floor * 100 + room; + db.Classrooms.Add(new Classroom + { + Code = $"J1-{roomNumber}", + Name = $"J1-{roomNumber}", + BuildingId = building.Id, + Capacity = room % 3 == 0 ? 120 : 60, + RoomType = room % 3 == 0 ? "多媒体阶梯教室" : "多媒体教室", + Equipment = "投影、扩音、网络" + }); + } + } + await db.SaveChangesAsync(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(); + 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(); + 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(); + 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 = "生产演示环境批量测试教师。" + }); + } + } + 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(); + 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 = "生产演示环境批量测试学生。" + }); + } + } + 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(); + + 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 = "生产演示环境公共课程测试数据。" + }); + } + + 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(); + 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 = "生产演示环境批量生成的授课意向。", + 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( + "生产演示数据已就绪:学院/教学单位 {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 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 Majors, + IReadOnlyList 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); +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index c54179e..73b6a7c 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -15,8 +15,46 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using System.Threading.RateLimiting; +var migrateOnly = args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase); +var seedDemoData = args.Contains("--seed-demo-data", StringComparer.OrdinalIgnoreCase); +var confirmProductionDemoData = args.Contains( + "--confirm-production-demo-data", + StringComparer.OrdinalIgnoreCase); + +if (migrateOnly && seedDemoData) +{ + Console.Error.WriteLine( + "--migrate-only 与 --seed-demo-data 不能同时使用。请先迁移,再单独插入演示数据。"); + Environment.ExitCode = 2; + return; +} + +if (seedDemoData && !confirmProductionDemoData) +{ + Console.Error.WriteLine( + "插入生产演示数据必须同时传入 --confirm-production-demo-data。"); + Environment.ExitCode = 2; + return; +} + +if (confirmProductionDemoData && !seedDemoData) +{ + Console.Error.WriteLine( + "--confirm-production-demo-data 只能与 --seed-demo-data 同时使用。"); + Environment.ExitCode = 2; + return; +} + var builder = WebApplication.CreateBuilder(args); +if (seedDemoData && builder.Environment.IsDevelopment()) +{ + Console.Error.WriteLine( + "--seed-demo-data 仅用于独立的非 Development 演示环境数据库。"); + Environment.ExitCode = 2; + return; +} + var databaseOptions = builder.Configuration .GetSection(DatabaseOptions.SectionName) .Get() ?? new DatabaseOptions(); @@ -114,6 +152,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -305,11 +344,16 @@ app.MapFallback(async context => using (var scope = app.Services.CreateScope()) { await scope.ServiceProvider.GetRequiredService() - .InitializeAsync( - args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase)); + .InitializeAsync(migrateOnly); + + if (seedDemoData) + { + await scope.ServiceProvider.GetRequiredService() + .SeedAsync(); + } } -if (args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase)) +if (migrateOnly || seedDemoData) { return; } diff --git a/tests/Jiaowu.Api.Tests/DemoDataSeederTests.cs b/tests/Jiaowu.Api.Tests/DemoDataSeederTests.cs new file mode 100644 index 0000000..6fde9fd --- /dev/null +++ b/tests/Jiaowu.Api.Tests/DemoDataSeederTests.cs @@ -0,0 +1,123 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jiaowu.Api.Tests; + +public sealed class DemoDataSeederTests +{ + [Fact] + public async Task SeedAsync_populates_an_empty_business_database() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = await CreateDatabaseAsync(connection); + await SeedCourseCategoriesAsync(db); + + var seeder = new DemoDataSeeder( + db, + NullLogger.Instance); + + await seeder.SeedAsync(); + + Assert.Equal(1, await db.Campuses.CountAsync()); + Assert.Equal(16, await db.Colleges.CountAsync()); + Assert.Equal(52, await db.Majors.CountAsync()); + Assert.Equal(104, await db.AdministrativeClasses.CountAsync()); + Assert.Equal(128, await db.Teachers.CountAsync()); + Assert.Equal(3640, await db.Students.CountAsync()); + Assert.Equal(148, await db.Courses.CountAsync()); + Assert.Equal(736, await db.TeacherCourseApplications.CountAsync()); + Assert.Equal(1, await db.AcademicTerms.CountAsync(x => x.IsCurrent)); + Assert.Equal(30, await db.Classrooms.CountAsync()); + } + + [Fact] + public async Task SeedAsync_rejects_a_database_with_business_data() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = await CreateDatabaseAsync(connection); + await SeedCourseCategoriesAsync(db); + db.Campuses.Add(new Campus { Code = "EXISTING", Name = "现有校区" }); + await db.SaveChangesAsync(); + + var seeder = new DemoDataSeeder( + db, + NullLogger.Instance); + + var exception = await Assert.ThrowsAsync( + () => seeder.SeedAsync()); + + Assert.Contains("只允许写入没有业务数据的新数据库", exception.Message); + Assert.Contains("校区", exception.Message); + Assert.Equal(1, await db.Campuses.CountAsync()); + } + + [Fact] + public async Task SeedAsync_rolls_back_all_demo_rows_when_insertion_fails() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = await CreateDatabaseAsync(connection); + await SeedCourseCategoriesAsync(db); + var practiceCategory = await db.CourseCategories + .SingleAsync(x => x.Code == "PRACTICE"); + db.CourseCategories.Remove(practiceCategory); + await db.SaveChangesAsync(); + + var seeder = new DemoDataSeeder( + db, + NullLogger.Instance); + + await Assert.ThrowsAsync(() => seeder.SeedAsync()); + + db.ChangeTracker.Clear(); + Assert.Equal(0, await db.Campuses.CountAsync()); + Assert.Equal(0, await db.Colleges.CountAsync()); + Assert.Equal(0, await db.Students.CountAsync()); + Assert.Equal(0, await db.Courses.CountAsync()); + Assert.Equal(0, await db.AcademicTerms.CountAsync()); + Assert.Equal(0, await db.Classrooms.CountAsync()); + } + + private static async Task CreateDatabaseAsync( + SqliteConnection connection) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + return db; + } + + private static async Task SeedCourseCategoriesAsync(AppDbContext db) + { + db.CourseCategories.AddRange( + CreateCategory("BASIC", "基础课程", 10), + CreateCategory("MORAL", "德育课程", 20), + CreateCategory("AESTHETIC", "美育课程", 30), + CreateCategory("LABOR", "劳动教育", 40), + CreateCategory("INNOVATION", "创新创业", 50), + CreateCategory("ENGLISH", "大学英语", 60), + CreateCategory("SPORTS", "大学体育", 70), + CreateCategory("MILITARY", "国防教育", 80), + CreateCategory("MAJOR", "专业教育", 90), + CreateCategory("PRACTICE", "实践教学", 100)); + await db.SaveChangesAsync(); + } + + private static CourseCategory CreateCategory( + string code, + string name, + int sortOrder) => + new() + { + Code = code, + Name = name, + SortOrder = sortOrder + }; +}