新增 [Eis.Tools (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Tools/Program.cs:1),支持 database init/reset/seed。

数据库维护、安全校验和事务逻辑位于 [DatabaseMaintenanceService.cs (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs:1)。
内置完整演示数据:1200 名考生、10800 条成绩、2404 条招生记录。
删除旧 import-test-data.mjs 和 reset-dev-database.mjs 运行入口。
Docker 镜像同时包含 Web 和 /app/tools/Eis.Tools.dll。
[package.json (line 11)](C:/Users/BI/Documents/EIS-dotnet/package.json:11) 原数据库命令已改为调用 .NET。
新增统一发布脚本:[publish.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/publish.ps1:1)。
This commit is contained in:
2026-07-23 16:25:40 +08:00 Unverified
parent e3c04dad8a
commit f3ffc18aae
23 changed files with 1299 additions and 281 deletions
+44
View File
@@ -0,0 +1,44 @@
import { pbkdf2Sync } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
import { buildSeedOperations } from '../database.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const outputPath = join(root, 'src', 'Eis.Infrastructure', 'Data', 'demo-seed-operations.json.gz');
const generatedAt = '2026-07-23T00:00:00.000Z';
const deterministicSalt = 'eis-dotnet-demo-seed';
const hashPassword = password => {
const hash = pbkdf2Sync(password, deterministicSalt, 120000, 32, 'sha256').toString('hex');
return `${deterministicSalt}:${hash}`;
};
const database = createSeedDatabase({
nowIso: () => generatedAt,
hashPassword
});
database.meta.version = 20;
const operations = buildSeedOperations(database);
const payload = {
version: 1,
schemaVersion: 20,
generatedAt,
summary: {
schools: database.schools.length,
users: database.users.length,
candidates: database.candidateProfiles.length,
registrations: database.registrations.length,
results: database.results.length,
admissionRecords: database.admissionRecords.length,
firstRoundPreferences: database.admissionRecords.filter(
item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1
).length,
operations: operations.length
},
operations
};
const compressed = gzipSync(Buffer.from(JSON.stringify(payload)), { level: 9 });
await writeFile(outputPath, compressed);
console.log(JSON.stringify({ outputPath, compressedBytes: compressed.length, ...payload.summary }));
-202
View File
@@ -1,202 +0,0 @@
import { pbkdf2Sync, randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { loadEnvFile } from 'node:process';
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
import { createBaseDatabase } from '../src/data/base.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs';
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
const root = resolve(process.cwd());
const envPath = join(root, '.env');
if (existsSync(envPath)) loadEnvFile(envPath);
const options = new Set(process.argv.slice(2));
if (options.has('--mysql') && options.has('--sqlite')) throw new Error('不能同时指定 --mysql 和 --sqlite');
const configuredClient = options.has('--mysql') ? 'mysql' : options.has('--sqlite') ? 'sqlite' : process.env.DATABASE_CLIENT;
const client = String(configuredClient || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase();
if (!['sqlite', 'mysql'].includes(client)) throw new Error(`不支持的 DATABASE_CLIENT${client}`);
process.env.DATABASE_CLIENT = client;
const force = options.has('--force');
const initializeEmpty = options.has('--empty');
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
return `${salt}:${hash}`;
}
const createTargetState = () => initializeEmpty
? createBaseDatabase({
nowIso: () => new Date().toISOString(),
hashPassword,
initialAdmin: {
username: process.env.INITIAL_ADMIN_USERNAME,
password: process.env.INITIAL_ADMIN_PASSWORD,
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
}
})
: createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
async function prepareSqlite() {
const databasePath = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite'));
const relativePath = relative(root, databasePath);
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
throw new Error('拒绝覆盖工作区以外的 SQLite 数据库');
}
await rm(databasePath, { force: true });
await rm(`${databasePath}-shm`, { force: true });
await rm(`${databasePath}-wal`, { force: true });
process.env.SQLITE_PATH = databasePath;
return databasePath;
}
function mysqlPoolOptions(mysql) {
if (process.env.DATABASE_URL) return mysql.createPool(process.env.DATABASE_URL);
if (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !process.env.MYSQL_DATABASE) {
throw new Error('MySQL 配置不完整:请在 .env 设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE');
}
return mysql.createPool({
host: process.env.MYSQL_HOST,
port: Number(process.env.MYSQL_PORT || 3306),
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE,
waitForConnections: true,
connectionLimit: 2,
charset: 'utf8mb4',
timezone: 'Z',
enableKeepAlive: true
});
}
const mysqlBusinessTables = [
'schools', 'school_classes', 'candidate_profiles', 'notices', 'exams', 'exam_subjects',
'registrations', 'registration_subjects', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects',
'results', 'test_centers', 'test_rooms', 'center_change_requests', 'center_change_rooms',
'candidate_account_batches', 'candidate_account_batch_items', 'workflow_instances', 'workflow_actions', 'admission_records', 'audit_logs'
];
async function prepareMysql() {
const { default: mysql } = await import('mysql2/promise');
const pool = mysqlPoolOptions(mysql);
let connection;
try {
connection = await pool.getConnection();
const [[databaseRow]] = await connection.query('SELECT DATABASE() AS name');
const databaseName = String(databaseRow?.name || '');
if (!databaseName || ['mysql', 'information_schema', 'performance_schema', 'sys'].includes(databaseName.toLowerCase())) {
throw new Error(`拒绝向系统数据库导入测试数据:${databaseName || '未选择数据库'}`);
}
const [tableRows] = await connection.query(`
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
`);
const existingTables = new Set(tableRows.map(row => row.TABLE_NAME));
const existingAppTables = relationalTables.filter(table => existingTables.has(table));
if (!existingAppTables.length) return { location: `MySQL database ${databaseName}`, initialized: false };
if (existingAppTables.length !== relationalTables.length) {
const missing = relationalTables.filter(table => !existingTables.has(table));
throw new Error(`MySQL 数据库结构不完整,缺少:${missing.join(', ')}。请先使用空数据库启动一次应用完成建表`);
}
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) {
throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
}
const [examPartitionRows] = await connection.query(
'SELECT candidates_table, admissions_table, results_table, centers_table FROM exam_data_partitions'
);
const [schoolPartitionRows] = await connection.query('SELECT students_table FROM school_student_partitions');
const dynamicPartitionTables = [
...examPartitionRows.flatMap(row => [row.candidates_table, row.admissions_table, row.results_table, row.centers_table]),
...schoolPartitionRows.map(row => row.students_table)
];
if (dynamicPartitionTables.some(table => !/^[a-z][a-z0-9_]{0,63}$/.test(table))) {
throw new Error('分表登记中存在非法表名,拒绝替换测试数据');
}
const nonEmpty = [];
for (const table of mysqlBusinessTables) {
const [[row]] = await connection.query(`SELECT COUNT(*) AS count FROM \`${table}\``);
if (Number(row.count) > 0) nonEmpty.push(`${table}=${row.count}`);
}
const [[userRow]] = await connection.query('SELECT COUNT(*) AS count FROM users');
if (Number(userRow.count) > 1) nonEmpty.push(`users=${userRow.count}`);
let recognizedTestData = false;
if (initializeEmpty && nonEmpty.length) {
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')");
recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5;
}
if (nonEmpty.length && !force && !recognizedTestData) {
const forceCommand = initializeEmpty
? 'npm run initialize-system:mysql -- --force'
: 'npm run seed-test-data:mysql -- --force';
throw new Error(
`MySQL 数据库 ${databaseName} 已有业务数据(${nonEmpty.slice(0, 8).join(', ')}${nonEmpty.length > 8 ? ', ...' : ''})。` +
`如确认这是可覆盖的测试库,请运行 ${forceCommand}`
);
}
if (nonEmpty.length) {
console.warn(`[${recognizedTestData ? 'test-data cleanup' : 'force'}] Replacing existing business data in MySQL database ${databaseName}`);
} else {
console.log(`Preparing empty MySQL database ${databaseName} for ${initializeEmpty ? 'system initialization' : 'test data'}`);
}
const state = createTargetState();
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
await connection.beginTransaction();
try {
// 先移除考试,使归档成绩保护触发器在清理 results 时不再命中。
const clearOrder = ['exams', ...[...relationalTables].reverse().filter(table => !['schema_metadata', 'exams'].includes(table))];
for (const table of clearOrder) await connection.query(`DELETE FROM \`${table}\``);
for (const operation of buildSeedOperations(state)) await connection.execute(operation.sql, operation.params);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
}
for (const table of dynamicPartitionTables) await connection.query(`DROP TABLE IF EXISTS \`${table}\``);
return { location: `MySQL database ${databaseName}`, initialized: true };
} finally {
connection?.release();
await pool.end();
}
}
let location;
let mysqlAlreadyImported = false;
if (client === 'sqlite') {
location = await prepareSqlite();
} else {
const prepared = await prepareMysql();
location = prepared.location;
mysqlAlreadyImported = prepared.initialized;
}
const database = await createDatabase({
root,
seed: createTargetState
});
const state = await database.read();
await database.close();
const pending = state.registrations.filter(item => item.status === 'pending').length;
const rejected = state.registrations.filter(item => item.status === 'rejected').length;
const unpaid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length;
const paid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'paid').length;
if (initializeEmpty) {
console.log(`Initialized empty system in ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
} else {
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
console.log(`${state.schools.filter(item => item.isSourceSchool).length} source schools, ${state.schools.filter(item => item.isAdmissionSchool).length} admission schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
console.log(`${state.results.length} published subject scores, ${state.admissionRecords.filter(item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1).length} first-round preferences`);
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
console.log('all predefined test account passwords: 12345678');
}
+45
View File
@@ -0,0 +1,45 @@
[CmdletBinding()]
param(
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\publish')
)
$ErrorActionPreference = 'Stop'
$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$publishRoot = [IO.Path]::GetFullPath(
$(if ([IO.Path]::IsPathRooted($OutputDirectory)) {
$OutputDirectory
}
else {
Join-Path $repositoryRoot $OutputDirectory
}))
$projects = @(
@{
Project = Join-Path $repositoryRoot 'src\Eis.Web\Eis.Web.csproj'
Output = Join-Path $publishRoot 'web'
},
@{
Project = Join-Path $repositoryRoot 'src\Eis.Tools\Eis.Tools.csproj'
Output = Join-Path $publishRoot 'tools'
}
)
foreach ($item in $projects) {
$nativeArgs = @(
'publish'
$item.Project
'--configuration'
'Release'
'--output'
$item.Output
)
& dotnet @nativeArgs
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "dotnet publish 失败,退出码:$exitCode"
}
}
Write-Host "Web 发布目录:$(Join-Path $publishRoot 'web')"
Write-Host "数据库工具目录:$(Join-Path $publishRoot 'tools')"
Write-Host "工具示例:dotnet $(Join-Path $publishRoot 'tools\Eis.Tools.dll') database seed --dry-run"
-4
View File
@@ -1,4 +0,0 @@
// Keep the historical reset-db entry point, but run the same guarded initializer used
// by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync.
if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty');
await import('./import-test-data.mjs');
+14 -6
View File
@@ -245,7 +245,12 @@ try {
TOTP_ENCRYPTION_KEY = 'migration-smoke-totp-key-32-characters-minimum'
DOCUMENT_VERIFICATION_SECRET = 'migration-smoke-document-key-32-characters-minimum'
}
$seedProcess = Start-TestProcess -FileName $nodeExecutable -ArgumentList @('scripts/import-test-data.mjs', '--sqlite') -Environment $nodeEnvironment
$seedProcess = Start-TestProcess -FileName $dotnetExecutable -ArgumentList @(
'run', '--project', 'src/Eis.Tools', '--',
'database', 'seed', '--sqlite',
'--path', $smokeDatabasePath,
'--confirm-target', $smokeDatabasePath
) -Environment $nodeEnvironment
if (-not $seedProcess.WaitForExit(30000)) {
$seedProcess.Kill($true)
throw 'Timed out while preparing the migration smoke-test database'
@@ -404,6 +409,7 @@ try {
if ($noticePayload.notice.title -ne 'ASP.NET Core 迁移冒烟通知' -or $noticePayload.notice.content -match '<script') {
throw 'Native public notice endpoint did not preserve data or sanitize unsafe content'
}
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $loginBody -WebSession $session | Out-Null
$candidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
$candidateLoginBody = @{ username = '2026-HZ01-F-0001'; password = '12345678' } | ConvertTo-Json -Compress
@@ -411,6 +417,8 @@ try {
if ($candidateLogin.user.username -ne '2026-HZ01-F-0001') {
throw 'Could not sign in as the migration verification candidate'
}
$legacyCandidateSession = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
Invoke-RestMethod -Uri "$legacyBaseUrl/api/auth/login" -Method Post -ContentType 'application/json' -Body $candidateLoginBody -WebSession $legacyCandidateSession | Out-Null
$candidateResults = Invoke-RestMethod -Uri "$webBaseUrl/api/candidate/results" -WebSession $candidateSession
$scoreCode = @($candidateResults.summaries | Where-Object verificationCode | Select-Object -First 1).verificationCode
@@ -509,14 +517,14 @@ try {
throw 'Native authentication could not create the candidate parity-test session'
}
foreach ($candidateRoute in @('dashboard', 'notices', 'profile', 'exams', 'registrations')) {
$legacyCandidateResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateRoute" -WebSession $candidateSession
$legacyCandidateResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/$candidateRoute" -WebSession $legacyCandidateSession
$nativeCandidateResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/$candidateRoute" -WebSession $nativeCandidateSession
if ($nativeCandidateResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Candidate route '$candidateRoute' did not use the native ASP.NET Core endpoint"
}
Assert-JsonEquivalent -Expected $legacyCandidateResponse.Content -Actual $nativeCandidateResponse.Content -Label "Candidate route '$candidateRoute'"
}
$legacyCandidateResultsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/results" -WebSession $candidateSession
$legacyCandidateResultsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/results" -WebSession $legacyCandidateSession
$nativeCandidateResultsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/results" -WebSession $nativeCandidateSession
if ($nativeCandidateResultsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Candidate route 'results' did not use the native ASP.NET Core endpoint"
@@ -526,7 +534,7 @@ try {
if (@($nativeCandidateResults.summaries | Where-Object { $_.verificationQr -match '^data:image/png;base64,' }).Count -eq 0) {
throw 'Native candidate results did not include a local PNG verification QR code'
}
$legacyCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
$legacyCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $legacyCandidateSession
$nativeCandidateAdmissionsResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
if ($nativeCandidateAdmissionsResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core') {
throw "Candidate route 'admissions' did not use the native ASP.NET Core endpoint"
@@ -575,7 +583,7 @@ try {
if ($lockedPreference.StatusCode -ne 409) {
throw 'Native admission preference API accepted a submission after automatic locking'
}
$legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $candidateSession
$legacyAdmissionsAfterWrite = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/admissions" -WebSession $legacyCandidateSession
$nativeAdmissionsAfterWrite = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/admissions" -WebSession $nativeCandidateSession
Assert-CandidateAdmissionsEquivalent -Expected $legacyAdmissionsAfterWrite.Content -Actual $nativeAdmissionsAfterWrite.Content
$appealResultId = $null
@@ -610,7 +618,7 @@ try {
throw "Could not prepare the candidate admit-card smoke data`n$admitError"
}
$admitProcess.Dispose()
$legacyAdmitResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $candidateSession
$legacyAdmitResponse = Invoke-WebRequest -Uri "$legacyBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $legacyCandidateSession
$nativeAdmitResponse = Invoke-WebRequest -Uri "$nativeAuthBaseUrl/api/candidate/registrations/$admitRegistrationId/admit-card" -WebSession $nativeCandidateSession
if ($nativeAdmitResponse.StatusCode -ne 200 -or
$nativeAdmitResponse.Headers['X-EIS-Implementation'] -ne 'aspnet-core' -or