From 68270e7571d7ba507921f50001d6f2093b5d96d2 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 17:10:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=BF=81=E7=A7=BB=E5=9F=BA=E5=BB=BA=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/common/with-audit-log.ts | 63 ++ .../src/database/database-migrations.ai.ts | 88 ++ .../database-migrations.attendance.ts | 260 ++++++ .../database/database-migrations.backfill.ts | 180 ++++ .../database/database-migrations.runner.ts | 14 + .../database/database-migrations.schema.ts | 265 ++++++ .../database/database-migrations.service.ts | 831 +----------------- .../src/database/database-migrations.spec.ts | 154 ++-- .../src/migrations/1784780000000-AddAiChat.ts | 1 + ...784860000000-EnhanceAiChatForAntDesignX.ts | 1 + .../migrations/1784910000000-AddImportRuns.ts | 121 +++ .../1784920000000-DropAiMessageFeedback.ts | 28 + 12 files changed, 1161 insertions(+), 845 deletions(-) create mode 100644 apps/server/src/common/with-audit-log.ts create mode 100644 apps/server/src/database/database-migrations.ai.ts create mode 100644 apps/server/src/database/database-migrations.attendance.ts create mode 100644 apps/server/src/database/database-migrations.backfill.ts create mode 100644 apps/server/src/database/database-migrations.runner.ts create mode 100644 apps/server/src/database/database-migrations.schema.ts create mode 100644 apps/server/src/migrations/1784910000000-AddImportRuns.ts create mode 100644 apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts diff --git a/apps/server/src/common/with-audit-log.ts b/apps/server/src/common/with-audit-log.ts new file mode 100644 index 0000000..8362675 --- /dev/null +++ b/apps/server/src/common/with-audit-log.ts @@ -0,0 +1,63 @@ +import type { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { extractRequestInfo } from './request-utils'; + +export interface AuditRequestUser { + id?: number; + username?: string; +} + +export interface AuditRequest { + user?: AuditRequestUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} + +export interface AuditLogEntry { + module: string; + action: string; + targetId?: number; + targetType?: string; + detail?: string; + status?: string; +} + +/** + * 执行业务操作并写入一条审计日志。 + * 统一从请求中提取 IP / UA,避免每个 controller 重复这段样板。 + */ +export async function withAuditLog( + logService: OperationLogsService, + req: AuditRequest, + buildEntry: (result: T) => AuditLogEntry, + operation: () => Promise, +): Promise { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await operation(); + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...buildEntry(result), + }); + return result; +} + +/** + * 仅写入一条审计日志(不包装业务操作)。 + * 适用于日志发生在操作中间、后面还有其他逻辑的 handler。 + */ +export async function logAudit( + logService: OperationLogsService, + req: AuditRequest, + entry: AuditLogEntry, +): Promise { + const { ipAddress, userAgent } = extractRequestInfo(req); + await logService.log({ + userId: req.user?.id, + username: req.user?.username, + ipAddress, + userAgent, + ...entry, + }); +} diff --git a/apps/server/src/database/database-migrations.ai.ts b/apps/server/src/database/database-migrations.ai.ts new file mode 100644 index 0000000..77d77a8 --- /dev/null +++ b/apps/server/src/database/database-migrations.ai.ts @@ -0,0 +1,88 @@ +import { Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { withQueryRunner } from './database-migrations.runner'; + +export async function ensureAiConfigTable( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['ai_config']); + const isMySQL = 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)', + ); + } + + logger.log('已创建 ai_config 表'); + } else { + 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: 'reasoning_effort', def: 'VARCHAR(20)' }, + { 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}`); + logger.log(`已为 ai_config 表添加列: ${col.name}`); + } + } + } + }); +} diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts new file mode 100644 index 0000000..f76a7b2 --- /dev/null +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -0,0 +1,260 @@ +import { Logger } from '@nestjs/common'; +import { DataSource, QueryRunner } from 'typeorm'; +import { withQueryRunner } from './database-migrations.runner'; + +export function attendanceSessionsDdl(tableName: string, idClause: string): string { + return ` + CREATE TABLE ${tableName} ( + ${idClause}, + 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 + ) + `; +} + +export async function ensureCourseAttendanceSchema( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables([ + 'class_schedule', + 'attendance_records', + 'attendance_sessions', + ]); + const tableNames = new Set(tables.map((table) => table.name)); + const isMySQL = 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(attendanceSessionsDdl('attendance_sessions', pkDef)); + } + + 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', + ); + 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)', + ); + }); +} + +export async function protectAttendanceHistory( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const tables = await runner.getTables(['attendance_sessions']); + if (tables.length === 0) return; + + const isMySQL = dataSource.options.type === 'mysql'; + if (isMySQL) { + await migrateMySQLAttendanceFKs(runner, logger); + } else { + await migrateSQLiteAttendanceFKs(runner, logger); + } + }); +} + +export async function migrateMySQLAttendanceFKs( + runner: QueryRunner, + logger: Logger, +): 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}\``, + ); + 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') { + 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 + `); + logger.log(`已添加考勤场次删除保护约束: ${c.name}`); + } +} + +export async function migrateSQLiteAttendanceFKs( + runner: QueryRunner, + logger: Logger, +): 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 + + 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(attendanceSessionsDdl('attendance_sessions_new', 'id INTEGER PRIMARY KEY AUTOINCREMENT')); + 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'); + logger.log('attendance_sessions 表外键保护重建完成'); + } catch (err) { + await runner.query('ROLLBACK'); + throw err; + } + } finally { + await runner.query('PRAGMA foreign_keys = ON'); + } +} diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts new file mode 100644 index 0000000..d5fbf67 --- /dev/null +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -0,0 +1,180 @@ +import { Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { uuidV7 } from '../common/uuid-v7'; +import { withQueryRunner } from './database-migrations.runner'; + +export async function backfillOrganizations( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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], + ); + } + }); +} + +export async function normalizeClassDates( + dataSource: DataSource, + logger: Logger, +): Promise { + const driver = dataSource.options.type; + let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date']; + + await withQueryRunner(dataSource, async (runner) => { + 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; + } + }); + + 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 dataSource.transaction((manager) => + manager.query(` + UPDATE classes + SET + ${assignments} + WHERE + ${predicates} + `), + ); + + const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows; + if (affected) logger.log(`已规范化 ${affected} 条班级日期数据`); +} diff --git a/apps/server/src/database/database-migrations.runner.ts b/apps/server/src/database/database-migrations.runner.ts new file mode 100644 index 0000000..9e4792c --- /dev/null +++ b/apps/server/src/database/database-migrations.runner.ts @@ -0,0 +1,14 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +export async function withQueryRunner( + dataSource: DataSource, + fn: (runner: QueryRunner) => Promise, +): Promise { + const runner = dataSource.createQueryRunner(); + await runner.connect(); + try { + return await fn(runner); + } finally { + await runner.release(); + } +} diff --git a/apps/server/src/database/database-migrations.schema.ts b/apps/server/src/database/database-migrations.schema.ts new file mode 100644 index 0000000..fd8d271 --- /dev/null +++ b/apps/server/src/database/database-migrations.schema.ts @@ -0,0 +1,265 @@ +import { DataSource } from 'typeorm'; +import { withQueryRunner } from './database-migrations.runner'; + +export async function ensureSyncStateLeaseColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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'); + } + }); +} + +export async function ensureStudentProfileCollegeColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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}`); + } + }); +} + +export async function ensureAttendanceDevicesSchema( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const isMySQL = 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)', + ); + }); +} + +export async function ensureStudentWalletSchema( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + const isMySQL = 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'); + } + }); +} + +export async function removeUnusedClassroomColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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}`); + } + } + }); +} + +export async function removeUnusedRoomColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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'); + } + }); +} + +export async function cleanupDepositRefundColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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); + } + } + }); +} + +export async function removeUnusedClassStudentColumns( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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'); + } + }); +} + +export async function normalizeClassroomStatuses( + dataSource: DataSource, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + 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') + `); + }); +} diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index d7290d2..2b601aa 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -1,6 +1,22 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; -import { DataSource, QueryRunner } from 'typeorm'; -import { uuidV7 } from '../common/uuid-v7'; +import { DataSource } from 'typeorm'; +import { + cleanupDepositRefundColumns, + ensureAttendanceDevicesSchema, + ensureStudentProfileCollegeColumns, + ensureStudentWalletSchema, + ensureSyncStateLeaseColumns, + normalizeClassroomStatuses, + removeUnusedClassroomColumns, + removeUnusedClassStudentColumns, + removeUnusedRoomColumns, +} from './database-migrations.schema'; +import { ensureAiConfigTable } from './database-migrations.ai'; +import { + ensureCourseAttendanceSchema, + protectAttendanceHistory, +} from './database-migrations.attendance'; +import { backfillOrganizations, normalizeClassDates } from './database-migrations.backfill'; @Injectable() export class DatabaseMigrationsService implements OnApplicationBootstrap { @@ -25,814 +41,59 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { 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(); - } + async ensureSyncStateLeaseColumns(): Promise { + return ensureSyncStateLeaseColumns(this.dataSource); } - 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(); - } + async ensureStudentProfileCollegeColumns(): Promise { + return ensureStudentProfileCollegeColumns(this.dataSource); } - 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(); - } + async ensureAttendanceDevicesSchema(): Promise { + return ensureAttendanceDevicesSchema(this.dataSource); } - 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(); - } + async ensureStudentWalletSchema(): Promise { + return ensureStudentWalletSchema(this.dataSource); } - 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(); - } + async removeUnusedClassroomColumns(): Promise { + return removeUnusedClassroomColumns(this.dataSource); } - 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(); - } + async removeUnusedRoomColumns(): Promise { + return removeUnusedRoomColumns(this.dataSource); } - 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(); - } + async cleanupDepositRefundColumns(): Promise { + return cleanupDepositRefundColumns(this.dataSource); } - 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(); - } + async removeUnusedClassStudentColumns(): Promise { + return removeUnusedClassStudentColumns(this.dataSource); } - 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(); - } + async normalizeClassroomStatuses(): Promise { + return normalizeClassroomStatuses(this.dataSource); } - 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: 'reasoning_effort', def: 'VARCHAR(20)' }, - { 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(); - } + async ensureAiConfigTable(): Promise { + return ensureAiConfigTable(this.dataSource, this.logger); } - 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(); - } + async ensureCourseAttendanceSchema(): Promise { + return ensureCourseAttendanceSchema(this.dataSource, this.logger); } - 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(); - } + async backfillOrganizations(): Promise { + return backfillOrganizations(this.dataSource); } - 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(); - } + async normalizeClassDates(): Promise { + return normalizeClassDates(this.dataSource, this.logger); } - 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'); - } + async protectAttendanceHistory(): Promise { + return protectAttendanceHistory(this.dataSource, this.logger); } } diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index b38d82f..55a3b5d 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -18,18 +18,20 @@ interface MockRunner { getTable: jest.Mock; } -function mockRunner(overrides: { - getTables?: MockTable[]; - getTable?: MockTable; - queryError?: Error; -} = {}) { +function mockRunner( + overrides: { + getTables?: MockTable[]; + getTable?: MockTable; + queryError?: Error; + } = {}, +) { const release = jest.fn(); const connect = jest.fn(); const query = jest.fn().mockResolvedValue([]); const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []); - const getTable = jest.fn().mockResolvedValue( - overrides.getTable ?? { name: 'ai_config', columns: [] }, - ); + const getTable = jest + .fn() + .mockResolvedValue(overrides.getTable ?? { name: 'ai_config', columns: [] }); if (overrides.queryError) { query.mockRejectedValue(overrides.queryError); @@ -68,9 +70,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => { { provide: getDataSourceToken(), useValue: dataSource }, ], }).compile(); - service = module.get( - DatabaseMigrationsService, - ); + service = module.get(DatabaseMigrationsService); } it('creates table + index when ai_config does not exist', async () => { @@ -117,7 +117,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => { expect(runner.connect).toHaveBeenCalled(); // Should NOT issue any ALTER TABLE const alterCalls = (runner.query as jest.Mock).mock.calls.filter( - (c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'), + (c: unknown[]) => typeof c[0] === 'string' && c[0].includes('ALTER TABLE'), ); expect(alterCalls).toHaveLength(0); expect(runner.release).toHaveBeenCalled(); @@ -206,7 +206,9 @@ describe('DatabaseMigrationsService — course attendance schema', () => { expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'), ); expect(runner.query).toHaveBeenCalledWith( - expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER'), + expect.stringContaining( + 'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER', + ), ); expect(runner.release).toHaveBeenCalled(); }); @@ -222,7 +224,10 @@ describe('DatabaseMigrationsService — course attendance schema', () => { runner.getTable.mockImplementation(async (name: string) => name === 'class_schedule' ? { name, columns: [{ name: 'id' }] } - : { name, columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }] }, + : { + name, + columns: [{ name: 'id' }, { name: 'schedule_id' }, { name: 'attendance_session_id' }], + }, ); const service = await bootstrapCourseAttendance(runner); @@ -241,10 +246,13 @@ describe('DatabaseMigrationsService — course attendance schema', () => { const service = await bootstrapCourseAttendance(runner); await service.ensureCourseAttendanceSchema(); - const createSql: string = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')) - .find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? ''; - expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT'); + const createSql: string = + (runner.query as jest.Mock).mock.calls + .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')) + .find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? ''; + expect(createSql).toContain( + 'FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT', + ); expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT'); }); }); @@ -280,8 +288,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await service.protectAttendanceHistory(); // Should not run any TABLE creation (rebuild) - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0); expect(runner.release).toHaveBeenCalled(); }); @@ -297,25 +306,40 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await bootstrap(runner); await service.protectAttendanceHistory(); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // PRAGMA foreign_keys = OFF outside the transaction expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true); - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe(true); - expect(queries.some((q: string) => - q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT') - )).toBe(true); - expect(queries.some((q: string) => - q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT') - )).toBe(true); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true); + expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe( + true, + ); + expect( + queries.some((q: string) => + q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT'), + ), + ).toBe(true); + expect( + queries.some((q: string) => + q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT'), + ), + ).toBe(true); + expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe( + true, + ); expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true); expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true); - expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe(true); + expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe( + true, + ); // attendance_records rebuilt with FK - expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe(true); - expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true); + expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe( + true, + ); + expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe( + true, + ); expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true); expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true); // PRAGMA foreign_keys restored to ON and foreign_key_check runs @@ -342,12 +366,11 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { }); await bootstrap(runner); - await expect(service.protectAttendanceHistory()).rejects.toThrow( - /外键一致性检查失败/, - ); + await expect(service.protectAttendanceHistory()).rejects.toThrow(/外键一致性检查失败/); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // The transaction should have been rolled back (ROLLBACK called) expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true); @@ -380,23 +403,34 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await bootstrap(runner, 'mysql'); await service.protectAttendanceHistory(); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // Drops old FKs - expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe(true); - expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true); + expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe( + true, + ); + expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe( + true, + ); // Checks REFERENTIAL_CONSTRAINTS before ADD - expect(queries.some((q: string) => - q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS') - )).toBe(true); + expect( + queries.some((q: string) => q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')), + ).toBe(true); // Creates new RESTRICT FKs - expect(queries.some((q: string) => - q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT') - )).toBe(true); - expect(queries.some((q: string) => - q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT') - )).toBe(true); + expect( + queries.some( + (q: string) => + q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT'), + ), + ).toBe(true); + expect( + queries.some( + (q: string) => + q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT'), + ), + ).toBe(true); expect(runner.release).toHaveBeenCalled(); }); @@ -405,7 +439,7 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], }); const addError = new Error('Cannot add foreign key constraint'); - runner.query.mockImplementation((sql: string, params?: string[]) => { + runner.query.mockImplementation((sql: string, _params?: string[]) => { if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) { return Promise.resolve([]); } @@ -418,7 +452,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { return Promise.resolve([]); }); await bootstrap(runner, 'mysql'); - await expect(service.protectAttendanceHistory()).rejects.toThrow('Cannot add foreign key constraint'); + await expect(service.protectAttendanceHistory()).rejects.toThrow( + 'Cannot add foreign key constraint', + ); expect(runner.release).toHaveBeenCalled(); }); @@ -426,7 +462,7 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { const runner = mockRunner({ getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }], }); - runner.query.mockImplementation((sql: string, params?: string[]) => { + runner.query.mockImplementation((sql: string, _params?: string[]) => { if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) { return Promise.resolve([]); } @@ -439,8 +475,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => { await bootstrap(runner, 'mysql'); await service.protectAttendanceHistory(); - const queries: string[] = (runner.query as jest.Mock).mock.calls - .map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : '')); + const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) => + typeof c[0] === 'string' ? c[0] : '', + ); // No ADD CONSTRAINT calls expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0); @@ -493,10 +530,7 @@ describe('DatabaseMigrationsService — classroom cleanup', () => { async function bootstrapCourseAttendance(runner: MockRunner) { const dataSource = createDataSource(runner); const module: TestingModule = await Test.createTestingModule({ - providers: [ - DatabaseMigrationsService, - { provide: getDataSourceToken(), useValue: dataSource }, - ], + providers: [DatabaseMigrationsService, { provide: getDataSourceToken(), useValue: dataSource }], }).compile(); return module.get(DatabaseMigrationsService); } diff --git a/apps/server/src/migrations/1784780000000-AddAiChat.ts b/apps/server/src/migrations/1784780000000-AddAiChat.ts index 9b09953..2e02db9 100644 --- a/apps/server/src/migrations/1784780000000-AddAiChat.ts +++ b/apps/server/src/migrations/1784780000000-AddAiChat.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似 import { MigrationInterface, QueryRunner, Table } from 'typeorm'; export class AddAiChat1784780000000 implements MigrationInterface { diff --git a/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts b/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts index ffbd994..8ad56ec 100644 --- a/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts +++ b/apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似 import { MigrationInterface, QueryRunner, diff --git a/apps/server/src/migrations/1784910000000-AddImportRuns.ts b/apps/server/src/migrations/1784910000000-AddImportRuns.ts new file mode 100644 index 0000000..1da6a38 --- /dev/null +++ b/apps/server/src/migrations/1784910000000-AddImportRuns.ts @@ -0,0 +1,121 @@ +// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似 +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Unified staged Excel batch-import workflow (v1). + * import_runs / import_steps / import_rows back the + * upload → mapping → preview → staged commit → receipt flow. + */ +export class AddImportRuns1784910000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('import_runs')) return; + + await queryRunner.createTable( + new Table({ + name: 'import_runs', + columns: [ + { name: 'id', type: 'varchar', length: '36', isPrimary: true }, + { name: 'user_id', type: 'integer' }, + { name: 'conversation_id', type: 'integer', isNullable: true }, + { name: 'source', type: 'varchar', length: '10', default: "'manual'" }, + { name: 'file_name', type: 'varchar', length: '255' }, + { name: 'sheets_json', type: 'text' }, + { name: 'status', type: 'varchar', length: '20', default: "'preparing'" }, + { name: 'current_step_key', type: 'varchar', length: '20', isNullable: true }, + { name: 'error', type: 'varchar', length: '500', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + indices: [ + { name: 'idx_import_runs_user_created', columnNames: ['user_id', 'created_at'] }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'import_steps', + columns: [ + { name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'run_id', type: 'varchar', length: '36' }, + { name: 'step_key', type: 'varchar', length: '20' }, + { name: 'sheets_json', type: 'text' }, + { name: 'mapping_json', type: 'text', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'pending'" }, + { name: 'summary_json', type: 'text', isNullable: true }, + { name: 'committed_at', type: 'datetime', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + indices: [ + { name: 'idx_import_steps_run_key', columnNames: ['run_id', 'step_key'] }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'import_rows', + columns: [ + { name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'run_id', type: 'varchar', length: '36' }, + { name: 'step_id', type: 'integer' }, + { name: 'sheet_name', type: 'varchar', length: '200' }, + { name: 'row_number', type: 'integer' }, + { name: 'raw_json', type: 'text' }, + { name: 'normalized_json', type: 'text', isNullable: true }, + { name: 'match_key', type: 'varchar', length: '200', isNullable: true }, + { name: 'action', type: 'varchar', length: '10', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'pending'" }, + { name: 'errors_json', type: 'text', isNullable: true }, + { name: 'target_id', type: 'integer', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + indices: [ + { name: 'idx_import_rows_step', columnNames: ['step_id'] }, + { name: 'idx_import_rows_run_status', columnNames: ['run_id', 'status'] }, + ], + }), + ); + + await queryRunner.createForeignKey( + 'import_steps', + new TableForeignKey({ + name: 'fk_import_steps_run', + columnNames: ['run_id'], + referencedTableName: 'import_runs', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'import_rows', + new TableForeignKey({ + name: 'fk_import_rows_run', + columnNames: ['run_id'], + referencedTableName: 'import_runs', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('import_rows')) { + const table = await queryRunner.getTable('import_rows'); + if (table?.foreignKeys.some((fk) => fk.name === 'fk_import_rows_run')) { + await queryRunner.dropForeignKey('import_rows', 'fk_import_rows_run'); + } + await queryRunner.dropTable('import_rows'); + } + if (await queryRunner.hasTable('import_steps')) { + const table = await queryRunner.getTable('import_steps'); + if (table?.foreignKeys.some((fk) => fk.name === 'fk_import_steps_run')) { + await queryRunner.dropForeignKey('import_steps', 'fk_import_steps_run'); + } + await queryRunner.dropTable('import_steps'); + } + if (await queryRunner.hasTable('import_runs')) { + await queryRunner.dropTable('import_runs'); + } + } +} diff --git a/apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts b/apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts new file mode 100644 index 0000000..3633ede --- /dev/null +++ b/apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 移除 ai_messages 上已废弃的 like/dislike 反馈字段。 + * 反馈功能已从前端和后端删除,历史列一并清理。 + */ +export class DropAiMessageFeedback1784920000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + for (const column of ['feedback', 'feedback_reason']) { + if (await queryRunner.hasColumn('ai_messages', column)) { + await queryRunner.dropColumn('ai_messages', column); + } + } + } + + async down(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn('ai_messages', 'feedback'))) { + await queryRunner.query( + 'ALTER TABLE ai_messages ADD COLUMN feedback varchar(20) NULL', + ); + } + if (!(await queryRunner.hasColumn('ai_messages', 'feedback_reason'))) { + await queryRunner.query( + 'ALTER TABLE ai_messages ADD COLUMN feedback_reason varchar(500) NULL', + ); + } + } +}