diff --git a/apps/server/src/classes/class-roster-import.ts b/apps/server/src/classes/class-roster-import.ts index e9655ee1..742a800c 100644 --- a/apps/server/src/classes/class-roster-import.ts +++ b/apps/server/src/classes/class-roster-import.ts @@ -107,9 +107,9 @@ export function sanitizeCellText(value: string, keepPlus = false): string { keepPlus && ch === '+' && start === 0 && - // '+' 后不允许公式触发符(= - + @ 制表/换行),'+-86'、'+(1-2)' 这类开头仍会被 + // '+' 后不允许公式触发符(= - + @ 制表/换行/括号),'+-86'、'+(1-2)' 这类开头仍会被 // Excel 当公式解释,回退到剥离;空格合法(+ 86 138... 也是国际区号写法) - /^\+[^=\-+@\t\r\n][\d\s()-]*\d[\d\s()-]*$/.test(value) + /^\+[^=\-+@\t\r\n(][\d\s()-]*\d[\d\s()-]*$/.test(value) ) { // 整体形如国际区号(+86 138...、+86 (138) 1234-5678)时整串保留; // 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离 diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index eac3f294..bd1babae 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -91,10 +91,11 @@ async function assertRosterZipSafe(buffer: Buffer): Promise { const sheetPaths = new Set(); const relsEntry = zip.files['xl/_rels/workbook.xml.rels']; if (relsEntry && !relsEntry.dir) { - // rels 文件极小(正常 <10KB),但声明大小不可信:超阈值直接拒绝,避免先整体解压进内存 + // rels 文件极小(正常 <10KB),但声明大小不可信且 JSZip 内部结构可能随升级变化: + // 读不到声明大小(undefined)时保守按超限拒绝,避免静默失去防护后整体解压进内存 const declaredSize = (relsEntry as unknown as { _data?: { uncompressedSize?: number } })._data ?.uncompressedSize; - if (typeof declaredSize === 'number' && declaredSize > ROSTER_IMPORT_MAX_RELS_BYTES) { + if (typeof declaredSize !== 'number' || declaredSize > ROSTER_IMPORT_MAX_RELS_BYTES) { throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); } const relsXml = await relsEntry.async('string'); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index a0a70ce3..f79013ba 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -1,5 +1,6 @@ import { Injectable, + Logger, NotFoundException, BadRequestException, ForbiddenException, @@ -70,6 +71,8 @@ function resolveTeacherSubjects( @Injectable() export class ClassesService { + private readonly logger = new Logger(ClassesService.name); + constructor( @InjectRepository(Class) private classRepo: Repository, @@ -300,12 +303,23 @@ export class ClassesService { try { await this.batchImportStudents(savedClassId, users); } catch (error) { - // 钉钉导入失败会抛错,班级已在事务中提交——补偿删除刚创建的班级(学生/教师成员 - // 外键均 CASCADE),避免留下孤儿班级且前端重试会重复建班 - try { - await this.classRepo.delete(savedClassId); - } catch { - // 补偿删除失败:保留班级,优先抛原始错误 + // 数据/校验错误(BadRequestException)时班级已提交——补偿删除刚创建的班级 + // (学生/教师成员外键均 CASCADE),避免孤儿班级且前端重试撞 code 唯一索引; + // 第三方临时故障(钉钉网络等)保留班级(与事务化前行为一致),记录日志供排查 + if (error instanceof BadRequestException) { + try { + await this.classRepo.delete(savedClassId); + } catch (deleteError) { + this.logger.error( + `补偿删除班级 ${savedClassId} 失败:${(deleteError as Error).message}`, + (deleteError as Error).stack, + ); + } + } else { + this.logger.error( + `班级 ${savedClassId} 创建后钉钉导入失败:${(error as Error).message}`, + (error as Error).stack, + ); } throw error; }