forked from wangziqi/gongxue-base
670 lines
26 KiB
TypeScript
670 lines
26 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Record<string, unknown>> =
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<void> {
|
|
// 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');
|
|
}
|
|
}
|
|
}
|