From 257c2065b8f0966dff97dea987566e5a1b645e8d Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 11 Aug 2026 12:46:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E8=BF=81=E7=A7=BB=E8=B6=85?= =?UTF-8?q?=E9=95=BF=E7=A7=91=E7=9B=AE=E5=A4=87=E4=BB=BD=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E5=89=8D=E3=80=81keepPlus/rels=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=8A=A0=E5=9B=BA=E3=80=81=E9=92=89=E9=92=89=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=A4=B1=E8=B4=A5=E8=A1=A5=E5=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移:超长科目原值备份移到 step 2 拆分 UPDATE 之前(原位置截断后按长度查不到原值,备份无效) - keepPlus:'+' 后紧跟公式触发符(- + = @ 等)不保留前缀,写回 Excel 不会被当公式 - workbook rels:逐元素解析任意属性顺序/引号,声明大小超限拒绝,防文件名绕过与内存放大 - create():钉钉导入失败时补偿删除刚建班级(成员外键 CASCADE),避免孤儿班级 --- .../server/src/classes/class-roster-import.ts | 11 +- apps/server/src/classes/classes.controller.ts | 22 +++- .../src/classes/classes.roster-import.spec.ts | 106 +++++++++++------- apps/server/src/classes/classes.service.ts | 15 ++- .../database/database-migrations.schema.ts | 31 ++--- 5 files changed, 124 insertions(+), 61 deletions(-) diff --git a/apps/server/src/classes/class-roster-import.ts b/apps/server/src/classes/class-roster-import.ts index 0f6d56d9..e9655ee1 100644 --- a/apps/server/src/classes/class-roster-import.ts +++ b/apps/server/src/classes/class-roster-import.ts @@ -103,8 +103,15 @@ 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\s()-]*\d[\d\s()-]*$/.test(value)) { - // 整体形如国际区号(+86 138...、+86 (138) 1234-5678、+ 86 138...)时整串保留; + if ( + keepPlus && + ch === '+' && + start === 0 && + // '+' 后不允许公式触发符(= - + @ 制表/换行),'+-86'、'+(1-2)' 这类开头仍会被 + // Excel 当公式解释,回退到剥离;空格合法(+ 86 138... 也是国际区号写法) + /^\+[^=\-+@\t\r\n][\d\s()-]*\d[\d\s()-]*$/.test(value) + ) { + // 整体形如国际区号(+86 138...、+86 (138) 1234-5678)时整串保留; // 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离 // (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。 return value; diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index a2cbada7..eac3f294 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -75,6 +75,8 @@ const ROSTER_IMPORT_MAX_SHEETS = 5; // 单工作表行数预算:合法花名册 ≤2000 数据行 + 表头,留出空白行余量; // ExcelJS 会把整表物化为对象图后才执行行数上限,此处先流式预扫描拦截超限文件 const ROSTER_IMPORT_MAX_ROWS_PER_SHEET = 2500; +// workbook.xml.rels 正常远小于此值;超阈值视为异常(防压缩比放大后整体解压进内存) +const ROSTER_IMPORT_MAX_RELS_BYTES = 1024 * 1024; const ROSTER_IMPORT_TEMPLATE_FILENAME = '班级花名册导入模板.xlsx'; /** 校验上传的 xlsx zip 结构:条目数与真实解压总大小超限即拒绝(在 ExcelJS 整体解压前执行)。 */ @@ -89,10 +91,24 @@ 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),但声明大小不可信:超阈值直接拒绝,避免先整体解压进内存 + const declaredSize = (relsEntry as unknown as { _data?: { uncompressedSize?: number } })._data + ?.uncompressedSize; + if (typeof declaredSize === 'number' && declaredSize > ROSTER_IMPORT_MAX_RELS_BYTES) { + throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); + } 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}`); + // XML 属性顺序任意、单双引号皆合法,逐元素提取 Type/Target(避免仅匹配 Type 在前的写法被绕过) + for (const m of relsXml.matchAll(/]*)>/g)) { + const attrs = m[1] ?? ''; + const type = attrs.match(/Type\s*=\s*["']([^"']+)["']/)?.[1]; + const target = attrs.match(/Target\s*=\s*["']([^"']+)["']/)?.[1]; + if (type?.endsWith('/worksheet') && target) { + const normalized = target.replace(/^\/+/, ''); + if (normalized) { + sheetPaths.add(normalized.startsWith('xl/') ? normalized : `xl/${normalized}`); + } + } } } const worksheetCount = entries.filter( diff --git a/apps/server/src/classes/classes.roster-import.spec.ts b/apps/server/src/classes/classes.roster-import.spec.ts index 4199020e..25bff75b 100644 --- a/apps/server/src/classes/classes.roster-import.spec.ts +++ b/apps/server/src/classes/classes.roster-import.spec.ts @@ -1,6 +1,10 @@ import * as ExcelJS from 'exceljs'; import { ClassesQueriesService } from './classes-queries.service'; -import { createClassRosterTemplateWorkbook, parseClassRosterWorkbook, sanitizeCellText } from './class-roster-import'; +import { + createClassRosterTemplateWorkbook, + parseClassRosterWorkbook, + sanitizeCellText, +} from './class-roster-import'; import { Class, ClassStudent, Organization, Student } from '../entities'; /** 模拟 TypeORM QueryBuilder 链:getMany 委托给传入的 rows 获取函数;andWhere 表示带 status 过滤。 */ @@ -75,9 +79,7 @@ describe('class roster import workbook', () => { ]; ws.addRow({ name: '张三', phone: '13800138000' }); ws.getCell(2, 2).value = { error: '#DIV/0!' } as never; - expect(parseClassRosterWorkbook(workbook)).toEqual([ - { rowNumber: 2, name: '张三' }, - ]); + expect(parseClassRosterWorkbook(workbook)).toEqual([{ rowNumber: 2, name: '张三' }]); }); it('strips formula-leading characters from text cells', () => { @@ -109,7 +111,9 @@ describe('class roster import workbook', () => { const ws = workbook.addWorksheet('其他表'); ws.addRow(['字段A', '字段B']); ws.addRow(['值1', '值2']); - expect(() => parseClassRosterWorkbook(workbook)).toThrow('未识别到班级花名册工作表,请使用下载的导入模板'); + expect(() => parseClassRosterWorkbook(workbook)).toThrow( + '未识别到班级花名册工作表,请使用下载的导入模板', + ); }); it('throws when the required name column is missing', () => { @@ -135,7 +139,9 @@ describe('ClassesQueriesService.previewRosterImport', () => { const dataSource = { getRepository: jest.fn().mockReturnValue({ ...studentRepo, - createQueryBuilder: jest.fn().mockImplementation(() => makeQueryBuilder(() => studentRepo.find())), + createQueryBuilder: jest + .fn() + .mockImplementation(() => makeQueryBuilder(() => studentRepo.find())), }), transaction: jest.fn(), }; @@ -241,7 +247,14 @@ describe('ClassesQueriesService.previewRosterImport', () => { studentRepo.find .mockResolvedValueOnce([]) .mockResolvedValueOnce([ - { id: 99, name: '归档生', phone: '13600000000', idNumber: null, studentNo: null, status: 'archived' }, + { + id: 99, + name: '归档生', + phone: '13600000000', + idNumber: null, + studentNo: null, + status: 'archived', + }, ]); const result = await queries.previewRosterImport(1, [ { rowNumber: 2, name: '归档生', phone: '13600000000' }, @@ -396,10 +409,7 @@ describe('ClassesQueriesService.commitRosterImport', () => { it('deduplicates name-only create rows within the same batch', async () => { const { queries } = buildManager(); const result = await queries.commitRosterImport(1, { - createRows: [ - { name: '甲' }, - { name: '甲' }, - ], + createRows: [{ name: '甲' }, { name: '甲' }], }); expect(result.created).toBe(1); @@ -445,7 +455,14 @@ describe('ClassesQueriesService.commitRosterImport', () => { // 全量状态查询(无 status 条件)返回已归档学生;在读查询返回空 if (Array.isArray(where) && where[0]?.status === undefined) { return [ - { id: 98, phone: '13600000000', idNumber: null, studentNo: null, name: '归档生', status: 'archived' }, + { + id: 98, + phone: '13600000000', + idNumber: null, + studentNo: null, + name: '归档生', + status: 'archived', + }, ]; } return []; @@ -453,11 +470,30 @@ describe('ClassesQueriesService.commitRosterImport', () => { if (entity === ClassStudent) return []; return []; }), - create: jest.fn().mockImplementation((_entity: unknown, value: object) => ({ id: 2000, ...value })), - save: jest.fn().mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])), - createQueryBuilder: jest + create: jest .fn() - .mockImplementation(() => + .mockImplementation((_entity: unknown, value: object) => ({ id: 2000, ...value })), + save: jest + .fn() + .mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])), + createQueryBuilder: jest.fn().mockImplementation(() => + makeQueryBuilder((hasStatusFilter) => + hasStatusFilter + ? [] + : [ + { + id: 98, + phone: '13600000000', + idNumber: null, + studentNo: null, + name: '归档生', + status: 'archived', + }, + ], + ), + ), + getRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockImplementation(() => makeQueryBuilder((hasStatusFilter) => hasStatusFilter ? [] @@ -473,25 +509,6 @@ describe('ClassesQueriesService.commitRosterImport', () => { ], ), ), - getRepository: jest.fn().mockReturnValue({ - createQueryBuilder: jest - .fn() - .mockImplementation(() => - makeQueryBuilder((hasStatusFilter) => - hasStatusFilter - ? [] - : [ - { - id: 98, - phone: '13600000000', - idNumber: null, - studentNo: null, - name: '归档生', - status: 'archived', - }, - ], - ), - ), }), }; const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) }; @@ -542,9 +559,10 @@ describe('ClassesQueriesService.commitRosterImport', () => { }); expect(result.created).toBe(1); - const created = manager.create.mock.calls.find( - (call) => call[0] === Student, - )?.[1] as Record; + const created = manager.create.mock.calls.find((call) => call[0] === Student)?.[1] as Record< + string, + unknown + >; expect(created.name).toBe('1+1'); expect(created.phone).toBe('HACK'); expect(created.idNumber).toBe('ID'); @@ -566,7 +584,9 @@ describe('ClassesQueriesService.commitRosterImport', () => { {} as never, dataSource as never, ); - await expect(queries.commitRosterImport(1, { addStudentIds: [1] })).rejects.toThrow('班级不存在'); + await expect(queries.commitRosterImport(1, { addStudentIds: [1] })).rejects.toThrow( + '班级不存在', + ); }); it('rejects an entirely empty commit payload', async () => { @@ -606,10 +626,16 @@ describe('sanitizeCellText formula injection guard', () => { it('strips the + prefix when formula characters follow, keeping the remainder', () => { // 非纯号码前缀:回退到普通剥离(去掉前导 +),触发符不再位于首位, // 写回 Excel/CSV 时不会被解释为公式,同时不整串丢弃合法国际号码分隔符 - expect(sanitizeCellText('+86=HYPERLINK("http://evil")', true)).toBe('86=HYPERLINK("http://evil")'); + expect(sanitizeCellText('+86=HYPERLINK("http://evil")', true)).toBe( + '86=HYPERLINK("http://evil")', + ); expect(sanitizeCellText('+86@cmd', true)).toBe('86@cmd'); expect(sanitizeCellText('+86 138 0013 8000', true)).toBe('+86 138 0013 8000'); expect(sanitizeCellText('+86-138-0013-8000', true)).toBe('+86-138-0013-8000'); expect(sanitizeCellText('+86 138.0013.8000', true)).toBe('86 138.0013.8000'); + // '+' 后紧跟公式触发符(- + = @ 等)不保留前缀:'+-' 开头写回 Excel 仍会被当公式 + expect(sanitizeCellText('+-86 138', true)).toBe('86 138'); + expect(sanitizeCellText('++86 138', true)).toBe('86 138'); + expect(sanitizeCellText('+ 86 138', true)).toBe('+ 86 138'); }); }); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 3f0e2d17..a0a70ce3 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -295,9 +295,20 @@ export class ClassesService { return saved.id; }); - // batch import students by dingUserIds + // batch import students by dingUserIds(自带事务,事务外调用) if (users?.length) { - await this.batchImportStudents(savedClassId, users); + try { + await this.batchImportStudents(savedClassId, users); + } catch (error) { + // 钉钉导入失败会抛错,班级已在事务中提交——补偿删除刚创建的班级(学生/教师成员 + // 外键均 CASCADE),避免留下孤儿班级且前端重试会重复建班 + try { + await this.classRepo.delete(savedClassId); + } catch { + // 补偿删除失败:保留班级,优先抛原始错误 + } + throw error; + } } return this.findOne(savedClassId); diff --git a/apps/server/src/database/database-migrations.schema.ts b/apps/server/src/database/database-migrations.schema.ts index fc4d2ccf..de8ae635 100644 --- a/apps/server/src/database/database-migrations.schema.ts +++ b/apps/server/src/database/database-migrations.schema.ts @@ -396,6 +396,22 @@ async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promi // 2) 历史多科目值拆分:按内容判断(含分隔符或超长)而非列类型——标准升级路径 // (varchar(50) NULL)下同样可能存在 '语文、数学' 这类合并值。 + // 任何 >50 字符的值(如单个超长科目)都会在拆分/兜底中截断,截断前先把原值备份到审计表 + // (任课老师的超长科目不在 1b 的备份范围内,否则截断后原值永久丢失)。 + // 必须在本步骤的破坏性 UPDATE 之前执行,否则截断后按 CHAR_LENGTH(subject) > 50 查不到原值。 + // 备份表在 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`); 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`, @@ -435,20 +451,7 @@ 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`); + // 原值已在上方 step 2 之前备份到 class_teacher_legacy_subject_backup。 await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50) WHERE CHAR_LENGTH(subject) > 50`);