Files
gongxue-base/apps/server/src/database/database-migrations.schema.ts
T
wangziqi 5f03f7a164 feat(server): class_teacher 科目列迁移——一行一科目 + 4 列唯一索引
- 迁移确保 subject NOT NULL DEFAULT '' 并建 (class_id,user_id,role_type,subject) 唯一索引
- 历史多科目值按内容拆分(不依赖列类型),首科目截断与 TS 侧 trim 归一化一致
- 破坏性清理前备份到审计表;暂存表自包含身份列,恢复不依赖原行存活
- 4 列兜底去重防 CREATE UNIQUE INDEX 撞 ER_DUP_ENTRY;GET_LOCK 串行化多实例迁移
- 收敛后启动为纯 no-op,滚动部署不再反复清理
- init.sql 与实体对齐:subject NOT NULL DEFAULT '',唯一索引显式命名
2026-08-11 11:53:47 +08:00

486 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DataSource, QueryRunner } 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 pk = 'INTEGER PRIMARY KEY AUTO_INCREMENT';
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(
'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)',
);
}
await createIndex(
'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)',
);
});
}
export async function ensureStudentWalletSchema(dataSource: DataSource): Promise<void> {
await withQueryRunner(dataSource, async (runner) => {
const pk = 'INTEGER PRIMARY KEY AUTO_INCREMENT';
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(
'CREATE UNIQUE INDEX 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')
`);
});
}
/**
* 老师多科目改为「一行一科目」存储:
* - 回退历史草稿把 subject 扩为 TEXT 的改动,恢复 varchar(50) 单值;
* - 唯一索引从 (class_id, user_id, role_type) 改为 (class_id, user_id, role_type, subject)
* 允许同一任课老师在同一班级存在多个科目行,同时阻止同科目重复行。
* - 非科目角色(班主任/生活老师/学服老师)subject 用空串 '' 占位(不再存 NULL),
* 使 4 列唯一索引对这类角色同样生效,数据库层兜底防重复分配。
* - 历史 TEXT 阶段可能写入超长/多科目值:迁移先把首科目截断到 50 以便回退列类型,
* 完整值暂存到 class_teacher_subject_split_staging,待旧唯一索引移除后再补插为
* 「一行一科目」;中断后下次启动可幂等续跑(暂存表存在即恢复)。
* 幂等:旧索引不存在时跳过,新索引已存在时不重复创建。
*/
const LEGACY_SUBJECT_SEPARATOR = /[、,,;]+/;
/** 历史草稿多科目值按常见分隔符拆分:去空白、去重、截断到 50 字符。
* 注意不含 '/':科目是用户自由输入(如「体育/健康」),'/' 不是多科目分隔符。 */
function splitLegacyTeacherSubjects(value: string): string[] {
const parts = value
.split(LEGACY_SUBJECT_SEPARATOR)
.map((part) => part.trim())
.filter((part) => part.length > 0)
// 按码点截断:slice 按 UTF-16 单元可能拆散代理对(emoji),Array.from 按码点安全
.map((part) => Array.from(part).slice(0, 50).join(''));
return [...new Set(parts)];
}
/** 从暂存表把「第 2..n 个科目」补插为独立行(ON DUPLICATE KEY 幂等:重复跳过、非重复错误上抛),
* 随后清理暂存表。暂存表自包含 class_id/user_id/role_type:原行可能已被 4 列去重删除,
* 恢复不依赖原行存活。 */
async function resumeLegacySubjectSplit(runner: QueryRunner): Promise<void> {
const rows: Array<{
id: number;
class_id: number;
user_id: number;
role_type: string;
subject: string;
}> = (await runner.query(
'SELECT id, class_id, user_id, role_type, subject FROM class_teacher_subject_split_staging',
)) as Array<{
id: number;
class_id: number;
user_id: number;
role_type: string;
subject: string;
}>;
for (const row of rows) {
const parts = splitLegacyTeacherSubjects(String(row.subject ?? ''));
for (const part of parts.slice(1)) {
await runner.query(
`INSERT INTO class_teacher (class_id, user_id, role_type, subject)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = id`,
[row.class_id, row.user_id, row.role_type, part],
);
}
}
await runner.query('DROP TABLE class_teacher_subject_split_staging');
}
export async function ensureClassTeacherSubjectUnique(dataSource: DataSource): Promise<void> {
await withQueryRunner(dataSource, async (runner) => {
// 多实例并发启动时 DDL 会互相竞争(MySQL DDL 隐式提交无法用事务包裹),
// 用命名咨询锁串行化整段迁移,避免两个实例同时通过存在性检查后互相踩踏。
// 滚动部署中首个实例的清理可能较久,超时给足余量(10 分钟),避免其他实例启动失败崩溃循环
const lockRows = (await runner.query(
"SELECT GET_LOCK('gongxue_class_teacher_subject_unique', 600) AS locked",
)) as Array<{ locked: number | null }>;
if (Number(lockRows[0]?.locked) !== 1) {
throw new Error('获取 class_teacher 迁移锁失败(可能有其他实例正在执行)');
}
try {
await ensureClassTeacherSubjectUniqueLocked(runner);
} finally {
await runner.query("SELECT RELEASE_LOCK('gongxue_class_teacher_subject_unique')");
}
});
}
async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promise<void> {
const table = await runner.getTable('class_teacher');
if (!table) return;
const column = table.columns.find((item) => item.name === 'subject');
// TEXT 家族(text/mediumtext/longtext)都需先回退为 varchar(50)
// 否则后续 CREATE UNIQUE INDEX 会因 BLOB/TEXT 无键长报错 1170
const isText = !!column && typeof column.type === 'string' && column.type.includes('text');
const hasStaging = !!(await runner.getTable('class_teacher_subject_split_staging'));
// 旧 3 列唯一索引仍存在 = 迁移尚未收敛;步骤 1/1b 只在未收敛时执行,
// 收敛后每次启动为纯 no-op,避免滚动部署中旧实例新写的行被下一轮启动静默清空
const hasLegacy3ColIndex = table.indices.some(
(index) =>
index.isUnique &&
index.columnNames.length === 3 &&
index.columnNames.includes('class_id') &&
index.columnNames.includes('user_id') &&
index.columnNames.includes('role_type'),
);
const needsLegacyCleanup = isText || column?.isNullable || hasLegacy3ColIndex;
// 1) NULL → '' 占位(非科目角色)。先对同组重复 NULL 去重(保留最小 id),
// 避免回填后撞唯一索引;无 NULL 行时为空操作。
// 临时表先清理上次中断可能残留的同名表,避免复用会话时 CREATE 报已存在。
if (needsLegacyCleanup) {
await runner.query('DROP TEMPORARY TABLE IF EXISTS tmp_class_teacher_keep');
await runner.query(`CREATE TEMPORARY TABLE tmp_class_teacher_keep AS
SELECT MIN(id) AS id FROM class_teacher WHERE subject IS NULL GROUP BY class_id, user_id, role_type`);
await runner.query(`DELETE FROM class_teacher WHERE subject IS NULL
AND id NOT IN (SELECT id FROM tmp_class_teacher_keep)`);
await runner.query(`DROP TEMPORARY TABLE tmp_class_teacher_keep`);
await runner.query(`UPDATE class_teacher SET subject = '' WHERE subject IS NULL`);
// 1b) 旧版服务对任意角色都写 subject,非科目角色可能遗留非空/超长值:
// 先按 (class_id,user_id,role_type) 去重(保留最小 id),再统一置空,
// 保证 4 列唯一索引对这类角色真正生效,且不阻塞列类型回退。幂等:无遗留值时为空操作。
// 破坏性操作前先把受影响行原值备份到审计表(INSERT IGNORE 以 id 为主键,重启续跑不重复),
// 历史 subject 值不直接丢弃,可供人工核对/恢复。
await runner.query(`CREATE TABLE IF NOT EXISTS class_teacher_legacy_subject_backup (
id INT NOT NULL PRIMARY KEY,
class_id INT NOT NULL,
user_id INT NOT NULL,
role_type VARCHAR(30) NOT NULL,
subject TEXT NULL,
backed_up_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB`);
await runner.query(`INSERT IGNORE INTO class_teacher_legacy_subject_backup
(id, class_id, user_id, role_type, subject)
SELECT id, class_id, user_id, role_type, subject FROM class_teacher
WHERE role_type <> 'subject_teacher' AND subject <> '' AND subject IS NOT NULL`);
await runner.query(`DELETE FROM class_teacher WHERE role_type <> 'subject_teacher'
AND subject <> '' AND id NOT IN (
SELECT * FROM (
SELECT MIN(id) AS id FROM class_teacher
WHERE role_type <> 'subject_teacher' AND subject <> ''
GROUP BY class_id, user_id, role_type
) AS keep_rows)`);
await runner.query(`UPDATE class_teacher SET subject = ''
WHERE role_type <> 'subject_teacher' AND subject <> ''`);
}
// 2) 历史多科目值拆分:按内容判断(含分隔符或超长)而非列类型——标准升级路径
// varchar(50) NULL)下同样可能存在 '语文、数学' 这类合并值。
const splitCandidates = (await runner.query(
`SELECT 1 FROM class_teacher WHERE role_type = 'subject_teacher'
AND (CHAR_LENGTH(subject) > 50 OR subject REGEXP '[、,,;]') LIMIT 1`,
)) as Array<Record<string, unknown>>;
const shouldSplit = isText || hasStaging || splitCandidates.length > 0;
if (shouldSplit) {
if (!hasStaging) {
// 暂存表自包含身份列:后续 4 列去重可能删除原行,恢复时不依赖原行存活
await runner.query(`CREATE TABLE class_teacher_subject_split_staging (
id INT NOT NULL PRIMARY KEY,
class_id INT NOT NULL,
user_id INT NOT NULL,
role_type VARCHAR(30) NOT NULL,
subject TEXT NOT NULL
) ENGINE=InnoDB`);
}
const countRows = (await runner.query(
'SELECT COUNT(*) AS cnt FROM class_teacher_subject_split_staging',
)) as Array<{ cnt: string | number }>;
// 暂存表为空(例如 CREATE 与 INSERT 之间中断)时重新填充,保证续跑不丢拆分数据。
if (Number(countRows[0]?.cnt ?? 0) === 0) {
await runner.query(`INSERT INTO class_teacher_subject_split_staging
(id, class_id, user_id, role_type, subject)
SELECT id, class_id, user_id, role_type, subject FROM class_teacher
WHERE role_type = 'subject_teacher'
AND (CHAR_LENGTH(subject) > 50 OR subject REGEXP '[、,,;]')`);
}
// 幂等:再次执行时原值已拆成单科目,REGEXP_REPLACE/SUBSTRING_INDEX 保持不变。
// 与 splitLegacyTeacherSubjects 的 trim 归一化保持一致:先剥离开头分隔符('、语文' 的
// 前导 '、' 若只被替换成 '、' 则无净变化,SUBSTRING_INDEX 会取到空串导致首科目丢失),
// 再把其余分隔符归一化为 '、'。REGEXP_REPLACE 依赖 MySQL 8.0+。
await runner.query(`UPDATE class_teacher SET subject = TRIM(LEFT(
SUBSTRING_INDEX(
REGEXP_REPLACE(REGEXP_REPLACE(subject, '^[、,,;]+', ''), '[、,,;]+', '、'),
'、', 1), 50))
WHERE id IN (SELECT id FROM class_teacher_subject_split_staging)`);
}
// 兜底:其余任何 >50 字符的值(如单个超长科目)先截断,避免 ALTER 报 Data too long。
await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50)
WHERE CHAR_LENGTH(subject) > 50`);
// 列类型回退仅在确为 TEXT 或可空列时执行;已是 varchar NOT NULL 的库跳过。
if (isText || column?.isNullable) {
await runner.query(`ALTER TABLE class_teacher MODIFY subject VARCHAR(50) NOT NULL DEFAULT ''`);
}
// 3) 必须先建新索引再删旧索引:class_teacher.class_id 外键依赖旧唯一索引,
// 若先删旧索引 MySQL 会报 ER_DROP_INDEX_FK;新索引以 class_id 开头,可继续满足该外键。
const hasNewUnique = table.indices.some(
(index) =>
index.isUnique &&
index.columnNames.length === 4 &&
index.columnNames.includes('class_id') &&
index.columnNames.includes('user_id') &&
index.columnNames.includes('role_type') &&
index.columnNames.includes('subject'),
);
if (!hasNewUnique) {
// 兜底去重:旧 3 列唯一索引缺失时,subject_teacher 同科目重复行 / subject='' 的
// 重复角色行会让 CREATE UNIQUE INDEX 报 ER_DUP_ENTRY 阻断启动;先按 4 列保留最小 id。
await runner.query(`DELETE FROM class_teacher WHERE id NOT IN (
SELECT * FROM (
SELECT MIN(id) AS id FROM class_teacher GROUP BY class_id, user_id, role_type, subject
) AS keep_ids)`);
await runner.query(
'CREATE UNIQUE INDEX IDX_class_teacher_class_user_role_subject ON class_teacher (class_id, user_id, role_type, subject)',
);
}
const oldUnique = table.indices.find(
(index) =>
index.isUnique &&
index.columnNames.length === 3 &&
index.columnNames.includes('class_id') &&
index.columnNames.includes('user_id') &&
index.columnNames.includes('role_type'),
);
if (oldUnique) {
await runner.query(`DROP INDEX \`${oldUnique.name}\` ON class_teacher`);
}
// 4) 补插历史多科目拆分行(含上次中断后的恢复);新索引已就位、旧索引已移除。
if (shouldSplit) {
await resumeLegacySubjectSplit(runner);
}
}