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:
2026-08-11 11:53:47 +08:00
parent bfc8175277
commit 5f03f7a164
3 changed files with 273 additions and 41 deletions

View File

@@ -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 单元可能拆散代理对emojiArray.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);
}
}

View File

@@ -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);
}

View File

@@ -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;