新增 [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:
2026-07-23 16:25:40 +08:00 Unverified
parent e3c04dad8a
commit f3ffc18aae
23 changed files with 1299 additions and 281 deletions
+6 -2
View File
@@ -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"]
+1
View File
@@ -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
View File
@@ -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 行为对照工具,不属于生产启动链路。
+49 -16
View File
@@ -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
View File
@@ -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",
+44
View File
@@ -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 }));
-202
View File
@@ -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');
}
+45
View File
@@ -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"
-4
View File
@@ -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');
+14 -6
View File
@@ -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())
@@ -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")
@@ -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>
+11
View File
@@ -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>
+263
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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, '正常首次建库不得预置学校');