fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling

- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers
- H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables
- M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps)
- M2: split handleSave try/catch — save errors vs reload errors shown distinctly
- M3: added provider field validation before AI config test request
- Added SSE scoping regression tests (import service + controller)
- Added FK check failure rollback test (database-migrations.spec)
- Updated controller spec expectations for userId parameter

Co-authored-by: Code Review <branch-review>
This commit is contained in:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DataSource, QueryRunner } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
@Injectable()
@@ -10,8 +10,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable();
await this.ensureCourseAttendanceSchema();
await this.backfillOrganizations();
await this.normalizeClassDates();
await this.protectAttendanceHistory();
}
private async ensureAiConfigTable(): Promise<void> {
@@ -100,6 +102,70 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
}
}
private async ensureCourseAttendanceSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['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
)
`);
}
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();
@@ -246,4 +312,191 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
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');
}
}
}