diff --git a/apps/server/src/database/database-migrations.schema.ts b/apps/server/src/database/database-migrations.schema.ts index 09e9929..bcbd4f8 100644 --- a/apps/server/src/database/database-migrations.schema.ts +++ b/apps/server/src/database/database-migrations.schema.ts @@ -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 { +export async function ensureSyncStateLeaseColumns(dataSource: DataSource): Promise { 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 { +export async function ensureStudentProfileCollegeColumns(dataSource: DataSource): Promise { 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 { +export async function ensureAttendanceDevicesSchema(dataSource: DataSource): Promise { 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 { +export async function ensureStudentWalletSchema(dataSource: DataSource): Promise { 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 { +export async function removeUnusedClassroomColumns(dataSource: DataSource): Promise { 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 { +export async function removeUnusedRoomColumns(dataSource: DataSource): Promise { 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 { +export async function cleanupDepositRefundColumns(dataSource: DataSource): Promise { 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 { +export async function removeUnusedClassStudentColumns(dataSource: DataSource): Promise { 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 { +export async function normalizeClassroomStatuses(dataSource: DataSource): Promise { 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 { + 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 { + 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 { + 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>; + 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); + } +} diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 63848f0..dfd7d28 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -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 { + return ensureClassTeacherSubjectUnique(this.dataSource); + } + async removeUnusedClassroomColumns(): Promise { return removeUnusedClassroomColumns(this.dataSource); } diff --git a/init.sql b/init.sql index 52d7e43..42525f9 100644 --- a/init.sql +++ b/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;