项目迁移
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const input = resolve(process.argv[2] || 'ok_data_level3.csv');
|
||||
const output = resolve(process.argv[3] || 'src/data/china-regions.mjs');
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const values = [];
|
||||
let value = '';
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if (char === '"') {
|
||||
if (quoted && line[index + 1] === '"') { value += '"'; index += 1; }
|
||||
else quoted = !quoted;
|
||||
} else if (char === ',' && !quoted) {
|
||||
values.push(value);
|
||||
value = '';
|
||||
} else value += char;
|
||||
}
|
||||
values.push(value);
|
||||
return values;
|
||||
}
|
||||
|
||||
const source = await readFile(input, 'utf8');
|
||||
const rows = source.replace(/^\uFEFF/, '').trim().split(/\r?\n/).slice(1).map(line => {
|
||||
const [id, pid, deep, , , , extId, extName] = parseCsvLine(line);
|
||||
return { id, pid, deep: Number(deep), code: extId.slice(0, 6), name: extName };
|
||||
});
|
||||
|
||||
const provinces = rows.filter(item => item.deep === 0 && item.code !== '0').map(province => ({
|
||||
code: province.code,
|
||||
name: province.name,
|
||||
cities: rows.filter(city => city.deep === 1 && city.pid === province.id).map(city => ({
|
||||
code: city.code,
|
||||
name: city.name,
|
||||
districts: rows.filter(district => district.deep === 2 && district.pid === city.id).map(district => ({
|
||||
code: district.code,
|
||||
name: district.name
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
const hotan = provinces.find(item => item.code === '650000')?.cities.find(item => item.code === '653200');
|
||||
for (const district of [
|
||||
{ code: '653228', name: '和康县' },
|
||||
{ code: '653229', name: '和安县' }
|
||||
]) {
|
||||
if (hotan && !hotan.districts.some(item => item.code === district.code)) hotan.districts.push(district);
|
||||
}
|
||||
hotan?.districts.sort((a, b) => a.code.localeCompare(b.code));
|
||||
|
||||
const banner = `// Generated from AreaCity-JsSpider-StatsGov release 2025.251231.260403.\n// Source snapshot: 国家地名信息库 2025-12-31; generated 2026-07-20.\n// Manual official additions: 和康县 653228, 和安县 653229.\n`;
|
||||
await writeFile(output, `${banner}export const chinaRegionsVersion = '2025-12-31';\nexport const chinaRegions = ${JSON.stringify(provinces)};\n`, 'utf8');
|
||||
console.log(`Generated ${provinces.length} provinces at ${output}`);
|
||||
@@ -0,0 +1,202 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 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');
|
||||
Reference in New Issue
Block a user