新增 [Eis.Tools (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Tools/Program.cs:1),支持 database init/reset/seed。
数据库维护、安全校验和事务逻辑位于 [DatabaseMaintenanceService.cs (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs:1)。 内置完整演示数据:1200 名考生、10800 条成绩、2404 条招生记录。 删除旧 import-test-data.mjs 和 reset-dev-database.mjs 运行入口。 Docker 镜像同时包含 Web 和 /app/tools/Eis.Tools.dll。 [package.json (line 11)](C:/Users/BI/Documents/EIS-dotnet/package.json:11) 原数据库命令已改为调用 .NET。 新增统一发布脚本:[publish.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/publish.ps1:1)。
This commit is contained in:
+6
-2
@@ -3,7 +3,9 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore src/Eis.Web/Eis.Web.csproj \
|
||||
&& dotnet publish src/Eis.Web/Eis.Web.csproj --configuration Release --output /app/publish --no-restore
|
||||
&& dotnet restore src/Eis.Tools/Eis.Tools.csproj \
|
||||
&& dotnet publish src/Eis.Web/Eis.Web.csproj --configuration Release --output /app/web --no-restore \
|
||||
&& dotnet publish src/Eis.Tools/Eis.Tools.csproj --configuration Release --output /app/tools --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim AS runtime
|
||||
|
||||
@@ -11,7 +13,8 @@ WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build --chown=$APP_UID:$APP_UID /app/publish .
|
||||
COPY --from=build --chown=$APP_UID:$APP_UID /app/web /app/web
|
||||
COPY --from=build --chown=$APP_UID:$APP_UID /app/tools /app/tools
|
||||
RUN mkdir -p /app/data \
|
||||
&& chown -R $APP_UID:$APP_UID /app/data
|
||||
|
||||
@@ -24,6 +27,7 @@ EXPOSE 4173
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
USER $APP_UID
|
||||
WORKDIR /app/web
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:4173/health/live"]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<Project Path="src/Eis.Application/Eis.Application.csproj" />
|
||||
<Project Path="src/Eis.Domain/Eis.Domain.csproj" />
|
||||
<Project Path="src/Eis.Infrastructure/Eis.Infrastructure.csproj" />
|
||||
<Project Path="src/Eis.Tools/Eis.Tools.csproj" />
|
||||
<Project Path="src/Eis.Web/Eis.Web.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
|
||||
+4
-2
@@ -11,6 +11,7 @@
|
||||
- [x] 指标资格、招生账户、志愿、计划、投档、录取、报到和退档
|
||||
- [x] ClosedXML 文件导入导出、文书模板和 Redis/本机缓存
|
||||
- [x] SQLite / MySQL 自动建库和基础配置初始化
|
||||
- [x] 可发布的 .NET 数据库重建与 1200 人演示数据导入工具
|
||||
- [x] 容器入口切换及 Node.js 后端运行依赖移除
|
||||
|
||||
## 本地运行
|
||||
@@ -41,9 +42,10 @@ dotnet run --project .\src\Eis.Web\Eis.Web.csproj
|
||||
|
||||
```powershell
|
||||
dotnet test .\Eis.slnx
|
||||
dotnet publish .\src\Eis.Web\Eis.Web.csproj -c Release
|
||||
pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\publish.ps1
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database seed --sqlite --path .\artifacts\demo.sqlite --dry-run
|
||||
pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\smoke-dotnet-native.ps1
|
||||
docker compose up --build --detach
|
||||
```
|
||||
|
||||
`scripts/smoke-dotnet-native.ps1` 会发布并启动纯 .NET 产物,在操作系统临时目录创建一次性 SQLite 数据库,检查健康状态、首页、CKEditor、原生登录和未知 API 404 后清理。`scripts/smoke-dotnet-migration.ps1` 保留为迁移期 Node/.NET 行为对照工具,不属于生产启动链路。
|
||||
`Eis.Tools.dll` 提供 `database init/reset/seed`;破坏性命令要求 `--confirm-target`,SQLite 自动保留原库备份,MySQL 拒绝系统库和未显式强制覆盖的非演示业务数据。`scripts/smoke-dotnet-native.ps1` 会发布并启动纯 .NET 产物,在操作系统临时目录创建一次性 SQLite 数据库,检查健康状态、首页、CKEditor、原生登录和未知 API 404 后清理。`scripts/smoke-dotnet-migration.ps1` 保留为迁移期 Node/.NET 行为对照工具,不属于生产启动链路。
|
||||
|
||||
@@ -100,6 +100,25 @@ dotnet run --project .\src\Eis.Web\Eis.Web.csproj
|
||||
|
||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库会写入系统基础配置、默认审批流程和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v20。
|
||||
|
||||
统一发布 Web 和数据库工具:
|
||||
|
||||
```powershell
|
||||
pwsh.exe -NoLogo -NoProfile -NonInteractive -File .\scripts\publish.ps1
|
||||
```
|
||||
|
||||
发布结果分别位于 `artifacts/publish/web` 和 `artifacts/publish/tools`。服务器启动 Web:
|
||||
|
||||
```powershell
|
||||
dotnet .\artifacts\publish\web\Eis.Web.dll
|
||||
```
|
||||
|
||||
数据库工具使用编译后的 `Eis.Tools.dll`,不需要源代码或 `dotnet run`:
|
||||
|
||||
```powershell
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database init --sqlite --path .\data\exam.sqlite
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database seed --sqlite --path .\data\demo.sqlite --dry-run
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
项目根目录包含生产镜像和 Docker Compose 配置。默认使用 SQLite,数据库保存在命名卷 `exam-information-data` 中,因此重建容器不会丢失数据。
|
||||
@@ -131,7 +150,15 @@ docker compose logs --follow app
|
||||
docker build --tag hengzhun-exam-system:local .
|
||||
```
|
||||
|
||||
镜像仅包含 ASP.NET Core 10 运行时,默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/health/live` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT`、`DATABASE_URL`/`MYSQL_*`、`REDIS_URL`;认证状态默认使用同一 Redis 服务的独立 DB 1,也可以通过 `REDIS_SESSION_DB` 或 `REDIS_SESSION_URL` 单独配置。此时 SQLite 数据卷可以移除。
|
||||
镜像包含 ASP.NET Core 10 Web 宿主和编译后的 `/app/tools/Eis.Tools.dll`,默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/health/live` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT`、`DATABASE_URL`/`MYSQL_*`、`REDIS_URL`;认证状态默认使用同一 Redis 服务的独立 DB 1,也可以通过 `REDIS_SESSION_DB` 或 `REDIS_SESSION_URL` 单独配置。此时 SQLite 数据卷可以移除。
|
||||
|
||||
容器内执行数据库工具前应先停止 Web,SQLite 目标必须与命名卷路径完全一致:
|
||||
|
||||
```powershell
|
||||
docker compose stop app
|
||||
docker compose run --rm --entrypoint dotnet app /app/tools/Eis.Tools.dll database seed --sqlite --path /app/data/exam.sqlite --confirm-target /app/data/exam.sqlite
|
||||
docker compose up --detach app
|
||||
```
|
||||
|
||||
#### Gitea Actions 自动发布到 Docker Hub 与 Gitea 软件包
|
||||
|
||||
@@ -165,7 +192,7 @@ git push origin v1.3.0-rc.1
|
||||
|
||||
首次成功推送后,容器镜像会出现在 `biss` 所有者的软件包列表。Gitea 的软件包归属于用户或组织,不会天然归属于某个仓库;打开该软件包的设置页面,将它关联到 `Exam-Information-System`,即可让它显示在此仓库的“软件包”页。之后可使用 `docker pull git.biss.click/biss/exam-information-system:latest` 拉取。
|
||||
|
||||
应用会在空库上自动创建结构和基础配置。旧版 Node 数据重置、样例数据和地区快照构建脚本仍保留为开发维护工具,不会被生产镜像复制或执行;运行这些脚本前必须明确数据库目标,且不得对生产业务库执行。
|
||||
应用会在空库上自动创建结构和基础配置。测试数据导入和空库重建均由编译后的 `Eis.Tools.dll` 完成;旧 Node 导入入口已移除。演示数据包内置于 `Eis.Infrastructure.dll`,生产服务器不需要 Node.js。
|
||||
|
||||
## 数据库配置
|
||||
|
||||
@@ -252,21 +279,25 @@ dotnet run --project .\src\Eis.Web\Eis.Web.csproj
|
||||
|
||||
### 导入服务器 MySQL 测试数据
|
||||
|
||||
先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数,然后执行:
|
||||
先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数。先预检并确认工具输出的数据库名:
|
||||
|
||||
```powershell
|
||||
npm run seed-test-data:mysql
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database seed --mysql --dry-run
|
||||
```
|
||||
|
||||
脚本会读取 `.env`,校验当前连接的数据库名称、v15 表结构和已有数据。目标是新数据库时会自动建表并导入;目标只有首次启动生成的空业务结构时会在事务中替换为样例数据。若检测到学校、考生、考试、报名等业务数据,脚本默认拒绝覆盖。
|
||||
|
||||
仅在确认目标是可以完全覆盖的测试库时使用:
|
||||
确认目标测试数据库名为 `exam_test` 后执行:
|
||||
|
||||
```powershell
|
||||
npm run seed-test-data:mysql -- --force
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database seed --mysql --confirm-target exam_test
|
||||
```
|
||||
|
||||
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`。
|
||||
工具会读取 `.env`,拒绝 MySQL 系统数据库,校验 v20 结构和已有数据,并在同一事务内清理、导入。若检测到无法识别为内置演示数据的业务记录,默认拒绝覆盖。仅在确认目标确实是可完全覆盖的测试库时追加:
|
||||
|
||||
```powershell
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database seed --mysql --confirm-target exam_test --force
|
||||
```
|
||||
|
||||
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库本身。MySQL 操作前应另行完成数据库备份;不要对生产业务库执行该命令。
|
||||
|
||||
## 中考志愿填报与招生录取
|
||||
|
||||
@@ -307,20 +338,20 @@ npm run seed-test-data:mysql -- --force
|
||||
|
||||
## 测试结束后初始化系统
|
||||
|
||||
先停止应用,然后运行以下命令。命令会读取 `.env` 并自动选择 SQLite 或 MySQL,删除样例学校、考生、考试、报名等业务数据,恢复系统基础配置和一个初始超级管理员:
|
||||
先停止应用。SQLite 重建会自动生成带时间戳的原库备份,然后恢复系统基础配置和一个初始超级管理员:
|
||||
|
||||
```powershell
|
||||
npm run initialize-system
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database reset --sqlite --path .\data\exam.sqlite --confirm-target .\data\exam.sqlite
|
||||
```
|
||||
|
||||
也可以明确指定数据库类型:
|
||||
MySQL 应先执行 `--dry-run` 确认目标,再明确填写数据库名:
|
||||
|
||||
```powershell
|
||||
npm run initialize-system:sqlite
|
||||
npm run initialize-system:mysql
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database reset --mysql --dry-run
|
||||
dotnet .\artifacts\publish\tools\Eis.Tools.dll database reset --mysql --confirm-target exam_test
|
||||
```
|
||||
|
||||
MySQL 模式会自动识别由本项目生成的批量样例数据并清理。若目标包含无法识别为样例数据的业务记录,命令会拒绝执行;只有明确确认目标可完全清空时才可运行 `npm run initialize-system:mysql -- --force`。初始化完成后,初始管理员账号由 `.env` 中的 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 决定,然后再重新启动应用。
|
||||
MySQL 模式会自动识别由本项目生成的批量演示数据;目标包含非演示业务记录时仍会拒绝,只有确认可覆盖时才能追加 `--force`。初始化管理员由 `.env` 中的 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 决定。
|
||||
|
||||
## 自动化测试
|
||||
|
||||
@@ -340,13 +371,15 @@ Eis.slnx .NET 10 解决方案
|
||||
src/Eis.Domain 领域模型与业务值
|
||||
src/Eis.Application 应用服务契约
|
||||
src/Eis.Infrastructure SQLite/MySQL、认证、缓存、Excel 与业务实现
|
||||
src/Eis.Tools 可独立发布的数据库初始化、重建和演示数据工具
|
||||
src/Eis.Web ASP.NET Core 宿主、端点和发布静态资源
|
||||
tests/Eis.Infrastructure.Tests xUnit 自动化测试
|
||||
|
||||
index.html / styles.css / app.js 前端入口、样式和控制器
|
||||
src/client 公共、考生、管理及招生前端模块
|
||||
src/database/schema.mjs 兼容 SQLite/MySQL 的 v20 建库定义
|
||||
scripts 迁移验证与旧版开发数据维护工具
|
||||
scripts/publish.ps1 Web 与数据库工具统一发布脚本
|
||||
scripts 迁移验证及演示数据包维护工具
|
||||
server.mjs / src/routes 仅保留的旧 Node 行为对照源码
|
||||
data/exam.sqlite 本地运行后生成的 SQLite 数据库
|
||||
.env.example 开发与生产环境变量模板
|
||||
|
||||
+7
-7
@@ -8,13 +8,13 @@
|
||||
"test": "dotnet test Eis.slnx",
|
||||
"test:legacy": "node tests/client-auth.test.mjs && node tests/api-dedup.test.mjs && node tests/cache.test.mjs && node tests/auth-state.test.mjs && node tests/state-cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
|
||||
"test:cache": "node tests/cache.test.mjs",
|
||||
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||
"seed-test-data": "node scripts/import-test-data.mjs",
|
||||
"seed-test-data:sqlite": "node scripts/import-test-data.mjs --sqlite",
|
||||
"seed-test-data:mysql": "node scripts/import-test-data.mjs --mysql",
|
||||
"initialize-system": "node scripts/import-test-data.mjs --empty",
|
||||
"initialize-system:sqlite": "node scripts/import-test-data.mjs --empty --sqlite",
|
||||
"initialize-system:mysql": "node scripts/import-test-data.mjs --empty --mysql"
|
||||
"reset-db": "dotnet run --project src/Eis.Tools -- database reset",
|
||||
"seed-test-data": "dotnet run --project src/Eis.Tools -- database seed",
|
||||
"seed-test-data:sqlite": "dotnet run --project src/Eis.Tools -- database seed --sqlite",
|
||||
"seed-test-data:mysql": "dotnet run --project src/Eis.Tools -- database seed --mysql",
|
||||
"initialize-system": "dotnet run --project src/Eis.Tools -- database reset",
|
||||
"initialize-system:sqlite": "dotnet run --project src/Eis.Tools -- database reset --sqlite",
|
||||
"initialize-system:mysql": "dotnet run --project src/Eis.Tools -- database reset --mysql"
|
||||
},
|
||||
"dependencies": {
|
||||
"ckeditor5": "^48.3.1",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { pbkdf2Sync } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { buildSeedOperations } from '../database.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outputPath = join(root, 'src', 'Eis.Infrastructure', 'Data', 'demo-seed-operations.json.gz');
|
||||
const generatedAt = '2026-07-23T00:00:00.000Z';
|
||||
const deterministicSalt = 'eis-dotnet-demo-seed';
|
||||
const hashPassword = password => {
|
||||
const hash = pbkdf2Sync(password, deterministicSalt, 120000, 32, 'sha256').toString('hex');
|
||||
return `${deterministicSalt}:${hash}`;
|
||||
};
|
||||
|
||||
const database = createSeedDatabase({
|
||||
nowIso: () => generatedAt,
|
||||
hashPassword
|
||||
});
|
||||
database.meta.version = 20;
|
||||
const operations = buildSeedOperations(database);
|
||||
const payload = {
|
||||
version: 1,
|
||||
schemaVersion: 20,
|
||||
generatedAt,
|
||||
summary: {
|
||||
schools: database.schools.length,
|
||||
users: database.users.length,
|
||||
candidates: database.candidateProfiles.length,
|
||||
registrations: database.registrations.length,
|
||||
results: database.results.length,
|
||||
admissionRecords: database.admissionRecords.length,
|
||||
firstRoundPreferences: database.admissionRecords.filter(
|
||||
item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1
|
||||
).length,
|
||||
operations: operations.length
|
||||
},
|
||||
operations
|
||||
};
|
||||
const compressed = gzipSync(Buffer.from(JSON.stringify(payload)), { level: 9 });
|
||||
await writeFile(outputPath, compressed);
|
||||
console.log(JSON.stringify({ outputPath, compressedBytes: compressed.length, ...payload.summary }));
|
||||
@@ -1,202 +0,0 @@
|
||||
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const envPath = join(root, '.env');
|
||||
if (existsSync(envPath)) loadEnvFile(envPath);
|
||||
|
||||
const options = new Set(process.argv.slice(2));
|
||||
if (options.has('--mysql') && options.has('--sqlite')) throw new Error('不能同时指定 --mysql 和 --sqlite');
|
||||
const configuredClient = options.has('--mysql') ? 'mysql' : options.has('--sqlite') ? 'sqlite' : process.env.DATABASE_CLIENT;
|
||||
const client = String(configuredClient || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase();
|
||||
if (!['sqlite', 'mysql'].includes(client)) throw new Error(`不支持的 DATABASE_CLIENT:${client}`);
|
||||
process.env.DATABASE_CLIENT = client;
|
||||
const force = options.has('--force');
|
||||
const initializeEmpty = options.has('--empty');
|
||||
|
||||
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
const createTargetState = () => initializeEmpty
|
||||
? createBaseDatabase({
|
||||
nowIso: () => new Date().toISOString(),
|
||||
hashPassword,
|
||||
initialAdmin: {
|
||||
username: process.env.INITIAL_ADMIN_USERNAME,
|
||||
password: process.env.INITIAL_ADMIN_PASSWORD,
|
||||
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
||||
}
|
||||
})
|
||||
: createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
|
||||
|
||||
async function prepareSqlite() {
|
||||
const databasePath = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite'));
|
||||
const relativePath = relative(root, databasePath);
|
||||
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
|
||||
throw new Error('拒绝覆盖工作区以外的 SQLite 数据库');
|
||||
}
|
||||
await rm(databasePath, { force: true });
|
||||
await rm(`${databasePath}-shm`, { force: true });
|
||||
await rm(`${databasePath}-wal`, { force: true });
|
||||
process.env.SQLITE_PATH = databasePath;
|
||||
return databasePath;
|
||||
}
|
||||
|
||||
function mysqlPoolOptions(mysql) {
|
||||
if (process.env.DATABASE_URL) return mysql.createPool(process.env.DATABASE_URL);
|
||||
if (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !process.env.MYSQL_DATABASE) {
|
||||
throw new Error('MySQL 配置不完整:请在 .env 设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE');
|
||||
}
|
||||
return mysql.createPool({
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT || 3306),
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 2,
|
||||
charset: 'utf8mb4',
|
||||
timezone: 'Z',
|
||||
enableKeepAlive: true
|
||||
});
|
||||
}
|
||||
|
||||
const mysqlBusinessTables = [
|
||||
'schools', 'school_classes', 'candidate_profiles', 'notices', 'exams', 'exam_subjects',
|
||||
'registrations', 'registration_subjects', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects',
|
||||
'results', 'test_centers', 'test_rooms', 'center_change_requests', 'center_change_rooms',
|
||||
'candidate_account_batches', 'candidate_account_batch_items', 'workflow_instances', 'workflow_actions', 'admission_records', 'audit_logs'
|
||||
];
|
||||
|
||||
async function prepareMysql() {
|
||||
const { default: mysql } = await import('mysql2/promise');
|
||||
const pool = mysqlPoolOptions(mysql);
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
const [[databaseRow]] = await connection.query('SELECT DATABASE() AS name');
|
||||
const databaseName = String(databaseRow?.name || '');
|
||||
if (!databaseName || ['mysql', 'information_schema', 'performance_schema', 'sys'].includes(databaseName.toLowerCase())) {
|
||||
throw new Error(`拒绝向系统数据库导入测试数据:${databaseName || '未选择数据库'}`);
|
||||
}
|
||||
|
||||
const [tableRows] = await connection.query(`
|
||||
SELECT TABLE_NAME FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
||||
`);
|
||||
const existingTables = new Set(tableRows.map(row => row.TABLE_NAME));
|
||||
const existingAppTables = relationalTables.filter(table => existingTables.has(table));
|
||||
if (!existingAppTables.length) return { location: `MySQL database ${databaseName}`, initialized: false };
|
||||
if (existingAppTables.length !== relationalTables.length) {
|
||||
const missing = relationalTables.filter(table => !existingTables.has(table));
|
||||
throw new Error(`MySQL 数据库结构不完整,缺少:${missing.join(', ')}。请先使用空数据库启动一次应用完成建表`);
|
||||
}
|
||||
|
||||
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
|
||||
if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) {
|
||||
throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
||||
}
|
||||
|
||||
const [examPartitionRows] = await connection.query(
|
||||
'SELECT candidates_table, admissions_table, results_table, centers_table FROM exam_data_partitions'
|
||||
);
|
||||
const [schoolPartitionRows] = await connection.query('SELECT students_table FROM school_student_partitions');
|
||||
const dynamicPartitionTables = [
|
||||
...examPartitionRows.flatMap(row => [row.candidates_table, row.admissions_table, row.results_table, row.centers_table]),
|
||||
...schoolPartitionRows.map(row => row.students_table)
|
||||
];
|
||||
if (dynamicPartitionTables.some(table => !/^[a-z][a-z0-9_]{0,63}$/.test(table))) {
|
||||
throw new Error('分表登记中存在非法表名,拒绝替换测试数据');
|
||||
}
|
||||
|
||||
const nonEmpty = [];
|
||||
for (const table of mysqlBusinessTables) {
|
||||
const [[row]] = await connection.query(`SELECT COUNT(*) AS count FROM \`${table}\``);
|
||||
if (Number(row.count) > 0) nonEmpty.push(`${table}=${row.count}`);
|
||||
}
|
||||
const [[userRow]] = await connection.query('SELECT COUNT(*) AS count FROM users');
|
||||
if (Number(userRow.count) > 1) nonEmpty.push(`users=${userRow.count}`);
|
||||
let recognizedTestData = false;
|
||||
if (initializeEmpty && nonEmpty.length) {
|
||||
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
|
||||
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')");
|
||||
recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5;
|
||||
}
|
||||
if (nonEmpty.length && !force && !recognizedTestData) {
|
||||
const forceCommand = initializeEmpty
|
||||
? 'npm run initialize-system:mysql -- --force'
|
||||
: 'npm run seed-test-data:mysql -- --force';
|
||||
throw new Error(
|
||||
`MySQL 数据库 ${databaseName} 已有业务数据(${nonEmpty.slice(0, 8).join(', ')}${nonEmpty.length > 8 ? ', ...' : ''})。` +
|
||||
`如确认这是可覆盖的测试库,请运行 ${forceCommand}`
|
||||
);
|
||||
}
|
||||
if (nonEmpty.length) {
|
||||
console.warn(`[${recognizedTestData ? 'test-data cleanup' : 'force'}] Replacing existing business data in MySQL database ${databaseName}`);
|
||||
} else {
|
||||
console.log(`Preparing empty MySQL database ${databaseName} for ${initializeEmpty ? 'system initialization' : 'test data'}`);
|
||||
}
|
||||
|
||||
const state = createTargetState();
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
await connection.beginTransaction();
|
||||
try {
|
||||
// 先移除考试,使归档成绩保护触发器在清理 results 时不再命中。
|
||||
const clearOrder = ['exams', ...[...relationalTables].reverse().filter(table => !['schema_metadata', 'exams'].includes(table))];
|
||||
for (const table of clearOrder) await connection.query(`DELETE FROM \`${table}\``);
|
||||
for (const operation of buildSeedOperations(state)) await connection.execute(operation.sql, operation.params);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
for (const table of dynamicPartitionTables) await connection.query(`DROP TABLE IF EXISTS \`${table}\``);
|
||||
return { location: `MySQL database ${databaseName}`, initialized: true };
|
||||
} finally {
|
||||
connection?.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
let location;
|
||||
let mysqlAlreadyImported = false;
|
||||
if (client === 'sqlite') {
|
||||
location = await prepareSqlite();
|
||||
} else {
|
||||
const prepared = await prepareMysql();
|
||||
location = prepared.location;
|
||||
mysqlAlreadyImported = prepared.initialized;
|
||||
}
|
||||
const database = await createDatabase({
|
||||
root,
|
||||
seed: createTargetState
|
||||
});
|
||||
const state = await database.read();
|
||||
await database.close();
|
||||
|
||||
const pending = state.registrations.filter(item => item.status === 'pending').length;
|
||||
const rejected = state.registrations.filter(item => item.status === 'rejected').length;
|
||||
const unpaid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length;
|
||||
const paid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'paid').length;
|
||||
if (initializeEmpty) {
|
||||
console.log(`Initialized empty system in ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
} else {
|
||||
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||
console.log(`${state.schools.filter(item => item.isSourceSchool).length} source schools, ${state.schools.filter(item => item.isAdmissionSchool).length} admission schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
|
||||
console.log(`${state.results.length} published subject scores, ${state.admissionRecords.filter(item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1).length} first-round preferences`);
|
||||
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
|
||||
console.log('all predefined test account passwords: 12345678');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\publish')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
|
||||
$publishRoot = [IO.Path]::GetFullPath(
|
||||
$(if ([IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
$OutputDirectory
|
||||
}
|
||||
else {
|
||||
Join-Path $repositoryRoot $OutputDirectory
|
||||
}))
|
||||
|
||||
$projects = @(
|
||||
@{
|
||||
Project = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
|
||||
Output = Join-Path $publishRoot 'web'
|
||||
},
|
||||
@{
|
||||
Project = Join-Path $repositoryRoot 'src\Eis.Tools\Eis.Tools.csproj'
|
||||
Output = Join-Path $publishRoot 'tools'
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($item in $projects) {
|
||||
$nativeArgs = @(
|
||||
'publish'
|
||||
$item.Project
|
||||
'--configuration'
|
||||
'Release'
|
||||
'--output'
|
||||
$item.Output
|
||||
)
|
||||
& dotnet @nativeArgs
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "dotnet publish 失败,退出码:$exitCode"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Web 发布目录:$(Join-Path $publishRoot 'web')"
|
||||
Write-Host "数据库工具目录:$(Join-Path $publishRoot 'tools')"
|
||||
Write-Host "工具示例:dotnet $(Join-Path $publishRoot 'tools\Eis.Tools.dll') database seed --dry-run"
|
||||
@@ -1,4 +0,0 @@
|
||||
// Keep the historical reset-db entry point, but run the same guarded initializer used
|
||||
// by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync.
|
||||
if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty');
|
||||
await import('./import-test-data.mjs');
|
||||
@@ -245,7 +245,12 @@ try {
|
||||
TOTP_ENCRYPTION_KEY = 'migration-smoke-totp-key-32-characters-minimum'
|
||||
DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
|
||||
}
|
||||
$seedProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('scripts/import-test-data.mjs', '--sqlite') -Environment $nodeEnvironment
|
||||
$seedProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
|
||||
'run', '--project', 'src/Eis.Tools', '--',
|
||||
'database', 'seed', '--sqlite',
|
||||
'--path', $smokeDatabasePath,
|
||||
'--confirm-target', $smokeDatabasePath
|
||||
) -Environment $nodeEnvironment
|
||||
if (-not $seedProcess.WaitForExit(30000)) {
|
||||
$seedProcess.Kill($true)
|
||||
throw 'Timed out while preparing the migration smoke-test database'
|
||||
@@ -404,6 +409,7 @@ try {
|
||||
if ($noticePayload.notice.title -ne 'ASP.NET Core 迁移冒烟通知' -or $noticePayload.notice.content -match '<script') {
|
||||
throw 'Native public notice endpoint did not preserve data or sanitize unsafe content'
|
||||
}
|
||||
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $session | Out-Null
|
||||
|
||||
$candidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||
$candidateLoginBody = @{ username = '2026-HZ01-F-0001'; password = '12345678' } | ConvertTo-Json -Compress
|
||||
@@ -411,6 +417,8 @@ try {
|
||||
if ($candidateLogin.user.username -ne '2026-HZ01-F-0001') {
|
||||
throw 'Could not sign in as the migration verification candidate'
|
||||
}
|
||||
$legacyCandidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $candidateLoginBody -WebSession $legacyCandidateSession | Out-Null
|
||||
|
||||
$candidateResults = Invoke-RestMethod -Uri "$webBaseUrl/api/candidate/results" -WebSession $candidateSession
|
||||
$scoreCode = @($candidateResults.summaries | Where-Object verificationCode | Select-Object -First 1).verificationCode
|
||||
@@ -509,14 +517,14 @@ try {
|
||||
throw 'Native authentication could not create the candidate parity-test session'
|
||||
}
|
||||
foreach ($candidateRoute in @('dashboard', 'notices', 'profile', 'exams', 'registrations')) {
|
||||
$legacyCandidateResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateRoute" -WebSession $candidateSession
|
||||
$legacyCandidateResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateRoute" -WebSession $legacyCandidateSession
|
||||
$nativeCandidateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/$candidateRoute" -WebSession $nativeCandidateSession
|
||||
if ($nativeCandidateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||
throw "Candidate route '$candidateRoute' did not use the native ASP.NET Core endpoint"
|
||||
}
|
||||
Assert-JsonEquivalent -Expected $legacyCandidateResponse.Content -Actual $nativeCandidateResponse.Content -Label "Candidate route '$candidateRoute'"
|
||||
}
|
||||
$legacyCandidateResultsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/results" -WebSession $candidateSession
|
||||
$legacyCandidateResultsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/results" -WebSession $legacyCandidateSession
|
||||
$nativeCandidateResultsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results" -WebSession $nativeCandidateSession
|
||||
if ($nativeCandidateResultsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||
throw "Candidate route 'results' did not use the native ASP.NET Core endpoint"
|
||||
@@ -526,7 +534,7 @@ try {
|
||||
if (@($nativeCandidateResults.summaries | Where-Object { $_.verificationQr -match '^data:image/png;base64,' }).Count -eq 0) {
|
||||
throw 'Native candidate results did not include a local PNG verification QR code'
|
||||
}
|
||||
$legacyCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
|
||||
$legacyCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $legacyCandidateSession
|
||||
$nativeCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
|
||||
if ($nativeCandidateAdmissionsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
|
||||
throw "Candidate route 'admissions' did not use the native ASP.NET Core endpoint"
|
||||
@@ -575,7 +583,7 @@ try {
|
||||
if ($lockedPreference.StatusCode -ne 409) {
|
||||
throw 'Native admission preference API accepted a submission after automatic locking'
|
||||
}
|
||||
$legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
|
||||
$legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $legacyCandidateSession
|
||||
$nativeAdmissionsAfterWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
|
||||
Assert-CandidateAdmissionsEquivalent -Expected $legacyAdmissionsAfterWrite.Content -Actual $nativeAdmissionsAfterWrite.Content
|
||||
$appealResultId = $null
|
||||
@@ -610,7 +618,7 @@ try {
|
||||
throw "Could not prepare the candidate admit-card smoke data`n$admitError"
|
||||
}
|
||||
$admitProcess.Dispose()
|
||||
$legacyAdmitResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $candidateSession
|
||||
$legacyAdmitResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $legacyCandidateSession
|
||||
$nativeAdmitResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $nativeCandidateSession
|
||||
if ($nativeAdmitResponse.StatusCode -ne 200 -or
|
||||
$nativeAdmitResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or
|
||||
|
||||
@@ -30,7 +30,7 @@ internal sealed partial class AdminAdmissionService
|
||||
["school"] = SchoolJson(school),
|
||||
["exams"] = new JsonArray(data.Operational.Exams
|
||||
.Where(item => item.Data["archivedAt"] is null)
|
||||
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
|
||||
.Select(item => item.Data.DeepClone()).ToArray()),
|
||||
["template"] = template,
|
||||
["updatedAt"] = JsonValue.Create(record?.UpdatedAt)
|
||||
});
|
||||
@@ -265,7 +265,7 @@ internal sealed partial class AdminAdmissionService
|
||||
var completed = data.Operational.Exams.Where(exam =>
|
||||
Records(data, "setting", exam.Id).Any(setting => setting.Status == "completed") &&
|
||||
placements.Any(item => Text(item["examId"]) == exam.Id && Text(item["status"]) == "final"))
|
||||
.Select(item => (JsonNode)PublicExam(item)).ToArray();
|
||||
.Select(item => item.Data.DeepClone()).ToArray();
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
|
||||
@@ -32,7 +32,8 @@ internal sealed partial class AdminAdmissionService
|
||||
}).ToArray();
|
||||
var home = await publicQueries.GetHomeAsync(cancellationToken);
|
||||
var notifications = (home["notices"] as JsonArray ?? []).OfType<JsonObject>()
|
||||
.Where(item => Text(item["schoolId"]).Length == 0 || Text(item["schoolId"]) == school.Id)
|
||||
.Where(item => Text(item["sourceType"]).Length > 0 &&
|
||||
(Text(item["schoolId"]).Length == 0 || Text(item["schoolId"]) == school.Id))
|
||||
.Take(6)
|
||||
.Select(item =>
|
||||
{
|
||||
@@ -43,7 +44,7 @@ internal sealed partial class AdminAdmissionService
|
||||
var exams = data.Operational.Exams.Where(item =>
|
||||
item.Data["archivedAt"] is null &&
|
||||
Records(data, "setting", item.Id).Any(setting => Boolean(setting.Payload["enabled"])))
|
||||
.Select(item => (JsonNode)PublicExam(item)).ToArray();
|
||||
.Select(item => item.Data.DeepClone()).ToArray();
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
@@ -76,7 +77,7 @@ internal sealed partial class AdminAdmissionService
|
||||
["school"] = SchoolJson(school),
|
||||
["plans"] = new JsonArray(plans),
|
||||
["exams"] = new JsonArray(data.Operational.Exams.Where(item => item.Data["archivedAt"] is null)
|
||||
.Select(item => (JsonNode)PublicExam(item)).ToArray()),
|
||||
.Select(item => item.Data.DeepClone()).ToArray()),
|
||||
["sourceSchools"] = new JsonArray(data.Directory.Schools
|
||||
.Where(item => item.Active && item.IsSourceSchool)
|
||||
.Select(item => (JsonNode)SchoolJson(item)).ToArray())
|
||||
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
namespace Eis.Web.Configuration;
|
||||
namespace Eis.Infrastructure.Configuration;
|
||||
|
||||
public static class EnvironmentFile
|
||||
{
|
||||
@@ -35,7 +35,9 @@ public static class EnvironmentFile
|
||||
}
|
||||
|
||||
var value = line[(separator + 1)..].Trim();
|
||||
if (value.Length >= 2 && ((value[0] == '"' && value[^1] == '"') || (value[0] == '\'' && value[^1] == '\'')))
|
||||
if (value.Length >= 2 &&
|
||||
((value[0] == '"' && value[^1] == '"') ||
|
||||
(value[0] == '\'' && value[^1] == '\'')))
|
||||
{
|
||||
value = value[1..^1];
|
||||
}
|
||||
@@ -30,38 +30,41 @@ internal sealed partial class DatabaseInitializer(
|
||||
await ExecuteAsync(connection, match.Groups["sql"].Value, cancellationToken);
|
||||
}
|
||||
}
|
||||
await SeedBaseStateAsync(connection, cancellationToken);
|
||||
await SeedBaseStateAsync(connection, null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SeedBaseStateAsync(DbConnection connection, CancellationToken cancellationToken)
|
||||
internal async Task SeedBaseStateAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction? transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);
|
||||
if (!await ExistsAsync(connection, "schema_metadata", cancellationToken))
|
||||
if (!await ExistsAsync(connection, transaction, "schema_metadata", cancellationToken))
|
||||
{
|
||||
await ExecuteAsync(connection,
|
||||
"""
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, @version, @version, 0, @createdAt)
|
||||
""",
|
||||
[("@version", SchemaVersion), ("@createdAt", now)], cancellationToken);
|
||||
[("@version", SchemaVersion), ("@createdAt", now)], transaction, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var version = await ScalarLongAsync(connection,
|
||||
"SELECT schema_version FROM schema_metadata WHERE id = 1", cancellationToken);
|
||||
"SELECT schema_version FROM schema_metadata WHERE id = 1", transaction, cancellationToken);
|
||||
if (version < SchemaVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"数据库结构版本为 {version},低于 ASP.NET Core 要求的 {SchemaVersion};请先备份并执行旧版本升级流程");
|
||||
}
|
||||
}
|
||||
if (!await ExistsAsync(connection, "organization", cancellationToken))
|
||||
if (!await ExistsAsync(connection, transaction, "organization", cancellationToken))
|
||||
{
|
||||
await ExecuteAsync(connection,
|
||||
"INSERT INTO organization (id, name, code, phone, address) VALUES (1, @name, @code, '', '')",
|
||||
[("@name", "考试服务平台"), ("@code", "EXAM-SERVICE")], cancellationToken);
|
||||
[("@name", "考试服务平台"), ("@code", "EXAM-SERVICE")], transaction, cancellationToken);
|
||||
}
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken) == 0)
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", transaction, cancellationToken) == 0)
|
||||
{
|
||||
var username = Environment.GetEnvironmentVariable("INITIAL_ADMIN_USERNAME")?.Trim();
|
||||
var password = Environment.GetEnvironmentVariable("INITIAL_ADMIN_PASSWORD") ?? "Admin123!";
|
||||
@@ -80,25 +83,26 @@ internal sealed partial class DatabaseInitializer(
|
||||
("@passwordHash", passwords.Hash(password)),
|
||||
("@displayName", string.IsNullOrWhiteSpace(displayName) ? "系统管理员" : displayName),
|
||||
("@createdAt", now)
|
||||
], cancellationToken);
|
||||
], transaction, cancellationToken);
|
||||
}
|
||||
await SeedNumberRuleAsync(connection, now, cancellationToken);
|
||||
await SeedAdmissionNumberRulesAsync(connection, now, cancellationToken);
|
||||
await SeedWorkflowsAsync(connection, now, cancellationToken);
|
||||
await SeedNumberRuleAsync(connection, transaction, now, cancellationToken);
|
||||
await SeedAdmissionNumberRulesAsync(connection, transaction, now, cancellationToken);
|
||||
await SeedWorkflowsAsync(connection, transaction, now, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task SeedNumberRuleAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction? transaction,
|
||||
string now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM number_rules", cancellationToken) > 0) return;
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM number_rules", transaction, cancellationToken) > 0) return;
|
||||
await ExecuteAsync(connection,
|
||||
"""
|
||||
INSERT INTO number_rules (id, name, separator, active, created_by, updated_at)
|
||||
VALUES ('rule_default', @name, '-', 1, 'usr_admin', @updatedAt)
|
||||
""",
|
||||
[("@name", "年度学校性别流水号"), ("@updatedAt", now)], cancellationToken);
|
||||
[("@name", "年度学校性别流水号"), ("@updatedAt", now)], transaction, cancellationToken);
|
||||
var segments = new[]
|
||||
{
|
||||
("segment_year", 1, "year", "", 4),
|
||||
@@ -116,16 +120,17 @@ internal sealed partial class DatabaseInitializer(
|
||||
[
|
||||
("@id", segment.Item1), ("@position", segment.Item2), ("@type", segment.Item3),
|
||||
("@value", segment.Item4), ("@width", segment.Item5)
|
||||
], cancellationToken);
|
||||
], transaction, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SeedAdmissionNumberRulesAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction? transaction,
|
||||
string now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM admission_number_rules", cancellationToken) > 0) return;
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM admission_number_rules", transaction, cancellationToken) > 0) return;
|
||||
var rules = new[]
|
||||
{
|
||||
new AdmissionRule("admit_rule_district_room_seat", "district_room_seat", "县区编号 + 考场号 + 座位号",
|
||||
@@ -159,16 +164,17 @@ internal sealed partial class DatabaseInitializer(
|
||||
("@id", rule.Id), ("@code", rule.Code), ("@name", rule.Name),
|
||||
("@description", rule.Description), ("@segments", segments),
|
||||
("@example", rule.Example), ("@createdAt", now)
|
||||
], cancellationToken);
|
||||
], transaction, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SeedWorkflowsAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction? transaction,
|
||||
string now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM workflow_definitions", cancellationToken) > 0) return;
|
||||
if (await ScalarLongAsync(connection, "SELECT COUNT(*) FROM workflow_definitions", transaction, cancellationToken) > 0) return;
|
||||
var workflows = new[]
|
||||
{
|
||||
new Workflow("workflow_profile", "profile_change", "考生信息修改审批",
|
||||
@@ -196,7 +202,7 @@ internal sealed partial class DatabaseInitializer(
|
||||
[
|
||||
("@id", workflow.Id), ("@type", workflow.Type),
|
||||
("@name", workflow.Name), ("@updatedAt", now)
|
||||
], cancellationToken);
|
||||
], transaction, cancellationToken);
|
||||
for (var index = 0; index < workflow.Steps.Length; index++)
|
||||
{
|
||||
var step = workflow.Steps[index];
|
||||
@@ -208,24 +214,27 @@ internal sealed partial class DatabaseInitializer(
|
||||
[
|
||||
("@id", step.Id), ("@workflowId", workflow.Id), ("@position", index + 1),
|
||||
("@name", step.Name), ("@level", step.Level)
|
||||
], cancellationToken);
|
||||
], transaction, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ExistsAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction? transaction,
|
||||
string table,
|
||||
CancellationToken cancellationToken) =>
|
||||
await ScalarLongAsync(connection, $"SELECT COUNT(*) FROM {table}", cancellationToken) > 0;
|
||||
await ScalarLongAsync(connection, $"SELECT COUNT(*) FROM {table}", transaction, cancellationToken) > 0;
|
||||
|
||||
private static async Task<long> ScalarLongAsync(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
DbTransaction? transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Transaction = transaction;
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -233,16 +242,18 @@ internal sealed partial class DatabaseInitializer(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
CancellationToken cancellationToken) =>
|
||||
ExecuteAsync(connection, sql, [], cancellationToken);
|
||||
ExecuteAsync(connection, sql, [], null, cancellationToken);
|
||||
|
||||
private static async Task ExecuteAsync(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
IReadOnlyList<(string Name, object Value)> values,
|
||||
DbTransaction? transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Transaction = transaction;
|
||||
foreach (var value in values)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Eis.Infrastructure.Authentication;
|
||||
|
||||
namespace Eis.Infrastructure.Data;
|
||||
|
||||
public enum DatabaseMaintenanceMode
|
||||
{
|
||||
Init,
|
||||
Reset,
|
||||
Seed
|
||||
}
|
||||
|
||||
public sealed record DatabaseMaintenanceResult(
|
||||
DatabaseMaintenanceMode Mode,
|
||||
string Target,
|
||||
bool DryRun,
|
||||
string? BackupPath,
|
||||
DemoSeedSummary? Summary);
|
||||
|
||||
public sealed record DemoSeedSummary(
|
||||
int Schools,
|
||||
int Users,
|
||||
int Candidates,
|
||||
int Registrations,
|
||||
int Results,
|
||||
int AdmissionRecords,
|
||||
int FirstRoundPreferences,
|
||||
int Operations);
|
||||
|
||||
public sealed partial class DatabaseMaintenanceService(DatabaseOptions options)
|
||||
{
|
||||
private const int SchemaVersion = 20;
|
||||
|
||||
private static readonly string[] RelationalTables =
|
||||
[
|
||||
"schema_metadata",
|
||||
"organization",
|
||||
"schools",
|
||||
"school_classes",
|
||||
"school_student_partitions",
|
||||
"users",
|
||||
"candidate_profiles",
|
||||
"notices",
|
||||
"exams",
|
||||
"exam_data_partitions",
|
||||
"exam_subjects",
|
||||
"registrations",
|
||||
"registration_subjects",
|
||||
"admission_number_rules",
|
||||
"exam_arrangement_plans",
|
||||
"admit_cards",
|
||||
"admit_card_subjects",
|
||||
"results",
|
||||
"test_centers",
|
||||
"test_rooms",
|
||||
"center_change_requests",
|
||||
"center_change_rooms",
|
||||
"number_rules",
|
||||
"number_rule_segments",
|
||||
"candidate_account_batches",
|
||||
"candidate_account_batch_items",
|
||||
"workflow_definitions",
|
||||
"workflow_steps",
|
||||
"workflow_instances",
|
||||
"workflow_actions",
|
||||
"admission_records",
|
||||
"audit_logs"
|
||||
];
|
||||
|
||||
private static readonly string[] BusinessTables =
|
||||
[
|
||||
"schools",
|
||||
"school_classes",
|
||||
"candidate_profiles",
|
||||
"notices",
|
||||
"exams",
|
||||
"exam_subjects",
|
||||
"registrations",
|
||||
"registration_subjects",
|
||||
"exam_arrangement_plans",
|
||||
"admit_cards",
|
||||
"admit_card_subjects",
|
||||
"results",
|
||||
"test_centers",
|
||||
"test_rooms",
|
||||
"center_change_requests",
|
||||
"center_change_rooms",
|
||||
"candidate_account_batches",
|
||||
"candidate_account_batch_items",
|
||||
"workflow_instances",
|
||||
"workflow_actions",
|
||||
"admission_records",
|
||||
"audit_logs"
|
||||
];
|
||||
|
||||
public async Task<DatabaseMaintenanceResult> ExecuteAsync(
|
||||
DatabaseMaintenanceMode mode,
|
||||
string? confirmedTarget,
|
||||
bool force,
|
||||
bool dryRun,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateTarget();
|
||||
if (mode is (DatabaseMaintenanceMode.Reset or DatabaseMaintenanceMode.Seed) && !dryRun)
|
||||
{
|
||||
RequireConfirmedTarget(confirmedTarget);
|
||||
}
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
if (options.Client == "mysql")
|
||||
{
|
||||
await ValidateMySqlDryRunAsync(force, cancellationToken);
|
||||
}
|
||||
return new DatabaseMaintenanceResult(
|
||||
mode,
|
||||
options.TargetDescription,
|
||||
true,
|
||||
null,
|
||||
mode == DatabaseMaintenanceMode.Seed ? (await LoadDemoSeedAsync(cancellationToken)).Summary : null);
|
||||
}
|
||||
|
||||
if (mode == DatabaseMaintenanceMode.Init)
|
||||
{
|
||||
await CreateInitializer(options).InitializeAsync(cancellationToken);
|
||||
return new DatabaseMaintenanceResult(mode, options.TargetDescription, false, null, null);
|
||||
}
|
||||
|
||||
return options.Client == "sqlite"
|
||||
? await ReplaceSqliteAsync(mode, cancellationToken)
|
||||
: await ReplaceMySqlAsync(mode, force, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<DatabaseMaintenanceResult> ReplaceSqliteAsync(
|
||||
DatabaseMaintenanceMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var targetPath = options.SqlitePath
|
||||
?? throw new InvalidOperationException("SQLite 数据库路径不存在");
|
||||
var directory = Path.GetDirectoryName(targetPath)
|
||||
?? throw new InvalidOperationException("SQLite 数据库目录无效");
|
||||
Directory.CreateDirectory(directory);
|
||||
var stagingPath = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(targetPath)}.staging-{Guid.NewGuid():N}");
|
||||
var stagingOptions = DatabaseOptions.CreateSqlite(stagingPath);
|
||||
DemoSeedSummary? summary = null;
|
||||
try
|
||||
{
|
||||
await CreateInitializer(stagingOptions).InitializeAsync(cancellationToken);
|
||||
if (mode == DatabaseMaintenanceMode.Seed)
|
||||
{
|
||||
summary = await ImportDemoSeedAsync(stagingOptions, cancellationToken);
|
||||
}
|
||||
|
||||
await ValidateSqliteOutputAsync(stagingOptions, mode, cancellationToken);
|
||||
string? backupPath = null;
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
await CheckpointSqliteAsync(targetPath, cancellationToken);
|
||||
backupPath = $"{targetPath}.backup-{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfffZ}";
|
||||
File.Copy(targetPath, backupPath, overwrite: false);
|
||||
}
|
||||
|
||||
DeleteSqliteSidecars(targetPath);
|
||||
File.Move(stagingPath, targetPath, overwrite: true);
|
||||
return new DatabaseMaintenanceResult(
|
||||
mode,
|
||||
targetPath,
|
||||
false,
|
||||
backupPath,
|
||||
summary);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteSqliteSidecars(stagingPath);
|
||||
if (File.Exists(stagingPath))
|
||||
{
|
||||
File.Delete(stagingPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DatabaseMaintenanceResult> ReplaceMySqlAsync(
|
||||
DatabaseMaintenanceMode mode,
|
||||
bool force,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var initializer = CreateInitializer(options);
|
||||
await initializer.InitializeAsync(cancellationToken);
|
||||
var factory = new RelationalConnectionFactory(options);
|
||||
await using var connection = await factory.OpenAsync(cancellationToken);
|
||||
var nonEmpty = await FindNonEmptyBusinessTablesAsync(connection, cancellationToken);
|
||||
var recognizedDemo = nonEmpty.Count > 0 &&
|
||||
await IsRecognizedDemoDatabaseAsync(connection, cancellationToken);
|
||||
if (nonEmpty.Count > 0 && !force && !recognizedDemo)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MySQL 数据库已有业务数据({string.Join(", ", nonEmpty.Take(8))}" +
|
||||
$"{(nonEmpty.Count > 8 ? ", ..." : "")})。仅确认是可覆盖测试库时才可追加 --force");
|
||||
}
|
||||
|
||||
var partitionTables = await ReadPartitionTablesAsync(connection, cancellationToken);
|
||||
await ExecuteNonQueryAsync(connection, null, "SET FOREIGN_KEY_CHECKS = 0", [], cancellationToken);
|
||||
DemoSeedSummary? summary = null;
|
||||
try
|
||||
{
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ClearApplicationDataAsync(connection, transaction, cancellationToken);
|
||||
if (mode == DatabaseMaintenanceMode.Seed)
|
||||
{
|
||||
summary = await ImportDemoSeedAsync(connection, transaction, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await initializer.SeedBaseStateAsync(connection, transaction, cancellationToken);
|
||||
}
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ExecuteNonQueryAsync(connection, null, "SET FOREIGN_KEY_CHECKS = 1", [], cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var table in partitionTables)
|
||||
{
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
null,
|
||||
$"DROP TABLE IF EXISTS `{table}`",
|
||||
[],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return new DatabaseMaintenanceResult(
|
||||
mode,
|
||||
options.TargetDescription,
|
||||
false,
|
||||
null,
|
||||
summary);
|
||||
}
|
||||
|
||||
private async Task ValidateMySqlDryRunAsync(
|
||||
bool force,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var factory = new RelationalConnectionFactory(options);
|
||||
await using var connection = await factory.OpenAsync(cancellationToken);
|
||||
var existingTables = new HashSet<string>(StringComparer.Ordinal);
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText =
|
||||
"""
|
||||
SELECT TABLE_NAME
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
||||
""";
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
existingTables.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
|
||||
var applicationTables = RelationalTables.Where(existingTables.Contains).ToArray();
|
||||
if (applicationTables.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (applicationTables.Length != RelationalTables.Length)
|
||||
{
|
||||
var missing = RelationalTables.Where(item => !existingTables.Contains(item));
|
||||
throw new InvalidOperationException(
|
||||
$"MySQL 数据库结构不完整,缺少:{string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
var version = await ScalarLongAsync(
|
||||
connection,
|
||||
"SELECT schema_version FROM schema_metadata WHERE id = 1",
|
||||
cancellationToken);
|
||||
if (version != SchemaVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MySQL 数据库结构版本为 v{version},当前工具要求 v{SchemaVersion}");
|
||||
}
|
||||
|
||||
var nonEmpty = await FindNonEmptyBusinessTablesAsync(connection, cancellationToken);
|
||||
var recognizedDemo = nonEmpty.Count > 0 &&
|
||||
await IsRecognizedDemoDatabaseAsync(connection, cancellationToken);
|
||||
if (nonEmpty.Count > 0 && !force && !recognizedDemo)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MySQL 数据库已有业务数据({string.Join(", ", nonEmpty.Take(8))}" +
|
||||
$"{(nonEmpty.Count > 8 ? ", ..." : "")})。预检拒绝覆盖;确认是测试库时追加 --force");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DemoSeedSummary> ImportDemoSeedAsync(
|
||||
DatabaseOptions targetOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var factory = new RelationalConnectionFactory(targetOptions);
|
||||
await using var connection = await factory.OpenAsync(cancellationToken);
|
||||
await ExecuteNonQueryAsync(connection, null, "PRAGMA foreign_keys = OFF", [], cancellationToken);
|
||||
try
|
||||
{
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ClearApplicationDataAsync(connection, transaction, cancellationToken);
|
||||
var summary = await ImportDemoSeedAsync(connection, transaction, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return summary;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ExecuteNonQueryAsync(connection, null, "PRAGMA foreign_keys = ON", [], cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<DemoSeedSummary> ImportDemoSeedAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = await LoadDemoSeedAsync(cancellationToken);
|
||||
if (payload.SchemaVersion != SchemaVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"演示数据结构版本为 v{payload.SchemaVersion},当前程序要求 v{SchemaVersion}");
|
||||
}
|
||||
|
||||
foreach (var operation in payload.Operations)
|
||||
{
|
||||
var values = operation.Parameters
|
||||
.Select(JsonValue)
|
||||
.ToArray();
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
RewriteParameters(operation.Sql, values.Length),
|
||||
values,
|
||||
cancellationToken);
|
||||
}
|
||||
return payload.Summary;
|
||||
}
|
||||
|
||||
private static async Task ClearApplicationDataAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteNonQueryAsync(connection, transaction, "DELETE FROM exams", [], cancellationToken);
|
||||
foreach (var table in RelationalTables.Reverse()
|
||||
.Where(item => item is not ("schema_metadata" or "exams")))
|
||||
{
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
$"DELETE FROM `{table}`",
|
||||
[],
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ValidateSqliteOutputAsync(
|
||||
DatabaseOptions targetOptions,
|
||||
DatabaseMaintenanceMode mode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var factory = new RelationalConnectionFactory(targetOptions);
|
||||
await using var connection = await factory.OpenAsync(cancellationToken);
|
||||
var version = await ScalarLongAsync(
|
||||
connection,
|
||||
"SELECT schema_version FROM schema_metadata WHERE id = 1",
|
||||
cancellationToken);
|
||||
var users = await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken);
|
||||
if (version != SchemaVersion || users == 0)
|
||||
{
|
||||
throw new InvalidOperationException("生成的 SQLite 数据库未通过结构和账号校验");
|
||||
}
|
||||
|
||||
if (mode == DatabaseMaintenanceMode.Seed)
|
||||
{
|
||||
var candidates = await ScalarLongAsync(
|
||||
connection,
|
||||
"SELECT COUNT(*) FROM candidate_profiles",
|
||||
cancellationToken);
|
||||
var results = await ScalarLongAsync(connection, "SELECT COUNT(*) FROM results", cancellationToken);
|
||||
if (candidates != 1200 || results != 10800)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"演示数据校验失败:考生 {candidates},成绩 {results}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CheckpointSqliteAsync(
|
||||
string targetPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var factory = new RelationalConnectionFactory(DatabaseOptions.CreateSqlite(targetPath));
|
||||
await using var connection = await factory.OpenAsync(cancellationToken);
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
null,
|
||||
"PRAGMA wal_checkpoint(TRUNCATE)",
|
||||
[],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static void DeleteSqliteSidecars(string path)
|
||||
{
|
||||
foreach (var suffix in new[] { "-wal", "-shm" })
|
||||
{
|
||||
var sidecar = path + suffix;
|
||||
if (File.Exists(sidecar))
|
||||
{
|
||||
File.Delete(sidecar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<string>> FindNonEmptyBusinessTablesAsync(
|
||||
DbConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var table in BusinessTables)
|
||||
{
|
||||
var count = await ScalarLongAsync(
|
||||
connection,
|
||||
$"SELECT COUNT(*) FROM `{table}`",
|
||||
cancellationToken);
|
||||
if (count > 0)
|
||||
{
|
||||
result.Add($"{table}={count}");
|
||||
}
|
||||
}
|
||||
|
||||
var users = await ScalarLongAsync(connection, "SELECT COUNT(*) FROM users", cancellationToken);
|
||||
if (users > 1)
|
||||
{
|
||||
result.Add($"users={users}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<bool> IsRecognizedDemoDatabaseAsync(
|
||||
DbConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await ScalarLongAsync(
|
||||
connection,
|
||||
"SELECT COUNT(*) FROM users WHERE id LIKE 'usr_bulk_%'",
|
||||
cancellationToken);
|
||||
var schools = await ScalarLongAsync(
|
||||
connection,
|
||||
"""
|
||||
SELECT COUNT(*) FROM schools
|
||||
WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')
|
||||
""",
|
||||
cancellationToken);
|
||||
return users >= 1100 && schools == 5;
|
||||
}
|
||||
|
||||
private static async Task<string[]> ReadPartitionTablesAsync(
|
||||
DbConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tables = new List<string>();
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText =
|
||||
"SELECT candidates_table, admissions_table, results_table, centers_table FROM exam_data_partitions";
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
for (var index = 0; index < 4; index++)
|
||||
{
|
||||
tables.Add(reader.GetString(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "SELECT students_table FROM school_student_partitions";
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.Any(item => !PartitionTablePattern().IsMatch(item)))
|
||||
{
|
||||
throw new InvalidOperationException("分表登记中存在非法表名,拒绝替换数据库");
|
||||
}
|
||||
return tables.Distinct(StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private void ValidateTarget()
|
||||
{
|
||||
if (options.Client == "sqlite" && string.IsNullOrWhiteSpace(options.SqlitePath))
|
||||
{
|
||||
throw new InvalidOperationException("未指定 SQLite 数据库路径");
|
||||
}
|
||||
if (options.Client == "mysql")
|
||||
{
|
||||
var name = options.MySqlDatabaseName?.Trim() ?? "";
|
||||
var normalized = name.ToLowerInvariant();
|
||||
if (normalized.Length == 0 ||
|
||||
normalized is "mysql" or "information_schema" or "performance_schema" or "sys")
|
||||
{
|
||||
throw new InvalidOperationException($"拒绝操作 MySQL 系统数据库:{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RequireConfirmedTarget(string? confirmedTarget)
|
||||
{
|
||||
var expected = options.Client == "sqlite"
|
||||
? options.SqlitePath
|
||||
: options.MySqlDatabaseName;
|
||||
var comparison = options.Client == "sqlite" && OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
if (string.IsNullOrWhiteSpace(confirmedTarget) ||
|
||||
!string.Equals(expected, confirmedTarget, comparison))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"这是破坏性操作。请使用 --confirm-target \"{expected}\" 明确确认目标");
|
||||
}
|
||||
}
|
||||
|
||||
private static DatabaseInitializer CreateInitializer(DatabaseOptions targetOptions) =>
|
||||
new(
|
||||
new RelationalConnectionFactory(targetOptions),
|
||||
targetOptions,
|
||||
new PasswordCompatibilityService());
|
||||
|
||||
private static async Task<DemoSeedPayload> LoadDemoSeedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var assembly = typeof(DatabaseMaintenanceService).Assembly;
|
||||
var name = assembly.GetManifestResourceNames()
|
||||
.Single(item => item.EndsWith("demo-seed-operations.json.gz", StringComparison.Ordinal));
|
||||
await using var source = assembly.GetManifestResourceStream(name)
|
||||
?? throw new InvalidOperationException("内置演示数据包不存在");
|
||||
await using var gzip = new GZipStream(source, CompressionMode.Decompress);
|
||||
return await JsonSerializer.DeserializeAsync<DemoSeedPayload>(
|
||||
gzip,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
|
||||
cancellationToken)
|
||||
?? throw new InvalidOperationException("内置演示数据包格式无效");
|
||||
}
|
||||
|
||||
private static string RewriteParameters(string sql, int count)
|
||||
{
|
||||
var index = 0;
|
||||
var rewritten = ParameterPattern().Replace(sql, _ => $"@p{index++}");
|
||||
if (index != count)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"演示数据 SQL 参数数量不匹配:预期 {index},实际 {count}");
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
private static object JsonValue(JsonElement value) =>
|
||||
value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => DBNull.Value,
|
||||
JsonValueKind.String => value.GetString() ?? "",
|
||||
JsonValueKind.Number when value.TryGetInt64(out var integer) => integer,
|
||||
JsonValueKind.Number => value.GetDouble(),
|
||||
JsonValueKind.True => 1,
|
||||
JsonValueKind.False => 0,
|
||||
_ => value.GetRawText()
|
||||
};
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction? transaction,
|
||||
string sql,
|
||||
IReadOnlyList<object> values,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Transaction = transaction;
|
||||
for (var index = 0; index < values.Count; index++)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = $"@p{index}";
|
||||
parameter.Value = values[index];
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<long> ScalarLongAsync(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\?", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ParameterPattern();
|
||||
|
||||
[GeneratedRegex(@"^[a-z][a-z0-9_]{0,63}$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PartitionTablePattern();
|
||||
|
||||
private sealed record DemoSeedPayload(
|
||||
int Version,
|
||||
int SchemaVersion,
|
||||
string GeneratedAt,
|
||||
DemoSeedSummary Summary,
|
||||
DemoSeedOperation[] Operations);
|
||||
|
||||
private sealed record DemoSeedOperation(
|
||||
string Sql,
|
||||
[property: JsonPropertyName("params")] JsonElement[] Parameters);
|
||||
}
|
||||
@@ -17,9 +17,27 @@ public sealed class DatabaseOptions
|
||||
|
||||
public string? MySqlConnectionString { get; }
|
||||
|
||||
public string TargetDescription => Client == "sqlite"
|
||||
? SqlitePath ?? "SQLite"
|
||||
: $"MySQL database {MySqlDatabaseName}";
|
||||
|
||||
public string? MySqlDatabaseName =>
|
||||
Client == "mysql" && MySqlConnectionString is { Length: > 0 } connectionString
|
||||
? new MySqlConnectionStringBuilder(connectionString).Database
|
||||
: null;
|
||||
|
||||
internal static DatabaseOptions CreateSqliteForTests(string path) =>
|
||||
new("sqlite", Path.GetFullPath(path), null);
|
||||
|
||||
public static DatabaseOptions CreateSqlite(string path) =>
|
||||
new("sqlite", Path.GetFullPath(path), null);
|
||||
|
||||
public static DatabaseOptions CreateMySqlFromEnvironment() =>
|
||||
new("mysql", null, BuildMySqlConnectionString());
|
||||
|
||||
public static DatabaseOptions CreateMySql(string connectionString) =>
|
||||
new("mysql", null, new MySqlConnectionStringBuilder(connectionString).ConnectionString);
|
||||
|
||||
public static DatabaseOptions FromEnvironment(string applicationRoot, bool production)
|
||||
{
|
||||
var client = (Environment.GetEnvironmentVariable("DATABASE_CLIENT")
|
||||
|
||||
Binary file not shown.
@@ -19,5 +19,6 @@
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
|
||||
<EmbeddedResource Include="..\..\src\database\schema.mjs" Link="Data\database-schema.mjs" />
|
||||
<EmbeddedResource Include="Data\demo-seed-operations.json.gz" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Eis.Tools</RootNamespace>
|
||||
<AssemblyName>Eis.Tools</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Eis.Infrastructure\Eis.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,263 @@
|
||||
using Eis.Infrastructure.Configuration;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
namespace Eis.Tools;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = DatabaseToolCommand.Parse(args);
|
||||
if (command.ShowHelp)
|
||||
{
|
||||
Console.WriteLine(DatabaseToolCommand.Usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var applicationRoot = command.ApplicationRoot ?? FindApplicationRoot();
|
||||
EnvironmentFile.Load(applicationRoot);
|
||||
var options = BuildDatabaseOptions(command, applicationRoot);
|
||||
if (command.Force && options.Client != "mysql")
|
||||
{
|
||||
throw new DatabaseToolUsageException("--force 只能用于 MySQL 测试数据库。");
|
||||
}
|
||||
var confirmedTarget = NormalizeConfirmation(command.ConfirmedTarget, options, applicationRoot);
|
||||
|
||||
Console.WriteLine($"命令:database {command.Mode.ToString().ToLowerInvariant()}");
|
||||
Console.WriteLine($"目标:{options.TargetDescription}");
|
||||
if (command.DryRun)
|
||||
{
|
||||
Console.WriteLine("模式:只预检,不写入数据库");
|
||||
}
|
||||
|
||||
var service = new DatabaseMaintenanceService(options);
|
||||
var result = await service.ExecuteAsync(
|
||||
command.Mode,
|
||||
confirmedTarget,
|
||||
command.Force,
|
||||
command.DryRun,
|
||||
CancellationToken.None);
|
||||
PrintResult(result);
|
||||
return 0;
|
||||
}
|
||||
catch (DatabaseToolUsageException exception)
|
||||
{
|
||||
Console.Error.WriteLine(exception.Message);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(DatabaseToolCommand.Usage);
|
||||
return 2;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"数据库工具执行失败:{exception.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static DatabaseOptions BuildDatabaseOptions(
|
||||
DatabaseToolCommand command,
|
||||
string applicationRoot)
|
||||
{
|
||||
var client = command.Client ??
|
||||
Environment.GetEnvironmentVariable("DATABASE_CLIENT")?.Trim().ToLowerInvariant() ??
|
||||
"sqlite";
|
||||
return client switch
|
||||
{
|
||||
"sqlite" => DatabaseOptions.CreateSqlite(ResolveSqlitePath(command.SqlitePath, applicationRoot)),
|
||||
"mysql" when command.SqlitePath is not null =>
|
||||
throw new DatabaseToolUsageException("--path 只能和 --sqlite 一起使用"),
|
||||
"mysql" => DatabaseOptions.CreateMySqlFromEnvironment(),
|
||||
_ => throw new DatabaseToolUsageException($"不支持的数据库类型:{client}")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveSqlitePath(string? configuredPath, string applicationRoot)
|
||||
{
|
||||
var path = configuredPath ??
|
||||
Environment.GetEnvironmentVariable("SQLITE_PATH") ??
|
||||
Path.Combine("data", "exam.sqlite");
|
||||
return Path.GetFullPath(Path.IsPathRooted(path)
|
||||
? path
|
||||
: Path.Combine(applicationRoot, path));
|
||||
}
|
||||
|
||||
private static string? NormalizeConfirmation(
|
||||
string? confirmation,
|
||||
DatabaseOptions options,
|
||||
string applicationRoot)
|
||||
{
|
||||
if (confirmation is null || options.Client != "sqlite")
|
||||
{
|
||||
return confirmation;
|
||||
}
|
||||
return Path.GetFullPath(Path.IsPathRooted(confirmation)
|
||||
? confirmation
|
||||
: Path.Combine(applicationRoot, confirmation));
|
||||
}
|
||||
|
||||
private static string FindApplicationRoot()
|
||||
{
|
||||
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.GetFullPath(start));
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "Eis.slnx")) ||
|
||||
File.Exists(Path.Combine(directory.FullName, ".env")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
return Path.GetFullPath(Directory.GetCurrentDirectory());
|
||||
}
|
||||
|
||||
private static void PrintResult(DatabaseMaintenanceResult result)
|
||||
{
|
||||
if (result.DryRun)
|
||||
{
|
||||
Console.WriteLine("预检完成:未修改数据库。");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(result.Mode switch
|
||||
{
|
||||
DatabaseMaintenanceMode.Init => "数据库结构和基础配置初始化完成。",
|
||||
DatabaseMaintenanceMode.Reset => "数据库已重建为空业务系统。",
|
||||
_ => "演示数据导入完成。"
|
||||
});
|
||||
}
|
||||
|
||||
if (result.BackupPath is not null)
|
||||
{
|
||||
Console.WriteLine($"原数据库备份:{result.BackupPath}");
|
||||
}
|
||||
if (result.Summary is { } summary)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"演示数据:学校 {summary.Schools},用户 {summary.Users},考生 {summary.Candidates}," +
|
||||
$"报名 {summary.Registrations},成绩 {summary.Results},招生记录 {summary.AdmissionRecords}。");
|
||||
Console.WriteLine("预置测试账号密码:12345678");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record DatabaseToolCommand(
|
||||
DatabaseMaintenanceMode Mode,
|
||||
string? Client,
|
||||
string? SqlitePath,
|
||||
string? ConfirmedTarget,
|
||||
string? ApplicationRoot,
|
||||
bool Force,
|
||||
bool DryRun,
|
||||
bool ShowHelp)
|
||||
{
|
||||
public const string Usage =
|
||||
"""
|
||||
用法:
|
||||
dotnet Eis.Tools.dll database init [--sqlite|--mysql] [--path <sqlite文件>] [--dry-run]
|
||||
dotnet Eis.Tools.dll database reset [--sqlite|--mysql] [--path <sqlite文件>] [--confirm-target <目标>] [--force] [--dry-run]
|
||||
dotnet Eis.Tools.dll database seed [--sqlite|--mysql] [--path <sqlite文件>] [--confirm-target <目标>] [--force] [--dry-run]
|
||||
|
||||
说明:
|
||||
init 非破坏性建表和基础配置初始化。
|
||||
reset 重建为空业务库;SQLite 自动备份原文件。
|
||||
seed 导入内置的 1200 名考生演示数据;SQLite 自动备份原文件。
|
||||
|
||||
--confirm-target 对 SQLite 填写最终数据库路径,对 MySQL 填写数据库名。
|
||||
--force 仅在确认 MySQL 中已有非演示业务数据可覆盖时使用。
|
||||
--root 指定 .env 和相对 SQLite 路径的应用根目录。
|
||||
""";
|
||||
|
||||
public static DatabaseToolCommand Parse(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.Any(item => item is "-h" or "--help"))
|
||||
{
|
||||
return new(
|
||||
DatabaseMaintenanceMode.Init,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
true);
|
||||
}
|
||||
if (args.Length < 2 || !args[0].Equals("database", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new DatabaseToolUsageException("第一个参数必须是 database。");
|
||||
}
|
||||
|
||||
var mode = args[1].ToLowerInvariant() switch
|
||||
{
|
||||
"init" => DatabaseMaintenanceMode.Init,
|
||||
"reset" => DatabaseMaintenanceMode.Reset,
|
||||
"seed" => DatabaseMaintenanceMode.Seed,
|
||||
_ => throw new DatabaseToolUsageException($"不支持的 database 命令:{args[1]}")
|
||||
};
|
||||
string? client = null;
|
||||
string? path = null;
|
||||
string? confirmation = null;
|
||||
string? root = null;
|
||||
var force = false;
|
||||
var dryRun = false;
|
||||
for (var index = 2; index < args.Length; index++)
|
||||
{
|
||||
switch (args[index])
|
||||
{
|
||||
case "--sqlite":
|
||||
SetClient(ref client, "sqlite");
|
||||
break;
|
||||
case "--mysql":
|
||||
SetClient(ref client, "mysql");
|
||||
break;
|
||||
case "--path":
|
||||
path = NextValue(args, ref index, "--path");
|
||||
break;
|
||||
case "--confirm-target":
|
||||
confirmation = NextValue(args, ref index, "--confirm-target");
|
||||
break;
|
||||
case "--root":
|
||||
root = Path.GetFullPath(NextValue(args, ref index, "--root"));
|
||||
break;
|
||||
case "--force":
|
||||
force = true;
|
||||
break;
|
||||
case "--dry-run":
|
||||
dryRun = true;
|
||||
break;
|
||||
default:
|
||||
throw new DatabaseToolUsageException($"无法识别的参数:{args[index]}");
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == DatabaseMaintenanceMode.Init && force)
|
||||
{
|
||||
throw new DatabaseToolUsageException("database init 不接受 --force。");
|
||||
}
|
||||
return new(mode, client, path, confirmation, root, force, dryRun, false);
|
||||
}
|
||||
|
||||
private static void SetClient(ref string? current, string value)
|
||||
{
|
||||
if (current is not null && current != value)
|
||||
{
|
||||
throw new DatabaseToolUsageException("--sqlite 和 --mysql 不能同时使用。");
|
||||
}
|
||||
current = value;
|
||||
}
|
||||
|
||||
private static string NextValue(string[] args, ref int index, string option)
|
||||
{
|
||||
if (++index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new DatabaseToolUsageException($"{option} 缺少参数值。");
|
||||
}
|
||||
return args[index];
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DatabaseToolUsageException(string message) : Exception(message);
|
||||
@@ -2,6 +2,7 @@ using Eis.Infrastructure.Authentication;
|
||||
using Eis.Infrastructure.Administration;
|
||||
using Eis.Infrastructure.Candidate;
|
||||
using Eis.Infrastructure.Caching;
|
||||
using Eis.Infrastructure.Configuration;
|
||||
using Eis.Application.Public;
|
||||
using Eis.Infrastructure;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using Eis.Infrastructure.Data;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Eis.Infrastructure.Tests.Data;
|
||||
|
||||
public sealed class DatabaseMaintenanceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeedAndReset_UseValidatedReplacementAndCreateBackups()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"eis-tools-{Guid.NewGuid():N}");
|
||||
var path = Path.Combine(root, "eis.sqlite");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var service = new DatabaseMaintenanceService(DatabaseOptions.CreateSqlite(path));
|
||||
await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Init,
|
||||
null,
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None);
|
||||
|
||||
var seed = await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Seed,
|
||||
path,
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None);
|
||||
Assert.NotNull(seed.BackupPath);
|
||||
Assert.True(File.Exists(seed.BackupPath));
|
||||
Assert.Equal(1200, seed.Summary?.Candidates);
|
||||
Assert.Equal(10800, seed.Summary?.Results);
|
||||
Assert.Equal(1200, await ScalarAsync(path, "SELECT COUNT(*) FROM candidate_profiles"));
|
||||
Assert.Equal(10800, await ScalarAsync(path, "SELECT COUNT(*) FROM results"));
|
||||
|
||||
var reset = await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Reset,
|
||||
path,
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None);
|
||||
Assert.NotNull(reset.BackupPath);
|
||||
Assert.True(File.Exists(reset.BackupPath));
|
||||
Assert.Equal(0, await ScalarAsync(path, "SELECT COUNT(*) FROM candidate_profiles"));
|
||||
Assert.Equal(1, await ScalarAsync(path, "SELECT COUNT(*) FROM users"));
|
||||
Assert.Equal(20, await ScalarAsync(path,
|
||||
"SELECT schema_version FROM schema_metadata WHERE id = 1"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DestructiveCommand_RequiresExactTargetConfirmation()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"eis-tools-confirm-{Guid.NewGuid():N}");
|
||||
var path = Path.Combine(root, "eis.sqlite");
|
||||
try
|
||||
{
|
||||
var service = new DatabaseMaintenanceService(DatabaseOptions.CreateSqlite(path));
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Seed,
|
||||
path + ".wrong",
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("--confirm-target", exception.Message);
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_ReportsSeedWithoutWritingDatabase()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"eis-tools-dry-{Guid.NewGuid():N}");
|
||||
var path = Path.Combine(root, "eis.sqlite");
|
||||
var service = new DatabaseMaintenanceService(DatabaseOptions.CreateSqlite(path));
|
||||
|
||||
var result = await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Seed,
|
||||
null,
|
||||
force: false,
|
||||
dryRun: true,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.DryRun);
|
||||
Assert.Equal(1200, result.Summary?.Candidates);
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MySqlSystemDatabase_IsRejectedBeforeConnecting()
|
||||
{
|
||||
var options = DatabaseOptions.CreateMySql(
|
||||
"Server=127.0.0.1;User ID=test;Database=mysql;Connection Timeout=1");
|
||||
var service = new DatabaseMaintenanceService(options);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Init,
|
||||
null,
|
||||
force: false,
|
||||
dryRun: true,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("系统数据库", exception.Message);
|
||||
}
|
||||
|
||||
private static async Task<long> ScalarAsync(string path, string sql)
|
||||
{
|
||||
await using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync());
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -53,19 +53,17 @@ assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetad
|
||||
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
||||
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
|
||||
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
|
||||
const testDataImportSource = await readFile(resolve(root, 'scripts', 'import-test-data.mjs'), 'utf8');
|
||||
assert.match(testDataImportSource, /loadEnvFile\(envPath\)/, '测试数据脚本应读取项目 .env');
|
||||
assert.match(testDataImportSource, /options\.has\('--mysql'\).*options\.has\('--sqlite'\)/, '测试数据脚本应支持显式选择 MySQL 或 SQLite');
|
||||
assert.doesNotMatch(testDataImportSource, /process\.env\.DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '测试数据脚本不得再强制使用 SQLite');
|
||||
assert.match(testDataImportSource, /nonEmpty\.length && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖');
|
||||
assert.match(testDataImportSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例数据替换应在受控外键环境中执行');
|
||||
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
||||
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||
assert.match(testDataImportSource, /CURRENT_SCHEMA_VERSION/, '数据库导入脚本应复用统一的当前结构版本');
|
||||
const resetDatabaseSource = await readFile(resolve(root, 'scripts', 'reset-dev-database.mjs'), 'utf8');
|
||||
assert.match(resetDatabaseSource, /import\(['"]\.\/import-test-data\.mjs['"]\)/, '重置入口应复用统一的数据库初始化流程');
|
||||
assert.doesNotMatch(resetDatabaseSource, /DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '重置入口不得再强制使用 SQLite');
|
||||
const databaseToolSource = await readFile(resolve(root, 'src', 'Eis.Tools', 'Program.cs'), 'utf8');
|
||||
const databaseMaintenanceSource = await readFile(resolve(root, 'src', 'Eis.Infrastructure', 'Data', 'DatabaseMaintenanceService.cs'), 'utf8');
|
||||
assert.match(databaseToolSource, /EnvironmentFile\.Load\(applicationRoot\)/, '.NET 数据库工具应读取项目 .env');
|
||||
assert.match(databaseToolSource, /case "--sqlite".*case "--mysql"/s, '.NET 数据库工具应支持显式选择 MySQL 或 SQLite');
|
||||
assert.match(databaseToolSource, /--confirm-target/, '破坏性数据库命令必须精确确认目标');
|
||||
assert.match(databaseMaintenanceSource, /nonEmpty\.Count > 0 && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖');
|
||||
assert.match(databaseMaintenanceSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例数据替换应在受控外键环境中执行');
|
||||
assert.match(databaseMaintenanceSource, /BeginTransactionAsync.*ImportDemoSeedAsync.*CommitAsync/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||
assert.match(databaseMaintenanceSource, /DatabaseMaintenanceMode\.Reset.*SeedBaseStateAsync/s, '测试结束后应支持恢复空业务系统');
|
||||
assert.match(databaseMaintenanceSource, /recognizedDemo/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||
assert.match(databaseMaintenanceSource, /SchemaVersion = 20/, '数据库导入工具应使用当前结构版本');
|
||||
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||
assert.equal(baseState.meta.version, CURRENT_SCHEMA_VERSION, '空库初始状态必须使用当前结构版本');
|
||||
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
||||
|
||||
Reference in New Issue
Block a user