- 迁移确保 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 '',唯一索引显式命名
486 lines
22 KiB
TypeScript
486 lines
22 KiB
TypeScript
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);
|
||
}
|
||
}
|