生产环境

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
+52 -27
View File
@@ -12,17 +12,14 @@
## 本地开发:热更新模式 ## 本地开发:热更新模式
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。 本地开发固定使用 SQLite。首次启动会自动创建空的
`src/Jiaowu.Api/data/jiaowu-dev.sqlite`,只初始化系统角色和课程分类,不再写入
Development 环境还会按业务编码幂等补齐一套大规模测试数据:16 个常见学院或教学单位、52 个常见本科专业、每个专业 2 个 2026 级行政班、每班 35 名学生、每学院 8 名教师,以及覆盖公共必修、公共选修、专业必修、专业选修和实践教学的 150 余门课程。测试教师同时生成当前学期已审核通过的授课资格;重复启动不会重复累加数据,也不会覆盖已有记录 演示组织、人员、课程、业务记录或内置测试账号
只执行数据库迁移和测试数据补齐、不启动 Web 服务时,可以运行:
```powershell
dotnet run --project src/Jiaowu.Api -- --seed-only
```
```powershell ```powershell
$env:SeedAdmin__UserName = 'admin'
$env:SeedAdmin__Password = '请替换为本机开发密码'
$env:SeedAdmin__DisplayName = '系统管理员'
dotnet run --project src/Jiaowu.Api dotnet run --project src/Jiaowu.Api
``` ```
@@ -34,17 +31,8 @@ npm install
npm run dev npm run dev
``` ```
访问 `http://localhost:5173`本地开发会自动创建以下分级权限账号: 访问 `http://localhost:5173`首次创建管理员后可以清除三个
`SeedAdmin__*` 环境变量;后续账号和基础数据均通过管理界面维护。
| 数据范围 | 账号 | 密码 |
| --- | --- | --- |
| 超级管理员 | `admin` | `Admin@123456` |
| 校级教务 | `academic` | `Academic@123456` |
| 学院教务 | `college` | `College@123456` |
| 所带班级 | `counselor` | `Counselor@123456` |
| 教师本人 | `teacher` | `Teacher@123456` |
| 学生本人 | `student` | `Student@123456` |
| 领导查看 | `leader` | `Leader@123456` |
## 本地开发:单服务模式 ## 本地开发:单服务模式
@@ -57,36 +45,73 @@ dotnet run --project src/Jiaowu.Api
访问 `http://localhost:5255``/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html` 访问 `http://localhost:5255``/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`
## MySQL 部署 ## MySQL 8.4 生产部署
非 Development 环境只允许使用 MySQL。`dotnet publish` 会自动执行 `npm ci``npm run build`,并将 Vue 静态文件放入发布目录的 `wwwroot` 非 Development 环境只允许使用 MySQL。`dotnet publish` 会自动执行 `npm ci``npm run build`,并将 Vue 静态文件放入发布目录的 `wwwroot`
```powershell ```powershell
$env:ASPNETCORE_ENVIRONMENT = 'Production' $env:ASPNETCORE_ENVIRONMENT = 'Production'
$env:Database__Provider = 'MySql' $env:Database__Provider = 'MySql'
$env:ConnectionStrings__MySql = 'Server=127.0.0.1;Port=3306;Database=jiaowu;User=YOUR_USER;Password=YOUR_PASSWORD;' $env:ConnectionStrings__MySql = 'Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=YOUR_USER;Password=YOUR_PASSWORD;SslMode=VerifyFull;SslCa=C:\certs\mysql-ca.pem;'
$env:Jwt__Key = '至少32字节的随机生产密钥' $env:Jwt__Key = '至少32字节的随机生产密钥'
$env:AllowedHosts = 'jiaowu.example.edu.cn'
dotnet publish src/Jiaowu.Api -c Release -o .artifacts/publish dotnet publish src/Jiaowu.Api -c Release -o .artifacts/publish
.artifacts/publish/Jiaowu.Api.exe
``` ```
如需在特殊流水线中跳过自动前端构建,可传入 `-p:BuildFrontendOnPublish=false` 如需在特殊流水线中跳过自动前端构建,可传入 `-p:BuildFrontendOnPublish=false`
生产环境不会创建默认管理员。首次部署前可以临时配置 `SeedAdmin__UserName``SeedAdmin__Password``SeedAdmin__DisplayName`,账号创建后移除这些配置。 数据库应明确使用 `utf8mb4`;MySQL 8.4 的默认排序规则为
`utf8mb4_0900_ai_ci`。新建数据库时可执行:
```sql
CREATE DATABASE `jiaowu`
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
```
发布前使用具备 DDL 权限的迁移账号单独执行迁移:
```powershell
& '.artifacts\publish\Jiaowu.Api.exe' --migrate-only
```
迁移成功后,改用仅具备应用所需 DML 权限的运行账号并启动服务:
```powershell
& '.artifacts\publish\Jiaowu.Api.exe'
```
`Database:ApplyMigrationsOnStartup` 默认关闭。普通启动会检查待执行迁移并在架构落后时
直接失败,避免多实例同时执行 DDL。只有明确接受启动期 DDL 风险的单实例部署才应将
`Database__ApplyMigrationsOnStartup` 设为 `true`
生产环境不会创建默认管理员。首次部署可以临时配置
`SeedAdmin__UserName``SeedAdmin__Password``SeedAdmin__DisplayName`
账号创建后立即移除这些配置。
生产数据库使用 MySQL 专用 EF Core 迁移。部署前先恢复仓库工具并检查迁移: 生产数据库使用 MySQL 专用 EF Core 迁移。部署前先恢复仓库工具并检查迁移:
```powershell ```powershell
dotnet tool restore dotnet tool restore
dotnet ef migrations list --project src/Jiaowu.Api --startup-project src/Jiaowu.Api dotnet ef migrations list --no-connect --project src/Jiaowu.Api --startup-project src/Jiaowu.Api
``` ```
应用启动时会自动执行尚未应用的 MySQL 迁移。SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开发库通过轻量、版本化的本地升级脚本补齐结构,不需要手动删除数据文件。SQLite 文件不能用于生产。 不要使用当前提供程序生成的 `dotnet ef migrations script --idempotent` 作为 MySQL
部署脚本;其条件块不是 MySQL 8.4 可直接执行的语法。应使用上述
`--migrate-only` 入口,或在明确知道目标迁移状态时生成非幂等脚本并先做备份。
MySQL 的 DDL 会隐式提交,迁移不能依赖外层事务整体回滚。
SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开发库通过轻量、版本化的
本地升级脚本补齐结构,不需要手动删除数据文件。SQLite 文件不能用于生产。
服务探针:
- `/health/live`:只检查进程存活。
- `/health``/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
## 验证 ## 验证
```powershell ```powershell
dotnet test Jiaowu.slnx dotnet test Jiaowu.slnx
npm --prefix web run build npm --prefix web run build
pwsh.exe -NoLogo -NoProfile -NonInteractive -File scripts/smoke-test.ps1
``` ```
-831
View File
@@ -1,831 +0,0 @@
param(
[string] $ApiExecutablePath,
[string] $ApiContentRoot
)
$ErrorActionPreference = 'Stop'
$workspaceRoot = Split-Path -Parent $PSScriptRoot
$apiExecutable = if ([string]::IsNullOrWhiteSpace($ApiExecutablePath)) {
Join-Path $workspaceRoot 'src/Jiaowu.Api/bin/Debug/net10.0/Jiaowu.Api.exe'
}
else {
[System.IO.Path]::GetFullPath($ApiExecutablePath, $workspaceRoot)
}
$apiWorkingDirectory = if ([string]::IsNullOrWhiteSpace($ApiContentRoot)) {
Join-Path $workspaceRoot 'src/Jiaowu.Api'
}
else {
[System.IO.Path]::GetFullPath($ApiContentRoot, $workspaceRoot)
}
$stdoutPath = Join-Path $env:TEMP 'jiaowu-api-smoke.out.log'
$stderrPath = Join-Path $env:TEMP 'jiaowu-api-smoke.err.log'
$courseExcelToken = [guid]::NewGuid().ToString('N')
$courseTemplatePath = Join-Path $env:TEMP "jiaowu-course-template-$courseExcelToken.xlsx"
$courseExportPath = Join-Path $env:TEMP "jiaowu-course-export-$courseExcelToken.xlsx"
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$env:ASPNETCORE_URLS = 'http://localhost:5255'
$startParameters = @{
FilePath = $apiExecutable
WorkingDirectory = $apiWorkingDirectory
WindowStyle = 'Hidden'
RedirectStandardOutput = $stdoutPath
RedirectStandardError = $stderrPath
PassThru = $true
}
$process = Start-Process @startParameters
try {
$healthy = $false
for ($attempt = 0; $attempt -lt 30; $attempt++) {
Start-Sleep -Milliseconds 500
try {
$health = Invoke-RestMethod -Uri 'http://localhost:5255/health' -TimeoutSec 2
$healthy = $true
break
}
catch {
# The API may still be starting.
}
}
if (-not $healthy) {
Get-Content -LiteralPath $stdoutPath -ErrorAction SilentlyContinue
Get-Content -LiteralPath $stderrPath -ErrorAction SilentlyContinue
throw 'API did not become healthy.'
}
$loginBody = @{
userName = 'admin'
password = 'Admin@123456'
} | ConvertTo-Json
$loginParameters = @{
Method = 'Post'
Uri = 'http://localhost:5255/api/auth/login'
ContentType = 'application/json'
Body = $loginBody
}
$login = Invoke-RestMethod @loginParameters
$headers = @{ Authorization = "Bearer $($login.token)" }
$dashboard = Invoke-RestMethod -Uri 'http://localhost:5255/api/dashboard' -Headers $headers
$campuses = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/campuses' -Headers $headers
$classes = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/classes' -Headers $headers
$counselors = Invoke-RestMethod -Uri 'http://localhost:5255/api/base-data/counselors' -Headers $headers
$assignedClassCount = @($classes | Where-Object {
$null -ne $_.counselorUserId -and
@($counselors).id -contains $_.counselorUserId
}).Count
if ($assignedClassCount -lt 1) {
throw 'No administrative class is linked to a counselor account.'
}
$teachers = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' -Headers $headers
$students = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' -Headers $headers
$courses = Invoke-RestMethod -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' -Headers $headers
$courseCategories = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/base-data/course-categories' `
-Headers $headers
if (@($courseCategories).Count -lt 10 -or
@($courses.items | Where-Object { $null -eq $_.courseCategoryId }).Count -gt 0) {
throw 'Course categories were not seeded or assigned to all courses.'
}
Invoke-WebRequest `
-Uri 'http://localhost:5255/api/courses/template' `
-Headers $headers `
-OutFile $courseTemplatePath |
Out-Null
Invoke-WebRequest `
-Uri 'http://localhost:5255/api/courses/export' `
-Headers $headers `
-OutFile $courseExportPath |
Out-Null
if ((Get-Item -LiteralPath $courseTemplatePath).Length -lt 1 -or
(Get-Item -LiteralPath $courseExportPath).Length -lt 1) {
throw 'Course Excel template or export is empty.'
}
$courseImport = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/courses/import' `
-Headers $headers `
-Form @{ file = Get-Item -LiteralPath $courseExportPath }
if ($courseImport.created -ne 0 -or
$courseImport.updated -ne $courses.total -or
$courseImport.total -ne $courses.total) {
throw 'Course Excel round-trip did not update the expected records.'
}
$curriculumPlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/curriculum-plans?page=1&pageSize=10' -Headers $headers
if ($curriculumPlans.total -gt 0) {
$curriculumDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/curriculum-plans/$($curriculumPlans.items[0].id)" `
-Headers $headers
}
$teachingTasks = Invoke-RestMethod -Uri 'http://localhost:5255/api/teaching-tasks?page=1&pageSize=10' -Headers $headers
$schedulePlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/schedules/plans' -Headers $headers
if (@($schedulePlans).Count -gt 0) {
$scheduleDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" `
-Headers $headers
}
$selectionRounds = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/course-selections/rounds' `
-Headers $headers
if (@($selectionRounds).Count -lt 1) {
throw 'Development course-selection round was not seeded.'
}
$activeSelectionRound = @($selectionRounds) |
Where-Object { $_.isAvailableNow } |
Select-Object -First 1
if ($null -eq $activeSelectionRound) {
throw 'No course-selection round is open for the smoke test.'
}
$selectionOfferings = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/rounds/$($activeSelectionRound.id)/offerings" `
-Headers $headers
if (@($selectionOfferings).Count -lt 1) {
throw 'Development course-selection offering was not seeded.'
}
$managedUsers = Invoke-RestMethod -Uri 'http://localhost:5255/api/users' -Headers $headers
$teacherAccount = @($managedUsers) |
Where-Object { $_.userName -eq 'teacher' } |
Select-Object -First 1
if ($null -eq $teacherAccount) {
throw 'Development teacher account was not seeded.'
}
$teacherAccessBody = @{
staffNumber = $teacherAccount.staffNumber
collegeId = $teacherAccount.collegeId
roles = @('Teacher')
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/users/$($teacherAccount.id)/roles" `
-Headers $headers `
-ContentType 'application/json' `
-Body $teacherAccessBody
$scopeScenarios = @(
@{
UserName = 'college'; Password = 'College@123456'; Scope = 'College'
Teachers = 2; Students = 3; Courses = 3
},
@{
UserName = 'counselor'; Password = 'Counselor@123456'; Scope = 'Class'
Teachers = 1; Students = 3; Courses = 1
},
@{
UserName = 'teacher'; Password = 'Teacher@123456'; Scope = 'Self'
Teachers = 1; Students = 3; Courses = 1
},
@{
UserName = 'student'; Password = 'Student@123456'; Scope = 'Self'
Teachers = 0; Students = 1; Courses = 1
}
)
$scopeChecks = foreach ($scenario in $scopeScenarios) {
$scenarioLoginBody = @{
userName = $scenario.UserName
password = $scenario.Password
} | ConvertTo-Json
$scenarioLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $scenarioLoginBody
$scenarioHeaders = @{ Authorization = "Bearer $($scenarioLogin.token)" }
$scenarioTeachers = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' `
-Headers $scenarioHeaders
$scenarioStudents = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' `
-Headers $scenarioHeaders
$scenarioCourses = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' `
-Headers $scenarioHeaders
if ($scenarioLogin.user.effectiveDataScope -ne $scenario.Scope -or
$scenarioTeachers.total -ne $scenario.Teachers -or
$scenarioStudents.total -ne $scenario.Students -or
$scenarioCourses.total -ne $scenario.Courses) {
throw ("Data-scope check failed for {0}: scope={1}, teachers={2}, students={3}, courses={4}." -f
$scenario.UserName,
$scenarioLogin.user.effectiveDataScope,
$scenarioTeachers.total,
$scenarioStudents.total,
$scenarioCourses.total)
}
"$($scenario.UserName):$($scenario.Scope)"
}
$studentLoginBody = @{
userName = 'student'
password = 'Student@123456'
} | ConvertTo-Json
$studentLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $studentLoginBody
$studentHeaders = @{ Authorization = "Bearer $($studentLogin.token)" }
$studentOptions = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/student/options?roundId=$($activeSelectionRound.id)" `
-Headers $studentHeaders
$studentOffering = @($studentOptions.offerings) | Select-Object -First 1
if ($null -eq $studentOffering) {
throw 'Student has no eligible course-selection offering.'
}
if ($studentOffering.enrollmentStatus -ne 'Enrolled') {
$enrollmentBody = @{ offeringId = $studentOffering.id } | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/course-selections/student/enrollments' `
-Headers $studentHeaders `
-ContentType 'application/json' `
-Body $enrollmentBody |
Out-Null
}
$studentEnrollments = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/student/enrollments?academicTermId=$($activeSelectionRound.academicTermId)" `
-Headers $studentHeaders
$activeEnrollments = @($studentEnrollments) |
Where-Object { $_.status -eq 'Enrolled' }
if ($activeEnrollments.Count -lt 1) {
throw 'Student course enrollment was not persisted.'
}
$selectionRoster = Invoke-RestMethod `
-Uri "http://localhost:5255/api/course-selections/offerings/$($studentOffering.id)/roster" `
-Headers $headers
if ($selectionRoster.enrolledCount -lt 1) {
throw 'Course-selection roster did not include the selected student.'
}
$gradeTasks = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/grades/sheets' `
-Headers $headers
$gradeTask = @($gradeTasks) |
Where-Object { $null -ne $_.sheet } |
Select-Object -First 1
if ($null -eq $gradeTask) {
throw 'Development grade sheet was not seeded.'
}
$gradeDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/grades/sheets/$($gradeTask.sheet.id)" `
-Headers $headers
if (@($gradeDetail.sheet.records).Count -lt 1) {
throw 'Development grade sheet has no student records.'
}
$teacherLoginBody = @{
userName = 'teacher'
password = 'Teacher@123456'
} | ConvertTo-Json
$teacherLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $teacherLoginBody
$teacherHeaders = @{ Authorization = "Bearer $($teacherLogin.token)" }
$teacherGradeTasks = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/grades/sheets' `
-Headers $teacherHeaders
$teacherGradeTask = @($teacherGradeTasks) |
Where-Object { $_.id -eq $gradeTask.id } |
Select-Object -First 1
if ($null -eq $teacherGradeTask) {
throw 'Assigned teacher cannot access the grade sheet.'
}
if ($teacherGradeTask.sheet.status -eq 'Draft' -or
$teacherGradeTask.sheet.status -eq 'Returned') {
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/grades/sheets/$($teacherGradeTask.sheet.id)/submit" `
-Headers $teacherHeaders |
Out-Null
}
$collegeLoginBody = @{
userName = 'college'
password = 'College@123456'
} | ConvertTo-Json
$collegeLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $collegeLoginBody
$collegeHeaders = @{ Authorization = "Bearer $($collegeLogin.token)" }
$collegeGradeTasks = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/grades/sheets' `
-Headers $collegeHeaders
$collegeGradeTask = @($collegeGradeTasks) |
Where-Object { $_.id -eq $gradeTask.id } |
Select-Object -First 1
if ($collegeGradeTask.sheet.status -eq 'Submitted') {
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/grades/sheets/$($collegeGradeTask.sheet.id)/approve" `
-Headers $collegeHeaders |
Out-Null
}
$adminGradeTasks = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/grades/sheets' `
-Headers $headers
$adminGradeTask = @($adminGradeTasks) |
Where-Object { $_.id -eq $gradeTask.id } |
Select-Object -First 1
if ($adminGradeTask.sheet.status -eq 'Approved') {
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/grades/sheets/$($adminGradeTask.sheet.id)/publish" `
-Headers $headers |
Out-Null
}
$studentTranscript = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/grades/student/transcript' `
-Headers $studentHeaders
if (@($studentTranscript.records).Count -lt 1) {
throw 'Published grade is not visible in the student transcript.'
}
$examPlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/exams/plans' -Headers $headers
if (@($examPlans).Count -lt 1) { throw 'Development exam plan was not seeded.' }
$examDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/exams/plans/$($examPlans[0].id)" `
-Headers $headers
if (@($examDetail.sessions).Count -lt 1) { throw 'Exam plan has no sessions.' }
$examRoster = Invoke-RestMethod `
-Uri "http://localhost:5255/api/exams/sessions/$($examDetail.sessions[0].id)/roster" `
-Headers $headers
$studentExams = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/exams/my-schedule' `
-Headers $studentHeaders
$teacherExams = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/exams/my-schedule' `
-Headers $teacherHeaders
if (@($studentExams).Count -lt 1 -or @($teacherExams).Count -lt 1) {
throw 'Personal exam schedule is not visible to student or invigilator.'
}
$statusOptions = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes/options' `
-Headers $studentHeaders
$statusChanges = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders
$openStatusChange = @($statusChanges) |
Where-Object { $_.state -in @('Submitted', 'CounselorApproved', 'CollegeApproved') } |
Select-Object -First 1
if ($null -eq $openStatusChange) {
$statusChangeType = if ($statusOptions.status -eq 'Active') {
'Suspension'
}
elseif ($statusOptions.status -eq 'Suspended') {
'Resumption'
}
else {
throw "Smoke-test student status '$($statusOptions.status)' cannot start a reversible change."
}
$statusChangeBody = @{
type = $statusChangeType
reason = '端到端验证学籍异动三级审核流程。'
} | ConvertTo-Json
$createdStatusChange = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders `
-ContentType 'application/json' `
-Body $statusChangeBody
$statusChanges = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders
$openStatusChange = @($statusChanges) |
Where-Object { $_.id -eq $createdStatusChange.id } |
Select-Object -First 1
}
$expectedStudentStatus = $openStatusChange.targetStatus
$approvalBody = @{ approved = $true; comment = '端到端审核通过。' } | ConvertTo-Json
if ($openStatusChange.state -eq 'Submitted') {
$prematureCollegeReview = Invoke-WebRequest `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body $approvalBody `
-SkipHttpErrorCheck
if ($prematureCollegeReview.StatusCode -ne 409) {
throw 'College reviewer was able to bypass the counselor stage.'
}
$counselorLoginBody = @{
userName = 'counselor'
password = 'Counselor@123456'
} | ConvertTo-Json
$counselorLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $counselorLoginBody
$counselorHeaders = @{ Authorization = "Bearer $($counselorLogin.token)" }
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $counselorHeaders `
-ContentType 'application/json' `
-Body $approvalBody |
Out-Null
$openStatusChange.state = 'CounselorApproved'
}
if ($openStatusChange.state -eq 'CounselorApproved') {
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body $approvalBody |
Out-Null
$openStatusChange.state = 'CollegeApproved'
}
if ($openStatusChange.state -eq 'CollegeApproved') {
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/student-status-changes/$($openStatusChange.id)/review" `
-Headers $headers `
-ContentType 'application/json' `
-Body $approvalBody |
Out-Null
}
$completedStatusChanges = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes' `
-Headers $studentHeaders
$completedStatusChange = @($completedStatusChanges) |
Where-Object { $_.id -eq $openStatusChange.id } |
Select-Object -First 1
$updatedStatusOptions = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/student-status-changes/options' `
-Headers $studentHeaders
if ($completedStatusChange.state -ne 'Approved' -or
$updatedStatusOptions.status -ne $expectedStudentStatus) {
throw 'Student status change was not finalized or did not update the student record.'
}
$graduationBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers
$graduationBatch = @($graduationBatches) |
Where-Object { $_.name -eq '2030届端到端毕业资格审核' } |
Select-Object -First 1
if ($null -eq $graduationBatch) {
$graduationBatchBody = @{
name = '2030届端到端毕业资格审核'
graduationYear = 2030
enrollmentYear = 2026
notes = '用于验证培养方案、成绩快照、人工复核与结果发布。'
} | ConvertTo-Json
$createdGraduationBatch = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $graduationBatchBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdGraduationBatch.id)/calculate" `
-Headers $headers |
Out-Null
$graduationBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdGraduationBatch.id)" `
-Headers $headers
$studentGraduationResult = @($graduationBatch.results) |
Where-Object { $_.studentNumber -eq '202601001' } |
Select-Object -First 1
if ($null -eq $studentGraduationResult) {
throw 'Calculated graduation batch does not contain the smoke-test student.'
}
$overrideDecisionBody = @{
conclusion = 'Eligible'
comment = '端到端验证人工复核调整。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-audits/results/$($studentGraduationResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $overrideDecisionBody |
Out-Null
$restoreDecisionBody = @{
conclusion = 'Ineligible'
comment = '恢复规则结论后发布测试结果。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-audits/results/$($studentGraduationResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $restoreDecisionBody |
Out-Null
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($graduationBatch.id)/publish" `
-Headers $headers |
Out-Null
}
else {
$graduationBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($graduationBatch.id)" `
-Headers $headers
}
$graduationBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($graduationBatch.id)" `
-Headers $headers
$studentGraduationResult = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-audits/my-result' `
-Headers $studentHeaders
if ($graduationBatch.status -ne 'Published' -or
$studentGraduationResult.conclusion -ne 'Ineligible') {
throw 'Published graduation result is not visible to the student.'
}
$graduationBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers
$degreeSourceBatch = @($graduationBatches) |
Where-Object { $_.name -eq '2030届学位授予资格演示' } |
Select-Object -First 1
if ($null -eq $degreeSourceBatch) {
$degreeSourceBody = @{
name = '2030届学位授予资格演示'
graduationYear = 2030
enrollmentYear = 2026
notes = '为未关联登录账号的演示学生生成学位授予前置资格。'
} | ConvertTo-Json
$createdDegreeSource = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/graduation-audits/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeSourceBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdDegreeSource.id)/calculate" `
-Headers $headers |
Out-Null
$degreeSourceDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdDegreeSource.id)" `
-Headers $headers
$degreeSourceResult = @($degreeSourceDetail.results) |
Where-Object { $_.studentNumber -eq '202601002' } |
Select-Object -First 1
$degreeSourceDecision = @{
conclusion = 'Eligible'
comment = '演示学生经校级专项复核符合毕业条件。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-audits/results/$($degreeSourceResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeSourceDecision |
Out-Null
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-audits/batches/$($createdDegreeSource.id)/publish" `
-Headers $headers |
Out-Null
}
$degreeBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/degree-awards/batches' `
-Headers $headers
$degreeBatch = @($degreeBatches) |
Where-Object { $_.name -eq '2030届端到端学位授予审核' } |
Select-Object -First 1
if ($null -eq $degreeBatch) {
$degreeBatchBody = @{
name = '2030届端到端学位授予审核'
graduationYear = 2030
degreeName = '工学学士'
minimumGradePoint = 0
notes = '验证毕业资格来源、绩点快照、人工复核与发布锁定。'
} | ConvertTo-Json
$createdDegreeBatch = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/degree-awards/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeBatchBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/degree-awards/batches/$($createdDegreeBatch.id)/calculate" `
-Headers $headers |
Out-Null
$degreeBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/degree-awards/batches/$($createdDegreeBatch.id)" `
-Headers $headers
$degreeResult = @($degreeBatch.results) | Select-Object -First 1
if ($null -eq $degreeResult -or $degreeResult.conclusion -ne 'Granted') {
throw 'Degree-award calculation did not include the eligible graduated student.'
}
$degreeRejectBody = @{
conclusion = 'NotGranted'
comment = '端到端验证学位结论人工调整。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/degree-awards/results/$($degreeResult.id)" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body $degreeRejectBody |
Out-Null
$degreeRestoreBody = @{
conclusion = 'Granted'
comment = '恢复规则结论并完成授予发布。'
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/degree-awards/results/$($degreeResult.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body $degreeRestoreBody |
Out-Null
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/degree-awards/batches/$($degreeBatch.id)/publish" `
-Headers $headers |
Out-Null
}
$degreeBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/degree-awards/batches/$($degreeBatch.id)" `
-Headers $headers
$collegeDegreeBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/degree-awards/batches/$($degreeBatch.id)" `
-Headers $collegeHeaders
if ($degreeBatch.status -ne 'Published' -or
@($degreeBatch.results).Count -lt 1 -or
@($collegeDegreeBatch.results).Count -lt 1) {
throw 'Published degree-award results are not visible in the authorized college scope.'
}
$clearanceBatches = Invoke-RestMethod `
-Uri 'http://localhost:5255/api/graduation-clearance/batches' `
-Headers $headers
$clearanceBatch = @($clearanceBatches) |
Where-Object { $_.name -eq '2030届端到端毕业离校手续' } |
Select-Object -First 1
if ($null -eq $clearanceBatch) {
$clearanceBatchBody = @{
name = '2030届端到端毕业离校手续'
graduationYear = 2030
notes = '验证校级、学院、辅导员分工办理与批次关闭。'
items = @(
@{
code = 'FINANCE'
name = '财务费用结清'
responsibleUnit = '财务处'
responsibleRole = 'AcademicAdmin'
isRequired = $true
},
@{
code = 'COLLEGE'
name = '学院材料归档'
responsibleUnit = '计算机学院'
responsibleRole = 'CollegeAdmin'
isRequired = $true
},
@{
code = 'DORM'
name = '宿舍退宿确认'
responsibleUnit = '学生工作办公室'
responsibleRole = 'Counselor'
isRequired = $true
}
)
} | ConvertTo-Json -Depth 5
$createdClearanceBatch = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/graduation-clearance/batches' `
-Headers $headers `
-ContentType 'application/json' `
-Body $clearanceBatchBody
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($createdClearanceBatch.id)/generate" `
-Headers $headers |
Out-Null
$clearanceBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($createdClearanceBatch.id)" `
-Headers $headers
if (@($clearanceBatch.records).Count -ne 3) {
throw 'Graduation-clearance records were not generated for each configured item.'
}
$academicClearanceRecord = @($clearanceBatch.records) |
Where-Object { $_.responsibleRole -eq 'AcademicAdmin' } |
Select-Object -First 1
$wrongRoleResponse = Invoke-WebRequest `
-Method Put `
-Uri "http://localhost:5255/api/graduation-clearance/records/$($academicClearanceRecord.id)" `
-Headers $collegeHeaders `
-ContentType 'application/json' `
-Body (@{ status = 'Completed'; notes = '越权测试。' } | ConvertTo-Json) `
-SkipHttpErrorCheck
if ($wrongRoleResponse.StatusCode -ne 403) {
throw 'College role was able to complete an academic-level clearance item.'
}
$counselorLoginBody = @{
userName = 'counselor'
password = 'Counselor@123456'
} | ConvertTo-Json
$counselorLogin = Invoke-RestMethod `
-Method Post `
-Uri 'http://localhost:5255/api/auth/login' `
-ContentType 'application/json' `
-Body $counselorLoginBody
$counselorHeaders = @{ Authorization = "Bearer $($counselorLogin.token)" }
foreach ($record in @($clearanceBatch.records)) {
$recordHeaders = switch ($record.responsibleRole) {
'CollegeAdmin' { $collegeHeaders }
'Counselor' { $counselorHeaders }
default { $headers }
}
$recordBody = @{
status = 'Completed'
notes = "$($record.responsibleUnit)端到端办理完成。"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Put `
-Uri "http://localhost:5255/api/graduation-clearance/records/$($record.id)" `
-Headers $recordHeaders `
-ContentType 'application/json' `
-Body $recordBody |
Out-Null
}
Invoke-RestMethod `
-Method Post `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($clearanceBatch.id)/close" `
-Headers $headers |
Out-Null
}
$clearanceBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($clearanceBatch.id)" `
-Headers $headers
$collegeClearanceBatch = Invoke-RestMethod `
-Uri "http://localhost:5255/api/graduation-clearance/batches/$($clearanceBatch.id)" `
-Headers $collegeHeaders
if ($clearanceBatch.status -ne 'Closed' -or
@($clearanceBatch.records | Where-Object { $_.status -eq 'Pending' }).Count -ne 0 -or
@($collegeClearanceBatch.records).Count -lt 1) {
throw 'Graduation-clearance batch was not closed or is missing from college scope.'
}
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
$unknownApiParameters = @{
Uri = 'http://localhost:5255/api/does-not-exist'
SkipHttpErrorCheck = $true
TimeoutSec = 5
}
$unknownApi = Invoke-WebRequest @unknownApiParameters
[pscustomobject]@{
Health = $health.status
Database = $health.database
User = $login.user.displayName
Term = $dashboard.currentTerm.name
Campuses = @($campuses).Count
CounselorAssignments = $assignedClassCount
Teachers = $teachers.total
Students = $students.total
Courses = $courses.total
CourseCategories = @($courseCategories).Count
CourseExcelRows = $courseImport.total
Plans = $curriculumPlans.total
PlanModules = if ($null -ne $curriculumDetail) { @($curriculumDetail.modules).Count } else { 0 }
TeachingTasks = $teachingTasks.total
Schedules = @($schedulePlans).Count
ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 }
SelectionRounds = @($selectionRounds).Count
SelectionOfferings = @($selectionOfferings).Count
StudentEnrollments = $activeEnrollments.Count
RosterStudents = $selectionRoster.enrolledCount
GradeSheets = @($gradeTasks).Count
GradeRecords = @($gradeDetail.sheet.records).Count
TranscriptRecords = @($studentTranscript.records).Count
ExamSessions = @($examDetail.sessions).Count
ExamRoster = @($examRoster.students).Count
StatusChanges = @($completedStatusChanges).Count
StudentStatus = $updatedStatusOptions.status
GraduationResults = @($graduationBatch.results).Count
GraduationConclusion = $studentGraduationResult.conclusion
DegreeResults = @($degreeBatch.results).Count
DegreeConclusion = $degreeBatch.results[0].conclusion
ClearanceRecords = @($clearanceBatch.records).Count
ClearanceStatus = $clearanceBatch.status
AccessUpdate = $true
ScopeChecks = $scopeChecks -join ', '
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
SpaFallback = $spaFallback.StatusCode
ApiNotFound = $unknownApi.StatusCode
} | Format-List
}
finally {
if (-not $process.HasExited) {
Stop-Process -Id $process.Id -Force
}
Remove-Item -LiteralPath $courseTemplatePath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $courseExportPath -Force -ErrorAction SilentlyContinue
}
-13
View File
@@ -1,13 +0,0 @@
$ErrorActionPreference = 'Stop'
$env:ASPNETCORE_ENVIRONMENT = 'Development'
$env:ASPNETCORE_URLS = 'http://localhost:5256'
$workspaceRoot = Split-Path -Parent $PSScriptRoot
$publishRoot = Join-Path $workspaceRoot '.artifacts/publish'
$process = Start-Process `
-FilePath (Join-Path $publishRoot 'Jiaowu.Api.exe') `
-WorkingDirectory $publishRoot `
-WindowStyle Hidden `
-PassThru
$process.Id
@@ -295,7 +295,8 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
{ {
origRecord.TotalScore = passedRecord.TotalScore; origRecord.TotalScore = passedRecord.TotalScore;
origRecord.GradePoint = passedRecord.GradePoint; 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, RoleManager<ApplicationRole> roleManager,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
DevelopmentSqliteMigrator sqliteMigrator, DevelopmentSqliteMigrator sqliteMigrator,
DevelopmentDemoDataSeeder developmentDemoDataSeeder, DatabaseOptions databaseOptions,
IConfiguration configuration, IConfiguration configuration,
IHostEnvironment environment, IHostEnvironment environment,
ILogger<DatabaseInitializer> logger) ILogger<DatabaseInitializer> logger)
{ {
public async Task InitializeAsync() public async Task InitializeAsync(bool migrateOnly = false)
{ {
if (environment.IsDevelopment()) if (environment.IsDevelopment())
{ {
if (migrateOnly)
{
throw new InvalidOperationException(
"--migrate-only 仅用于非 Development 环境的 MySQL 数据库。");
}
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await sqliteMigrator.MigrateAsync(); await sqliteMigrator.MigrateAsync();
} }
else else if (migrateOnly || databaseOptions.ApplyMigrationsOnStartup)
{ {
await db.Database.MigrateAsync(); 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 SeedRolesAsync();
await SeedAdministratorAsync(); await SeedAdministratorAsync();
await SeedCourseCategoriesAsync(); await SeedCourseCategoriesAsync();
if (environment.IsDevelopment())
{
await SeedDevelopmentDataAsync();
}
} }
private async Task SeedRolesAsync() private async Task SeedRolesAsync()
@@ -131,7 +149,11 @@ public sealed class DatabaseInitializer(
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (code, name, sortOrder) in defaults) foreach (var (code, name, sortOrder) in defaults)
{ {
if (existingCodes.Contains(code)) continue; if (existingCodes.Contains(code))
{
continue;
}
db.CourseCategories.Add(new CourseCategory db.CourseCategories.Add(new CourseCategory
{ {
Code = code, Code = code,
@@ -159,523 +181,6 @@ public sealed class DatabaseInitializer(
await db.SaveChangesAsync(); 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) private static void EnsureSucceeded(IdentityResult result, string action)
{ {
if (result.Succeeded) if (result.Succeeded)
@@ -686,12 +191,4 @@ public sealed class DatabaseInitializer(
throw new InvalidOperationException( throw new InvalidOperationException(
$"{action}失败:{string.Join("", result.Errors.Select(x => x.Description))}"); $"{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 const string SectionName = "Database";
public string Provider { get; set; } = "MySql"; 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> <ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot> <SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
<BuildFrontendOnPublish Condition="'$(BuildFrontendOnPublish)' == ''">true</BuildFrontendOnPublish> <BuildFrontendOnPublish Condition="'$(BuildFrontendOnPublish)' == ''">true</BuildFrontendOnPublish>
<NpmCommand Condition="'$(OS)' == 'Windows_NT'">call npm.cmd</NpmCommand>
<NpmCommand Condition="'$(NpmCommand)' == ''">npm</NpmCommand>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Remove="wwwroot\**\*" /> <Content Remove="wwwroot\**\*" />
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -29,10 +32,19 @@
<Target <Target
Name="BuildVueFrontendForPublish" Name="BuildVueFrontendForPublish"
AfterTargets="ComputeFilesToPublish" AfterTargets="ComputeFilesToPublish"
Condition="'$(BuildFrontendOnPublish)' == 'true' and Exists('$(SpaRoot)/package.json')"> Condition="Exists('$(SpaRoot)/package.json')">
<Message Importance="high" Text="Building Vue frontend for ASP.NET Core publish..." /> <Message
<Exec WorkingDirectory="$(SpaRoot)" Command="npm ci" /> Condition="'$(BuildFrontendOnPublish)' == 'true'"
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" /> 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> <ItemGroup>
<FrontendFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" /> <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"
}
+61 -15
View File
@@ -27,7 +27,22 @@ if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase
throw new InvalidOperationException("SQLite 仅允许在 Development 环境使用。生产环境请配置 MySql。"); 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)) if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
{ {
@@ -59,7 +74,14 @@ builder.Services.AddDbContext<AppDbContext>(options =>
throw new InvalidOperationException( throw new InvalidOperationException(
"缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。"); "缺少 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 builder.Services
@@ -79,9 +101,11 @@ builder.Services
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("缺少 Jwt 配置。"); ?? 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>( builder.Services.Configure<JwtOptions>(
@@ -92,7 +116,6 @@ builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>(); builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>(); builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>(); builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
builder.Services.AddScoped<AutomaticScheduleGenerator>(); builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<AutomaticScheduleJobProcessor>(); builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
builder.Services.AddSingleton<AutomaticScheduleJobQueue>(); builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
@@ -147,13 +170,17 @@ builder.Services.AddCors(options =>
options.AddPolicy("Web", policy => options.AddPolicy("Web", policy =>
{ {
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:5173"]; ?? [];
if (origins.Length > 0)
{
policy.WithOrigins(origins) policy.WithOrigins(origins)
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod(); .AllowAnyMethod();
}
}); });
}); });
builder.Services.AddResponseCompression(options => options.EnableForHttps = true);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler(options => builder.Services.AddExceptionHandler(options =>
{ {
@@ -215,6 +242,7 @@ builder.Services.AddSwaggerGen(options =>
var app = builder.Build(); var app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseResponseCompression();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
@@ -246,13 +274,10 @@ app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<AuditMiddleware>(); app.UseMiddleware<AuditMiddleware>();
app.MapControllers(); app.MapControllers();
app.MapGet("/health", () => Results.Ok(new app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
{ .AllowAnonymous();
Status = "healthy", app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
Database = databaseOptions.Provider, app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
Environment = app.Environment.EnvironmentName,
Time = DateTimeOffset.UtcNow
})).AllowAnonymous();
app.MapFallback(async context => app.MapFallback(async context =>
{ {
if (context.Request.Path.StartsWithSegments("/api") || if (context.Request.Path.StartsWithSegments("/api") ||
@@ -280,14 +305,35 @@ 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(
args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase));
} }
if (args.Contains("--seed-only", StringComparer.OrdinalIgnoreCase)) if (args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase))
{ {
return; return;
} }
app.Run(); 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; public partial class Program;
+6 -5
View File
@@ -6,12 +6,13 @@
"SQLite": "Data Source=data/jiaowu-dev.sqlite" "SQLite": "Data Source=data/jiaowu-dev.sqlite"
}, },
"Jwt": { "Jwt": {
"Key": "jiaowu-development-secret-key-change-before-production" "Key": "jiaowu-development-secret-key-change-before-production",
"ExpireMinutes": 480
}, },
"SeedAdmin": { "Cors": {
"UserName": "admin", "Origins": [
"Password": "Admin@123456", "http://localhost:5173"
"DisplayName": "系统管理员" ]
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
+6 -6
View File
@@ -1,6 +1,8 @@
{ {
"Database": { "Database": {
"Provider": "MySql" "Provider": "MySql",
"ApplyMigrationsOnStartup": false,
"CommandTimeoutSeconds": 30
}, },
"ConnectionStrings": { "ConnectionStrings": {
"MySql": "" "MySql": ""
@@ -8,13 +10,11 @@
"Jwt": { "Jwt": {
"Issuer": "Jiaowu.Api", "Issuer": "Jiaowu.Api",
"Audience": "Jiaowu.Web", "Audience": "Jiaowu.Web",
"Key": "REPLACE_IN_PRODUCTION_WITH_A_LONG_RANDOM_SECRET", "Key": "",
"ExpireMinutes": 480 "ExpireMinutes": 60
}, },
"Cors": { "Cors": {
"Origins": [ "Origins": []
"http://localhost:5173"
]
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
@@ -18,7 +18,10 @@ public sealed class GradeCalculatorTests
{ {
Assert.Equal( Assert.Equal(
expected, expected,
GradeCalculator.AreWeightsValid(regular, midterm, final)); GradeCalculator.AreWeightsValid(
regular,
final,
[new GradeItem { Name = "期中成绩", Weight = midterm }]));
} }
[Fact] [Fact]
@@ -26,11 +29,11 @@ public sealed class GradeCalculatorTests
{ {
var total = GradeCalculator.CalculateTotal( var total = GradeCalculator.CalculateTotal(
83, 83,
null,
91, 91,
[],
30, 30,
0,
70, 70,
[],
GradeExamStatus.Normal); GradeExamStatus.Normal);
Assert.Equal(88.6m, total); Assert.Equal(88.6m, total);
@@ -43,10 +46,10 @@ public sealed class GradeCalculatorTests
Assert.Null(GradeCalculator.CalculateTotal( Assert.Null(GradeCalculator.CalculateTotal(
83, 83,
null, null,
null, [],
30, 30,
0,
70, 70,
[],
GradeExamStatus.Normal)); GradeExamStatus.Normal));
} }
@@ -56,6 +59,6 @@ public sealed class GradeCalculatorTests
[InlineData(GradeExamStatus.Exempt)] [InlineData(GradeExamStatus.Exempt)]
public void Exceptional_exam_status_has_no_numeric_total(GradeExamStatus status) public void Exceptional_exam_status_has_no_numeric_total(GradeExamStatus status)
{ {
Assert.Null(GradeCalculator.CalculateTotal(90, null, 90, 30, 0, 70, status)); Assert.Null(GradeCalculator.CalculateTotal(90, 90, [], 30, 70, [], status));
} }
} }
@@ -0,0 +1,36 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Jiaowu.Api.Tests;
public sealed class MySqlMigrationTests
{
private const string LatestMigration =
"20260725120917_ProductionSchemaCompletion";
[Fact]
public void Production_migration_is_discoverable_and_generates_mysql_sql()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySQL(
"Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;")
.Options;
using var db = new AppDbContext(options);
Assert.Contains(LatestMigration, db.Database.GetMigrations());
var script = db.GetService<IMigrator>().GenerateScript(
fromMigration: "20260725040310_SchedulePublishJobs",
toMigration: LatestMigration);
Assert.Contains("CREATE TABLE `GradeItems`", script);
Assert.Contains("CREATE TABLE `EvaluationSetups`", script);
Assert.Contains("CREATE TABLE `WarningRules`", script);
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
Assert.Contains("DEFAULT 1", script);
Assert.DoesNotContain("0001-01-01", script);
Assert.DoesNotContain("0000-00-00", script);
}
}
@@ -83,7 +83,8 @@ public sealed class TimetableExcelExporterTests
["李老师"], ["李老师"],
["计算机科学 2601 班"], ["计算机科学 2601 班"],
null) null)
]); ],
[]);
var bytes = TimetableExcelExporter.Create(timetable); var bytes = TimetableExcelExporter.Create(timetable);
-1
View File
@@ -1052,7 +1052,6 @@ button { cursor: pointer; }
.public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; } .public-timetable-link { display: block; margin: 14px 0 18px; color: #176b87; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
.account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; } .account-activation-link { display: block; margin: -8px 0 18px; color: #315b73; font-size: 13px; font-weight: 650; text-align: center; text-decoration: none; }
.login-submit { width: 100%; margin-top: 6px; height: 46px; } .login-submit { width: 100%; margin-top: 6px; height: 46px; }
.dev-hint { margin-top: 24px; padding: 13px 15px; display: flex; justify-content: space-between; color: #767e8d; background: #f5f7fa; font-size: 11px; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.metric-grid { grid-template-columns: repeat(2, 1fr); } .metric-grid { grid-template-columns: repeat(2, 1fr); }
+2 -6
View File
@@ -9,8 +9,8 @@ const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(false) const loading = ref(false)
const form = reactive({ const form = reactive({
userName: String(route.query.userName ?? 'admin'), userName: String(route.query.userName ?? ''),
password: route.query.activated ? '' : 'Admin@123456', password: '',
}) })
async function submit() { async function submit() {
@@ -84,10 +84,6 @@ async function submit() {
</el-button> </el-button>
<router-link class="public-timetable-link" to="/timetable">无需登录查询班级课表 </router-link> <router-link class="public-timetable-link" to="/timetable">无需登录查询班级课表 </router-link>
<router-link class="account-activation-link" to="/activate">学生首次登录自助激活账号 </router-link> <router-link class="account-activation-link" to="/activate">学生首次登录自助激活账号 </router-link>
<div class="dev-hint">
<b>本地开发账号</b>
<span>admin / Admin@123456</span>
</div>
</form> </form>
</section> </section>
</main> </main>