From 122f3843a236d34b8c53039e20e97154bc741af4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 11 Aug 2026 12:38:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E7=8F=AD=E7=BA=A7=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E4=BA=8B=E5=8A=A1=E5=8C=96=E3=80=81=E8=8A=B1=E5=90=8D?= =?UTF-8?q?=E5=86=8C=E5=90=8C=E5=90=8D=E6=96=B0=E7=94=9F=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E3=80=81=E8=BF=81=E7=A7=BB=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E7=A7=91=E7=9B=AE=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create() 班级/学生/教师写入整体包事务,避免教师失败留下孤儿班级 - 花名册提交:行内带标识的同名新生不再复用本批姓名兜底(避免被误判冲突丢弃,与预览一致) - 迁移:超长科目截断前备份原值,且备份表确保存在(全新部署不因缺表启动失败) - workbook rels 解析真实工作表路径,防自定义文件名绕过 sheet/行数预扫描 - keepPlus 完整正则对齐注释意图(+ 86 前缀保留);formatDate 用 UTC getter - main.ts 大 JSON 路由前缀与限制提取为常量并注明耦合点 --- .../server/src/classes/class-roster-import.ts | 21 +- .../src/classes/classes-queries.service.ts | 254 ++++++++++-------- apps/server/src/classes/classes.controller.ts | 112 ++++++-- apps/server/src/classes/classes.service.ts | 129 +++++---- .../classes/classes.teacher-subjects.spec.ts | 42 ++- apps/server/src/classes/dto/class.dto.ts | 4 +- .../database/database-migrations.schema.ts | 14 + apps/server/src/main.ts | 8 +- 8 files changed, 369 insertions(+), 215 deletions(-) diff --git a/apps/server/src/classes/class-roster-import.ts b/apps/server/src/classes/class-roster-import.ts index 1f3bee17..0f6d56d9 100644 --- a/apps/server/src/classes/class-roster-import.ts +++ b/apps/server/src/classes/class-roster-import.ts @@ -68,9 +68,10 @@ function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown { } function formatDate(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + // ExcelJS 日期单元格解码为 UTC 午夜 Date;用 UTC getter 保证输出与服务器时区无关 + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } @@ -102,12 +103,11 @@ export function sanitizeCellText(value: string, keepPlus = false): string { let start = 0; while (start < value.length) { const ch = value[start]; - if (keepPlus && ch === '+' && start === 0 && /^\d/.test(value[start + 1] ?? '')) { - // 仅当 '+' 后为数字/空格/连字符/括号且至少含一位数字(国际区号,如 +86 138... / - // +86 (138) 1234-5678)时整串保留,'+1('、'+1()' 等畸形串回退剥离; - // 含 '.'/'#'/'x' 等分隔符的合法号码(+86 138.0013.8000)不是公式触发字符,同样回退到 - // 普通剥离(去掉前导 '+'),避免整串丢弃丢失关键匹配标识。 - return /^\+[\d\s()-]*\d[\d\s()-]*$/.test(value) ? value : sanitizeCellText(value); + if (keepPlus && ch === '+' && start === 0 && /^\+[\d\s()-]*\d[\d\s()-]*$/.test(value)) { + // 整体形如国际区号(+86 138...、+86 (138) 1234-5678、+ 86 138...)时整串保留; + // 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离 + // (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。 + return value; } if (!FORMULA_LEADING_CHARS.has(ch)) break; start += 1; @@ -158,7 +158,8 @@ export function parseClassRosterWorkbook(workbook: ExcelJS.Workbook): ClassRoste ws.eachRow((row, idx) => { if (idx === 1) return; - const parsed: Partial> = {}; + const parsed: Partial> = + {}; headerIndex.forEach((key, colNumber) => { const value = cellToText(row.getCell(colNumber)); if (value) parsed[key] = value; diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts index df840cd0..7e73c308 100644 --- a/apps/server/src/classes/classes-queries.service.ts +++ b/apps/server/src/classes/classes-queries.service.ts @@ -1,7 +1,14 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, Not, Repository } from 'typeorm'; -import { Class, ClassStudent, ClassSchedule, AttendanceRecord, Organization, Student } from '../entities'; +import { + Class, + ClassStudent, + ClassSchedule, + AttendanceRecord, + Organization, + Student, +} from '../entities'; import { Classroom } from '../entities/classroom.entity'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; @@ -19,7 +26,8 @@ interface AgentClassRow { /** 判断是否为 MySQL 唯一键冲突(1062 / ER_DUP_ENTRY),兼容 TypeORM QueryFailedError 包装。 */ export function isDuplicateEntryError(error: unknown): boolean { - const driver = (error as { driverError?: { code?: string; errno?: number } })?.driverError ?? error; + const driver = + (error as { driverError?: { code?: string; errno?: number } })?.driverError ?? error; return ( (driver as { code?: string })?.code === 'ER_DUP_ENTRY' || (driver as { errno?: number })?.errno === 1062 @@ -146,7 +154,8 @@ export class ClassesQueriesService { @InjectRepository(Class) private readonly classRepo: Repository, @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, - @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository, + @InjectRepository(AttendanceRecord) + private readonly attendanceRepo: Repository, private readonly dataSource: DataSource, ) {} @@ -154,90 +163,102 @@ export class ClassesQueriesService { accessibleClassIds: number[] | undefined, query: { keyword?: string; status?: string; limit?: number }, ) { - if (accessibleClassIds?.length === 0) return []; + if (accessibleClassIds?.length === 0) return []; - const qb = this.classRepo - .createQueryBuilder('class') - .leftJoin( - ClassStudent, - 'classStudent', - 'classStudent.classId = class.id AND classStudent.status = :activeStudent', - { activeStudent: 'active' }, - ) - .select('class.id', 'id'); - const classSelects = [ - ['class.name', 'name'], - ['class.code', 'code'], - ['class.classType', 'classType'], - ['class.status', 'status'], - ['class.startDate', 'startDate'], - ['class.endDate', 'endDate'], - ['COUNT(classStudent.id)', 'studentCount'], - ] as const; - for (const [column, alias] of classSelects) { - qb.addSelect(column, alias); - } - qb.where('class.isArchived = :isArchived', { isArchived: false }); - if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); - if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${escapeLike(query.keyword)}%` }); - if (query.status) qb.andWhere('class.status = :status', { status: query.status }); - const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); - return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); + const qb = this.classRepo + .createQueryBuilder('class') + .leftJoin( + ClassStudent, + 'classStudent', + 'classStudent.classId = class.id AND classStudent.status = :activeStudent', + { activeStudent: 'active' }, + ) + .select('class.id', 'id'); + const classSelects = [ + ['class.name', 'name'], + ['class.code', 'code'], + ['class.classType', 'classType'], + ['class.status', 'status'], + ['class.startDate', 'startDate'], + ['class.endDate', 'endDate'], + ['COUNT(classStudent.id)', 'studentCount'], + ] as const; + for (const [column, alias] of classSelects) { + qb.addSelect(column, alias); + } + qb.where('class.isArchived = :isArchived', { isArchived: false }); + if (accessibleClassIds) + qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); + if (query.keyword) + qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { + keyword: `%${escapeLike(query.keyword)}%`, + }); + if (query.status) qb.andWhere('class.status = :status', { status: query.status }); + const rows = await qb + .groupBy('class.id') + .orderBy('class.name', 'ASC') + .limit(query.limit ?? 20) + .getRawMany(); + return rows.map((row) => ({ + ...row, + id: Number(row.id), + studentCount: Number(row.studentCount || 0), + })); } async batchImportStudents( classId: number, users: Array<{ dingUserId: string; name: string; mobile?: string }>, ): Promise<{ imported: number; skipped: number; conflicts: number }> { - if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; + if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; - return this.dataSource.transaction(async (manager) => { - const classEntity = await manager.findOne(Class, { where: { id: classId } }); - if (!classEntity) throw new NotFoundException('班级不存在'); + return this.dataSource.transaction(async (manager) => { + const classEntity = await manager.findOne(Class, { where: { id: classId } }); + if (!classEntity) throw new NotFoundException('班级不存在'); - const synced = await syncDingTalkStudents(manager, users); - const studentIds = [...new Set(synced.studentIds.values())]; - if (studentIds.length === 0) { - return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; - } - - const existingClassStudents = await manager.find(ClassStudent, { - where: { classId, studentId: In(studentIds) }, - }); - const existingByStudentId = new Map( - existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), - ); - const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); - let skipped = 0; - const memberships = studentIds.flatMap((studentId) => { - const existing = existingByStudentId.get(studentId); - if (existing?.status === 'active') { - skipped++; - return []; + const synced = await syncDingTalkStudents(manager, users); + const studentIds = [...new Set(synced.studentIds.values())]; + if (studentIds.length === 0) { + return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; } - if (existing) { - existing.status = 'active'; - existing.joinDate = today; - existing.leaveDate = null; - return [existing]; - } - return [ - manager.create(ClassStudent, { - classId, - studentId, - status: 'active', - joinDate: today, - }), - ]; - }); - if (memberships.length > 0) await manager.save(ClassStudent, memberships); - return { - imported: memberships.length, - skipped, - conflicts: synced.conflicts.length, - }; - }); + const existingClassStudents = await manager.find(ClassStudent, { + where: { classId, studentId: In(studentIds) }, + }); + const existingByStudentId = new Map( + existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), + ); + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); + let skipped = 0; + const memberships = studentIds.flatMap((studentId) => { + const existing = existingByStudentId.get(studentId); + if (existing?.status === 'active') { + skipped++; + return []; + } + if (existing) { + existing.status = 'active'; + existing.joinDate = today; + existing.leaveDate = null; + return [existing]; + } + return [ + manager.create(ClassStudent, { + classId, + studentId, + status: 'active', + joinDate: today, + }), + ]; + }); + + if (memberships.length > 0) await manager.save(ClassStudent, memberships); + return { + imported: memberships.length, + skipped, + conflicts: synced.conflicts.length, + }; + }); } /** 班级花名册导入预览:解析行后按 手机号→身份证号→学号→唯一姓名 匹配已有学生,不写库。 */ @@ -253,18 +274,14 @@ export class ClassesQueriesService { memberships.filter((m) => m.status === 'active').map((m) => m.studentId), ); - const phones = [ - ...new Set(rows.map((r) => normalizeIdentifier(r.phone)).filter((v) => !!v)), - ]; + const phones = [...new Set(rows.map((r) => normalizeIdentifier(r.phone)).filter((v) => !!v))]; const idNumbers = [ ...new Set(rows.map((r) => normalizeIdentifier(r.idNumber)).filter((v) => !!v)), ]; const studentNos = [ ...new Set(rows.map((r) => normalizeIdentifier(r.studentNo)).filter((v) => !!v)), ]; - const names = [ - ...new Set(rows.map((r) => normalizeIdentifier(r.name)).filter((v) => !!v)), - ]; + const names = [...new Set(rows.map((r) => normalizeIdentifier(r.name)).filter((v) => !!v))]; // 只匹配未归档且非员工账号的学生,避免已归档学生被重新拉回在读班级; // 用 LOWER(TRIM(col)) 与 Map 索引的归一化保持一致,避免存储值带空格/大小写差异导致漏匹配 @@ -312,9 +329,9 @@ export class ClassesQueriesService { const identifierMatches = new Map(); for (const match of [ - ...(phone ? byPhone.get(normalizeIdentifier(phone)) ?? [] : []), - ...(idNumber ? byIdNumber.get(normalizeIdentifier(idNumber)) ?? [] : []), - ...(studentNo ? byStudentNo.get(normalizeIdentifier(studentNo)) ?? [] : []), + ...(phone ? (byPhone.get(normalizeIdentifier(phone)) ?? []) : []), + ...(idNumber ? (byIdNumber.get(normalizeIdentifier(idNumber)) ?? []) : []), + ...(studentNo ? (byStudentNo.get(normalizeIdentifier(studentNo)) ?? []) : []), ]) { identifierMatches.set(match.id, match); } @@ -350,7 +367,7 @@ export class ClassesQueriesService { continue; } - const nameMatches = name ? byName.get(normalizeIdentifier(name)) ?? [] : []; + const nameMatches = name ? (byName.get(normalizeIdentifier(name)) ?? []) : []; if (nameMatches.length > 1) { previewRows.push({ ...row, @@ -416,8 +433,10 @@ export class ClassesQueriesService { if (row.status !== 'unmatched') continue; const blocked = (row.phone && identifierHasArchivedOrStaff(blockedIndexes, row.phone, 'byPhone')) || - (row.idNumber && identifierHasArchivedOrStaff(blockedIndexes, row.idNumber, 'byIdNumber')) || - (row.studentNo && identifierHasArchivedOrStaff(blockedIndexes, row.studentNo, 'byStudentNo')); + (row.idNumber && + identifierHasArchivedOrStaff(blockedIndexes, row.idNumber, 'byIdNumber')) || + (row.studentNo && + identifierHasArchivedOrStaff(blockedIndexes, row.studentNo, 'byStudentNo')); if (blocked) { row.status = 'conflict'; row.reason = '手机号/学号/身份证对应已归档或员工账号,无法导入'; @@ -590,9 +609,14 @@ export class ClassesQueriesService { } } } - // 无标识命中时按唯一姓名兜底,与预览逻辑保持一致,避免绕过预览产生重复学生 + // 无标识命中时按唯一姓名兜底;仅当行内完全没有标识时才能复用本批新建的同名学生—— + // 同一批两个同名新生(不同手机号/学号)若复用本批姓名,第二个会被 + // inconsistentFieldReason 因标识不一致误判为冲突丢弃,且与预览「可新建」矛盾 if (candidates.size === 0 && name) { - const byBatch = createdByName.get(normalizeIdentifier(name)); + const byBatch = + !phone && !idNumber && !studentNo + ? createdByName.get(normalizeIdentifier(name)) + : undefined; const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined; if (batchStudent) { candidates.set(batchStudent.id, batchStudent); @@ -623,8 +647,10 @@ export class ClassesQueriesService { } else { const blocked = (phone && identifierHasArchivedOrStaff(allStatusIndexes, phone, 'byPhone')) || - (idNumber && identifierHasArchivedOrStaff(allStatusIndexes, idNumber, 'byIdNumber')) || - (studentNo && identifierHasArchivedOrStaff(allStatusIndexes, studentNo, 'byStudentNo')); + (idNumber && + identifierHasArchivedOrStaff(allStatusIndexes, idNumber, 'byIdNumber')) || + (studentNo && + identifierHasArchivedOrStaff(allStatusIndexes, studentNo, 'byStudentNo')); if (blocked) { conflicts++; continue; @@ -656,7 +682,9 @@ export class ClassesQueriesService { } catch (error) { // 并发创建同标识学生:按标识重查并复用已有档案,避免整批回滚。 // 仅复用未归档/非员工账号且与行内信息自洽的学生,否则按冲突处理。 - // REPEATABLE READ 下普通一致读基于旧快照看不到并发提交的行,需用锁定读刷新可见性 + // REPEATABLE READ 下普通一致读基于旧快照看不到并发提交的行,需用锁定读刷新可见性。 + // 注意:students 表当前对 phone/idNumber/studentNo 无唯一索引,此分支实际不会触发 + // (save 不抛 1062);若未来补唯一索引,需同时处理死锁重试(ER_LOCK_DEADLOCK)。 if (!isDuplicateEntryError(error)) throw error; const dup = await manager.findOne(Student, { where: [ @@ -700,9 +728,7 @@ export class ClassesQueriesService { where: { classId, studentId: In(validIds) }, }) : []; - const existingByStudentId = new Map( - existingMemberships.map((m) => [m.studentId, m]), - ); + const existingByStudentId = new Map(existingMemberships.map((m) => [m.studentId, m])); const memberships = validIds.flatMap((studentId) => { const current = existingByStudentId.get(studentId); if (current?.status === 'active') { @@ -778,27 +804,27 @@ export class ClassesQueriesService { } async getSchedule(classId: number, query: QueryClassScheduleDto) { - const qb = this.scheduleRepo - .createQueryBuilder('cs') - .leftJoinAndSelect('cs.classroom', 'classroom') - .where('cs.classId = :classId', { classId }); + const qb = this.scheduleRepo + .createQueryBuilder('cs') + .leftJoinAndSelect('cs.classroom', 'classroom') + .where('cs.classId = :classId', { classId }); - if (query.startDate) { - qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); - } - if (query.endDate) { - qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); - } + if (query.startDate) { + qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); + } + if (query.endDate) { + qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); + } - const schedules = await qb - .orderBy('cs.weekDay', 'ASC') - .addOrderBy('cs.startTime', 'ASC') - .getMany(); + const schedules = await qb + .orderBy('cs.weekDay', 'ASC') + .addOrderBy('cs.startTime', 'ASC') + .getMany(); - return schedules.map((s) => ({ - ...s, - classroomName: (s.classroom as Classroom | undefined)?.name || null, - })); + return schedules.map((s) => ({ + ...s, + classroomName: (s.classroom as Classroom | undefined)?.name || null, + })); } async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 551cfe0b..a2cbada7 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -84,10 +84,19 @@ async function assertRosterZipSafe(buffer: Buffer): Promise { if (entries.length > ROSTER_IMPORT_MAX_ZIP_ENTRIES) { throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); } - // ExcelJS 整体加载前按 zip 结构拒绝异常文件:工作表数量超限直接拦截, - // 避免多 sheet 文件先被完整解压/解析进内存(load 后检查只是兜底) - const worksheetCount = entries.filter((entry) => - /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name), + // 工作表路径:ExcelJS 按 xl/_rels/workbook.xml.rels 定位 worksheet(文件名可被自定义绕过, + // 仅匹配标准 sheetN.xml 会漏判),先解析 rels 拿到真实 part 路径,再叠加标准文件名兜底 + const sheetPaths = new Set(); + const relsEntry = zip.files['xl/_rels/workbook.xml.rels']; + if (relsEntry && !relsEntry.dir) { + const relsXml = await relsEntry.async('string'); + for (const m of relsXml.matchAll(/Type="[^"]*\/worksheet"[^>]*\bTarget="([^"]+)"/g)) { + const target = (m[1] ?? '').replace(/^\/+/, ''); + if (target) sheetPaths.add(target.startsWith('xl/') ? target : `xl/${target}`); + } + } + const worksheetCount = entries.filter( + (entry) => sheetPaths.has(entry.name) || /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name), ).length; if (worksheetCount > ROSTER_IMPORT_MAX_SHEETS) { throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); @@ -100,7 +109,8 @@ async function assertRosterZipSafe(buffer: Buffer): Promise { let entryUncompressed = 0; // 工作表 XML 流式预扫描:按 `` 计数(空行自闭合 `` 不计), // 超限立即拒绝,避免 ExcelJS 物化整表后再由行数上限兜底 - const isSheetEntry = /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name); + const isSheetEntry = + sheetPaths.has(entry.name) || /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name); let sheetRowCount = 0; let tail = ''; await new Promise((resolve, reject) => { @@ -174,11 +184,7 @@ export class ClassesController { } private assertReadAccess(req: AuthenticatedRequest, classId: number) { - return this.service.assertClassAccess( - req.user.id, - classId, - this.canManageAllClasses(req), - ); + return this.service.assertClassAccess(req.user.id, classId, this.canManageAllClasses(req)); } @Get() @@ -207,7 +213,10 @@ export class ClassesController { await workbook.xlsx.write(res); res.end(); } catch (error) { - this.logger.error(`班级花名册模板下载失败: ${(error as Error).message}`, (error as Error).stack); + this.logger.error( + `班级花名册模板下载失败: ${(error as Error).message}`, + (error as Error).stack, + ); if (!res.headersSent) { res.status(500).json({ statusCode: 500, message: '模板生成失败' }); } else { @@ -250,7 +259,11 @@ export class ClassesController { async create(@Body() dto: CreateClassDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { - module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`, + module: '班级管理', + action: '创建班级', + targetId: result.id, + targetType: 'class', + detail: `班级${result.code} ${result.name}`, }); return result; } @@ -312,7 +325,10 @@ export class ClassesController { this.logger.warn(`班级花名册导入解析失败 classId=${id}: ${(error as Error).message}`); throw error; } - this.logger.error(`班级花名册导入文件解析失败 classId=${id}: ${(error as Error).message}`, (error as Error).stack); + this.logger.error( + `班级花名册导入文件解析失败 classId=${id}: ${(error as Error).message}`, + (error as Error).stack, + ); // zip 结构异常/内部错误等统一转通用文案,避免暴露内部细节 if (error instanceof BadRequestException) throw error; throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); @@ -331,7 +347,10 @@ export class ClassesController { await this.assertReadAccess(req, +id); const result = await this.service.commitRosterImport(+id, dto); await logAudit(this.logService, req, { - module: '班级管理', action: '批量导入学生', targetId: +id, targetType: 'class', + module: '班级管理', + action: '批量导入学生', + targetId: +id, + targetType: 'class', detail: `成功加入${result.added}名(新建${result.created}名),跳过${result.skipped}名,冲突${result.conflicts}行`, }); return result; @@ -355,11 +374,19 @@ export class ClassesController { @Put(':id') @RequirePermission('class:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) { + async update( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateClassDto, + @Request() req: AuthenticatedRequest, + ) { await this.assertReadAccess(req, +id); const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { - module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), + module: '班级管理', + action: '编辑班级', + targetId: +id, + targetType: 'class', + detail: JSON.stringify(dto), }); return result; } @@ -370,7 +397,10 @@ export class ClassesController { await this.assertReadAccess(req, +id); const result = await this.service.remove(+id); await logAudit(this.logService, req, { - module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', + module: '班级管理', + action: '归档班级', + targetId: +id, + targetType: 'class', }); return result; } @@ -381,7 +411,11 @@ export class ClassesController { await this.assertReadAccess(req, +id); const result = await this.service.purge(+id); await logAudit(this.logService, req, { - module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', + module: '班级管理', + action: '永久删除班级', + targetId: +id, + targetType: 'class', + detail: '物理删除,不可恢复', }); return result; } @@ -438,11 +472,19 @@ export class ClassesController { @Post(':id/students') @RequirePermission('class:edit') - async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) { + async addStudents( + @Param('id', ParseIntPipe) id: number, + @Body() dto: AddStudentsDto, + @Request() req: AuthenticatedRequest, + ) { await this.assertReadAccess(req, +id); const result = await this.service.addStudents(+id, dto.studentIds); await logAudit(this.logService, req, { - module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, + module: '班级管理', + action: '添加学生', + targetId: +id, + targetType: 'class', + detail: `新增${result.added}名学生`, }); try { const cls = await this.service.findOne(+id); @@ -470,7 +512,11 @@ export class ClassesController { await this.assertReadAccess(req, +id); const result = await this.service.removeStudent(+id, +studentId); await logAudit(this.logService, req, { - module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`, + module: '班级管理', + action: '移除学生', + targetId: +id, + targetType: 'class', + detail: `移除学生${studentId}`, }); return result; } @@ -484,13 +530,21 @@ export class ClassesController { @Post(':id/teachers') @RequirePermission('class:edit') - async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) { + async addTeacher( + @Param('id', ParseIntPipe) id: number, + @Body() dto: AddTeacherDto, + @Request() req: AuthenticatedRequest, + ) { await this.assertReadAccess(req, +id); const result = await this.service.addTeacher(+id, dto); // 全部科目已存在时为 no-op(result 为空):不写审计、不发通知,避免误导用户以为有新增 if (result.length === 0) return result; await logAudit(this.logService, req, { - module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, + module: '班级管理', + action: '添加教师', + targetId: +id, + targetType: 'class', + detail: `教师${dto.userId} 角色${dto.roleType}`, }); try { void this.notificationsService.create({ @@ -515,7 +569,11 @@ export class ClassesController { await this.assertReadAccess(req, +id); const result = await this.service.removeTeacherAssignment(+id, +assignmentId); await logAudit(this.logService, req, { - module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`, + module: '班级管理', + action: '移除教师角色', + targetId: +id, + targetType: 'class', + detail: `移除教师分配${assignmentId}`, }); return result; } @@ -530,7 +588,11 @@ export class ClassesController { await this.assertReadAccess(req, +id); const result = await this.service.removeTeacher(+id, +userId); await logAudit(this.logService, req, { - module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`, + module: '班级管理', + action: '移除教师', + targetId: +id, + targetType: 'class', + detail: `移除教师${userId}`, }); return result; } diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index fb37686a..3f0e2d17 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -1,14 +1,11 @@ import { -Injectable, -NotFoundException, -BadRequestException, -ForbiddenException, + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, -Repository, -In, -Like } from 'typeorm'; +import { DataSource, EntityManager, In, Like, Repository } from 'typeorm'; import { escapeLike } from '../common/like-escape'; import { Class, @@ -232,71 +229,78 @@ export class ClassesService { } } - const cls = this.classRepo.create({ - ...classData, - startDate: normalizeDateOnly(classData.startDate) ?? undefined, - endDate: normalizeDateOnly(classData.endDate) ?? undefined, - }); - const saved = await this.classRepo.save(cls); + // 班级/学生/教师写入整体包进事务:任一环节失败(并发唯一索引冲突、断连等)整体回滚, + // 避免「班级与已加入学生已落库、教师写入失败」留下孤儿数据。 + // batchImportStudents(钉钉导入)自带事务,保持事务外调用,失败行为与原来一致。 + const savedClassId = await this.dataSource.transaction(async (manager) => { + const cls = manager.create(Class, { + ...classData, + startDate: normalizeDateOnly(classData.startDate) ?? undefined, + endDate: normalizeDateOnly(classData.endDate) ?? undefined, + }); + const saved = await manager.save(cls); - // add students - if (studentIds?.length) { - const entries = studentIds.map((sid: number) => - this.classStudentRepo.create({ - classId: saved.id, - studentId: sid, - joinDate: dayjs().utcOffset(8).format('YYYY-MM-DD'), - }), - ); - await this.classStudentRepo.save(entries); - } + // add students + if (studentIds?.length) { + const entries = studentIds.map((sid: number) => + manager.create(ClassStudent, { + classId: saved.id, + studentId: sid, + joinDate: dayjs().utcOffset(8).format('YYYY-MM-DD'), + }), + ); + await manager.save(ClassStudent, entries); + } - // add teachers:任课老师一行一科目;其他角色单行无科目 - if (teacherPlans.length) { - const entries: ClassTeacher[] = []; - const seen = new Set(); - for (const plan of teacherPlans) { - if (plan.roleType === 'subject_teacher') { - for (const subject of plan.subjects) { - // 唯一索引 collation 大小写不敏感,去重键统一小写避免 'Math'+'math' 撞 1062 - const key = `${plan.userId}:${plan.roleType}:${subject.toLowerCase()}`; + // add teachers:任课老师一行一科目;其他角色单行无科目 + if (teacherPlans.length) { + const entries: ClassTeacher[] = []; + const seen = new Set(); + for (const plan of teacherPlans) { + if (plan.roleType === 'subject_teacher') { + for (const subject of plan.subjects) { + // 唯一索引 collation 大小写不敏感,去重键统一小写避免 'Math'+'math' 撞 1062 + const key = `${plan.userId}:${plan.roleType}:${subject.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + entries.push( + manager.create(ClassTeacher, { + classId: saved.id, + userId: plan.userId, + roleType: plan.roleType, + subject, + }), + ); + } + } else { + const key = `${plan.userId}:${plan.roleType}:`; if (seen.has(key)) continue; seen.add(key); entries.push( - this.classTeacherRepo.create({ + manager.create(ClassTeacher, { classId: saved.id, userId: plan.userId, roleType: plan.roleType, - subject, + subject: '', }), ); } - } else { - const key = `${plan.userId}:${plan.roleType}:`; - if (seen.has(key)) continue; - seen.add(key); - entries.push( - this.classTeacherRepo.create({ - classId: saved.id, - userId: plan.userId, - roleType: plan.roleType, - subject: '', - }), - ); } - } - if (entries.length) await this.classTeacherRepo.save(entries); + if (entries.length) await manager.save(ClassTeacher, entries); - // sync head/life/academic teacher IDs - await this.syncClassTeacherIds(saved.id); - } + // sync head/life/academic teacher IDs(事务内用 manager 保证同事务可见性) + await this.syncClassTeacherIds(saved.id, manager); + } + + return saved.id; + }); // batch import students by dingUserIds if (users?.length) { - await this.batchImportStudents(saved.id, users); + await this.batchImportStudents(savedClassId, users); } - return this.findOne(saved.id); + return this.findOne(savedClassId); } async update(id: number, dto: UpdateClassDto) { @@ -546,15 +550,22 @@ export class ClassesService { return { success: true }; } - private async syncClassTeacherIds(classId: number) { - const teachers = await this.classTeacherRepo.find({ where: { classId } }); + private async syncClassTeacherIds(classId: number, manager?: EntityManager) { + const teachers = manager + ? await manager.find(ClassTeacher, { where: { classId } }) + : await this.classTeacherRepo.find({ where: { classId } }); const head = teachers.find((t) => t.roleType === 'head_teacher'); const life = teachers.find((t) => t.roleType === 'life_teacher'); const academic = teachers.find((t) => t.roleType === 'academic_teacher'); - await this.classRepo.update(classId, { + const patch = { headTeacherId: head?.userId ?? null, lifeTeacherId: life?.userId ?? null, academicTeacherId: academic?.userId ?? null, - } as Partial); + } as Partial; + if (manager) { + await manager.update(Class, classId, patch); + } else { + await this.classRepo.update(classId, patch); + } } } diff --git a/apps/server/src/classes/classes.teacher-subjects.spec.ts b/apps/server/src/classes/classes.teacher-subjects.spec.ts index 532c16fb..bbfd8666 100644 --- a/apps/server/src/classes/classes.teacher-subjects.spec.ts +++ b/apps/server/src/classes/classes.teacher-subjects.spec.ts @@ -1,6 +1,27 @@ import { BadRequestException } from '@nestjs/common'; import { ClassesService, normalizeTeacherSubjects } from './classes.service'; -import { ClassTeacher, TeacherRoleType } from '../entities'; +import { Class, ClassStudent, ClassTeacher, TeacherRoleType } from '../entities'; + +/** 模拟 TypeORM EntityManager:按实体类型分发 create/save/find/update, + * 班级/学生/教师写入走各自 repo mock,供 create() 事务化后的测试使用。 */ +function entityAwareManager( + classRepo: Record, + classTeacherRepo: Record, + classStudentRepo: Record, +) { + return { + create: (entity: unknown, value: unknown) => + entity === ClassTeacher ? classTeacherRepo.create(value) : classRepo.create(value), + save: async (entity: unknown, value?: unknown) => { + if (entity === ClassTeacher) return classTeacherRepo.save(value); + if (entity === ClassStudent) return value; + return classRepo.save(entity); + }, + find: (entity: unknown, opts: unknown) => + entity === ClassTeacher ? classTeacherRepo.find(opts) : classStudentRepo.find(opts), + update: (_entity: unknown, id: unknown, patch: unknown) => classRepo.update(id, patch), + }; +} function createService( classRepo: Record, @@ -9,9 +30,14 @@ function createService( dataSource: Record = {}, ) { const ds = { - // 默认把事务回调的 manager 代理到 classTeacherRepo 的 save/create,保持既有断言语义 + // 默认把事务回调的 manager 代理到 repo mock,保持既有断言语义 transaction: async (fn: (manager: Record) => unknown) => - fn({ save: classTeacherRepo.save, create: classTeacherRepo.create }), + fn({ + save: classTeacherRepo.save, + create: classTeacherRepo.create, + find: classTeacherRepo.find ?? jest.fn().mockResolvedValue([]), + update: classRepo.update ?? jest.fn().mockResolvedValue(undefined), + }), ...dataSource, }; return new ClassesService( @@ -246,7 +272,10 @@ describe('ClassesService — teacher multi-subject (one row per subject)', () => find: jest.fn().mockResolvedValue([]), }; const classStudentRepo = { find: jest.fn().mockResolvedValue([]) }; - const service = createService(classRepo, classTeacherRepo, classStudentRepo); + const service = createService(classRepo, classTeacherRepo, classStudentRepo, { + transaction: async (fn: (m: unknown) => unknown) => + fn(entityAwareManager(classRepo, classTeacherRepo, classStudentRepo)), + }); await service.create({ name: '高三1班', @@ -308,7 +337,10 @@ describe('ClassesService — teacher multi-subject (one row per subject)', () => }), find: jest.fn().mockResolvedValue([]), }; - const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) }); + const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) }, { + transaction: async (fn: (m: unknown) => unknown) => + fn(entityAwareManager(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) })), + }); await service.create({ name: '高三1班', diff --git a/apps/server/src/classes/dto/class.dto.ts b/apps/server/src/classes/dto/class.dto.ts index 301d8d63..38459339 100644 --- a/apps/server/src/classes/dto/class.dto.ts +++ b/apps/server/src/classes/dto/class.dto.ts @@ -338,7 +338,9 @@ export class CommitRosterImportDto { createRows?: RosterCreateRowDto[]; } -/** 校验 addStudentIds/createRows 至少一项且合计不超过文件解析上限。 */ +/** 校验 addStudentIds/createRows 合计不超过文件解析上限。 + * 挂在 addStudentIds 上:@IsOptional 使该字段缺失时(如仅 createRows 或空 body)此校验器不运行, + * 空 body 由 service 抛「提交内容不能为空」兜底;双数组都提交时(唯一需要合计限制的场景)本校验器生效。 */ function ValidateRosterCommitLimits(validationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ diff --git a/apps/server/src/database/database-migrations.schema.ts b/apps/server/src/database/database-migrations.schema.ts index bcbd4f82..fc4d2ccf 100644 --- a/apps/server/src/database/database-migrations.schema.ts +++ b/apps/server/src/database/database-migrations.schema.ts @@ -435,6 +435,20 @@ async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promi } // 兜底:其余任何 >50 字符的值(如单个超长科目)先截断,避免 ALTER 报 Data too long。 + // 截断前先把原值备份到审计表(任课老师的超长科目不在 1b 的备份范围内,否则截断后原值永久丢失)。 + // 备份表在 1b 才创建(收敛 schema 下 needsLegacyCleanup=false),此处无条件执行需先确保表存在 + 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 CHAR_LENGTH(subject) > 50`); await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50) WHERE CHAR_LENGTH(subject) > 50`); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 6c0d808e..9f925594 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -6,13 +6,19 @@ import compression from 'compression'; import { AppModule } from './app.module'; import { runMigrationsOnStartup } from './migration-runner'; +// 与 app.setGlobalPrefix('api') + ClassesController('classes') 保持同步: +// 花名册提交/批量导入走这里,放宽 JSON 上限;改前缀或控制器路径时需同步修改。 +// 注意:Express 按前缀匹配,/api/classes 开头的路由都会放宽到 2mb。 +const LARGE_BODY_ROUTES_PREFIX = '/api/classes'; +const LARGE_BODY_LIMIT = '2mb'; + async function bootstrap() { await runMigrationsOnStartup(); // 默认 express.json 上限 100kb;花名册提交(最多 2000 行 createRows)与批量导入会超限 413, // 仅对 classes 路由提高上限,避免全局放宽造成大请求 DoS 面扩大 const app = await NestFactory.create(AppModule, { bodyParser: false }); - app.use('/api/classes', json({ limit: '2mb' })); + app.use(LARGE_BODY_ROUTES_PREFIX, json({ limit: LARGE_BODY_LIMIT })); app.use(json()); app.use(urlencoded({ extended: true })); // 全局 DTO 校验:对带 class-validator 装饰器的 DTO 生效。