From 6aeed6b880fa3559f0d3c5a450185199bcca0d4c Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 18:17:49 +0800 Subject: [PATCH] SQL FIX --- src/database/mysql-adapter.mjs | 115 +++++++++++---------------------- tests/system.test.mjs | 18 ++++++ 2 files changed, 56 insertions(+), 77 deletions(-) diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 7481308..be54b0e 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -36,71 +36,32 @@ export function createMysqlAdapter(context) { enableKeepAlive: true }); - for (const statement of mysqlSchema) await pool.execute(statement); - const mysqlColumnMigrations = [ - "ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_level ENUM('super', 'school', 'class') NULL", - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS candidate_number VARCHAR(120) NULL', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS archived_at VARCHAR(35) NULL', - 'ALTER TABLE users ADD COLUMN IF NOT EXISTS archived_by VARCHAR(64) NULL', - 'ALTER TABLE schema_metadata ADD COLUMN IF NOT EXISTS self_registration_enabled BOOLEAN NOT NULL DEFAULT FALSE', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS native_place VARCHAR(160) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS birth_date VARCHAR(20) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS ethnicity VARCHAR(60) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_name VARCHAR(100) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS guardian_phone VARCHAR(60) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS profile_completed BOOLEAN NOT NULL DEFAULT FALSE', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS province_code VARCHAR(6) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS province_name VARCHAR(80) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS city_code VARCHAR(6) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS city_name VARCHAR(100) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS district_code VARCHAR(6) NULL', - 'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL', - 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL', - 'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL', - "ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_policy ENUM('fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none') NOT NULL DEFAULT 'rank_percent'", - 'ALTER TABLE exams ADD COLUMN IF NOT EXISTS pass_value DOUBLE NOT NULL DEFAULT 60', - 'ALTER TABLE exams ADD COLUMN IF NOT EXISTS archived_at VARCHAR(35) NULL', - 'ALTER TABLE exams ADD COLUMN IF NOT EXISTS archived_by VARCHAR(64) NULL', - 'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS full_score DOUBLE NOT NULL DEFAULT 150', - 'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS pass_score DOUBLE NOT NULL DEFAULT 90', - "ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS pass_rule ENUM('fixed_score', 'score_ratio', 'none') NOT NULL DEFAULT 'fixed_score'", - 'ALTER TABLE exam_subjects ADD COLUMN IF NOT EXISTS pass_value DOUBLE NOT NULL DEFAULT 90', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS emergency_phone VARCHAR(60) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS gate_open_time VARCHAR(40) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL', - "ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'", - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS province_code VARCHAR(6) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS province_name VARCHAR(80) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS city_code VARCHAR(6) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS city_name VARCHAR(100) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS district_code VARCHAR(6) NULL', - 'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL', - 'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS province_code VARCHAR(6) NULL', - 'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS province_name VARCHAR(80) NULL', - 'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS city_code VARCHAR(6) NULL', - 'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS city_name VARCHAR(100) NULL', - 'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS district_code VARCHAR(6) NULL', - 'ALTER TABLE center_change_requests ADD COLUMN IF NOT EXISTS district_name VARCHAR(100) NULL', - 'ALTER TABLE test_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', - 'ALTER TABLE center_change_rooms ADD COLUMN IF NOT EXISTS seat_plan VARCHAR(500) NULL', - "ALTER TABLE admit_cards ADD COLUMN IF NOT EXISTS center_code VARCHAR(40) NOT NULL DEFAULT ''", - "ALTER TABLE admit_cards ADD COLUMN IF NOT EXISTS center_address VARCHAR(500) NOT NULL DEFAULT ''", - "ALTER TABLE admit_card_subjects ADD COLUMN IF NOT EXISTS building VARCHAR(120) NOT NULL DEFAULT ''", - "ALTER TABLE admit_card_subjects ADD COLUMN IF NOT EXISTS floor VARCHAR(80) NOT NULL DEFAULT ''", - "ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL" - ]; - for (const statement of mysqlColumnMigrations) await pool.execute(statement); + const mysqlTableNames = mysqlSchema.map(statement => + statement.match(/^CREATE TABLE IF NOT EXISTS\s+([a-z0-9_]+)/i)?.[1] + ).filter(Boolean); + const [databaseTables] = await pool.execute(` + SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE' + `); + const existingTableNames = new Set(databaseTables.map(row => row.TABLE_NAME)); + const existingAppTables = mysqlTableNames.filter(table => existingTableNames.has(table)); + let hasSchemaMetadata = false; + let existingSchemaVersion = null; + if (existingTableNames.has('schema_metadata')) { + const [metadataRows] = await pool.execute('SELECT id, schema_version FROM schema_metadata WHERE id = 1'); + hasSchemaMetadata = metadataRows.length > 0; + existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null; + } + if (existingAppTables.length && (!hasSchemaMetadata || existingSchemaVersion !== 15)) { + for (const table of [...mysqlTableNames].reverse()) { + await pool.query(`DROP TABLE IF EXISTS \`${table}\``); + } + } + + for (const statement of mysqlSchema) await pool.query(statement); + // Development schemas are created from the current DDL as a whole. MySQL 8.4 lacks + // MariaDB-style conditional column addition; outdated schemas are rejected by the + // version check below and should be rebuilt instead of migrated column by column. const [resultLockTriggers] = await pool.execute(` SELECT TRIGGER_NAME FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() AND TRIGGER_NAME LIKE 'trg_results_lock_archived_%' @@ -130,23 +91,23 @@ export function createMysqlAdapter(context) { END` }; for (const [name, statement] of Object.entries(mysqlResultLockTriggers)) { - if (!existingResultLockTriggers.has(name)) await pool.execute(statement); + if (!existingResultLockTriggers.has(name)) await pool.query(statement); } - const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); - if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number'); + const [legacyRegistrationNumberIndexes] = await pool.query("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'"); + if (legacyRegistrationNumberIndexes.length) await pool.query('ALTER TABLE registrations DROP INDEX uq_registrations_number'); const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1'); if (existing.length) { const [metadataRows] = await pool.execute('SELECT app_version, schema_version FROM schema_metadata WHERE id = 1'); if (Number(metadataRows[0]?.schema_version || 1) < 9) { - await pool.execute('DROP TABLE IF EXISTS admit_card_subjects'); - await pool.execute('DROP TABLE IF EXISTS admit_cards'); - await pool.execute('DROP TABLE IF EXISTS exam_arrangement_plans'); - await pool.execute('DROP TABLE IF EXISTS admission_number_rules'); + await pool.query('DROP TABLE IF EXISTS admit_card_subjects'); + await pool.query('DROP TABLE IF EXISTS admit_cards'); + await pool.query('DROP TABLE IF EXISTS exam_arrangement_plans'); + await pool.query('DROP TABLE IF EXISTS admission_number_rules'); const admissionTables = ['admission_number_rules', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects']; for (const table of admissionTables) { const statement = mysqlSchema.find(item => item.includes(`CREATE TABLE IF NOT EXISTS ${table} (`)); if (!statement) throw new Error(`缺少 ${table} 的 MySQL 表定义`); - await pool.execute(statement); + await pool.query(statement); } const extension = seed(); for (const rule of extension.admissionNumberRules) await pool.execute( @@ -387,13 +348,13 @@ export function createMysqlAdapter(context) { } } - const [centerCodeIndexes] = await pool.execute("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'"); + const [centerCodeIndexes] = await pool.query("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'"); if (!centerCodeIndexes.length) { - await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); + await pool.query('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)'); } - const [candidateNumberIndexes] = await pool.execute("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'"); + const [candidateNumberIndexes] = await pool.query("SHOW INDEX FROM users WHERE Key_name = 'uq_users_candidate_number'"); if (!candidateNumberIndexes.length) { - await pool.execute('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); + await pool.query('ALTER TABLE users ADD UNIQUE KEY uq_users_candidate_number (candidate_number)'); } const transaction = async operations => { diff --git a/tests/system.test.mjs b/tests/system.test.mjs index a588e54..8c34591 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -18,6 +18,24 @@ for (const statement of mysqlRuleSchemas) { } const mysqlRegistrationSchema = mysqlSchema.find(statement => /CREATE TABLE IF NOT EXISTS registrations\b/.test(statement)); assert.match(mysqlRegistrationSchema || '', /paid_at VARCHAR\(35\).*paid_by VARCHAR\(64\)/s, 'MySQL 报名表应保存缴费确认时间和班级负责人'); +const mysqlWorkflowSchema = mysqlSchema.find(statement => /CREATE TABLE IF NOT EXISTS workflow_definitions\b/.test(statement)); +assert.match(mysqlWorkflowSchema || '', /'score_appeal'/, 'MySQL 工作流枚举应包含成绩复议'); +const mysqlTableOrder = new Map(mysqlSchema.map((statement, index) => [ + statement.match(/^CREATE TABLE IF NOT EXISTS\s+([a-z0-9_]+)/i)?.[1], index +])); +for (const [table, index] of mysqlTableOrder) { + const statement = mysqlSchema[index]; + for (const match of statement.matchAll(/REFERENCES\s+([a-z0-9_]+)/gi)) { + assert.ok(mysqlTableOrder.has(match[1]) && mysqlTableOrder.get(match[1]) <= index, `MySQL 表 ${table} 的外键依赖必须先创建`); + } +} +const mysqlAdapterSource = await readFile(resolve(root, 'src', 'database', 'mysql-adapter.mjs'), 'utf8'); +assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'MySQL 8.4 不支持 ADD COLUMN IF NOT EXISTS'); +assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行'); +assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建'); +assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行'); +assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| existingSchemaVersion !== 15\)/, 'MySQL 应重建未完成或过期的开发结构'); +assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理'); await rm(testDb, { force: true }); await rm(`${testDb}-shm`, { force: true }); await rm(`${testDb}-wal`, { force: true });