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'); }