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 '