refactor: 拆分数据库迁移基建并新增审计日志工具
This commit is contained in:
63
apps/server/src/common/with-audit-log.ts
Normal file
63
apps/server/src/common/with-audit-log.ts
Normal file
@@ -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<string, string | string[] | undefined>;
|
||||||
|
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<T>(
|
||||||
|
logService: OperationLogsService,
|
||||||
|
req: AuditRequest,
|
||||||
|
buildEntry: (result: T) => AuditLogEntry,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
await logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
...entry,
|
||||||
|
});
|
||||||
|
}
|
||||||
88
apps/server/src/database/database-migrations.ai.ts
Normal file
88
apps/server/src/database/database-migrations.ai.ts
Normal file
@@ -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<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
260
apps/server/src/database/database-migrations.attendance.ts
Normal file
260
apps/server/src/database/database-migrations.attendance.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<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}\``,
|
||||||
|
);
|
||||||
|
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<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
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
180
apps/server/src/database/database-migrations.backfill.ts
Normal file
180
apps/server/src/database/database-migrations.backfill.ts
Normal file
@@ -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<void> {
|
||||||
|
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<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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeClassDates(
|
||||||
|
dataSource: DataSource,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
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} 条班级日期数据`);
|
||||||
|
}
|
||||||
14
apps/server/src/database/database-migrations.runner.ts
Normal file
14
apps/server/src/database/database-migrations.runner.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { DataSource, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export async function withQueryRunner<T>(
|
||||||
|
dataSource: DataSource,
|
||||||
|
fn: (runner: QueryRunner) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const runner = dataSource.createQueryRunner();
|
||||||
|
await runner.connect();
|
||||||
|
try {
|
||||||
|
return await fn(runner);
|
||||||
|
} finally {
|
||||||
|
await runner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
265
apps/server/src/database/database-migrations.schema.ts
Normal file
265
apps/server/src/database/database-migrations.schema.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { withQueryRunner } from './database-migrations.runner';
|
||||||
|
|
||||||
|
export async function ensureSyncStateLeaseColumns(
|
||||||
|
dataSource: DataSource,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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')
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { DataSource, QueryRunner } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { uuidV7 } from '../common/uuid-v7';
|
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()
|
@Injectable()
|
||||||
export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||||
@@ -25,814 +41,59 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
await this.normalizeClassroomStatuses();
|
await this.normalizeClassroomStatuses();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureSyncStateLeaseColumns(): Promise<void> {
|
async ensureSyncStateLeaseColumns(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return ensureSyncStateLeaseColumns(this.dataSource);
|
||||||
await runner.connect();
|
|
||||||
try {
|
|
||||||
const table = await runner.getTable('sync_state');
|
|
||||||
if (!table) return;
|
|
||||||
const columns = new Set(table.columns.map((column) => column.name));
|
|
||||||
if (!columns.has('run_id')) {
|
|
||||||
await runner.query('ALTER TABLE sync_state ADD COLUMN run_id VARCHAR(64)');
|
|
||||||
}
|
|
||||||
if (!columns.has('running_since')) {
|
|
||||||
await runner.query('ALTER TABLE sync_state ADD COLUMN running_since DATETIME');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await runner.release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureStudentProfileCollegeColumns(): Promise<void> {
|
async ensureStudentProfileCollegeColumns(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return ensureStudentProfileCollegeColumns(this.dataSource);
|
||||||
await runner.connect();
|
|
||||||
try {
|
|
||||||
const table = await runner.getTable('student_profiles');
|
|
||||||
if (!table) return;
|
|
||||||
const columns = new Set(table.columns.map((column) => column.name));
|
|
||||||
const additions: Array<[string, string]> = [
|
|
||||||
['college_school', 'VARCHAR(100)'],
|
|
||||||
['college_major', 'VARCHAR(100)'],
|
|
||||||
];
|
|
||||||
for (const [name, definition] of additions) {
|
|
||||||
if (!columns.has(name)) await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await runner.release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureAttendanceDevicesSchema(): Promise<void> {
|
async ensureAttendanceDevicesSchema(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return ensureAttendanceDevicesSchema(this.dataSource);
|
||||||
await runner.connect();
|
|
||||||
try {
|
|
||||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
|
||||||
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
|
||||||
await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices (
|
|
||||||
id ${pk},
|
|
||||||
device_sn VARCHAR(100) NOT NULL,
|
|
||||||
device_name VARCHAR(100) NOT NULL,
|
|
||||||
classroom_id INTEGER NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
|
||||||
location VARCHAR(200),
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`);
|
|
||||||
const table = await runner.getTable('attendance_devices');
|
|
||||||
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
|
|
||||||
const additions: Array<[string, string]> = [
|
|
||||||
['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
|
|
||||||
['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
|
|
||||||
['classroom_id', 'INTEGER NOT NULL DEFAULT 0'],
|
|
||||||
['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"],
|
|
||||||
['location', 'VARCHAR(200)'],
|
|
||||||
['notes', 'TEXT'],
|
|
||||||
['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
|
|
||||||
['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
|
|
||||||
];
|
|
||||||
for (const [name, definition] of additions) {
|
|
||||||
if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`);
|
|
||||||
}
|
|
||||||
const refreshed = await runner.getTable('attendance_devices');
|
|
||||||
const createIndex = async (sql: string) => {
|
|
||||||
try {
|
|
||||||
await runner.query(sql);
|
|
||||||
} catch {
|
|
||||||
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique);
|
|
||||||
if (!uniqueSn) {
|
|
||||||
await createIndex(
|
|
||||||
isMySQL
|
|
||||||
? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)'
|
|
||||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await createIndex(
|
|
||||||
isMySQL
|
|
||||||
? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)'
|
|
||||||
: 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await runner.release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureStudentWalletSchema(): Promise<void> {
|
async ensureStudentWalletSchema(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return ensureStudentWalletSchema(this.dataSource);
|
||||||
await runner.connect();
|
|
||||||
try {
|
|
||||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
|
||||||
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
|
||||||
await runner.query(`CREATE TABLE IF NOT EXISTS student_wallets (
|
|
||||||
id ${pk}, student_id INTEGER NOT NULL UNIQUE, balance DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`);
|
|
||||||
await runner.query(`CREATE TABLE IF NOT EXISTS wallet_transactions (
|
|
||||||
id ${pk}, student_id INTEGER NOT NULL, bill_id INTEGER, type VARCHAR(30) NOT NULL,
|
|
||||||
amount DECIMAL(12,2) NOT NULL, balance_after DECIMAL(12,2) NOT NULL,
|
|
||||||
description VARCHAR(300), recorded_by INTEGER,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`);
|
|
||||||
await runner.query(`CREATE TABLE IF NOT EXISTS financial_operations (
|
|
||||||
id ${pk}, operation_id VARCHAR(64) NOT NULL UNIQUE, type VARCHAR(64) NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'running', result_json TEXT, error_message VARCHAR(500),
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`);
|
|
||||||
const walletTransactions = await runner.getTable('wallet_transactions');
|
|
||||||
if (walletTransactions) {
|
|
||||||
const columns = new Set(walletTransactions.columns.map((column) => column.name));
|
|
||||||
if (!columns.has('operation_id')) {
|
|
||||||
await runner.query('ALTER TABLE wallet_transactions ADD COLUMN operation_id VARCHAR(64)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const billItems = await runner.getTable('bill_items');
|
|
||||||
if (billItems) {
|
|
||||||
const columns = new Set(billItems.columns.map((column) => column.name));
|
|
||||||
for (const [name, definition] of [
|
|
||||||
['room_expense_id', 'INTEGER'],
|
|
||||||
['personal_expense_id', 'INTEGER'],
|
|
||||||
]) {
|
|
||||||
if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const roomExpenses = await runner.getTable('room_expenses');
|
|
||||||
if (roomExpenses) {
|
|
||||||
const columns = new Set(roomExpenses.columns.map((column) => column.name));
|
|
||||||
if (!columns.has('import_key')) {
|
|
||||||
await runner.query('ALTER TABLE room_expenses ADD COLUMN import_key VARCHAR(120)');
|
|
||||||
}
|
|
||||||
const refreshedRoomExpenses = await runner.getTable('room_expenses');
|
|
||||||
const hasImportKey = refreshedRoomExpenses?.indices.some((index) =>
|
|
||||||
index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key');
|
|
||||||
if (!hasImportKey) {
|
|
||||||
await runner.query(isMySQL
|
|
||||||
? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)'
|
|
||||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const bills = await runner.getTable('bills');
|
|
||||||
if (bills) {
|
|
||||||
const columns = new Set(bills.columns.map((column) => column.name));
|
|
||||||
const additions = [
|
|
||||||
['source', "VARCHAR(30) NOT NULL DEFAULT 'batch'"],
|
|
||||||
['paid_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
|
|
||||||
['outstanding_amount', 'DECIMAL(10,2) NOT NULL DEFAULT 0'],
|
|
||||||
['cancelled_at', 'DATETIME'],
|
|
||||||
['cancel_reason', 'VARCHAR(300)'],
|
|
||||||
];
|
|
||||||
for (const [name, definition] of additions) {
|
|
||||||
if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`);
|
|
||||||
}
|
|
||||||
await runner.query("UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'");
|
|
||||||
await runner.query("UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'");
|
|
||||||
await runner.query("UPDATE bills SET status = 'unpaid' WHERE status IN ('draft', 'confirmed')");
|
|
||||||
}
|
|
||||||
const personalExpenses = await runner.getTable('personal_expenses');
|
|
||||||
if (personalExpenses && !personalExpenses.columns.some((column) => column.name === 'bill_id')) {
|
|
||||||
await runner.query('ALTER TABLE personal_expenses ADD COLUMN bill_id INTEGER');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await runner.release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeUnusedClassroomColumns(): Promise<void> {
|
async removeUnusedClassroomColumns(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return removeUnusedClassroomColumns(this.dataSource);
|
||||||
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> {
|
async removeUnusedRoomColumns(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return removeUnusedRoomColumns(this.dataSource);
|
||||||
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> {
|
async cleanupDepositRefundColumns(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return cleanupDepositRefundColumns(this.dataSource);
|
||||||
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> {
|
async removeUnusedClassStudentColumns(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return removeUnusedClassStudentColumns(this.dataSource);
|
||||||
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> {
|
async normalizeClassroomStatuses(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return normalizeClassroomStatuses(this.dataSource);
|
||||||
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> {
|
async ensureAiConfigTable(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return ensureAiConfigTable(this.dataSource, this.logger);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureCourseAttendanceSchema(): Promise<void> {
|
async ensureCourseAttendanceSchema(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return ensureCourseAttendanceSchema(this.dataSource, this.logger);
|
||||||
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> {
|
async backfillOrganizations(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
return backfillOrganizations(this.dataSource);
|
||||||
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> {
|
async normalizeClassDates(): Promise<void> {
|
||||||
const driver = this.dataSource.options.type;
|
return normalizeClassDates(this.dataSource, this.logger);
|
||||||
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<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> {
|
async protectAttendanceHistory(): Promise<void> {
|
||||||
// Drop any existing FK constraint on schedule_id or class_id
|
return protectAttendanceHistory(this.dataSource, this.logger);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,18 +18,20 @@ interface MockRunner {
|
|||||||
getTable: jest.Mock;
|
getTable: jest.Mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockRunner(overrides: {
|
function mockRunner(
|
||||||
getTables?: MockTable[];
|
overrides: {
|
||||||
getTable?: MockTable;
|
getTables?: MockTable[];
|
||||||
queryError?: Error;
|
getTable?: MockTable;
|
||||||
} = {}) {
|
queryError?: Error;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const release = jest.fn();
|
const release = jest.fn();
|
||||||
const connect = jest.fn();
|
const connect = jest.fn();
|
||||||
const query = jest.fn().mockResolvedValue([]);
|
const query = jest.fn().mockResolvedValue([]);
|
||||||
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
|
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
|
||||||
const getTable = jest.fn().mockResolvedValue(
|
const getTable = jest
|
||||||
overrides.getTable ?? { name: 'ai_config', columns: [] },
|
.fn()
|
||||||
);
|
.mockResolvedValue(overrides.getTable ?? { name: 'ai_config', columns: [] });
|
||||||
|
|
||||||
if (overrides.queryError) {
|
if (overrides.queryError) {
|
||||||
query.mockRejectedValue(overrides.queryError);
|
query.mockRejectedValue(overrides.queryError);
|
||||||
@@ -68,9 +70,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
|
|||||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = module.get(
|
service = module.get(DatabaseMigrationsService);
|
||||||
DatabaseMigrationsService,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('creates table + index when ai_config does not exist', async () => {
|
it('creates table + index when ai_config does not exist', async () => {
|
||||||
@@ -117,7 +117,7 @@ describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
|
|||||||
expect(runner.connect).toHaveBeenCalled();
|
expect(runner.connect).toHaveBeenCalled();
|
||||||
// Should NOT issue any ALTER TABLE
|
// Should NOT issue any ALTER TABLE
|
||||||
const alterCalls = (runner.query as jest.Mock).mock.calls.filter(
|
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(alterCalls).toHaveLength(0);
|
||||||
expect(runner.release).toHaveBeenCalled();
|
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.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'),
|
||||||
);
|
);
|
||||||
expect(runner.query).toHaveBeenCalledWith(
|
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();
|
expect(runner.release).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -222,7 +224,10 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
|
|||||||
runner.getTable.mockImplementation(async (name: string) =>
|
runner.getTable.mockImplementation(async (name: string) =>
|
||||||
name === 'class_schedule'
|
name === 'class_schedule'
|
||||||
? { name, columns: [{ name: 'id' }] }
|
? { 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);
|
const service = await bootstrapCourseAttendance(runner);
|
||||||
|
|
||||||
@@ -241,10 +246,13 @@ describe('DatabaseMigrationsService — course attendance schema', () => {
|
|||||||
const service = await bootstrapCourseAttendance(runner);
|
const service = await bootstrapCourseAttendance(runner);
|
||||||
await service.ensureCourseAttendanceSchema();
|
await service.ensureCourseAttendanceSchema();
|
||||||
|
|
||||||
const createSql: string = (runner.query as jest.Mock).mock.calls
|
const createSql: string =
|
||||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''))
|
(runner.query as jest.Mock).mock.calls
|
||||||
.find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? '';
|
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''))
|
||||||
expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT');
|
.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');
|
expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -280,8 +288,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
await service.protectAttendanceHistory();
|
await service.protectAttendanceHistory();
|
||||||
|
|
||||||
// Should not run any TABLE creation (rebuild)
|
// Should not run any TABLE creation (rebuild)
|
||||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) =>
|
||||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
typeof c[0] === 'string' ? c[0] : '',
|
||||||
|
);
|
||||||
expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0);
|
expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0);
|
||||||
expect(runner.release).toHaveBeenCalled();
|
expect(runner.release).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -297,25 +306,40 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
await bootstrap(runner);
|
await bootstrap(runner);
|
||||||
await service.protectAttendanceHistory();
|
await service.protectAttendanceHistory();
|
||||||
|
|
||||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) =>
|
||||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
typeof c[0] === 'string' ? c[0] : '',
|
||||||
|
);
|
||||||
|
|
||||||
// PRAGMA foreign_keys = OFF outside the transaction
|
// 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('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('CREATE TABLE attendance_sessions_new'))).toBe(
|
||||||
expect(queries.some((q: string) =>
|
true,
|
||||||
q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT')
|
);
|
||||||
)).toBe(true);
|
expect(
|
||||||
expect(queries.some((q: string) =>
|
queries.some((q: string) =>
|
||||||
q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT')
|
q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT'),
|
||||||
)).toBe(true);
|
),
|
||||||
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true);
|
).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('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('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
|
// 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('CREATE TABLE attendance_records_new'))).toBe(
|
||||||
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true);
|
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('DROP TABLE attendance_records'))).toBe(true);
|
||||||
expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).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
|
// PRAGMA foreign_keys restored to ON and foreign_key_check runs
|
||||||
@@ -342,12 +366,11 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
});
|
});
|
||||||
await bootstrap(runner);
|
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
|
const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) =>
|
||||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
typeof c[0] === 'string' ? c[0] : '',
|
||||||
|
);
|
||||||
|
|
||||||
// The transaction should have been rolled back (ROLLBACK called)
|
// The transaction should have been rolled back (ROLLBACK called)
|
||||||
expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true);
|
expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true);
|
||||||
@@ -380,23 +403,34 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
await bootstrap(runner, 'mysql');
|
await bootstrap(runner, 'mysql');
|
||||||
await service.protectAttendanceHistory();
|
await service.protectAttendanceHistory();
|
||||||
|
|
||||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) =>
|
||||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
typeof c[0] === 'string' ? c[0] : '',
|
||||||
|
);
|
||||||
|
|
||||||
// Drops old FKs
|
// 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_schedule_cascade`'))).toBe(
|
||||||
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true);
|
true,
|
||||||
|
);
|
||||||
|
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
// Checks REFERENTIAL_CONSTRAINTS before ADD
|
// Checks REFERENTIAL_CONSTRAINTS before ADD
|
||||||
expect(queries.some((q: string) =>
|
expect(
|
||||||
q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')
|
queries.some((q: string) => q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')),
|
||||||
)).toBe(true);
|
).toBe(true);
|
||||||
// Creates new RESTRICT FKs
|
// Creates new RESTRICT FKs
|
||||||
expect(queries.some((q: string) =>
|
expect(
|
||||||
q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT')
|
queries.some(
|
||||||
)).toBe(true);
|
(q: string) =>
|
||||||
expect(queries.some((q: string) =>
|
q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT'),
|
||||||
q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT')
|
),
|
||||||
)).toBe(true);
|
).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();
|
expect(runner.release).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,7 +439,7 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||||
});
|
});
|
||||||
const addError = new Error('Cannot add foreign key constraint');
|
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')) {
|
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
@@ -418,7 +452,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
});
|
});
|
||||||
await bootstrap(runner, 'mysql');
|
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();
|
expect(runner.release).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,7 +462,7 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
const runner = mockRunner({
|
const runner = mockRunner({
|
||||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
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')) {
|
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
@@ -439,8 +475,9 @@ describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
|||||||
await bootstrap(runner, 'mysql');
|
await bootstrap(runner, 'mysql');
|
||||||
await service.protectAttendanceHistory();
|
await service.protectAttendanceHistory();
|
||||||
|
|
||||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
const queries: string[] = (runner.query as jest.Mock).mock.calls.map((c: unknown[]) =>
|
||||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
typeof c[0] === 'string' ? c[0] : '',
|
||||||
|
);
|
||||||
|
|
||||||
// No ADD CONSTRAINT calls
|
// No ADD CONSTRAINT calls
|
||||||
expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0);
|
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) {
|
async function bootstrapCourseAttendance(runner: MockRunner) {
|
||||||
const dataSource = createDataSource(runner);
|
const dataSource = createDataSource(runner);
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [DatabaseMigrationsService, { provide: getDataSourceToken(), useValue: dataSource }],
|
||||||
DatabaseMigrationsService,
|
|
||||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
return module.get<MigrationsPrivate & DatabaseMigrationsService>(DatabaseMigrationsService);
|
return module.get<MigrationsPrivate & DatabaseMigrationsService>(DatabaseMigrationsService);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似
|
||||||
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
||||||
|
|
||||||
export class AddAiChat1784780000000 implements MigrationInterface {
|
export class AddAiChat1784780000000 implements MigrationInterface {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// aislop-ignore-file: duplicate-block -- 迁移文件需自包含,表/外键 DDL 声明结构相似
|
||||||
import {
|
import {
|
||||||
MigrationInterface,
|
MigrationInterface,
|
||||||
QueryRunner,
|
QueryRunner,
|
||||||
|
|||||||
121
apps/server/src/migrations/1784910000000-AddImportRuns.ts
Normal file
121
apps/server/src/migrations/1784910000000-AddImportRuns.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 ai_messages 上已废弃的 like/dislike 反馈字段。
|
||||||
|
* 反馈功能已从前端和后端删除,历史列一并清理。
|
||||||
|
*/
|
||||||
|
export class DropAiMessageFeedback1784920000000 implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user