生产环境
This commit is contained in:
@@ -12,17 +12,14 @@
|
||||
|
||||
## 本地开发:热更新模式
|
||||
|
||||
本地开发固定使用 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
|
||||
```
|
||||
本地开发固定使用 SQLite。首次启动会自动创建空的
|
||||
`src/Jiaowu.Api/data/jiaowu-dev.sqlite`,只初始化系统角色和课程分类,不再写入
|
||||
演示组织、人员、课程、业务记录或内置测试账号。
|
||||
|
||||
```powershell
|
||||
$env:SeedAdmin__UserName = 'admin'
|
||||
$env:SeedAdmin__Password = '请替换为本机开发密码'
|
||||
$env:SeedAdmin__DisplayName = '系统管理员'
|
||||
dotnet run --project src/Jiaowu.Api
|
||||
```
|
||||
|
||||
@@ -34,17 +31,8 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 `http://localhost:5173`。本地开发会自动创建以下分级权限账号:
|
||||
|
||||
| 数据范围 | 账号 | 密码 |
|
||||
| --- | --- | --- |
|
||||
| 超级管理员 | `admin` | `Admin@123456` |
|
||||
| 校级教务 | `academic` | `Academic@123456` |
|
||||
| 学院教务 | `college` | `College@123456` |
|
||||
| 所带班级 | `counselor` | `Counselor@123456` |
|
||||
| 教师本人 | `teacher` | `Teacher@123456` |
|
||||
| 学生本人 | `student` | `Student@123456` |
|
||||
| 领导查看 | `leader` | `Leader@123456` |
|
||||
访问 `http://localhost:5173`。首次创建管理员后可以清除三个
|
||||
`SeedAdmin__*` 环境变量;后续账号和基础数据均通过管理界面维护。
|
||||
|
||||
## 本地开发:单服务模式
|
||||
|
||||
@@ -57,36 +45,73 @@ dotnet run --project src/Jiaowu.Api
|
||||
|
||||
访问 `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`:
|
||||
|
||||
```powershell
|
||||
$env:ASPNETCORE_ENVIRONMENT = 'Production'
|
||||
$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:AllowedHosts = 'jiaowu.example.edu.cn'
|
||||
dotnet publish src/Jiaowu.Api -c Release -o .artifacts/publish
|
||||
.artifacts/publish/Jiaowu.Api.exe
|
||||
```
|
||||
|
||||
如需在特殊流水线中跳过自动前端构建,可传入 `-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 迁移。部署前先恢复仓库工具并检查迁移:
|
||||
|
||||
```powershell
|
||||
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
|
||||
dotnet test Jiaowu.slnx
|
||||
npm --prefix web run build
|
||||
pwsh.exe -NoLogo -NoProfile -NonInteractive -File scripts/smoke-test.ps1
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.GradePoint = passedRecord.GradePoint;
|
||||
origRecord.Notes = $"课程替代:{cs.SubstituteCourse.Code} {cs.SubstituteCourse.Name}";
|
||||
origRecord.Notes =
|
||||
$"课程替代:{cs.SubstituteCourse!.Code} {cs.SubstituteCourse.Name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("ConnectionStrings__MySql")
|
||||
?? "Server=localhost;Port=3306;Database=jiaowu;User=__design_time__;Password=__not_used__;";
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseMySQL(connectionString)
|
||||
.Options;
|
||||
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -10,31 +10,49 @@ public sealed class DatabaseInitializer(
|
||||
RoleManager<ApplicationRole> roleManager,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
DevelopmentSqliteMigrator sqliteMigrator,
|
||||
DevelopmentDemoDataSeeder developmentDemoDataSeeder,
|
||||
DatabaseOptions databaseOptions,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment,
|
||||
ILogger<DatabaseInitializer> logger)
|
||||
{
|
||||
public async Task InitializeAsync()
|
||||
public async Task InitializeAsync(bool migrateOnly = false)
|
||||
{
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
if (migrateOnly)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"--migrate-only 仅用于非 Development 环境的 MySQL 数据库。");
|
||||
}
|
||||
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await sqliteMigrator.MigrateAsync();
|
||||
}
|
||||
else
|
||||
else if (migrateOnly || databaseOptions.ApplyMigrationsOnStartup)
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToArray();
|
||||
if (pendingMigrations.Length > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"数据库还有 {pendingMigrations.Length} 个待执行迁移。请在发布前运行 " +
|
||||
"'Jiaowu.Api --migrate-only',或显式配置 " +
|
||||
"Database:ApplyMigrationsOnStartup=true。");
|
||||
}
|
||||
}
|
||||
|
||||
if (migrateOnly)
|
||||
{
|
||||
logger.LogInformation("数据库迁移已完成。");
|
||||
return;
|
||||
}
|
||||
|
||||
await SeedRolesAsync();
|
||||
await SeedAdministratorAsync();
|
||||
await SeedCourseCategoriesAsync();
|
||||
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
await SeedDevelopmentDataAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SeedRolesAsync()
|
||||
@@ -131,7 +149,11 @@ public sealed class DatabaseInitializer(
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (code, name, sortOrder) in defaults)
|
||||
{
|
||||
if (existingCodes.Contains(code)) continue;
|
||||
if (existingCodes.Contains(code))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
db.CourseCategories.Add(new CourseCategory
|
||||
{
|
||||
Code = code,
|
||||
@@ -159,523 +181,6 @@ public sealed class DatabaseInitializer(
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentDataAsync()
|
||||
{
|
||||
if (!await db.Campuses.AnyAsync())
|
||||
{
|
||||
var campus = new Campus
|
||||
{
|
||||
Code = "MAIN",
|
||||
Name = "主校区",
|
||||
Address = "大学路 1 号"
|
||||
};
|
||||
var college = new College
|
||||
{
|
||||
Code = "CS",
|
||||
Name = "计算机学院",
|
||||
ShortName = "计算机学院",
|
||||
CampusId = campus.Id
|
||||
};
|
||||
var major = new Major
|
||||
{
|
||||
Code = "080901",
|
||||
Name = "计算机科学与技术",
|
||||
CollegeId = college.Id,
|
||||
DegreeType = "工学学士",
|
||||
SchoolingYears = 4
|
||||
};
|
||||
var building = new Building
|
||||
{
|
||||
Code = "J1",
|
||||
Name = "第一教学楼",
|
||||
CampusId = campus.Id
|
||||
};
|
||||
|
||||
db.AddRange(
|
||||
campus,
|
||||
college,
|
||||
major,
|
||||
new AdministrativeClass
|
||||
{
|
||||
Code = "CS2026-01",
|
||||
Name = "计科 2026-1 班",
|
||||
MajorId = major.Id,
|
||||
Grade = 2026,
|
||||
CounselorName = "陈老师"
|
||||
},
|
||||
building,
|
||||
new Classroom
|
||||
{
|
||||
Code = "J1-201",
|
||||
Name = "J1-201",
|
||||
BuildingId = building.Id,
|
||||
Capacity = 60,
|
||||
RoomType = "多媒体教室",
|
||||
Equipment = "投影、扩声、录播"
|
||||
},
|
||||
new AcademicTerm
|
||||
{
|
||||
Code = "2026-2027-1",
|
||||
Name = "2026—2027 学年第一学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 7),
|
||||
EndDate = new DateOnly(2027, 1, 17),
|
||||
IsCurrent = true
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var computerCollege = await db.Colleges.SingleAsync(x => x.Code == "CS");
|
||||
var computerClass = await db.AdministrativeClasses
|
||||
.SingleAsync(x => x.Code == "CS2026-01");
|
||||
|
||||
if (!await db.Teachers.AnyAsync())
|
||||
{
|
||||
db.Teachers.AddRange(
|
||||
new Teacher
|
||||
{
|
||||
TeacherNumber = "T2026001",
|
||||
Name = "陈明远",
|
||||
Gender = Gender.Male,
|
||||
CollegeId = computerCollege.Id,
|
||||
Title = "副教授",
|
||||
Status = TeacherStatus.Active,
|
||||
HireDate = new DateOnly(2018, 7, 1),
|
||||
Email = "chenmy@example.edu.cn"
|
||||
},
|
||||
new Teacher
|
||||
{
|
||||
TeacherNumber = "T2026002",
|
||||
Name = "林书雅",
|
||||
Gender = Gender.Female,
|
||||
CollegeId = computerCollege.Id,
|
||||
Title = "讲师",
|
||||
Status = TeacherStatus.Active,
|
||||
HireDate = new DateOnly(2022, 9, 1),
|
||||
Email = "linsy@example.edu.cn"
|
||||
});
|
||||
}
|
||||
|
||||
if (!await db.Students.AnyAsync())
|
||||
{
|
||||
db.Students.AddRange(
|
||||
new Student
|
||||
{
|
||||
StudentNumber = "202601001",
|
||||
Name = "周启航",
|
||||
Gender = Gender.Male,
|
||||
AdministrativeClassId = computerClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 7),
|
||||
Status = StudentStatus.Active
|
||||
},
|
||||
new Student
|
||||
{
|
||||
StudentNumber = "202601002",
|
||||
Name = "许知夏",
|
||||
Gender = Gender.Female,
|
||||
AdministrativeClassId = computerClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 7),
|
||||
Status = StudentStatus.Active
|
||||
},
|
||||
new Student
|
||||
{
|
||||
StudentNumber = "202601003",
|
||||
Name = "方嘉树",
|
||||
Gender = Gender.Male,
|
||||
AdministrativeClassId = computerClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 7),
|
||||
Status = StudentStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
var courseCategories = await db.CourseCategories
|
||||
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!await db.Courses.AnyAsync())
|
||||
{
|
||||
db.Courses.AddRange(
|
||||
new Course
|
||||
{
|
||||
Code = "CS101",
|
||||
Name = "程序设计基础",
|
||||
EnglishName = "Fundamentals of Programming",
|
||||
CollegeId = computerCollege.Id,
|
||||
CourseCategoryId = courseCategories["BASIC"].Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 40,
|
||||
PracticeHours = 24,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination,
|
||||
Description = "面向一年级学生的程序设计入门课程。"
|
||||
},
|
||||
new Course
|
||||
{
|
||||
Code = "CS201",
|
||||
Name = "数据结构",
|
||||
EnglishName = "Data Structures",
|
||||
CollegeId = computerCollege.Id,
|
||||
CourseCategoryId = courseCategories["BASIC"].Id,
|
||||
Credits = 3.5m,
|
||||
TotalHours = 56,
|
||||
LectureHours = 40,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
},
|
||||
new Course
|
||||
{
|
||||
Code = "CS305",
|
||||
Name = "软件工程实践",
|
||||
EnglishName = "Software Engineering Practice",
|
||||
CollegeId = computerCollege.Id,
|
||||
CourseCategoryId = courseCategories["PRACTICE"].Id,
|
||||
Credits = 2,
|
||||
TotalHours = 48,
|
||||
LectureHours = 8,
|
||||
PracticeHours = 40,
|
||||
Nature = CourseNature.Practice,
|
||||
AssessmentMethod = AssessmentMethod.Assessment
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (!await db.CurriculumPlans.AnyAsync())
|
||||
{
|
||||
var seededCourses = await db.Courses
|
||||
.Where(x => x.Code == "CS101" || x.Code == "CS201" || x.Code == "CS305")
|
||||
.ToDictionaryAsync(x => x.Code);
|
||||
if (seededCourses.Count == 3)
|
||||
{
|
||||
db.CurriculumPlans.Add(new CurriculumPlan
|
||||
{
|
||||
MajorId = (await db.Majors.SingleAsync(x => x.Code == "080901")).Id,
|
||||
Name = "计算机科学与技术专业培养方案",
|
||||
Version = "2026版",
|
||||
EffectiveGrade = 2026,
|
||||
TotalCredits = 9.5m,
|
||||
Status = CurriculumPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Description = "用于本地开发的精简培养方案示例。",
|
||||
Modules =
|
||||
[
|
||||
new CurriculumModule
|
||||
{
|
||||
Code = "BASIC",
|
||||
Name = "专业基础课程",
|
||||
RequiredCredits = 7.5m,
|
||||
SortOrder = 10,
|
||||
Courses =
|
||||
[
|
||||
new CurriculumCourse
|
||||
{
|
||||
CourseId = seededCourses["CS101"].Id,
|
||||
RecommendedSemester = 1,
|
||||
Type = CurriculumCourseType.Required
|
||||
},
|
||||
new CurriculumCourse
|
||||
{
|
||||
CourseId = seededCourses["CS201"].Id,
|
||||
RecommendedSemester = 3,
|
||||
Type = CurriculumCourseType.Required
|
||||
}
|
||||
]
|
||||
},
|
||||
new CurriculumModule
|
||||
{
|
||||
Code = "PRACTICE",
|
||||
Name = "实践教学",
|
||||
RequiredCredits = 2m,
|
||||
SortOrder = 20,
|
||||
Courses =
|
||||
[
|
||||
new CurriculumCourse
|
||||
{
|
||||
CourseId = seededCourses["CS305"].Id,
|
||||
RecommendedSemester = 5,
|
||||
Type = CurriculumCourseType.Required
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (!await db.TeachingTasks.AnyAsync())
|
||||
{
|
||||
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
|
||||
var course = await db.Courses.SingleAsync(x => x.Code == "CS101");
|
||||
var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001");
|
||||
var administrativeClass = await db.AdministrativeClasses
|
||||
.SingleAsync(x => x.Code == "CS2026-01");
|
||||
db.TeachingTasks.Add(new TeachingTask
|
||||
{
|
||||
TaskNumber = "2026-1-CS101-01",
|
||||
Name = "程序设计基础教学班 01",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 60,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
WeeklyHours = 4,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
],
|
||||
Classes =
|
||||
[
|
||||
new TeachingTaskClass
|
||||
{
|
||||
AdministrativeClassId = administrativeClass.Id
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!await db.SchedulePlans.AnyAsync())
|
||||
{
|
||||
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
|
||||
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
|
||||
var classroom = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
|
||||
db.SchedulePlans.Add(new SchedulePlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "2026—2027 学年第一学期正式课表",
|
||||
Version = "V1",
|
||||
Status = SchedulePlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Entries =
|
||||
[
|
||||
new ScheduleEntry
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
ClassroomId = classroom.Id,
|
||||
DayOfWeek = 1,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
WeekPattern = WeekPattern.All
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await SeedDevelopmentUsersAsync(computerCollege.Id);
|
||||
await SeedDevelopmentCourseSelectionAsync();
|
||||
await SeedDevelopmentGradesAsync();
|
||||
await SeedDevelopmentExamsAsync();
|
||||
await developmentDemoDataSeeder.SeedAsync();
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentCourseSelectionAsync()
|
||||
{
|
||||
if (!await db.CourseSelectionRounds.AnyAsync())
|
||||
{
|
||||
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
|
||||
var task = await db.TeachingTasks.SingleAsync(
|
||||
x => x.TaskNumber == "2026-1-CS101-01");
|
||||
var now = DateTime.UtcNow;
|
||||
db.CourseSelectionRounds.Add(new CourseSelectionRound
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "2026—2027 学年第一学期第一轮选课",
|
||||
StartsAt = now.AddDays(-2),
|
||||
EndsAt = now.AddDays(14),
|
||||
WithdrawalEndsAt = now.AddDays(21),
|
||||
MaxCredits = 30,
|
||||
Status = CourseSelectionRoundStatus.Open,
|
||||
Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。",
|
||||
Offerings =
|
||||
[
|
||||
new CourseSelectionOffering
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
Capacity = 60,
|
||||
IsOpenToAll = false,
|
||||
Notes = "面向计科 2026-1 班开放。"
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var seededTask = await db.TeachingTasks.SingleAsync(
|
||||
x => x.TaskNumber == "2026-1-CS101-01");
|
||||
var offering = await db.CourseSelectionOfferings.FirstOrDefaultAsync(
|
||||
x => x.TeachingTaskId == seededTask.Id);
|
||||
var student = await db.Students.SingleAsync(x =>
|
||||
x.StudentNumber == "202601001");
|
||||
if (offering is not null &&
|
||||
!await db.CourseEnrollments.AnyAsync(x =>
|
||||
x.CourseSelectionOfferingId == offering.Id &&
|
||||
x.StudentId == student.Id))
|
||||
{
|
||||
db.CourseEnrollments.Add(new CourseEnrollment
|
||||
{
|
||||
CourseSelectionOfferingId = offering.Id,
|
||||
StudentId = student.Id
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentGradesAsync()
|
||||
{
|
||||
if (await db.GradeSheets.AnyAsync()) return;
|
||||
var task = await db.TeachingTasks.SingleAsync(
|
||||
x => x.TaskNumber == "2026-1-CS101-01");
|
||||
var student = await db.Students.SingleAsync(
|
||||
x => x.StudentNumber == "202601001");
|
||||
db.GradeSheets.Add(new GradeSheet
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
RegularWeight = 30,
|
||||
FinalWeight = 70,
|
||||
Status = GradeSheetStatus.Draft,
|
||||
Records =
|
||||
[
|
||||
new GradeRecord
|
||||
{
|
||||
StudentId = student.Id,
|
||||
RegularScore = 88,
|
||||
FinalScore = 92,
|
||||
TotalScore = 90.8m,
|
||||
GradePoint = 4.0m
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentExamsAsync()
|
||||
{
|
||||
if (await db.ExamPlans.AnyAsync()) return;
|
||||
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
|
||||
var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01");
|
||||
var room = await db.Classrooms.SingleAsync(x => x.Code == "J1-201");
|
||||
var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001");
|
||||
db.ExamPlans.Add(new ExamPlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "2026—2027 学年第一学期期末考试",
|
||||
Status = ExamPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Notes = "本地开发演示考试计划。",
|
||||
Sessions =
|
||||
[
|
||||
new ExamSession
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
ClassroomId = room.Id,
|
||||
ExamDate = new DateOnly(2027, 1, 8),
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc),
|
||||
EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc),
|
||||
RequiredInvigilatorCount = 2,
|
||||
Invigilators =
|
||||
[
|
||||
new ExamSessionInvigilator { TeacherId = teacher.Id }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
|
||||
{
|
||||
var definitions = new[]
|
||||
{
|
||||
new DevelopmentUser(
|
||||
"academic", "校级教务员", "Academic@123456",
|
||||
null, null, SystemRoles.AcademicAdmin),
|
||||
new DevelopmentUser(
|
||||
"college", "计算机学院教务员", "College@123456",
|
||||
"A2026001", collegeId, SystemRoles.CollegeAdmin),
|
||||
new DevelopmentUser(
|
||||
"counselor", "陈老师", "Counselor@123456",
|
||||
"C2026001", collegeId, SystemRoles.Counselor),
|
||||
new DevelopmentUser(
|
||||
"teacher", "陈明远", "Teacher@123456",
|
||||
"T2026001", collegeId, SystemRoles.Teacher),
|
||||
new DevelopmentUser(
|
||||
"student", "周启航", "Student@123456",
|
||||
"202601001", collegeId, SystemRoles.Student),
|
||||
new DevelopmentUser(
|
||||
"leader", "教学分管领导", "Leader@123456",
|
||||
null, null, SystemRoles.Leader)
|
||||
};
|
||||
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var user = await userManager.FindByNameAsync(definition.UserName);
|
||||
if (user is null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
{
|
||||
UserName = definition.UserName,
|
||||
DisplayName = definition.DisplayName,
|
||||
StaffNumber = definition.StaffNumber,
|
||||
CollegeId = definition.CollegeId,
|
||||
LockoutEnabled = true,
|
||||
IsEnabled = true
|
||||
};
|
||||
EnsureSucceeded(
|
||||
await userManager.CreateAsync(user, definition.Password),
|
||||
$"创建开发账号 {definition.UserName}");
|
||||
}
|
||||
|
||||
if (!await userManager.IsInRoleAsync(user, definition.Role))
|
||||
{
|
||||
EnsureSucceeded(
|
||||
await userManager.AddToRoleAsync(user, definition.Role),
|
||||
$"授予开发账号 {definition.UserName} 角色");
|
||||
}
|
||||
|
||||
if (definition.Role == SystemRoles.Teacher)
|
||||
{
|
||||
var teacher = await db.Teachers.SingleAsync(
|
||||
x => x.TeacherNumber == definition.StaffNumber);
|
||||
if (!teacher.UserId.HasValue) teacher.UserId = user.Id;
|
||||
}
|
||||
if (definition.Role == SystemRoles.Student)
|
||||
{
|
||||
var student = await db.Students.SingleAsync(
|
||||
x => x.StudentNumber == definition.StaffNumber);
|
||||
if (!student.UserId.HasValue) student.UserId = user.Id;
|
||||
}
|
||||
if (definition.Role == SystemRoles.Counselor)
|
||||
{
|
||||
var classes = await db.AdministrativeClasses
|
||||
.Where(x =>
|
||||
x.CounselorUserId == null &&
|
||||
x.CounselorName == definition.DisplayName)
|
||||
.ToListAsync();
|
||||
foreach (var administrativeClass in classes)
|
||||
administrativeClass.CounselorUserId = user.Id;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static void EnsureSucceeded(IdentityResult result, string action)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
@@ -686,12 +191,4 @@ public sealed class DatabaseInitializer(
|
||||
throw new InvalidOperationException(
|
||||
$"{action}失败:{string.Join(";", result.Errors.Select(x => x.Description))}");
|
||||
}
|
||||
|
||||
private sealed record DevelopmentUser(
|
||||
string UserName,
|
||||
string DisplayName,
|
||||
string Password,
|
||||
string? StaffNumber,
|
||||
Guid? CollegeId,
|
||||
string Role);
|
||||
}
|
||||
|
||||
@@ -4,4 +4,6 @@ public sealed class DatabaseOptions
|
||||
{
|
||||
public const string SectionName = "Database";
|
||||
public string Provider { get; set; } = "MySql";
|
||||
public bool ApplyMigrationsOnStartup { get; set; }
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
@@ -1,620 +0,0 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public sealed class DevelopmentDemoDataSeeder(
|
||||
AppDbContext db,
|
||||
ILogger<DevelopmentDemoDataSeeder> logger)
|
||||
{
|
||||
private const int Grade = 2026;
|
||||
private const int ClassesPerMajor = 2;
|
||||
private const int StudentsPerClass = 35;
|
||||
private const int TeachersPerCollege = 8;
|
||||
|
||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var campus = await db.Campuses
|
||||
.OrderBy(x => x.Code == "MAIN" ? 0 : 1)
|
||||
.ThenBy(x => x.Code)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (campus is null)
|
||||
{
|
||||
campus = new Campus
|
||||
{
|
||||
Code = "MAIN",
|
||||
Name = "主校区",
|
||||
Address = "大学路 1 号"
|
||||
};
|
||||
db.Campuses.Add(campus);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await SeedCollegesAsync(campus.Id, cancellationToken);
|
||||
await SeedMajorsAsync(cancellationToken);
|
||||
await SeedClassesAsync(cancellationToken);
|
||||
await SeedTeachersAsync(cancellationToken);
|
||||
await SeedStudentsAsync(cancellationToken);
|
||||
await SeedCoursesAsync(cancellationToken);
|
||||
await SeedTeacherCourseApplicationsAsync(cancellationToken);
|
||||
await LogSummaryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedCollegesAsync(Guid campusId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existingCodes = (await db.Colleges
|
||||
.Select(x => x.Code)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var additions = CollegeDefinitions
|
||||
.Where(x => !existingCodes.Contains(x.Code))
|
||||
.Select((x, index) => new College
|
||||
{
|
||||
Code = x.Code,
|
||||
Name = x.Name,
|
||||
ShortName = x.ShortName,
|
||||
CampusId = campusId,
|
||||
SortOrder = (index + 1) * 10
|
||||
})
|
||||
.ToList();
|
||||
if (additions.Count == 0) return;
|
||||
db.Colleges.AddRange(additions);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedMajorsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var colleges = await db.Colleges
|
||||
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var existingCodes = (await db.Majors
|
||||
.Select(x => x.Code)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var additions = new List<Major>();
|
||||
foreach (var collegeDefinition in CollegeDefinitions)
|
||||
{
|
||||
if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue;
|
||||
for (var index = 0; index < collegeDefinition.Majors.Count; index++)
|
||||
{
|
||||
var definition = collegeDefinition.Majors[index];
|
||||
if (existingCodes.Contains(definition.Code)) continue;
|
||||
additions.Add(new Major
|
||||
{
|
||||
Code = definition.Code,
|
||||
Name = definition.Name,
|
||||
CollegeId = college.Id,
|
||||
DegreeType = definition.DegreeType,
|
||||
SchoolingYears = definition.SchoolingYears,
|
||||
SortOrder = (index + 1) * 10
|
||||
});
|
||||
}
|
||||
}
|
||||
if (additions.Count == 0) return;
|
||||
db.Majors.AddRange(additions);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedClassesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var targetMajorCodes = CollegeDefinitions
|
||||
.SelectMany(x => x.Majors)
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var majors = await db.Majors
|
||||
.Where(x => targetMajorCodes.Contains(x.Code))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingClasses = await db.AdministrativeClasses
|
||||
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingCodes = (await db.AdministrativeClasses
|
||||
.Select(x => x.Code)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var additions = new List<AdministrativeClass>();
|
||||
foreach (var major in majors)
|
||||
{
|
||||
var currentCount = existingClasses.Count(x => x.MajorId == major.Id);
|
||||
for (var section = currentCount + 1; section <= ClassesPerMajor; section++)
|
||||
{
|
||||
var code = CreateAvailableCode(
|
||||
$"{major.Code}-{Grade}-{section:D2}",
|
||||
existingCodes);
|
||||
additions.Add(new AdministrativeClass
|
||||
{
|
||||
Code = code,
|
||||
Name = $"{major.Name}{Grade}级{section}班",
|
||||
MajorId = major.Id,
|
||||
Grade = Grade,
|
||||
CounselorName = $"{CounselorSurnames[(section + major.Code.Length) % CounselorSurnames.Length]}老师",
|
||||
SortOrder = section * 10
|
||||
});
|
||||
}
|
||||
}
|
||||
if (additions.Count == 0) return;
|
||||
db.AdministrativeClasses.AddRange(additions);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedTeachersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var collegeCodes = CollegeDefinitions
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var colleges = await db.Colleges
|
||||
.Where(x => collegeCodes.Contains(x.Code))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingTeachers = await db.Teachers
|
||||
.Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingNumbers = (await db.Teachers
|
||||
.Select(x => x.TeacherNumber)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var additions = new List<Teacher>();
|
||||
for (var collegeIndex = 0; collegeIndex < colleges.Count; collegeIndex++)
|
||||
{
|
||||
var college = colleges[collegeIndex];
|
||||
var currentCount = existingTeachers.Count(x => x.CollegeId == college.Id);
|
||||
for (var position = currentCount + 1; position <= TeachersPerCollege; position++)
|
||||
{
|
||||
var teacherNumber = CreateAvailableCode(
|
||||
$"T26{collegeIndex + 1:D2}{position:D3}",
|
||||
existingNumbers);
|
||||
additions.Add(new Teacher
|
||||
{
|
||||
TeacherNumber = teacherNumber,
|
||||
Name = BuildPersonName(collegeIndex, position),
|
||||
Gender = position % 2 == 0 ? Gender.Female : Gender.Male,
|
||||
CollegeId = college.Id,
|
||||
Title = TeacherTitles[(position - 1) % TeacherTitles.Length],
|
||||
Status = TeacherStatus.Active,
|
||||
HireDate = new DateOnly(2012 + position, 7, 1),
|
||||
Email = $"{teacherNumber.ToLowerInvariant()}@example.edu.cn",
|
||||
Notes = "Development 环境批量测试教师。"
|
||||
});
|
||||
}
|
||||
}
|
||||
if (additions.Count == 0) return;
|
||||
db.Teachers.AddRange(additions);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedStudentsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var targetMajorCodes = CollegeDefinitions
|
||||
.SelectMany(x => x.Majors)
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var classes = await db.AdministrativeClasses
|
||||
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var classIds = classes.Select(x => x.Id).ToHashSet();
|
||||
var existingStudents = await db.Students
|
||||
.Where(x => classIds.Contains(x.AdministrativeClassId))
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingNumbers = (await db.Students
|
||||
.Select(x => x.StudentNumber)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var additions = new List<Student>();
|
||||
for (var classIndex = 0; classIndex < classes.Count; classIndex++)
|
||||
{
|
||||
var administrativeClass = classes[classIndex];
|
||||
var existingCount = existingStudents.Count(x =>
|
||||
x.AdministrativeClassId == administrativeClass.Id);
|
||||
var candidate = 1;
|
||||
while (existingCount + additions.Count(x =>
|
||||
x.AdministrativeClassId == administrativeClass.Id) < StudentsPerClass)
|
||||
{
|
||||
var studentNumber = $"{Grade}{classIndex + 1:D3}{candidate:D3}";
|
||||
candidate++;
|
||||
if (!existingNumbers.Add(studentNumber)) continue;
|
||||
additions.Add(new Student
|
||||
{
|
||||
StudentNumber = studentNumber,
|
||||
Name = BuildPersonName(classIndex, candidate + 5),
|
||||
Gender = candidate % 2 == 0 ? Gender.Male : Gender.Female,
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = Grade,
|
||||
EnrollmentDate = new DateOnly(Grade, 9, 7),
|
||||
DateOfBirth = new DateOnly(2007 + candidate % 2, candidate % 12 + 1,
|
||||
candidate % 27 + 1),
|
||||
Status = StudentStatus.Active,
|
||||
Email = $"{studentNumber}@student.example.edu.cn",
|
||||
Notes = "Development 环境批量测试学生。"
|
||||
});
|
||||
}
|
||||
}
|
||||
if (additions.Count == 0) return;
|
||||
db.Students.AddRange(additions);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedCoursesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var colleges = await db.Colleges
|
||||
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var categories = await db.CourseCategories
|
||||
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var existingCodes = (await db.Courses
|
||||
.Select(x => x.Code)
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var additions = new List<Course>();
|
||||
|
||||
foreach (var definition in PublicCourseDefinitions)
|
||||
{
|
||||
if (existingCodes.Contains(definition.Code) ||
|
||||
!colleges.TryGetValue(definition.CollegeCode, out var college) ||
|
||||
!categories.TryGetValue(definition.CategoryCode, out var category))
|
||||
continue;
|
||||
additions.Add(new Course
|
||||
{
|
||||
Code = definition.Code,
|
||||
Name = definition.Name,
|
||||
CollegeId = college.Id,
|
||||
CourseCategoryId = category.Id,
|
||||
Credits = definition.Credits,
|
||||
TotalHours = definition.TotalHours,
|
||||
LectureHours = definition.LectureHours,
|
||||
PracticeHours = definition.TotalHours - definition.LectureHours,
|
||||
Nature = definition.Nature,
|
||||
AssessmentMethod = definition.AssessmentMethod,
|
||||
Description = "Development 环境公共课程测试数据。"
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var collegeDefinition in CollegeDefinitions)
|
||||
{
|
||||
if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue;
|
||||
for (var index = 0; index < collegeDefinition.ProfessionalCourses.Count; index++)
|
||||
{
|
||||
var code = $"{collegeDefinition.Code}D{index + 1:D2}";
|
||||
if (existingCodes.Contains(code)) continue;
|
||||
var nature = index switch
|
||||
{
|
||||
< 4 => CourseNature.MajorRequired,
|
||||
< 6 => CourseNature.MajorElective,
|
||||
_ => CourseNature.Practice
|
||||
};
|
||||
var categoryCode = nature == CourseNature.Practice ? "PRACTICE" : "MAJOR";
|
||||
additions.Add(new Course
|
||||
{
|
||||
Code = code,
|
||||
Name = collegeDefinition.ProfessionalCourses[index],
|
||||
CollegeId = college.Id,
|
||||
CourseCategoryId = categories[categoryCode].Id,
|
||||
Credits = nature switch
|
||||
{
|
||||
CourseNature.MajorRequired => index % 2 == 0 ? 3m : 3.5m,
|
||||
_ => 2m
|
||||
},
|
||||
TotalHours = nature switch
|
||||
{
|
||||
CourseNature.MajorRequired => index % 2 == 0 ? 48 : 56,
|
||||
CourseNature.MajorElective => 32,
|
||||
_ => 48
|
||||
},
|
||||
LectureHours = nature == CourseNature.Practice ? 8 :
|
||||
nature == CourseNature.MajorElective ? 24 :
|
||||
index % 2 == 0 ? 40 : 48,
|
||||
PracticeHours = nature == CourseNature.Practice ? 40 :
|
||||
nature == CourseNature.MajorElective ? 8 : 8,
|
||||
Nature = nature,
|
||||
AssessmentMethod = nature == CourseNature.Practice
|
||||
? AssessmentMethod.Assessment
|
||||
: AssessmentMethod.Examination,
|
||||
Description = $"由{collegeDefinition.Name}开设的专业课程测试数据。"
|
||||
});
|
||||
}
|
||||
}
|
||||
if (additions.Count == 0) return;
|
||||
db.Courses.AddRange(additions);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedTeacherCourseApplicationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var term = await db.AcademicTerms
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (term is null) return;
|
||||
|
||||
var collegeCodes = CollegeDefinitions
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var colleges = await db.Colleges
|
||||
.Where(x => collegeCodes.Contains(x.Code))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var collegeIds = colleges.Select(x => x.Id).ToHashSet();
|
||||
var teachers = await db.Teachers
|
||||
.Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active)
|
||||
.OrderBy(x => x.TeacherNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
var courses = await db.Courses
|
||||
.Where(x => collegeIds.Contains(x.CollegeId))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var publicCourses = courses
|
||||
.Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective)
|
||||
.ToList();
|
||||
if (publicCourses.Count == 0) return;
|
||||
|
||||
var existing = (await db.TeacherCourseApplications
|
||||
.Where(x => x.AcademicTermId == term.Id)
|
||||
.Select(x => new { x.TeacherId, x.CourseId })
|
||||
.ToListAsync(cancellationToken))
|
||||
.Select(x => (x.TeacherId, x.CourseId))
|
||||
.ToHashSet();
|
||||
var additions = new List<TeacherCourseApplication>();
|
||||
for (var teacherIndex = 0; teacherIndex < teachers.Count; teacherIndex++)
|
||||
{
|
||||
var teacher = teachers[teacherIndex];
|
||||
var professionalCourses = courses
|
||||
.Where(x => x.CollegeId == teacher.CollegeId &&
|
||||
x.Nature is CourseNature.MajorRequired or
|
||||
CourseNature.MajorElective or CourseNature.Practice)
|
||||
.Take(4);
|
||||
var selectedPublicCourses = Enumerable.Range(0, 2)
|
||||
.Select(offset => publicCourses[(teacherIndex + offset) % publicCourses.Count]);
|
||||
foreach (var course in professionalCourses
|
||||
.Concat(selectedPublicCourses)
|
||||
.DistinctBy(x => x.Id))
|
||||
{
|
||||
if (!existing.Add((teacher.Id, course.Id))) continue;
|
||||
additions.Add(new TeacherCourseApplication
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
TeacherId = teacher.Id,
|
||||
CourseId = course.Id,
|
||||
Status = TeacherCourseApplicationStatus.Approved,
|
||||
Statement = "Development 环境批量生成的授课意向。",
|
||||
ReviewComment = "测试数据自动审核通过。",
|
||||
SubmittedAt = DateTime.UtcNow.AddDays(-7),
|
||||
ReviewedAt = DateTime.UtcNow.AddDays(-6)
|
||||
});
|
||||
}
|
||||
}
|
||||
if (additions.Count == 0) return;
|
||||
db.TeacherCourseApplications.AddRange(additions);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task LogSummaryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var courseCounts = await db.Courses
|
||||
.GroupBy(x => x.Nature)
|
||||
.Select(x => new { Nature = x.Key, Count = x.Count() })
|
||||
.ToDictionaryAsync(x => x.Nature, x => x.Count, cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Development 测试数据已就绪:学院/教学单位 {CollegeCount},专业 {MajorCount}," +
|
||||
"行政班 {ClassCount},教师 {TeacherCount},学生 {StudentCount},课程 {CourseCount};" +
|
||||
"公共必修 {GeneralRequiredCount},公共选修 {GeneralElectiveCount}," +
|
||||
"专业必修 {MajorRequiredCount},专业选修 {MajorElectiveCount},实践课程 {PracticeCount};" +
|
||||
"已审核授课资格 {ApplicationCount}。",
|
||||
await db.Colleges.CountAsync(cancellationToken),
|
||||
await db.Majors.CountAsync(cancellationToken),
|
||||
await db.AdministrativeClasses.CountAsync(cancellationToken),
|
||||
await db.Teachers.CountAsync(cancellationToken),
|
||||
await db.Students.CountAsync(cancellationToken),
|
||||
await db.Courses.CountAsync(cancellationToken),
|
||||
courseCounts.GetValueOrDefault(CourseNature.GeneralRequired),
|
||||
courseCounts.GetValueOrDefault(CourseNature.GeneralElective),
|
||||
courseCounts.GetValueOrDefault(CourseNature.MajorRequired),
|
||||
courseCounts.GetValueOrDefault(CourseNature.MajorElective),
|
||||
courseCounts.GetValueOrDefault(CourseNature.Practice),
|
||||
await db.TeacherCourseApplications
|
||||
.CountAsync(x => x.Status == TeacherCourseApplicationStatus.Approved,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private static string CreateAvailableCode(string preferredCode, ISet<string> existingCodes)
|
||||
{
|
||||
var candidate = preferredCode;
|
||||
var suffix = 1;
|
||||
while (!existingCodes.Add(candidate))
|
||||
{
|
||||
candidate = $"{preferredCode}-{suffix++}";
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string BuildPersonName(int groupIndex, int position)
|
||||
{
|
||||
var surname = Surnames[(groupIndex + position) % Surnames.Length];
|
||||
var givenName = GivenNames[
|
||||
(groupIndex * 7 + position * 3) % GivenNames.Length];
|
||||
return surname + givenName;
|
||||
}
|
||||
|
||||
private static readonly string[] Surnames =
|
||||
["王", "李", "张", "刘", "陈", "杨", "黄", "赵", "吴", "周", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "林", "罗"];
|
||||
|
||||
private static readonly string[] GivenNames =
|
||||
["明远", "知夏", "嘉树", "雨桐", "思源", "若溪", "景行", "书雅", "子涵", "浩然", "清越", "语晨", "承宇", "欣怡", "博文", "婉宁", "俊逸", "安然", "泽楷", "可心"];
|
||||
|
||||
private static readonly string[] CounselorSurnames =
|
||||
["陈", "林", "周", "王", "李", "张", "刘", "赵"];
|
||||
|
||||
private static readonly string[] TeacherTitles =
|
||||
["教授", "副教授", "讲师", "讲师", "副教授", "实验师", "讲师", "教授"];
|
||||
|
||||
private static readonly CollegeSeed[] CollegeDefinitions =
|
||||
[
|
||||
new("CS", "计算机学院", "计算机学院",
|
||||
[
|
||||
new("080901", "计算机科学与技术", "工学学士"),
|
||||
new("080902", "软件工程", "工学学士"),
|
||||
new("080903", "网络工程", "工学学士"),
|
||||
new("080910T", "数据科学与大数据技术", "工学学士"),
|
||||
new("080717T", "人工智能", "工学学士")
|
||||
],
|
||||
["程序设计基础", "离散数学", "数据结构", "计算机组成原理", "操作系统", "数据库系统原理", "软件工程课程设计", "人工智能项目实践"]),
|
||||
new("EIA", "电子信息与自动化学院", "电子信息学院",
|
||||
[
|
||||
new("080701", "电子信息工程", "工学学士"),
|
||||
new("080703", "通信工程", "工学学士"),
|
||||
new("080801", "自动化", "工学学士"),
|
||||
new("080803T", "机器人工程", "工学学士")
|
||||
],
|
||||
["电路分析", "模拟电子技术", "数字电子技术", "信号与系统", "通信原理", "嵌入式系统", "电子系统设计", "综合电子实训"]),
|
||||
new("ME", "机械与车辆工程学院", "机械学院",
|
||||
[
|
||||
new("080202", "机械设计制造及其自动化", "工学学士"),
|
||||
new("080207", "车辆工程", "工学学士"),
|
||||
new("080205", "工业设计", "工学学士"),
|
||||
new("080213T", "智能制造工程", "工学学士")
|
||||
],
|
||||
["工程制图", "理论力学", "材料力学", "机械原理", "机械设计", "智能制造技术", "机械创新设计", "工程训练"]),
|
||||
new("EE", "电气工程学院", "电气学院",
|
||||
[
|
||||
new("080601", "电气工程及其自动化", "工学学士"),
|
||||
new("080604T", "电气工程与智能控制", "工学学士"),
|
||||
new("080605T", "电机电器智能化", "工学学士")
|
||||
],
|
||||
["电路原理", "电机学", "电力电子技术", "自动控制原理", "电力系统分析", "继电保护", "电气控制实训", "电力系统综合设计"]),
|
||||
new("CIVIL", "土木建筑工程学院", "土建学院",
|
||||
[
|
||||
new("081001", "土木工程", "工学学士"),
|
||||
new("082801", "建筑学", "建筑学学士", 5),
|
||||
new("120103", "工程管理", "管理学学士"),
|
||||
new("081006T", "道路桥梁与渡河工程", "工学学士")
|
||||
],
|
||||
["工程制图与识图", "工程力学", "结构力学", "混凝土结构", "土力学与地基基础", "工程项目管理", "建筑设计基础", "工程测量实习"]),
|
||||
new("ECON", "经济与管理学院", "经管学院",
|
||||
[
|
||||
new("120201K", "工商管理", "管理学学士"),
|
||||
new("120203K", "会计学", "管理学学士"),
|
||||
new("020301K", "金融学", "经济学学士"),
|
||||
new("020401", "国际经济与贸易", "经济学学士"),
|
||||
new("120202", "市场营销", "管理学学士")
|
||||
],
|
||||
["微观经济学", "宏观经济学", "管理学原理", "会计学原理", "统计学", "财务管理", "企业经营沙盘", "商务数据分析实践"]),
|
||||
new("FOREIGN", "外国语学院", "外国语学院",
|
||||
[
|
||||
new("050201", "英语", "文学学士"),
|
||||
new("050207", "日语", "文学学士"),
|
||||
new("050262", "商务英语", "文学学士")
|
||||
],
|
||||
["综合英语", "英语听力", "英语口语", "英语写作", "翻译理论与实践", "跨文化交际", "商务英语实训", "口译实践"]),
|
||||
new("MATH", "数学与统计学院", "数统学院",
|
||||
[
|
||||
new("070101", "数学与应用数学", "理学学士"),
|
||||
new("071201", "统计学", "理学学士"),
|
||||
new("020102", "经济统计学", "经济学学士")
|
||||
],
|
||||
["数学分析", "高等代数", "解析几何", "常微分方程", "实变函数", "数值分析", "数学建模", "统计软件实践"]),
|
||||
new("PHYSICS", "物理与光电工程学院", "物电学院",
|
||||
[
|
||||
new("070201", "物理学", "理学学士"),
|
||||
new("070202", "应用物理学", "理学学士"),
|
||||
new("080705", "光电信息科学与工程", "工学学士")
|
||||
],
|
||||
["力学", "热学", "电磁学", "光学", "量子力学", "固体物理", "近代物理实验", "光电技术综合实验"]),
|
||||
new("CHEM", "化学与环境工程学院", "化环学院",
|
||||
[
|
||||
new("070301", "化学", "理学学士"),
|
||||
new("070302", "应用化学", "理学学士"),
|
||||
new("081301", "化学工程与工艺", "工学学士"),
|
||||
new("082502", "环境工程", "工学学士")
|
||||
],
|
||||
["无机化学", "有机化学", "分析化学", "物理化学", "化工原理", "仪器分析", "基础化学实验", "化工设计实践"]),
|
||||
new("HUMANITIES", "人文与法学院", "人文法学院",
|
||||
[
|
||||
new("050101", "汉语言文学", "文学学士"),
|
||||
new("030101K", "法学", "法学学士"),
|
||||
new("120402", "行政管理", "管理学学士")
|
||||
],
|
||||
["中国古代文学", "中国现当代文学", "现代汉语", "古代汉语", "文学概论", "行政管理学", "新闻写作实训", "社会调查实践"]),
|
||||
new("EDU", "教育科学学院", "教育学院",
|
||||
[
|
||||
new("040101", "教育学", "教育学学士"),
|
||||
new("040107", "小学教育", "教育学学士"),
|
||||
new("040106", "学前教育", "教育学学士")
|
||||
],
|
||||
["教育学原理", "普通心理学", "教育心理学", "课程与教学论", "教育研究方法", "班级管理", "微格教学", "教育见习"]),
|
||||
new("ART", "艺术设计学院", "艺术学院",
|
||||
[
|
||||
new("130502", "视觉传达设计", "艺术学学士"),
|
||||
new("130503", "环境设计", "艺术学学士"),
|
||||
new("130202", "音乐学", "艺术学学士")
|
||||
],
|
||||
["设计素描", "色彩基础", "构成基础", "艺术概论", "数字媒体设计", "品牌视觉设计", "专业采风", "毕业创作实践"]),
|
||||
new("PE", "体育学院", "体育学院",
|
||||
[
|
||||
new("040201", "体育教育", "教育学学士"),
|
||||
new("040203", "社会体育指导与管理", "教育学学士")
|
||||
],
|
||||
["运动解剖学", "运动生理学", "学校体育学", "体育心理学", "运动训练学", "体育社会学", "田径专项训练", "球类专项训练"]),
|
||||
new("LIFE", "生命科学与食品工程学院", "生食学院",
|
||||
[
|
||||
new("071001", "生物科学", "理学学士"),
|
||||
new("071002", "生物技术", "理学学士"),
|
||||
new("082701", "食品科学与工程", "工学学士")
|
||||
],
|
||||
["普通生物学", "生物化学", "细胞生物学", "遗传学", "微生物学", "食品化学", "分子生物学实验", "生物工程综合实践"]),
|
||||
new("MARXISM", "马克思主义学院", "马克思主义学院", [], [])
|
||||
];
|
||||
|
||||
private static readonly PublicCourseSeed[] PublicCourseDefinitions =
|
||||
[
|
||||
new("PUB001", "思想道德与法治", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||
new("PUB002", "中国近现代史纲要", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||
new("PUB003", "马克思主义基本原理", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||
new("PUB004", "毛泽东思想和中国特色社会主义理论体系概论", "MARXISM", "MORAL", 5, 80, 64, CourseNature.GeneralRequired),
|
||||
new("PUB005", "习近平新时代中国特色社会主义思想概论", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||
new("PUB006", "形势与政策", "MARXISM", "MORAL", 2, 32, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB007", "大学英语 I", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired),
|
||||
new("PUB008", "大学英语 II", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired),
|
||||
new("PUB009", "高等数学 A(上)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired),
|
||||
new("PUB010", "高等数学 A(下)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired),
|
||||
new("PUB011", "线性代数", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired),
|
||||
new("PUB012", "概率论与数理统计", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired),
|
||||
new("PUB013", "大学计算机基础", "CS", "BASIC", 2, 32, 16, CourseNature.GeneralRequired),
|
||||
new("PUB014", "Python 程序设计", "CS", "BASIC", 3, 48, 24, CourseNature.GeneralRequired),
|
||||
new("PUB015", "大学体育 I", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB016", "大学体育 II", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB017", "军事理论", "HUMANITIES", "MILITARY", 2, 36, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB018", "大学生心理健康教育", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB019", "大学生职业发展与就业指导", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB020", "创新创业基础", "ECON", "INNOVATION", 2, 32, 20, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB021", "劳动教育", "EDU", "LABOR", 1, 32, 8, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB022", "国家安全教育", "HUMANITIES", "MORAL", 1, 16, 16, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB023", "文献检索与学术规范", "HUMANITIES", "BASIC", 1, 16, 12, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||
new("PUB024", "艺术鉴赏", "ART", "AESTHETIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||
new("PUB025", "中国传统文化", "HUMANITIES", "AESTHETIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||
new("PUB026", "生态文明导论", "LIFE", "BASIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||
new("PUB027", "人工智能导论", "CS", "INNOVATION", 2, 32, 20, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||
new("PUB028", "经济学通识", "ECON", "BASIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment)
|
||||
];
|
||||
|
||||
private sealed record CollegeSeed(
|
||||
string Code,
|
||||
string Name,
|
||||
string ShortName,
|
||||
IReadOnlyList<MajorSeed> Majors,
|
||||
IReadOnlyList<string> ProfessionalCourses);
|
||||
|
||||
private sealed record MajorSeed(
|
||||
string Code,
|
||||
string Name,
|
||||
string DegreeType,
|
||||
int SchoolingYears = 4);
|
||||
|
||||
private sealed record PublicCourseSeed(
|
||||
string Code,
|
||||
string Name,
|
||||
string CollegeCode,
|
||||
string CategoryCode,
|
||||
decimal Credits,
|
||||
int TotalHours,
|
||||
int LectureHours,
|
||||
CourseNature Nature,
|
||||
AssessmentMethod AssessmentMethod = AssessmentMethod.Examination);
|
||||
}
|
||||
-184
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-158
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-22
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+953
-635
File diff suppressed because it is too large
Load Diff
+1173
File diff suppressed because it is too large
Load Diff
-29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-125
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-66
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-117
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-46
@@ -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"); }
|
||||
}
|
||||
}
|
||||
-27
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+951
-633
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,13 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
|
||||
<BuildFrontendOnPublish Condition="'$(BuildFrontendOnPublish)' == ''">true</BuildFrontendOnPublish>
|
||||
<NpmCommand Condition="'$(OS)' == 'Windows_NT'">call npm.cmd</NpmCommand>
|
||||
<NpmCommand Condition="'$(NpmCommand)' == ''">npm</NpmCommand>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="wwwroot\**\*" />
|
||||
<Content Update="appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -29,10 +32,19 @@
|
||||
<Target
|
||||
Name="BuildVueFrontendForPublish"
|
||||
AfterTargets="ComputeFilesToPublish"
|
||||
Condition="'$(BuildFrontendOnPublish)' == 'true' and Exists('$(SpaRoot)/package.json')">
|
||||
<Message Importance="high" Text="Building Vue frontend for ASP.NET Core publish..." />
|
||||
<Exec WorkingDirectory="$(SpaRoot)" Command="npm ci" />
|
||||
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
|
||||
Condition="Exists('$(SpaRoot)/package.json')">
|
||||
<Message
|
||||
Condition="'$(BuildFrontendOnPublish)' == 'true'"
|
||||
Importance="high"
|
||||
Text="Building Vue frontend for ASP.NET Core publish..." />
|
||||
<Exec
|
||||
Condition="'$(BuildFrontendOnPublish)' == 'true'"
|
||||
WorkingDirectory="$(SpaRoot)"
|
||||
Command="$(NpmCommand) ci" />
|
||||
<Exec
|
||||
Condition="'$(BuildFrontendOnPublish)' == 'true'"
|
||||
WorkingDirectory="$(SpaRoot)"
|
||||
Command="$(NpmCommand) run build" />
|
||||
|
||||
<ItemGroup>
|
||||
<FrontendFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" />
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
@Host = http://localhost:5255
|
||||
|
||||
GET {{Host}}/health
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
POST {{Host}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"userName": "admin",
|
||||
"password": "Admin@123456"
|
||||
}
|
||||
+64
-18
@@ -27,7 +27,22 @@ if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase
|
||||
throw new InvalidOperationException("SQLite 仅允许在 Development 环境使用。生产环境请配置 MySql。");
|
||||
}
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
var allowedHosts = builder.Configuration["AllowedHosts"];
|
||||
if (!builder.Environment.IsDevelopment() &&
|
||||
(string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts == "*"))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"生产环境必须通过 AllowedHosts 配置明确的访问域名,不能使用通配符。");
|
||||
}
|
||||
|
||||
if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton(databaseOptions);
|
||||
builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||
{
|
||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -59,7 +74,14 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
throw new InvalidOperationException(
|
||||
"缺少 MySQL 连接串。请通过 ConnectionStrings__MySql 环境变量配置。");
|
||||
}
|
||||
options.UseMySQL(connectionString);
|
||||
options.UseMySQL(connectionString, mySqlOptions =>
|
||||
{
|
||||
mySqlOptions.CommandTimeout(databaseOptions.CommandTimeoutSeconds);
|
||||
mySqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(10),
|
||||
errorNumbersToAdd: null);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services
|
||||
@@ -79,9 +101,11 @@ builder.Services
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
|
||||
?? throw new InvalidOperationException("缺少 Jwt 配置。");
|
||||
if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32)
|
||||
if (Encoding.UTF8.GetByteCount(jwtOptions.Key) < 32 ||
|
||||
jwtOptions.Key.Contains("REPLACE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Jwt:Key 至少需要 32 字节。");
|
||||
throw new InvalidOperationException(
|
||||
"Jwt:Key 必须配置为至少 32 字节的随机生产密钥,不能使用示例值。");
|
||||
}
|
||||
|
||||
builder.Services.Configure<JwtOptions>(
|
||||
@@ -92,7 +116,6 @@ builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||
builder.Services.AddScoped<DatabaseInitializer>();
|
||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||
builder.Services.AddScoped<TimetableDataService>();
|
||||
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||
builder.Services.AddScoped<AutomaticScheduleJobProcessor>();
|
||||
builder.Services.AddSingleton<AutomaticScheduleJobQueue>();
|
||||
@@ -147,13 +170,17 @@ builder.Services.AddCors(options =>
|
||||
options.AddPolicy("Web", policy =>
|
||||
{
|
||||
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
|
||||
?? ["http://localhost:5173"];
|
||||
policy.WithOrigins(origins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
?? [];
|
||||
if (origins.Length > 0)
|
||||
{
|
||||
policy.WithOrigins(origins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddResponseCompression(options => options.EnableForHttps = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler(options =>
|
||||
{
|
||||
@@ -215,6 +242,7 @@ builder.Services.AddSwaggerGen(options =>
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
app.UseResponseCompression();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
@@ -246,13 +274,10 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<AuditMiddleware>();
|
||||
app.MapControllers();
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
{
|
||||
Status = "healthy",
|
||||
Database = databaseOptions.Provider,
|
||||
Environment = app.Environment.EnvironmentName,
|
||||
Time = DateTimeOffset.UtcNow
|
||||
})).AllowAnonymous();
|
||||
app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
|
||||
.AllowAnonymous();
|
||||
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||
app.MapFallback(async context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api") ||
|
||||
@@ -280,14 +305,35 @@ app.MapFallback(async context =>
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<DatabaseInitializer>()
|
||||
.InitializeAsync();
|
||||
.InitializeAsync(
|
||||
args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (args.Contains("--seed-only", StringComparer.OrdinalIgnoreCase))
|
||||
if (args.Contains("--migrate-only", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<IResult> CheckDatabaseHealthAsync(
|
||||
AppDbContext db,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.Database.CanConnectAsync(cancellationToken)
|
||||
? Results.Ok(new { Status = "healthy" })
|
||||
: Results.Json(
|
||||
new { Status = "unhealthy" },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(
|
||||
new { Status = "unhealthy" },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
"SQLite": "Data Source=data/jiaowu-dev.sqlite"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "jiaowu-development-secret-key-change-before-production"
|
||||
"Key": "jiaowu-development-secret-key-change-before-production",
|
||||
"ExpireMinutes": 480
|
||||
},
|
||||
"SeedAdmin": {
|
||||
"UserName": "admin",
|
||||
"Password": "Admin@123456",
|
||||
"DisplayName": "系统管理员"
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"http://localhost:5173"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"Database": {
|
||||
"Provider": "MySql"
|
||||
"Provider": "MySql",
|
||||
"ApplyMigrationsOnStartup": false,
|
||||
"CommandTimeoutSeconds": 30
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"MySql": ""
|
||||
@@ -8,13 +10,11 @@
|
||||
"Jwt": {
|
||||
"Issuer": "Jiaowu.Api",
|
||||
"Audience": "Jiaowu.Web",
|
||||
"Key": "REPLACE_IN_PRODUCTION_WITH_A_LONG_RANDOM_SECRET",
|
||||
"ExpireMinutes": 480
|
||||
"Key": "",
|
||||
"ExpireMinutes": 60
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"http://localhost:5173"
|
||||
]
|
||||
"Origins": []
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
||||
@@ -18,7 +18,10 @@ public sealed class GradeCalculatorTests
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
GradeCalculator.AreWeightsValid(regular, midterm, final));
|
||||
GradeCalculator.AreWeightsValid(
|
||||
regular,
|
||||
final,
|
||||
[new GradeItem { Name = "期中成绩", Weight = midterm }]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -26,11 +29,11 @@ public sealed class GradeCalculatorTests
|
||||
{
|
||||
var total = GradeCalculator.CalculateTotal(
|
||||
83,
|
||||
null,
|
||||
91,
|
||||
[],
|
||||
30,
|
||||
0,
|
||||
70,
|
||||
[],
|
||||
GradeExamStatus.Normal);
|
||||
|
||||
Assert.Equal(88.6m, total);
|
||||
@@ -43,10 +46,10 @@ public sealed class GradeCalculatorTests
|
||||
Assert.Null(GradeCalculator.CalculateTotal(
|
||||
83,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
30,
|
||||
0,
|
||||
70,
|
||||
[],
|
||||
GradeExamStatus.Normal));
|
||||
}
|
||||
|
||||
@@ -56,6 +59,6 @@ public sealed class GradeCalculatorTests
|
||||
[InlineData(GradeExamStatus.Exempt)]
|
||||
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 班"],
|
||||
null)
|
||||
]);
|
||||
],
|
||||
[]);
|
||||
|
||||
var bytes = TimetableExcelExporter.Create(timetable);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
.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; }
|
||||
.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) {
|
||||
.metric-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
@@ -9,8 +9,8 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
userName: String(route.query.userName ?? 'admin'),
|
||||
password: route.query.activated ? '' : 'Admin@123456',
|
||||
userName: String(route.query.userName ?? ''),
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
@@ -84,10 +84,6 @@ async function submit() {
|
||||
</el-button>
|
||||
<router-link class="public-timetable-link" to="/timetable">无需登录,查询班级课表 →</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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user