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 '',唯一索引显式命名
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, QueryRunner } from 'typeorm';
|
||||
import { withQueryRunner } from './database-migrations.runner';
|
||||
|
||||
export async function ensureSyncStateLeaseColumns(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
export async function ensureSyncStateLeaseColumns(dataSource: DataSource): Promise<void> {
|
||||
await withQueryRunner(dataSource, async (runner) => {
|
||||
const table = await runner.getTable('sync_state');
|
||||
if (!table) return;
|
||||
@@ -17,9 +15,7 @@ export async function ensureSyncStateLeaseColumns(
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureStudentProfileCollegeColumns(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
export async function ensureStudentProfileCollegeColumns(dataSource: DataSource): Promise<void> {
|
||||
await withQueryRunner(dataSource, async (runner) => {
|
||||
const table = await runner.getTable('student_profiles');
|
||||
if (!table) return;
|
||||
@@ -29,14 +25,13 @@ export async function ensureStudentProfileCollegeColumns(
|
||||
['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}`);
|
||||
if (!columns.has(name))
|
||||
await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureAttendanceDevicesSchema(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
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 (
|
||||
@@ -53,8 +48,8 @@ export async function ensureAttendanceDevicesSchema(
|
||||
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 \'\''],
|
||||
['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)'],
|
||||
@@ -63,7 +58,8 @@ export async function ensureAttendanceDevicesSchema(
|
||||
['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}`);
|
||||
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) => {
|
||||
@@ -73,7 +69,10 @@ export async function ensureAttendanceDevicesSchema(
|
||||
// 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);
|
||||
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)',
|
||||
@@ -85,9 +84,7 @@ export async function ensureAttendanceDevicesSchema(
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureStudentWalletSchema(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
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 (
|
||||
@@ -121,7 +118,8 @@ export async function ensureStudentWalletSchema(
|
||||
['room_expense_id', 'INTEGER'],
|
||||
['personal_expense_id', 'INTEGER'],
|
||||
]) {
|
||||
if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`);
|
||||
if (!columns.has(name))
|
||||
await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
}
|
||||
const roomExpenses = await runner.getTable('room_expenses');
|
||||
@@ -131,8 +129,10 @@ export async function ensureStudentWalletSchema(
|
||||
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');
|
||||
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)',
|
||||
@@ -150,11 +150,18 @@ export async function ensureStudentWalletSchema(
|
||||
['cancel_reason', 'VARCHAR(300)'],
|
||||
];
|
||||
for (const [name, definition] of additions) {
|
||||
if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`);
|
||||
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')");
|
||||
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')) {
|
||||
@@ -163,9 +170,7 @@ export async function ensureStudentWalletSchema(
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeUnusedClassroomColumns(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
export async function removeUnusedClassroomColumns(dataSource: DataSource): Promise<void> {
|
||||
await withQueryRunner(dataSource, async (runner) => {
|
||||
const tables = await runner.getTables(['classrooms']);
|
||||
if (tables.length === 0) return;
|
||||
@@ -180,9 +185,7 @@ export async function removeUnusedClassroomColumns(
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeUnusedRoomColumns(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
export async function removeUnusedRoomColumns(dataSource: DataSource): Promise<void> {
|
||||
await withQueryRunner(dataSource, async (runner) => {
|
||||
const tables = await runner.getTables(['rooms']);
|
||||
if (tables.length === 0) return;
|
||||
@@ -194,9 +197,7 @@ export async function removeUnusedRoomColumns(
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupDepositRefundColumns(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
export async function cleanupDepositRefundColumns(dataSource: DataSource): Promise<void> {
|
||||
await withQueryRunner(dataSource, async (runner) => {
|
||||
const tables = await runner.getTables(['deposits']);
|
||||
if (tables.length === 0) return;
|
||||
@@ -230,9 +231,7 @@ export async function cleanupDepositRefundColumns(
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeUnusedClassStudentColumns(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
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;
|
||||
@@ -244,9 +243,7 @@ export async function removeUnusedClassStudentColumns(
|
||||
});
|
||||
}
|
||||
|
||||
export async function normalizeClassroomStatuses(
|
||||
dataSource: DataSource,
|
||||
): Promise<void> {
|
||||
export async function normalizeClassroomStatuses(dataSource: DataSource): Promise<void> {
|
||||
await withQueryRunner(dataSource, async (runner) => {
|
||||
const tables = await runner.getTables(['classrooms']);
|
||||
if (tables.length === 0) return;
|
||||
@@ -257,3 +254,232 @@ export async function normalizeClassroomStatuses(
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师多科目改为「一行一科目」存储:
|
||||
* - 回退历史草稿把 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DataSource } from 'typeorm';
|
||||
import {
|
||||
cleanupDepositRefundColumns,
|
||||
ensureAttendanceDevicesSchema,
|
||||
ensureClassTeacherSubjectUnique,
|
||||
ensureStudentProfileCollegeColumns,
|
||||
ensureStudentWalletSchema,
|
||||
ensureSyncStateLeaseColumns,
|
||||
@@ -33,6 +34,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
await this.ensureDingLeaveSchema();
|
||||
await this.ensureAttendanceDevicesSchema();
|
||||
await this.ensureStudentWalletSchema();
|
||||
await this.ensureClassTeacherSubjectUnique();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
await this.protectAttendanceHistory();
|
||||
@@ -59,6 +61,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
return ensureStudentWalletSchema(this.dataSource);
|
||||
}
|
||||
|
||||
async ensureClassTeacherSubjectUnique(): Promise<void> {
|
||||
return ensureClassTeacherSubjectUnique(this.dataSource);
|
||||
}
|
||||
|
||||
async removeUnusedClassroomColumns(): Promise<void> {
|
||||
return removeUnusedClassroomColumns(this.dataSource);
|
||||
}
|
||||
|
||||
2
init.sql
2
init.sql
@@ -110,7 +110,7 @@ CREATE TABLE `classroom_rentals` (`id` int NOT NULL AUTO_INCREMENT, `classroom_i
|
||||
|
||||
CREATE TABLE `classes` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `code` varchar(50) NOT NULL, `class_type` varchar(20) NOT NULL, `start_date` date NULL, `end_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'enrolling', `head_teacher_id` int NULL, `life_teacher_id` int NULL, `academic_teacher_id` int NULL, `max_students` int NOT NULL DEFAULT '0', `notes` text NULL, `is_archived` tinyint NOT NULL DEFAULT 0, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_cf7491878e0fca859943862998` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE `class_teacher` (`id` int NOT NULL AUTO_INCREMENT, `class_id` int NOT NULL, `user_id` int NOT NULL, `role_type` varchar(30) NOT NULL, `subject` varchar(50) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_0117e9171e1533ff7b5c63f5d6` (`class_id`, `user_id`, `role_type`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
|
||||
CREATE TABLE `class_teacher` (`id` int NOT NULL AUTO_INCREMENT, `class_id` int NOT NULL, `user_id` int NOT NULL, `role_type` varchar(30) NOT NULL, `subject` varchar(50) NOT NULL DEFAULT '', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_class_teacher_class_user_role_subject` (`class_id`, `user_id`, `role_type`, `subject`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE `class_student` (`id` int NOT NULL AUTO_INCREMENT, `class_id` int NOT NULL, `student_id` int NOT NULL, `join_date` date NULL, `leave_date` date NULL, `status` varchar(10) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_b0ed786e05e93dd9bf77189af1` (`class_id`, `student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user