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.ensureCourseAttendanceSchema(); 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 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 )`); 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; const dateExpression = (column: string) => driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`; const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length'; const result = await this.dataSource.transaction((manager) => manager.query(` UPDATE classes SET start_date = CASE WHEN start_date IS NULL OR start_date = '' THEN start_date ELSE ${dateExpression('start_date')} END, end_date = CASE WHEN end_date IS NULL OR end_date = '' THEN end_date ELSE ${dateExpression('end_date')} END WHERE (start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10) OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10) `), ); 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'); } } }