基础数据集

This commit is contained in:
2026-07-20 20:03:16 +08:00 Unverified
parent b8f2799ed6
commit a6e07eebbb
5 changed files with 164 additions and 14 deletions
+134 -11
View File
@@ -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}`);