Files
Exam-Information-System/scripts/import-test-data.mjs
T
2026-07-20 20:03:16 +08:00

161 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { createSeedDatabase } from '../src/data/seed.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');
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
return `${salt}:${hash}`;
}
const seed = () => 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', '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
});
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;
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}`);