import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { DataSource, QueryRunner } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; @Injectable() export class DatabaseMigrationsService implements OnApplicationBootstrap { private readonly logger = new Logger(DatabaseMigrationsService.name); constructor(private readonly dataSource: DataSource) {} async onApplicationBootstrap(): Promise { await this.ensureAiConfigTable(); await this.ensureSyncStateLeaseColumns(); await this.ensureStudentProfileCollegeColumns(); await this.ensureCourseAttendanceSchema(); await this.ensureAttendanceDevicesSchema(); await this.ensureStudentWalletSchema(); await this.backfillOrganizations(); await this.normalizeClassDates(); await this.protectAttendanceHistory(); await this.removeUnusedClassroomColumns(); await this.removeUnusedRoomColumns(); await this.cleanupDepositRefundColumns(); await this.removeUnusedClassStudentColumns(); await this.normalizeClassroomStatuses(); } private async ensureSyncStateLeaseColumns(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const table = await runner.getTable('sync_state'); if (!table) return; const columns = new Set(table.columns.map((column) => column.name)); if (!columns.has('run_id')) { await runner.query('ALTER TABLE sync_state ADD COLUMN run_id VARCHAR(64)'); } if (!columns.has('running_since')) { await runner.query('ALTER TABLE sync_state ADD COLUMN running_since DATETIME'); } } finally { await runner.release(); } } private async ensureStudentProfileCollegeColumns(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const table = await runner.getTable('student_profiles'); if (!table) return; const columns = new Set(table.columns.map((column) => column.name)); const additions: Array<[string, string]> = [ ['college_school', 'VARCHAR(100)'], ['college_major', 'VARCHAR(100)'], ]; for (const [name, definition] of additions) { if (!columns.has(name)) await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`); } } finally { await runner.release(); } } private async ensureAttendanceDevicesSchema(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const isMySQL = this.dataSource.options.type === 'mysql'; const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices ( id ${pk}, device_sn VARCHAR(100) NOT NULL, device_name VARCHAR(100) NOT NULL, classroom_id INTEGER NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', location VARCHAR(200), notes TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`); const table = await runner.getTable('attendance_devices'); const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); const additions: Array<[string, string]> = [ ['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''], ['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''], ['classroom_id', 'INTEGER NOT NULL DEFAULT 0'], ['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"], ['location', 'VARCHAR(200)'], ['notes', 'TEXT'], ['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'], ['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'], ]; for (const [name, definition] of additions) { if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`); } const refreshed = await runner.getTable('attendance_devices'); const createIndex = async (sql: string) => { try { await runner.query(sql); } catch { // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. } }; const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique); if (!uniqueSn) { await createIndex( isMySQL ? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)' : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)', ); } await createIndex( isMySQL ? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)' : 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)', ); } finally { await runner.release(); } } private async ensureStudentWalletSchema(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const isMySQL = this.dataSource.options.type === 'mysql'; const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT'; await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets ( id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`); await runner.query(`CREATE TABLE IF NOT EXISTS wallet_transactions ( id ${pk}, student_id INTEGER NOT NULL, bill_id INTEGER, type VARCHAR(30) NOT NULL, amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL, description VARCHAR(300), recorded_by INTEGER, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`); await runner.query(`CREATE TABLE IF NOT EXISTS financial_operations ( id ${pk}, operation_id VARCHAR(64) NOT NULL UNIQUE, type VARCHAR(64) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'running', result_json TEXT, error_message VARCHAR(500), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`); const walletTransactions = await runner.getTable('wallet_transactions'); if (walletTransactions) { const columns = new Set(walletTransactions.columns.map((column) => column.name)); if (!columns.has('operation_id')) { await runner.query('ALTER TABLE wallet_transactions ADD COLUMN operation_id VARCHAR(64)'); } } const billItems = await runner.getTable('bill_items'); if (billItems) { const columns = new Set(billItems.columns.map((column) => column.name)); for (const [name, definition] of [ ['room_expense_id', 'INTEGER'], ['personal_expense_id', 'INTEGER'], ]) { if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`); } } const roomExpenses = await runner.getTable('room_expenses'); if (roomExpenses) { const columns = new Set(roomExpenses.columns.map((column) => column.name)); if (!columns.has('import_key')) { await runner.query('ALTER TABLE room_expenses ADD COLUMN import_key VARCHAR(120)'); } const refreshedRoomExpenses = await runner.getTable('room_expenses'); const hasImportKey = refreshedRoomExpenses?.indices.some((index) => index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key'); if (!hasImportKey) { await runner.query(isMySQL ? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)' : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)'); } } const bills = await runner.getTable('bills'); if (bills) { const columns = new Set(bills.columns.map((column) => column.name)); const additions = [ ['source', "VARCHAR(30) NOT NULL DEFAULT 'batch'"], ['paid_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'], ['outstanding_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'], ['cancelled_at', 'DATETIME'], ['cancel_reason', 'VARCHAR(300)'], ]; for (const [name, definition] of additions) { if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`); } await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'"); await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'"); await runner.query("UPDATE bills SET status = 'unpaid' WHERE status IN ('draft', 'confirmed')"); } const personalExpenses = await runner.getTable('personal_expenses'); if (personalExpenses && !personalExpenses.columns.some((column) => column.name === 'bill_id')) { await runner.query('ALTER TABLE personal_expenses ADD COLUMN bill_id INTEGER'); } } finally { await runner.release(); } } private async removeUnusedClassroomColumns(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables(['classrooms']); if (tables.length === 0) return; const table = await runner.getTable('classrooms'); const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); for (const columnName of ['course_type', 'supervisor']) { if (columnNames.has(columnName)) { await runner.query(`ALTER TABLE classrooms DROP COLUMN ${columnName}`); } } } finally { await runner.release(); } } private async removeUnusedRoomColumns(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables(['rooms']); if (tables.length === 0) return; const table = await runner.getTable('rooms'); if (table?.columns.some((column) => column.name === 'gender')) { await runner.dropColumn('rooms', 'gender'); } } finally { await runner.release(); } } private async cleanupDepositRefundColumns(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables(['deposits']); if (tables.length === 0) return; const table = await runner.getTable('deposits'); const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); for (const [legacyName, currentName] of [ ['refund_approved_by', 'refunded_by'], ['refund_approved_at', 'refunded_at'], ] as const) { if (!columnNames.has(legacyName)) continue; if (columnNames.has(currentName)) { await runner.query( `UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`, ); await runner.dropColumn('deposits', legacyName); } else { await runner.renameColumn('deposits', legacyName, currentName); columnNames.add(currentName); } columnNames.delete(legacyName); } for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) { if (columnNames.has(columnName)) { await runner.dropColumn('deposits', columnName); columnNames.delete(columnName); } } } finally { await runner.release(); } } private async removeUnusedClassStudentColumns(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables(['class_student']); if (tables.length === 0) return; const table = await runner.getTable('class_student'); if (table?.columns.some((column) => column.name === 'enrollment_id')) { await runner.dropColumn('class_student', 'enrollment_id'); } } finally { await runner.release(); } } private async normalizeClassroomStatuses(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables(['classrooms']); if (tables.length === 0) return; await runner.query(` UPDATE classrooms SET status = 'available' WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived') `); } finally { await runner.release(); } } private async ensureAiConfigTable(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables(['ai_config']); const isMySQL = this.dataSource.options.type === 'mysql'; if (tables.length === 0) { const pkDef = isMySQL ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN'; const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP'; await runner.query(` CREATE TABLE ai_config ( ${pkDef}, singleton_key VARCHAR(20) NOT NULL DEFAULT 'GLOBAL', provider VARCHAR(50) NOT NULL DEFAULT 'OPENAI', base_url VARCHAR(500), encrypted_api_key TEXT, api_key_iv VARCHAR(50), api_key_auth_tag VARCHAR(50), key_last4 VARCHAR(4), default_model VARCHAR(100), enabled ${boolType} DEFAULT 0, timeout_ms INT DEFAULT 30000, verified ${boolType} DEFAULT 0, last_tested_at DATETIME, last_test_latency_ms INT, created_at DATETIME NOT NULL DEFAULT ${datetimeFn}, updated_at DATETIME NOT NULL DEFAULT ${datetimeFn} ) `); if (isMySQL) { try { await runner.query( 'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)', ); } catch { // Index may already exist; MySQL has no IF NOT EXISTS for indexes } } else { await runner.query( 'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)', ); } this.logger.log('已创建 ai_config 表'); } else { // Check for missing columns const table = await runner.getTable('ai_config'); const columnNames = new Set(table?.columns.map((c) => c.name) ?? []); const desiredColumns: Array<{ name: string; def: string }> = [ { name: 'id', def: '' }, // skip — primary key { name: 'singleton_key', def: "VARCHAR(20) NOT NULL DEFAULT 'GLOBAL'" }, { name: 'provider', def: "VARCHAR(50) NOT NULL DEFAULT 'OPENAI'" }, { name: 'base_url', def: 'VARCHAR(500)' }, { name: 'encrypted_api_key', def: 'TEXT' }, { name: 'api_key_iv', def: 'VARCHAR(50)' }, { name: 'api_key_auth_tag', def: 'VARCHAR(50)' }, { name: 'key_last4', def: 'VARCHAR(4)' }, { name: 'default_model', def: 'VARCHAR(100)' }, { name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, { name: 'timeout_ms', def: 'INT DEFAULT 30000' }, { name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' }, { name: 'last_tested_at', def: 'DATETIME' }, { name: 'last_test_latency_ms', def: 'INT' }, { name: 'created_at', def: 'DATETIME' }, { name: 'updated_at', def: 'DATETIME' }, ]; for (const col of desiredColumns) { if (col.def && !columnNames.has(col.name)) { await runner.query(`ALTER TABLE ai_config ADD COLUMN ${col.name} ${col.def}`); this.logger.log(`已为 ai_config 表添加列: ${col.name}`); } } } } finally { await runner.release(); } } private async ensureCourseAttendanceSchema(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables([ 'class_schedule', 'attendance_records', 'attendance_sessions', ]); const tableNames = new Set(tables.map((table) => table.name)); const isMySQL = this.dataSource.options.type === 'mysql'; if (!tableNames.has('attendance_sessions')) { const pkDef = isMySQL ? 'id INTEGER PRIMARY KEY AUTO_INCREMENT' : 'id INTEGER PRIMARY KEY AUTOINCREMENT'; await runner.query(` CREATE TABLE attendance_sessions ( ${pkDef}, schedule_id INTEGER NOT NULL, class_id INTEGER NOT NULL, lesson_date DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'in_progress', started_by INTEGER, started_at DATETIME, completed_by INTEGER, completed_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT ) `); } if (tableNames.has('class_schedule')) { const scheduleTable = await runner.getTable('class_schedule'); const scheduleColumns = new Set(scheduleTable?.columns.map((column) => column.name) ?? []); if (!scheduleColumns.has('attendance_advance_minutes')) { await runner.query( 'ALTER TABLE class_schedule ADD COLUMN attendance_advance_minutes INTEGER NOT NULL DEFAULT 30', ); this.logger.log('已为排课添加课前签到分钟配置'); } } const attendanceTable = await runner.getTable('attendance_records'); const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []); if (!columnNames.has('schedule_id')) { await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'); } if (!columnNames.has('attendance_session_id')) { await runner.query( 'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER', ); } const createIndex = async (sql: string) => { try { await runner.query(sql); } catch { // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. } }; await createIndex( isMySQL ? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)' : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', ); await createIndex( isMySQL ? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)' : 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', ); } finally { await runner.release(); } } private async backfillOrganizations(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables([ 'tenants', 'organizations', 'students', 'occupancies', 'classroom_rentals', ]); const tableNames = new Set(tables.map((table) => table.name)); if (!tableNames.has('organizations')) return; const organizationRows = () => runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1'); let host = (await organizationRows())[0]; if (!host) { await runner.query( `INSERT INTO organizations (public_id, code, name, is_host, color, notes, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, [ uuidV7(), 'HOST', process.env.HOST_ORGANIZATION_NAME || '本机构', 1, '#1677ff', '系统默认运营主体', 'active', ], ); host = (await organizationRows())[0]; } if (!host) return; if (tableNames.has('tenants')) { const legacyTenants: Array> = await runner.query('SELECT * FROM tenants'); for (const legacy of legacyTenants) { const name = String(legacy.name || '').trim(); if (!name) continue; let external = ( await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) )[0]; if (!external) { await runner.query( `INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, [ uuidV7(), `ORG_${legacy.id}`, name, 0, legacy.contact || null, legacy.phone || null, legacy.color || null, legacy.notes || null, legacy.status || 'active', ], ); external = ( await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) )[0]; } if (!external) continue; if (tableNames.has('students')) { await runner .query( 'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?', [external.id, legacy.id], ) .catch(() => undefined); } if (tableNames.has('occupancies')) { await runner .query( 'UPDATE occupancies SET responsible_organization_id = ? WHERE responsible_organization_id IS NULL AND tenant_id = ?', [external.id, legacy.id], ) .catch(() => undefined); } if (tableNames.has('classroom_rentals')) { await runner .query( 'UPDATE classroom_rentals SET lessee_organization_id = ?, lessor_organization_id = ? WHERE lessee_organization_id IS NULL AND tenant_id = ?', [external.id, host.id, legacy.id], ) .catch(() => undefined); } } } if (tableNames.has('students')) { await runner.query( 'UPDATE students SET organization_id = ? WHERE organization_id IS NULL', [host.id], ); } if (tableNames.has('occupancies')) { await runner.query( `UPDATE occupancies SET responsible_organization_id = COALESCE( (SELECT organization_id FROM students WHERE students.id = occupancies.student_id), ? ) WHERE responsible_organization_id IS NULL`, [host.id], ); } if (tableNames.has('classroom_rentals')) { await runner.query( 'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL', [host.id], ); } } finally { await runner.release(); } } private async normalizeClassDates(): Promise { const driver = this.dataSource.options.type; let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date']; const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const table = await runner.getTable('classes'); if (!table) return; // Fresh MySQL schemas created by TypeORM already use native DATE columns. // This cleanup is only for legacy schemas that stored dates as strings; // comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE // in strict SQL mode. if (driver === 'mysql') { columns = columns.filter((columnName) => { const column = table.columns.find((item) => item.name === columnName); const type = String(column?.type ?? '').toLowerCase(); return !['date', 'datetime', 'timestamp'].includes(type); }); if (columns.length === 0) return; } } finally { await runner.release(); } const columnText = (column: string) => driver === 'mysql' ? `CAST(${column} AS CHAR)` : column; const firstTenChars = (column: string) => driver === 'mysql' ? `NULLIF(LEFT(${columnText(column)}, 10), '')` : `NULLIF(substr(${column}, 1, 10), '')`; const normalizedDate = (column: string) => `CASE WHEN ${column} IS NULL THEN NULL ELSE ${firstTenChars(column)} END`; const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length'; const needsNormalization = (column: string) => `( ${column} IS NOT NULL AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10) )`; const assignments = columns .map((column) => `${column} = ${normalizedDate(column)}`) .join(',\n '); const predicates = columns.map((column) => needsNormalization(column)).join('\n OR '); const result = await this.dataSource.transaction((manager) => manager.query(` UPDATE classes SET ${assignments} WHERE ${predicates} `), ); const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows; if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`); } private async protectAttendanceHistory(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); try { const tables = await runner.getTables(['attendance_sessions']); if (tables.length === 0) return; const isMySQL = this.dataSource.options.type === 'mysql'; if (isMySQL) { await this.migrateMySQLAttendanceFKs(runner); } else { await this.migrateSQLiteAttendanceFKs(runner); } } finally { await runner.release(); } } private async migrateMySQLAttendanceFKs(runner: QueryRunner): Promise { // Drop any existing FK constraint on schedule_id or class_id const fkColumns = ['schedule_id', 'class_id']; for (const col of fkColumns) { const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query( ` SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'attendance_sessions' AND COLUMN_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL `, [col], ); for (const row of fkRows) { try { await runner.query( `ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``, ); this.logger.log(`已移除考勤场次 FK 约束: ${row.CONSTRAINT_NAME}`); } catch { // constraint may have already been dropped } } } const constraints: Array<{ name: string; col: string; ref: string }> = [ { name: 'fk_as_schedule_protect', col: 'schedule_id', ref: 'class_schedule(id)' }, { name: 'fk_as_class_protect', col: 'class_id', ref: 'classes(id)' }, ]; for (const c of constraints) { // Only skip if RESTRICT constraint is already confirmed via information_schema const existing: Array<{ DELETE_RULE: string }> = await runner.query( ` SELECT DELETE_RULE FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'attendance_sessions' AND CONSTRAINT_NAME = ? `, [c.name], ); if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') { this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`); continue; } // ADD RESTRICT must throw on failure — no catch await runner.query(` ALTER TABLE attendance_sessions ADD CONSTRAINT ${c.name} FOREIGN KEY (${c.col}) REFERENCES ${c.ref} ON DELETE RESTRICT `); this.logger.log(`已添加考勤场次删除保护约束: ${c.name}`); } } private async migrateSQLiteAttendanceFKs(runner: QueryRunner): Promise { // SQLite cannot ALTER TABLE to add foreign keys. // Rebuild the table inside a transaction: create a new table with FK constraints, // copy all rows, drop old, rename new, then recreate indexes. const fkRows: Array<{ id: number }> = await runner.query( "PRAGMA foreign_key_list('attendance_sessions')", ); if (fkRows.length > 0) return; // FKs already present this.logger.log('正在重建 attendance_sessions 表以添加外键保护…'); // PRAGMA foreign_keys=OFF must be issued outside the transaction await runner.query('PRAGMA foreign_keys = OFF'); try { await runner.query('BEGIN'); try { await runner.query(` CREATE TABLE attendance_sessions_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, schedule_id INTEGER NOT NULL, class_id INTEGER NOT NULL, lesson_date DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'in_progress', started_by INTEGER, started_at DATETIME, completed_by INTEGER, completed_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT, FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT ) `); await runner.query(` INSERT INTO attendance_sessions_new ( id, schedule_id, class_id, lesson_date, status, started_by, started_at, completed_by, completed_at, created_at, updated_at ) SELECT id, schedule_id, class_id, lesson_date, status, started_by, started_at, completed_by, completed_at, created_at, updated_at FROM attendance_sessions `); await runner.query('DROP TABLE attendance_sessions'); await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions'); await runner.query( 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', ); // Rebuild attendance_records to add/protect FK on attendance_session_id const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')"); const hasSessionFk = recordsFk.some( (r: { from: string }) => r.from === 'attendance_session_id', ); if (!hasSessionFk) { await runner.query(` CREATE TABLE attendance_records_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id INTEGER NOT NULL, class_id INTEGER, schedule_id INTEGER, attendance_session_id INTEGER, attendance_date DATE NOT NULL, session VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, remark VARCHAR(200), source VARCHAR(20) DEFAULT 'manual', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL ) `); await runner.query(` INSERT INTO attendance_records_new ( id, student_id, class_id, schedule_id, attendance_session_id, attendance_date, session, status, remark, source, created_at, updated_at ) SELECT id, student_id, class_id, schedule_id, attendance_session_id, attendance_date, session, status, remark, source, created_at, updated_at FROM attendance_records `); await runner.query('DROP TABLE attendance_records'); await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records'); await runner.query( 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', ); } // Verify foreign key integrity BEFORE committing the transaction. // If violations exist, the transaction rolls back and old tables are preserved. const checkRows = await runner.query('PRAGMA foreign_key_check'); if (checkRows.length > 0) { throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`); } await runner.query('COMMIT'); this.logger.log('attendance_sessions 表外键保护重建完成'); } catch (err) { await runner.query('ROLLBACK'); throw err; } } finally { await runner.query('PRAGMA foreign_keys = ON'); } } }