新增显式命令:--seed-demo-data --confirm-production-demo-data
仅允许非 Development 环境运行。 只允许写入没有业务数据的独立空库。 演示数据使用单个 InnoDB 事务,失败整体回滚;迁移 DDL 仍单独执行,因为 MySQL 8.4 的 DDL 会隐式提交。MySQL 事务文档、隐式提交说明 不包含通用测试密码或学生、教师登录账号。 包含校区、学期、30 间教室、16 个学院、52 个专业、104 个班级、128 名教师、3,640 名学生和148门课程。
This commit is contained in:
@@ -104,6 +104,37 @@ $env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jia
|
|||||||
`SeedAdmin__UserName`、`SeedAdmin__Password` 和 `SeedAdmin__DisplayName`,
|
`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 迁移。部署前先恢复仓库工具并检查迁移:
|
生产数据库使用 MySQL 专用 EF Core 迁移。部署前先恢复仓库工具并检查迁移:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -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<DemoDataSeeder> 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<string>();
|
||||||
|
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<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 = "生产演示环境批量测试教师。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = "生产演示环境批量测试学生。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = "生产演示环境公共课程测试数据。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "生产演示环境批量生成的授课意向。",
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
@@ -15,8 +15,46 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using System.Threading.RateLimiting;
|
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);
|
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
|
var databaseOptions = builder.Configuration
|
||||||
.GetSection(DatabaseOptions.SectionName)
|
.GetSection(DatabaseOptions.SectionName)
|
||||||
.Get<DatabaseOptions>() ?? new DatabaseOptions();
|
.Get<DatabaseOptions>() ?? new DatabaseOptions();
|
||||||
@@ -114,6 +152,7 @@ builder.Services.AddHttpContextAccessor();
|
|||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
|
builder.Services.AddScoped<DemoDataSeeder>();
|
||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
builder.Services.AddScoped<TimetableDataService>();
|
builder.Services.AddScoped<TimetableDataService>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
@@ -305,11 +344,16 @@ app.MapFallback(async context =>
|
|||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
|
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
|
||||||
.InitializeAsync(
|
.InitializeAsync(migrateOnly);
|
||||||
args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase));
|
|
||||||
|
if (seedDemoData)
|
||||||
|
{
|
||||||
|
await scope.ServiceProvider.GetRequiredService<DemoDataSeeder>()
|
||||||
|
.SeedAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase))
|
if (migrateOnly || seedDemoData)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DemoDataSeeder>.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<DemoDataSeeder>.Instance);
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => 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<DemoDataSeeder>.Instance);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => 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<AppDbContext> CreateDatabaseAsync(
|
||||||
|
SqliteConnection connection)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user