基础数据集
This commit is contained in:
@@ -89,7 +89,7 @@ npm start
|
||||
|
||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v15;低于 v15 的开发库会提示重建,不执行兼容迁移。
|
||||
|
||||
需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;该命令会覆盖本地 SQLite 数据库,生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||
需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。它会生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||
|
||||
## 数据库配置
|
||||
|
||||
@@ -144,6 +144,24 @@ npm start
|
||||
|
||||
也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。
|
||||
|
||||
### 导入服务器 MySQL 测试数据
|
||||
|
||||
先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数,然后执行:
|
||||
|
||||
```powershell
|
||||
npm run seed-test-data:mysql
|
||||
```
|
||||
|
||||
脚本会读取 `.env`,校验当前连接的数据库名称、v15 表结构和已有数据。目标是新数据库时会自动建表并导入;目标只有首次启动生成的空业务结构时会在事务中替换为样例数据。若检测到学校、考生、考试、报名等业务数据,脚本默认拒绝覆盖。
|
||||
|
||||
仅在确认目标是可以完全覆盖的测试库时使用:
|
||||
|
||||
```powershell
|
||||
npm run seed-test-data:mysql -- --force
|
||||
```
|
||||
|
||||
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`。
|
||||
|
||||
## 手动测试数据账号
|
||||
|
||||
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ function validateState(state, source = '数据库') {
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildSeedOperations(state) {
|
||||
export function buildSeedOperations(state) {
|
||||
validateState(state);
|
||||
const operations = [];
|
||||
const add = (sql, ...params) => operations.push({ sql, params });
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@
|
||||
"start": "node server.mjs",
|
||||
"test": "node tests/system.test.mjs",
|
||||
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||
"seed-test-data": "node scripts/import-test-data.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"
|
||||
},
|
||||
"dependencies": {
|
||||
"ckeditor5": "^48.3.1",
|
||||
|
||||
+134
-11
@@ -1,28 +1,151 @@
|
||||
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 { createDatabase } from '../database.mjs';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
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('拒绝覆盖工作区以外的数据库');
|
||||
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');
|
||||
|
||||
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
await rm(databasePath, { force: true });
|
||||
await rm(`${databasePath}-shm`, { force: true });
|
||||
await rm(`${databasePath}-wal`, { force: true });
|
||||
const seed = () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
|
||||
|
||||
process.env.DATABASE_CLIENT = 'sqlite';
|
||||
process.env.SQLITE_PATH = databasePath;
|
||||
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', '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) !== 15) {
|
||||
throw new Error(`MySQL 数据库结构版本不是 v15(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (nonEmpty.length && !force) {
|
||||
throw new Error(
|
||||
`MySQL 数据库 ${databaseName} 已有业务数据(${nonEmpty.slice(0, 8).join(', ')}${nonEmpty.length > 8 ? ', ...' : ''})。` +
|
||||
'如确认这是可覆盖的测试库,请运行 npm run seed-test-data:mysql -- --force'
|
||||
);
|
||||
}
|
||||
if (nonEmpty.length) {
|
||||
console.warn(`[force] Replacing existing business data in MySQL database ${databaseName}`);
|
||||
} else {
|
||||
console.log(`Preparing empty MySQL database ${databaseName} for test data`);
|
||||
}
|
||||
|
||||
const state = seed();
|
||||
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');
|
||||
}
|
||||
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: () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword })
|
||||
seed
|
||||
});
|
||||
const state = await database.read();
|
||||
await database.close();
|
||||
@@ -31,7 +154,7 @@ const pending = state.registrations.filter(item => item.status === 'pending').le
|
||||
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;
|
||||
console.log(`Imported test data into ${databasePath}`);
|
||||
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||
console.log(`${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
|
||||
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
|
||||
|
||||
@@ -41,6 +41,13 @@ 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 样例数据应在同一事务内清理并写入');
|
||||
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
||||
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
|
||||
|
||||
Reference in New Issue
Block a user