diff --git a/Dockerfile b/Dockerfile index b80855d..6a2734a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Eis.slnx b/Eis.slnx index 5bda0c2..0163606 100644 --- a/Eis.slnx +++ b/Eis.slnx @@ -3,6 +3,7 @@ + diff --git a/MIGRATION.md b/MIGRATION.md index 4650c18..7e6ca60 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 行为对照工具,不属于生产启动链路。 diff --git a/README.md b/README.md index deb46ab..32d4ebb 100644 --- a/README.md +++ b/README.md @@ -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 开发与生产环境变量模板 diff --git a/package.json b/package.json index 69f9b02..f305f31 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/export-dotnet-demo-seed.mjs b/scripts/export-dotnet-demo-seed.mjs new file mode 100644 index 0000000..479aecc --- /dev/null +++ b/scripts/export-dotnet-demo-seed.mjs @@ -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 })); diff --git a/scripts/import-test-data.mjs b/scripts/import-test-data.mjs deleted file mode 100644 index fb40c57..0000000 --- a/scripts/import-test-data.mjs +++ /dev/null @@ -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'); -} diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 new file mode 100644 index 0000000..06b515d --- /dev/null +++ b/scripts/publish.ps1 @@ -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" diff --git a/scripts/reset-dev-database.mjs b/scripts/reset-dev-database.mjs deleted file mode 100644 index 9492a52..0000000 --- a/scripts/reset-dev-database.mjs +++ /dev/null @@ -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'); diff --git a/scripts/smoke-dotnet-migration.ps1 b/scripts/smoke-dotnet-migration.ps1 index ff1ebb7..8d161cc 100644 --- a/scripts/smoke-dotnet-migration.ps1 +++ b/scripts/smoke-dotnet-migration.ps1 @@ -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 ' 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, diff --git a/src/Eis.Infrastructure/Administration/AdminAdmissionService.Operations.cs b/src/Eis.Infrastructure/Administration/AdminAdmissionService.Operations.cs index 481252c..65f9514 100644 --- a/src/Eis.Infrastructure/Administration/AdminAdmissionService.Operations.cs +++ b/src/Eis.Infrastructure/Administration/AdminAdmissionService.Operations.cs @@ -32,7 +32,8 @@ internal sealed partial class AdminAdmissionService }).ToArray(); var home = await publicQueries.GetHomeAsync(cancellationToken); var notifications = (home["notices"] as JsonArray ?? []).OfType() - .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()) diff --git a/src/Eis.Web/Configuration/EnvironmentFile.cs b/src/Eis.Infrastructure/Configuration/EnvironmentFile.cs similarity index 84% rename from src/Eis.Web/Configuration/EnvironmentFile.cs rename to src/Eis.Infrastructure/Configuration/EnvironmentFile.cs index b96f017..3e7a4a5 100644 --- a/src/Eis.Web/Configuration/EnvironmentFile.cs +++ b/src/Eis.Infrastructure/Configuration/EnvironmentFile.cs @@ -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]; } diff --git a/src/Eis.Infrastructure/Data/DatabaseInitializer.cs b/src/Eis.Infrastructure/Data/DatabaseInitializer.cs index b8678b2..792cf6f 100644 --- a/src/Eis.Infrastructure/Data/DatabaseInitializer.cs +++ b/src/Eis.Infrastructure/Data/DatabaseInitializer.cs @@ -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 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 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(); diff --git a/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs b/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs new file mode 100644 index 0000000..d29379e --- /dev/null +++ b/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs @@ -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 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 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 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(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 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 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> FindNonEmptyBusinessTablesAsync( + DbConnection connection, + CancellationToken cancellationToken) + { + var result = new List(); + 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 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 ReadPartitionTablesAsync( + DbConnection connection, + CancellationToken cancellationToken) + { + var tables = new List(); + 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 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( + 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 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 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); +} diff --git a/src/Eis.Infrastructure/Data/DatabaseOptions.cs b/src/Eis.Infrastructure/Data/DatabaseOptions.cs index c843fb0..f16a265 100644 --- a/src/Eis.Infrastructure/Data/DatabaseOptions.cs +++ b/src/Eis.Infrastructure/Data/DatabaseOptions.cs @@ -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") diff --git a/src/Eis.Infrastructure/Data/demo-seed-operations.json.gz b/src/Eis.Infrastructure/Data/demo-seed-operations.json.gz new file mode 100644 index 0000000..e2bfe31 Binary files /dev/null and b/src/Eis.Infrastructure/Data/demo-seed-operations.json.gz differ diff --git a/src/Eis.Infrastructure/Eis.Infrastructure.csproj b/src/Eis.Infrastructure/Eis.Infrastructure.csproj index 28cc380..cbf165f 100644 --- a/src/Eis.Infrastructure/Eis.Infrastructure.csproj +++ b/src/Eis.Infrastructure/Eis.Infrastructure.csproj @@ -19,5 +19,6 @@ + diff --git a/src/Eis.Tools/Eis.Tools.csproj b/src/Eis.Tools/Eis.Tools.csproj new file mode 100644 index 0000000..a018dea --- /dev/null +++ b/src/Eis.Tools/Eis.Tools.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + Eis.Tools + Eis.Tools + + + + + diff --git a/src/Eis.Tools/Program.cs b/src/Eis.Tools/Program.cs new file mode 100644 index 0000000..bd4ad8f --- /dev/null +++ b/src/Eis.Tools/Program.cs @@ -0,0 +1,263 @@ +using Eis.Infrastructure.Configuration; +using Eis.Infrastructure.Data; + +namespace Eis.Tools; + +internal static class Program +{ + public static async Task 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 ] [--dry-run] + dotnet Eis.Tools.dll database reset [--sqlite|--mysql] [--path ] [--confirm-target <目标>] [--force] [--dry-run] + dotnet Eis.Tools.dll database seed [--sqlite|--mysql] [--path ] [--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); diff --git a/src/Eis.Web/Program.cs b/src/Eis.Web/Program.cs index 0060704..0b65346 100644 --- a/src/Eis.Web/Program.cs +++ b/src/Eis.Web/Program.cs @@ -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; diff --git a/tests/Eis.Infrastructure.Tests/Data/DatabaseMaintenanceServiceTests.cs b/tests/Eis.Infrastructure.Tests/Data/DatabaseMaintenanceServiceTests.cs new file mode 100644 index 0000000..ee72af7 --- /dev/null +++ b/tests/Eis.Infrastructure.Tests/Data/DatabaseMaintenanceServiceTests.cs @@ -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(() => + 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(() => + service.ExecuteAsync( + DatabaseMaintenanceMode.Init, + null, + force: false, + dryRun: true, + CancellationToken.None)); + + Assert.Contains("系统数据库", exception.Message); + } + + private static async Task 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()); + } +} diff --git a/tests/system.test.mjs b/tests/system.test.mjs index dd4742c..66e26ee 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -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, '正常首次建库不得预置学校');