From 386c8dd5b314da9b35ff7813bd0f230a6721d1f8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 11 Aug 2026 11:51:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E7=8F=AD=E7=BA=A7=E8=8A=B1?= =?UTF-8?q?=E5=90=8D=E5=86=8C=E5=AF=BC=E5=85=A5=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=EF=BC=88zip=20=E7=82=B8=E5=BC=B9=E9=98=B2=E6=8A=A4/?= =?UTF-8?q?=E5=85=AC=E5=BC=8F=E6=B3=A8=E5=85=A5/=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E9=80=8F=E5=87=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assertRosterZipSafe 流式解压限流:单条目 8MB、总量 32MB 硬上限,超限即时中止 - 解压流超时兜底,防畸形 zip 挂起请求 - 工作表数量与行数在 ExcelJS 整体加载前流式预扫描拦截 - sanitizeCellText 补空格/换行公式触发符,keepPlus 支持括号且至少含一位数字 - 解析错误改 RosterParseError(400) 透出可操作提示,不再被 500 吞掉 - main.ts 仅对 /api/classes 路由放宽 JSON body 上限至 2mb --- .../server/src/classes/class-roster-import.ts | 200 ++++++ .../src/classes/classes.controller.spec.ts | 178 ++++- apps/server/src/classes/classes.controller.ts | 204 ++++++ .../src/classes/classes.roster-import.spec.ts | 615 ++++++++++++++++++ apps/server/src/main.ts | 8 +- 5 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/classes/class-roster-import.ts create mode 100644 apps/server/src/classes/classes.roster-import.spec.ts diff --git a/apps/server/src/classes/class-roster-import.ts b/apps/server/src/classes/class-roster-import.ts new file mode 100644 index 00000000..1f3bee17 --- /dev/null +++ b/apps/server/src/classes/class-roster-import.ts @@ -0,0 +1,200 @@ +import * as ExcelJS from 'exceljs'; +import { BadRequestException } from '@nestjs/common'; + +/** Excel 解析出的班级花名册行(rowNumber 为 Excel 行号,用于前端定位)。 */ +export interface ClassRosterImportRow { + rowNumber: number; + name: string; + phone?: string; + idNumber?: string; + studentNo?: string; +} + +/** 提交导入时勾选创建的新学生行;name 缺省时由服务端按冲突跳过。 */ +export interface ClassRosterCreateRow { + name?: string; + phone?: string; + idNumber?: string; + studentNo?: string; +} + +type ColumnDef = { + header: string; + key: 'name' | 'phone' | 'studentNo' | 'idNumber'; + width: number; + aliases?: string[]; +}; + +export const CLASS_ROSTER_MAX_ROWS = 2000; + +/** 用户可自行修复的解析错误(表头/工作表/行数上限等),controller 原样透出给用户, + * 区别于 zip 结构异常/内部错误(后者转通用文案)。 + * 继承 BadRequestException:Nest 默认异常过滤器对非 HttpException 一律回 500 并吞掉消息, + * 必须带 4xx 状态才能把可操作提示送到客户端。 */ +export class RosterParseError extends BadRequestException {} + +export const CLASS_ROSTER_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '姓名*', key: 'name', width: 15, aliases: ['姓名'] }, + { header: '手机号', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '学号', key: 'studentNo', width: 15 }, + // 注意:不设「学号/身份证」合并别名——同一列无法区分学号与身份证,误映射会把学号写进 idNumber + { header: '身份证号', key: 'idNumber', width: 22, aliases: ['身份证'] }, +]; + +function normalizeHeader(header: string): string { + return header.trim().replace(/\*+$/u, '').trim(); +} + +function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown { + const value = cell.value; + if (value === null || value === undefined) return undefined; + if (value instanceof Date) return value; + if (typeof value === 'object') { + if ('result' in value) { + const result = (value as { result?: unknown }).result; + // 公式结果若为非原始值(Excel 错误 { error: '#DIV/0!' }、超链接对象等), + // 返回空避免 cellToText 的 String(value) 得到 '[object Object]' 污染导入数据 + if (result && typeof result === 'object') return ''; + return result; + } + if ('text' in value) return value.text; + if ('richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join(''); + } + // 错误单元格(#DIV/0! 等)或未知结构化值:返回空而非 '[object Object]' + return ''; + } + return value; +} + +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'); + return `${year}-${month}-${day}`; +} + +function cellToText(cell: ExcelJS.Cell): string { + const value = getCellPrimitiveValue(cell); + if (value === null || value === undefined) return ''; + if (value instanceof Date) return formatDate(value); + // 手机号等长数字可能被 Excel 存成 number,直接 String() 会变成科学计数法,这里先转成整数文本 + if (typeof value === 'number') { + // NaN/Infinity 与超过 JS 安全整数范围的数字(18 位身份证等)都无法可靠还原, + // 返回空串让该字段保持缺失,避免把错误数据导入/匹配。 + // 模板列已强制文本格式(见 createClassRosterTemplateWorkbook)。 + if (!Number.isFinite(value) || !Number.isSafeInteger(value)) return ''; + return String(value); + } + // 对象值(错误单元格/共享公式等)保留既有 String() 行为 + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- 与 student-import.ts 保持一致的容错 + return String(value).trim(); +} + +const FORMULA_LEADING_CHARS = new Set(['=', '+', '-', '@', '\t', '\r', '\n', ' ']); + +/** + * 去除文本开头的公式注入字符(= + - @ 等),避免导入/提交的数据后续写回 Excel 时被解释为公式。 + * 手机号仅保留国际区号前缀 '+';'-' 本身也是公式触发符,一律剥离。 + * 导出供 commitRosterImport 直接提交 createRows 时复用,防止绕过上传路径注入公式。 + */ +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 (!FORMULA_LEADING_CHARS.has(ch)) break; + start += 1; + } + return value.slice(start); +} + +function buildHeaderMap(ws: ExcelJS.Worksheet): Map { + const headerIndex = new Map(); + const seenKeys = new Set(); + ws.getRow(1).eachCell((cell, colNumber) => { + const text = normalizeHeader(cellToText(cell)); + const column = CLASS_ROSTER_IMPORT_COLUMNS.find( + (def) => + normalizeHeader(def.header) === text || + (def.aliases || []).some((alias) => normalizeHeader(alias) === text), + ); + // 同一规范表头出现多列时保留第一列(首个匹配),避免后列静默覆盖前列数据 + if (column && !seenKeys.has(column.key)) { + seenKeys.add(column.key); + headerIndex.set(colNumber, column.key); + } + }); + return headerIndex; +} + +function findWorksheet(workbook: ExcelJS.Workbook, names: string[]): ExcelJS.Worksheet | undefined { + for (const name of names) { + const sheet = workbook.getWorksheet(name); + if (sheet) return sheet; + } + return undefined; +} + +/** 解析班级花名册工作簿:按命名匹配 sheet(不静默回退首表——用户误传其他工作簿时 + * 首表可能恰好含姓名/手机号列,静默读取会导入错误数据)。 */ +export function parseClassRosterWorkbook(workbook: ExcelJS.Workbook): ClassRosterImportRow[] { + const ws = findWorksheet(workbook, ['班级花名册', '花名册', '学生名单']); + if (!ws) throw new RosterParseError('未识别到班级花名册工作表,请使用下载的导入模板'); + const headerIndex = buildHeaderMap(ws); + if (headerIndex.size === 0) { + throw new RosterParseError('未识别到姓名/手机号/学号/身份证号列,请使用下载的导入模板'); + } + if (![...headerIndex.values()].includes('name')) { + throw new RosterParseError('未识别到必填的姓名列,请使用下载的导入模板'); + } + const rows: ClassRosterImportRow[] = []; + + ws.eachRow((row, idx) => { + if (idx === 1) return; + const parsed: Partial> = {}; + headerIndex.forEach((key, colNumber) => { + const value = cellToText(row.getCell(colNumber)); + if (value) parsed[key] = value; + }); + if (Object.keys(parsed).length === 0) return; + if (rows.length >= CLASS_ROSTER_MAX_ROWS) { + throw new RosterParseError(`单次导入最多支持 ${CLASS_ROSTER_MAX_ROWS} 行`); + } + rows.push({ + rowNumber: idx, + name: sanitizeCellText(parsed.name?.trim() ?? ''), + phone: sanitizeCellText(parsed.phone?.trim() || '', true) || undefined, + idNumber: sanitizeCellText(parsed.idNumber?.trim() || '') || undefined, + studentNo: sanitizeCellText(parsed.studentNo?.trim() || '') || undefined, + }); + }); + + return rows; +} + +function applyHeaderStyle(ws: ExcelJS.Worksheet) { + ws.getRow(1).font = { bold: true }; + ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; +} + +/** 生成班级花名册导入模板(仅表头行;列已强制文本格式,避免长数字被 Excel 舍入)。 */ +export function createClassRosterTemplateWorkbook(): ExcelJS.Workbook { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.columns = CLASS_ROSTER_IMPORT_COLUMNS.map(({ header, key, width }) => ({ + header, + key: String(key), + width, + // 手机号/学号/身份证号按文本格式写入,避免 Excel 将长数字按数值舍入丢失精度 + style: { numFmt: '@' }, + })); + applyHeaderStyle(ws); + return workbook; +} diff --git a/apps/server/src/classes/classes.controller.spec.ts b/apps/server/src/classes/classes.controller.spec.ts index 388b6ead..6d5e8918 100644 --- a/apps/server/src/classes/classes.controller.spec.ts +++ b/apps/server/src/classes/classes.controller.spec.ts @@ -3,10 +3,12 @@ import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; import { ValidationPipe } from '@nestjs/common'; import { ClassesController } from './classes.controller'; import { ClassesService } from './classes.service'; -import { QueryClassDto } from './dto/class.dto'; +import { CommitRosterImportDto, QueryClassDto } from './dto/class.dto'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { NotificationsService } from '../notifications/notifications.service'; import { CaslAction, SubjectName } from '../authorization'; +import * as ExcelJS from 'exceljs'; +import JSZip from 'jszip'; describe('ClassesController - class data scope', () => { const service = { @@ -144,3 +146,177 @@ describe('ClassesController purge route', () => { ); }); }); + +describe('CommitRosterImportDto - array caps', () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }); + + it('rejects more than 2000 addStudentIds', async () => { + await expect( + pipe.transform( + { addStudentIds: Array.from({ length: 2001 }, (_, i) => i) }, + { type: 'body', metatype: CommitRosterImportDto, data: undefined }, + ), + ).rejects.toThrow(); + }); + + it('rejects more than 2000 createRows', async () => { + await expect( + pipe.transform( + { createRows: Array.from({ length: 2001 }, () => ({ name: '学生' })) }, + { type: 'body', metatype: CommitRosterImportDto, data: undefined }, + ), + ).rejects.toThrow(); + }); + + it('rejects empty commit payloads', async () => { + await expect( + pipe.transform( + { addStudentIds: [], createRows: [] }, + { type: 'body', metatype: CommitRosterImportDto, data: undefined }, + ), + ).rejects.toThrow(); + }); + + it('accepts non-empty commit payloads', async () => { + await expect( + pipe.transform( + { addStudentIds: [1, 2] }, + { type: 'body', metatype: CommitRosterImportDto, data: undefined }, + ), + ).resolves.toEqual({ addStudentIds: [1, 2] }); + }); +}); + +describe('ClassesController roster import routes', () => { + const request = (permissions: string[] = [], isSuperAdmin = false) => ({ + user: { id: 21, username: 'user', permissions, isSuperAdmin, roles: [] }, + }); + + it('requires class:edit on preview and commit routes', () => { + expect( + Reflect.getMetadata(PERMISSION_KEY, ClassesController.prototype.previewRosterImport), + ).toEqual(['class:edit']); + expect( + Reflect.getMetadata(PERMISSION_KEY, ClassesController.prototype.commitRosterImport), + ).toEqual(['class:edit']); + }); + + it('rejects preview without an uploaded file', async () => { + const service = { + assertClassAccess: jest.fn().mockResolvedValue(undefined), + previewRosterImport: jest.fn(), + }; + const controller = new ClassesController( + service as never, + {} as never, + {} as never, + { can: jest.fn().mockReturnValue(true) } as never, + ); + await expect(controller.previewRosterImport('1', undefined, request(['class:edit']))).rejects.toThrow( + '缺少上传文件', + ); + expect(service.previewRosterImport).not.toHaveBeenCalled(); + }); + + it('parses the uploaded workbook and calls previewRosterImport', async () => { + const service = { + assertClassAccess: jest.fn().mockResolvedValue(undefined), + previewRosterImport: jest.fn().mockResolvedValue({ rows: [], summary: {} }), + }; + const controller = new ClassesController( + service as never, + {} as never, + {} as never, + { can: jest.fn().mockReturnValue(true) } as never, + ); + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.addRow(['姓名*', '手机号', '学号', '身份证号']); + ws.addRow(['张三', '13800138000', 'S1', 'ID1']); + const buf = await workbook.xlsx.writeBuffer(); + const file = { + buffer: Buffer.isBuffer(buf) ? buf : Buffer.from(buf as ArrayBuffer), + } as Express.Multer.File; + + await controller.previewRosterImport('1', file, request(['class:edit'])); + + expect(service.previewRosterImport).toHaveBeenCalledWith(1, [ + { rowNumber: 2, name: '张三', phone: '13800138000', studentNo: 'S1', idNumber: 'ID1' }, + ]); + }); + + it('rejects workbooks whose zip structure is oversized before parsing', async () => { + const service = { + assertClassAccess: jest.fn().mockResolvedValue(undefined), + previewRosterImport: jest.fn(), + }; + const controller = new ClassesController( + service as never, + {} as never, + {} as never, + { can: jest.fn().mockReturnValue(true) } as never, + ); + const zip = new JSZip(); + for (let i = 0; i < 201; i += 1) zip.file(`part${i}.xml`, 'x'); + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + const file = { buffer: buf as Buffer } as Express.Multer.File; + + await expect(controller.previewRosterImport('1', file, request(['class:edit']))).rejects.toThrow( + '文件解析失败', + ); + expect(service.previewRosterImport).not.toHaveBeenCalled(); + }); + + it('rejects an uploaded file that is not a valid Excel workbook', async () => { + const service = { + assertClassAccess: jest.fn().mockResolvedValue(undefined), + previewRosterImport: jest.fn(), + }; + const controller = new ClassesController( + service as never, + {} as never, + {} as never, + { can: jest.fn().mockReturnValue(true) } as never, + ); + const file = { buffer: Buffer.from('this is not an excel file') } as Express.Multer.File; + + await expect(controller.previewRosterImport('1', file, request(['class:edit']))).rejects.toThrow( + '文件解析失败', + ); + expect(service.previewRosterImport).not.toHaveBeenCalled(); + }); + + it('commits the roster and writes an audit log', async () => { + const service = { + assertClassAccess: jest.fn().mockResolvedValue(undefined), + commitRosterImport: jest.fn().mockResolvedValue({ + added: 2, + created: 1, + skipped: 1, + conflicts: 0, + message: '成功加入 2 名学生', + }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new ClassesController( + service as never, + { log } as never, + {} as never, + { can: jest.fn().mockReturnValue(true) } as never, + ); + const dto = { addStudentIds: [1, 2], createRows: [{ name: '新学生' }] }; + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + + await controller.commitRosterImport('1', dto, req); + + expect(service.commitRosterImport).toHaveBeenCalledWith(1, dto); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + module: '班级管理', + action: '批量导入学生', + targetId: 1, + detail: expect.stringContaining('成功加入2名'), + }), + ); + }); +}); diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index e27ac311..551cfe0b 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -1,6 +1,10 @@ import { + BadRequestException, Controller, Get, + HttpCode, + HttpStatus, + Logger, Post, Put, Delete, @@ -8,12 +12,15 @@ import { Param, ParseIntPipe, Query, + UploadedFile, UseGuards, + UseInterceptors, UsePipes, ValidationPipe, Request, Res, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { ClassesService } from './classes.service'; import { @@ -25,6 +32,7 @@ import { QueryClassScheduleDto, QueryClassAttendanceSummaryDto, BatchImportStudentsDto, + CommitRosterImportDto, } from './dto/class.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; @@ -34,6 +42,14 @@ import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; import { TeacherRoleType } from '../entities'; import * as ExcelJS from 'exceljs'; +import JSZip from 'jszip'; +import { bufferToArrayBuffer } from '../common/buffer'; +import { + createClassRosterTemplateWorkbook, + parseClassRosterWorkbook, + RosterParseError, + type ClassRosterImportRow, +} from './class-roster-import'; import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization'; interface AuthenticatedRequest { @@ -47,10 +63,101 @@ const teacherRoleLabels: Record = { [TeacherRoleType.ACADEMIC_TEACHER]: '学服老师', }; +const ROSTER_IMPORT_FILE_LIMIT_BYTES = 2 * 1024 * 1024; +// xlsx 是压缩容器,解压后规模可能远大于压缩包;ExcelJS.load 会整体解压到内存。 +// 此处为启发式防御(zip 中心目录的 uncompressedSize 由上传方提供,可信度有限), +// 真正硬性上限是 2MB 压缩包体积 + 解析后的 2000 行上限;保留较小的启发式阈值用于拦截明显异常文件。 +const ROSTER_IMPORT_MAX_ZIP_ENTRIES = 100; +const ROSTER_IMPORT_MAX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024; +const ROSTER_IMPORT_MAX_ENTRY_UNCOMPRESSED_BYTES = 8 * 1024 * 1024; +const ROSTER_IMPORT_STREAM_TIMEOUT_MS = 10_000; +const ROSTER_IMPORT_MAX_SHEETS = 5; +// 单工作表行数预算:合法花名册 ≤2000 数据行 + 表头,留出空白行余量; +// ExcelJS 会把整表物化为对象图后才执行行数上限,此处先流式预扫描拦截超限文件 +const ROSTER_IMPORT_MAX_ROWS_PER_SHEET = 2500; +const ROSTER_IMPORT_TEMPLATE_FILENAME = '班级花名册导入模板.xlsx'; + +/** 校验上传的 xlsx zip 结构:条目数与真实解压总大小超限即拒绝(在 ExcelJS 整体解压前执行)。 */ +async function assertRosterZipSafe(buffer: Buffer): Promise { + const zip = await JSZip.loadAsync(buffer); + const entries = Object.values(zip.files).filter((entry) => !entry.dir); + 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), + ).length; + if (worksheetCount > ROSTER_IMPORT_MAX_SHEETS) { + throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); + } + // zip 中心目录的 uncompressedSize 由上传方提供(不可信),必须按真实解压字节数校验; + // 流式解压并实时累计,任一条目解压中途超限立即中止,避免单个条目先整体缓冲进内存 + // (高压缩比 zip 炸弹可把 2MB 压缩包膨胀到数百 MB/GB),也不会把超大内容交给 ExcelJS。 + let totalUncompressed = 0; + for (const entry of entries) { + let entryUncompressed = 0; + // 工作表 XML 流式预扫描:按 `` 计数(空行自闭合 `` 不计), + // 超限立即拒绝,避免 ExcelJS 物化整表后再由行数上限兜底 + const isSheetEntry = /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name); + let sheetRowCount = 0; + let tail = ''; + await new Promise((resolve, reject) => { + // jszip nodeStream 运行时即 Node 的 stream.Readable,类型上仅暴露 ReadableStream, + // 这里收窄类型以使用 destroy 中止超限解压 + const stream = entry.nodeStream('nodebuffer') as unknown as NodeJS.ReadableStream & { + destroy: () => void; + }; + // 防挂起:畸形 zip 条目可能永不触发 end/error,超时后销毁流并拒绝,避免请求悬挂 + const timer = setTimeout(() => { + stream.destroy(); + reject(new Error('roster zip decompression timeout')); + }, ROSTER_IMPORT_STREAM_TIMEOUT_MS); + const settle = (fn: () => void) => { + clearTimeout(timer); + fn(); + }; + stream.on('data', (chunk: Buffer) => { + entryUncompressed += chunk.length; + // 单条目上限:单个超大 sheet XML 即便未超总量也会让 ExcelJS 内存对象膨胀数倍, + // 合法花名册单个条目远小于该阈值 + if (entryUncompressed > ROSTER_IMPORT_MAX_ENTRY_UNCOMPRESSED_BYTES) { + stream.destroy(); + settle(() => reject(new Error('roster zip entry too large'))); + return; + } + totalUncompressed += chunk.length; + if (totalUncompressed > ROSTER_IMPORT_MAX_UNCOMPRESSED_BYTES) { + stream.destroy(); + // 外层 catch 统一转 BadRequestException;这里用 Error 满足 prefer-promise-reject-errors + settle(() => reject(new Error('roster zip uncompressed size exceeds limit'))); + return; + } + if (isSheetEntry) { + // 带 6 字符尾缀拼接,跨 chunk 的 `` 也能被计数(尾缀可能重复计数,方向偏严) + const text = tail + chunk.toString('utf8'); + tail = text.slice(-6); + sheetRowCount += text.split('').length - 1; + if (sheetRowCount > ROSTER_IMPORT_MAX_ROWS_PER_SHEET) { + stream.destroy(); + settle(() => reject(new Error('roster zip sheet row limit exceeded'))); + return; + } + } + }); + stream.on('end', () => settle(resolve)); + stream.on('error', (error: Error) => settle(() => reject(error))); + }); + } +} + @UseGuards(JwtAuthGuard) @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) @Controller('classes') export class ClassesController { + private readonly logger = new Logger(ClassesController.name); + constructor( private readonly service: ClassesService, private readonly logService: OperationLogsService, @@ -84,6 +191,31 @@ export class ClassesController { return this.service.findAll(query, classIds); } + @Get('roster/import-template') + @RequirePermission('class:view') + async downloadRosterImportTemplate(@Res() res: Response) { + try { + const workbook = createClassRosterTemplateWorkbook(); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader( + 'Content-Disposition', + `attachment; filename*=UTF-8''${encodeURIComponent(ROSTER_IMPORT_TEMPLATE_FILENAME)}`, + ); + await workbook.xlsx.write(res); + res.end(); + } catch (error) { + this.logger.error(`班级花名册模板下载失败: ${(error as Error).message}`, (error as Error).stack); + if (!res.headersSent) { + res.status(500).json({ statusCode: 500, message: '模板生成失败' }); + } else { + res.end(); + } + } + } + @Get(':id') @RequirePermission('class:view') async findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { @@ -135,6 +267,76 @@ export class ClassesController { return this.service.batchImportStudents(+id, dto.users); } + /** 班级花名册导入预览:上传 Excel,返回逐行匹配结果,不写库 */ + @Post(':id/students/import-preview') + @HttpCode(HttpStatus.OK) + @RequirePermission('class:edit') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: ROSTER_IMPORT_FILE_LIMIT_BYTES }, + // MIME 由浏览器/客户端声明并不可信,仅按扩展名放行,内容真实性由后续魔数与 zip/xlsx 解析校验 + fileFilter: (_req, file, cb) => { + if (!/\.xlsx$/i.test(file.originalname)) { + return cb(new BadRequestException('仅支持 .xlsx 文件'), false); + } + cb(null, true); + }, + }), + ) + async previewRosterImport( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: Express.Multer.File | undefined, + @Request() req: AuthenticatedRequest, + ) { + await this.assertReadAccess(req, +id); + if (!file?.buffer) throw new BadRequestException('缺少上传文件'); + // xlsx 本质是 zip 容器,先校验魔数快速拒绝伪装文件,再交给 ExcelJS 解析 + const magic = file.buffer.subarray(0, 4).toString('latin1'); + if (magic !== 'PK\x03\x04') { + throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); + } + const workbook = new ExcelJS.Workbook(); + let rows: ClassRosterImportRow[]; + try { + await assertRosterZipSafe(file.buffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); + // 兜底限制工作表数量,防止多 sheet 放大(zip 结构检查已前置拦截,此为二次防线) + if (workbook.worksheets.length > ROSTER_IMPORT_MAX_SHEETS) { + throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); + } + rows = parseClassRosterWorkbook(workbook); + } catch (error) { + // 用户可自行修复的解析错误(表头/工作表/行数上限)用 warn,避免刷屏 error 日志; + // 其余异常记 error 便于排查 + if (error instanceof RosterParseError) { + this.logger.warn(`班级花名册导入解析失败 classId=${id}: ${(error as Error).message}`); + throw error; + } + this.logger.error(`班级花名册导入文件解析失败 classId=${id}: ${(error as Error).message}`, (error as Error).stack); + // zip 结构异常/内部错误等统一转通用文案,避免暴露内部细节 + if (error instanceof BadRequestException) throw error; + throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件'); + } + return this.service.previewRosterImport(+id, rows); + } + + /** 班级花名册导入提交:加入匹配学生 + 按勾选创建未匹配学生 */ + @Post(':id/students/import-commit') + @RequirePermission('class:edit') + async commitRosterImport( + @Param('id', ParseIntPipe) id: number, + @Body() dto: CommitRosterImportDto, + @Request() req: AuthenticatedRequest, + ) { + await this.assertReadAccess(req, +id); + const result = await this.service.commitRosterImport(+id, dto); + await logAudit(this.logService, req, { + module: '班级管理', action: '批量导入学生', targetId: +id, targetType: 'class', + detail: `成功加入${result.added}名(新建${result.created}名),跳过${result.skipped}名,冲突${result.conflicts}行`, + }); + return result; + } + /** 归档班级 */ @Put(':id/archive') @RequirePermission('class:edit') @@ -285,6 +487,8 @@ export class ClassesController { 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}`, }); diff --git a/apps/server/src/classes/classes.roster-import.spec.ts b/apps/server/src/classes/classes.roster-import.spec.ts new file mode 100644 index 00000000..4199020e --- /dev/null +++ b/apps/server/src/classes/classes.roster-import.spec.ts @@ -0,0 +1,615 @@ +import * as ExcelJS from 'exceljs'; +import { ClassesQueriesService } from './classes-queries.service'; +import { createClassRosterTemplateWorkbook, parseClassRosterWorkbook, sanitizeCellText } from './class-roster-import'; +import { Class, ClassStudent, Organization, Student } from '../entities'; + +/** 模拟 TypeORM QueryBuilder 链:getMany 委托给传入的 rows 获取函数;andWhere 表示带 status 过滤。 */ +function makeQueryBuilder(getRows: (hasStatusFilter: boolean) => unknown[] | Promise) { + let hasStatusFilter = false; + const qb = { + setParameter: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockImplementation(function () { + hasStatusFilter = true; + return this; + }), + getMany: jest.fn().mockImplementation(() => Promise.resolve(getRows(hasStatusFilter))), + }; + return qb; +} + +function buildWorkbook(rows: Array>): ExcelJS.Workbook { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.columns = [ + { header: '姓名*', key: 'name', width: 15 }, + { header: '手机号', key: 'phone', width: 18 }, + { header: '学号', key: 'studentNo', width: 15 }, + { header: '身份证号', key: 'idNumber', width: 22 }, + ]; + for (const row of rows) ws.addRow(row); + return workbook; +} + +describe('class roster import workbook', () => { + it('parses roster rows with Excel row numbers and trims values', () => { + const workbook = buildWorkbook([ + { name: ' 张三 ', phone: '13800138000', studentNo: 'S1', idNumber: 'ID1' }, + ]); + expect(parseClassRosterWorkbook(workbook)).toEqual([ + { rowNumber: 2, name: '张三', phone: '13800138000', studentNo: 'S1', idNumber: 'ID1' }, + ]); + }); + + it('keeps numeric phone cells as plain digit strings', () => { + const workbook = buildWorkbook([{ name: '李四', phone: 13900139000 }]); + expect(parseClassRosterWorkbook(workbook)).toEqual([ + { rowNumber: 2, name: '李四', phone: '13900139000' }, + ]); + }); + + it('drops numeric cells beyond the safe integer range instead of importing rounded values', () => { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.columns = [ + { header: '姓名*', key: 'name', width: 15 }, + { header: '手机号', key: 'phone', width: 18 }, + { header: '学号', key: 'studentNo', width: 15 }, + { header: '身份证号', key: 'idNumber', width: 22 }, + ]; + ws.addRow({ name: '张三', phone: 13800138000 }); + // 18 位身份证超过 Number.MAX_SAFE_INTEGER,读回时已被舍入,应作为缺失处理 + // 用运行时表达式构造超安全整数范围的数字,避免字面量本身触发 no-loss-of-precision + ws.getCell(2, 4).value = Number.MAX_SAFE_INTEGER + 1; + expect(parseClassRosterWorkbook(workbook)).toEqual([ + { rowNumber: 2, name: '张三', phone: '13800138000' }, + ]); + }); + + it('treats Excel error cells as empty text', () => { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.columns = [ + { header: '姓名*', key: 'name', width: 15 }, + { header: '手机号', key: 'phone', width: 18 }, + ]; + ws.addRow({ name: '张三', phone: '13800138000' }); + ws.getCell(2, 2).value = { error: '#DIV/0!' } as never; + expect(parseClassRosterWorkbook(workbook)).toEqual([ + { rowNumber: 2, name: '张三' }, + ]); + }); + + it('strips formula-leading characters from text cells', () => { + const workbook = buildWorkbook([ + { name: '=1+1', phone: '+8613800138000' }, + { name: '@张三', phone: '=HACK' }, + ]); + expect(parseClassRosterWorkbook(workbook)).toEqual([ + { rowNumber: 2, name: '1+1', phone: '+8613800138000' }, + { rowNumber: 3, name: '张三', phone: 'HACK' }, + ]); + }); + + it('throws when the number of rows exceeds the limit', () => { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.columns = [{ header: '姓名*', key: 'name', width: 15 }]; + for (let i = 0; i < 2001; i += 1) ws.addRow({ name: `学生${i}` }); + expect(() => parseClassRosterWorkbook(workbook)).toThrow('单次导入最多支持 2000 行'); + }); + + it('skips fully empty rows', () => { + const workbook = buildWorkbook([{ name: '张三' }, {}, { name: '李四' }]); + expect(parseClassRosterWorkbook(workbook).map((r) => r.name)).toEqual(['张三', '李四']); + }); + + it('throws when no recognizable roster sheet exists (no silent fallback to first sheet)', () => { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('其他表'); + ws.addRow(['字段A', '字段B']); + ws.addRow(['值1', '值2']); + expect(() => parseClassRosterWorkbook(workbook)).toThrow('未识别到班级花名册工作表,请使用下载的导入模板'); + }); + + it('throws when the required name column is missing', () => { + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.addRow(['手机号']); + ws.addRow(['13800138000']); + expect(() => parseClassRosterWorkbook(workbook)).toThrow('未识别到必填的姓名列'); + }); + + it('creates a template workbook with the roster sheet', () => { + const workbook = createClassRosterTemplateWorkbook(); + const ws = workbook.getWorksheet('班级花名册'); + expect(ws).toBeDefined(); + expect(ws!.getRow(1).getCell(1).value).toBe('姓名*'); + }); +}); + +describe('ClassesQueriesService.previewRosterImport', () => { + const studentRepo = { + find: jest.fn(), + }; + const dataSource = { + getRepository: jest.fn().mockReturnValue({ + ...studentRepo, + createQueryBuilder: jest.fn().mockImplementation(() => makeQueryBuilder(() => studentRepo.find())), + }), + transaction: jest.fn(), + }; + const queries = new ClassesQueriesService( + { findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never, + { + find: jest.fn().mockResolvedValue([{ studentId: 2, status: 'active' }]), + } as never, + {} as never, + {} as never, + dataSource as never, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('matches by phone/idNumber/studentNo and marks in-class students', async () => { + studentRepo.find.mockResolvedValue([ + { id: 1, name: '张三', phone: '13800138000', idNumber: null, studentNo: 'S1' }, + { id: 2, name: '李四', phone: '13900139000', idNumber: 'ID2', studentNo: 'S2' }, + ]); + const result = await queries.previewRosterImport(1, [ + { rowNumber: 2, name: '张三', phone: '13800138000' }, + { rowNumber: 3, name: '李四', phone: '13900139000' }, + { rowNumber: 4, name: '王五', phone: '13700137000' }, + ]); + expect(result.rows.map((r) => r.status)).toEqual(['matched', 'in-class', 'unmatched']); + expect(result.rows[0].student).toEqual({ id: 1, name: '张三', studentNo: 'S1' }); + expect(result.rows[1].student?.id).toBe(2); + expect(result.summary).toEqual({ total: 3, matched: 1, unmatched: 1, inClass: 1, conflict: 0 }); + }); + + it('falls back to a unique name match', async () => { + studentRepo.find.mockResolvedValue([ + { id: 1, name: '张三', phone: null, idNumber: null, studentNo: null }, + ]); + const result = await queries.previewRosterImport(1, [{ rowNumber: 2, name: '张三' }]); + expect(result.rows[0].status).toBe('matched'); + expect(result.rows[0].student?.id).toBe(1); + }); + + it('flags ambiguous names and cross-identifier conflicts', async () => { + studentRepo.find.mockResolvedValue([ + { id: 1, name: '重名', phone: '13800138000', idNumber: null, studentNo: null }, + { id: 2, name: '重名', phone: '13900139000', idNumber: null, studentNo: null }, + { id: 3, name: '赵六', phone: '13700137000', idNumber: 'AAA', studentNo: null }, + { id: 4, name: '钱七', phone: '13800000000', idNumber: 'ID3', studentNo: null }, + ]); + const result = await queries.previewRosterImport(1, [ + { rowNumber: 2, name: '重名' }, + { rowNumber: 3, name: '赵六', phone: '13700137000', idNumber: 'ID3' }, + ]); + expect(result.rows.map((r) => r.status)).toEqual(['conflict', 'conflict']); + expect(result.rows[0].reason).toContain('多名学生'); + expect(result.rows[1].reason).toContain('不同学生'); + }); + + it('matches identifiers case-insensitively with padded stored values', async () => { + studentRepo.find.mockResolvedValue([ + { id: 11, name: '赵六', phone: ' 13500000002 ', idNumber: 'IDX-7', studentNo: 's-num-7' }, + ]); + const result = await queries.previewRosterImport(1, [ + { rowNumber: 2, name: '赵六', phone: '13500000002', idNumber: 'idx-7', studentNo: 'S-NUM-7' }, + ]); + expect(result.rows[0].status).toBe('matched'); + expect(result.rows[0].student?.id).toBe(11); + }); + + it('flags a row whose name contradicts the student matched by phone', async () => { + studentRepo.find.mockResolvedValue([ + { id: 5, name: '王五', phone: '13500000001', idNumber: null, studentNo: null }, + ]); + const result = await queries.previewRosterImport(1, [ + { rowNumber: 2, name: '赵六', phone: '13500000001' }, + ]); + expect(result.rows[0].status).toBe('conflict'); + expect(result.rows[0].reason).toContain('姓名与匹配学生不一致'); + }); + + it('matches rows against students with padded stored identifiers', async () => { + studentRepo.find.mockResolvedValue([ + { id: 7, name: '周八', phone: ' 13300000001 ', idNumber: null, studentNo: null }, + ]); + const result = await queries.previewRosterImport(1, [ + { rowNumber: 2, name: '周八', phone: '13300000001' }, + ]); + expect(result.rows[0].status).toBe('matched'); + }); + + it('flags a row whose idNumber contradicts the student matched by phone', async () => { + studentRepo.find.mockResolvedValue([ + { id: 6, name: '钱七', phone: '13400000001', idNumber: 'ID6', studentNo: null }, + ]); + const result = await queries.previewRosterImport(1, [ + { rowNumber: 2, name: '钱七', phone: '13400000001', idNumber: 'ID9' }, + ]); + expect(result.rows[0].status).toBe('conflict'); + expect(result.rows[0].reason).toContain('身份证号与匹配学生不一致'); + }); + + it('flags unmatched rows whose identifier belongs to an archived student', async () => { + studentRepo.find + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { id: 99, name: '归档生', phone: '13600000000', idNumber: null, studentNo: null, status: 'archived' }, + ]); + const result = await queries.previewRosterImport(1, [ + { rowNumber: 2, name: '归档生', phone: '13600000000' }, + ]); + expect(result.rows[0].status).toBe('conflict'); + expect(result.rows[0].reason).toContain('已归档或员工账号'); + }); + + it('flags rows without any name or identifier', async () => { + studentRepo.find.mockResolvedValue([]); + const result = await queries.previewRosterImport(1, [{ rowNumber: 2, name: '' }]); + expect(result.rows[0].status).toBe('conflict'); + expect(result.rows[0].reason).toContain('缺少姓名'); + }); +}); + +describe('ClassesQueriesService.commitRosterImport', () => { + let nextStudentId: number; + + function buildManager() { + nextStudentId = 1000; + const baseStudents: Array> = [ + { id: 100, phone: '13800138000', idNumber: null, studentNo: 'S100', name: '已有学生' }, + { id: 200, name: '离班学生' }, + { id: 300, name: '在读学生' }, + ]; + const createdStudents: Array> = []; + const manager = { + findOne: jest.fn().mockImplementation(async (entity: unknown) => { + if (entity === Class) return { id: 1 }; + if (entity === Organization) return { id: 9, isHost: true, status: 'active' }; + return null; + }), + find: jest.fn().mockImplementation(async (entity: unknown) => { + if (entity === Student) { + return [...baseStudents, ...createdStudents]; + } + if (entity === ClassStudent) { + return [ + { studentId: 200, status: 'left' }, + { studentId: 300, status: 'active' }, + ]; + } + return []; + }), + create: jest.fn().mockImplementation((_entity: unknown, value: object) => { + const obj = { id: nextStudentId++, ...value }; + createdStudents.push(obj); + return obj; + }), + save: jest + .fn() + .mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])), + createQueryBuilder: jest + .fn() + .mockImplementation(() => makeQueryBuilder(() => manager.find(Student))), + getRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest + .fn() + .mockImplementation(() => makeQueryBuilder(() => manager.find(Student))), + }), + }; + const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) }; + const queries = new ClassesQueriesService( + {} as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ); + return { manager, queries }; + } + + it('reactivates left memberships, skips active ones, and creates unmatched students', async () => { + const { manager, queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + addStudentIds: [200, 300], + createRows: [{ name: '新学生', phone: '13700000001' }], + }); + + expect(result).toEqual({ + added: 2, + created: 1, + skipped: 1, + conflicts: 0, + message: expect.stringContaining('成功加入 2 名学生'), + }); + const savedMemberships = manager.save.mock.calls.filter((call) => call[0] === ClassStudent); + const memberships = savedMemberships[0][1] as Array>; + const left = memberships.find((m) => m.studentId === 200); + expect(left).toMatchObject({ status: 'active', leaveDate: null }); + expect(left?.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + const created = memberships.find((m) => m.studentId !== 200 && m.studentId !== 300); + expect(created).toBeDefined(); + expect( + manager.save.mock.calls.some((call) => (call[0] as { name?: string })?.name === '新学生'), + ).toBe(true); + }); + + it('reuses an existing student when a create row matches by phone', async () => { + const { manager, queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [{ name: '已有学生', phone: '13800138000' }], + }); + + expect(result.created).toBe(0); + expect(result.added).toBe(1); + expect( + manager.save.mock.calls.some((call) => (call[0] as { name?: string })?.name === '已有学生'), + ).toBe(false); + }); + + it('deduplicates create rows by phone within the same batch', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [ + { name: '甲', phone: '13700000002' }, + { name: '甲', phone: '13700000002' }, + ], + }); + + expect(result.created).toBe(1); + expect(result.added).toBe(1); + expect(result.conflicts).toBe(0); + }); + + it('flags a create row merged onto an earlier batch student with a different name', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [ + { name: '甲', phone: '13700000002' }, + { name: '乙', phone: '13700000002' }, + ], + }); + + expect(result.created).toBe(1); + expect(result.added).toBe(1); + expect(result.conflicts).toBe(1); + }); + + it('reuses an existing student when a create row matches by studentNo', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [{ name: '已有学生', studentNo: 'S100' }], + }); + + expect(result.created).toBe(0); + expect(result.added).toBe(1); + expect(result.conflicts).toBe(0); + }); + + it('deduplicates name-only create rows within the same batch', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [ + { name: '甲' }, + { name: '甲' }, + ], + }); + + expect(result.created).toBe(1); + expect(result.added).toBe(1); + expect(result.conflicts).toBe(0); + }); + + it('deduplicates create rows by studentNo within the same batch', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [ + { name: '甲', studentNo: 'SN1' }, + { name: '甲', studentNo: 'SN1' }, + ], + }); + + expect(result.created).toBe(1); + expect(result.added).toBe(1); + expect(result.conflicts).toBe(0); + }); + + it('counts rows with missing names as conflicts', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [{ name: '', phone: '13700000003' }], + }); + + expect(result.conflicts).toBe(1); + expect(result.added).toBe(0); + expect(result.created).toBe(0); + }); + + it('blocks creating a student whose identifier belongs to an archived student', async () => { + const manager = { + findOne: jest.fn().mockImplementation(async (entity: unknown) => { + if (entity === Class) return { id: 1 }; + if (entity === Organization) return { id: 9, isHost: true, status: 'active' }; + return null; + }), + find: jest.fn().mockImplementation(async (entity: unknown, opts?: { where?: unknown }) => { + if (entity === Student) { + const where = opts?.where as Array> | undefined; + // 全量状态查询(无 status 条件)返回已归档学生;在读查询返回空 + if (Array.isArray(where) && where[0]?.status === undefined) { + return [ + { id: 98, phone: '13600000000', idNumber: null, studentNo: null, name: '归档生', status: 'archived' }, + ]; + } + return []; + } + 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 + .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 + ? [] + : [ + { + id: 98, + phone: '13600000000', + idNumber: null, + studentNo: null, + name: '归档生', + status: 'archived', + }, + ], + ), + ), + }), + }; + const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) }; + const queries = new ClassesQueriesService( + {} as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ); + + const result = await queries.commitRosterImport(1, { + createRows: [{ name: '新学生', phone: '13600000000' }], + }); + + expect(result.conflicts).toBe(1); + expect(result.created).toBe(0); + expect(result.added).toBe(0); + }); + + it('flags a create row whose extra identifier is not stored on the matched student', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [{ name: '已有学生', phone: '13800138000', idNumber: 'IDX' }], + }); + + expect(result.conflicts).toBe(1); + expect(result.created).toBe(0); + expect(result.added).toBe(0); + }); + + it('counts missing addStudentIds as conflicts without failing the batch', async () => { + const { queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + addStudentIds: [9999], + createRows: [{ name: '新学生', phone: '13700000001' }], + }); + + expect(result.conflicts).toBe(1); + expect(result.created).toBe(1); + expect(result.added).toBe(1); + }); + + it('sanitizes formula-injection prefixes from directly submitted createRows', async () => { + const { manager, queries } = buildManager(); + const result = await queries.commitRosterImport(1, { + createRows: [{ name: '=1+1', phone: '=HACK', idNumber: '@ID', studentNo: '-SN' }], + }); + + expect(result.created).toBe(1); + const created = manager.create.mock.calls.find( + (call) => call[0] === Student, + )?.[1] as Record; + expect(created.name).toBe('1+1'); + expect(created.phone).toBe('HACK'); + expect(created.idNumber).toBe('ID'); + expect(created.studentNo).toBe('SN'); + }); + + it('throws when the class does not exist', async () => { + const manager = { + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockResolvedValue([]), + create: jest.fn(), + save: jest.fn(), + }; + const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) }; + const queries = new ClassesQueriesService( + {} as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ); + await expect(queries.commitRosterImport(1, { addStudentIds: [1] })).rejects.toThrow('班级不存在'); + }); + + it('rejects an entirely empty commit payload', async () => { + const dataSource = { transaction: jest.fn() }; + const queries = new ClassesQueriesService( + {} as never, + {} as never, + {} as never, + {} as never, + dataSource as never, + ); + await expect(queries.commitRosterImport(1, {})).rejects.toThrow('提交内容不能为空'); + await expect( + queries.commitRosterImport(1, { addStudentIds: [], createRows: [] }), + ).rejects.toThrow('提交内容不能为空'); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); +}); + +describe('sanitizeCellText formula injection guard', () => { + it('strips leading formula characters by default', () => { + expect(sanitizeCellText('=HYPERLINK("http://evil")')).toBe('HYPERLINK("http://evil")'); + expect(sanitizeCellText('@cmd')).toBe('cmd'); + expect(sanitizeCellText('-1+2')).toBe('1+2'); + }); + + it('keeps a leading + only when it looks like an international phone prefix', () => { + expect(sanitizeCellText('+8613800138000', true)).toBe('+8613800138000'); + }); + + it('strips formula-injection variants that start with + but are not phone prefixes', () => { + expect(sanitizeCellText('+=HYPERLINK("http://evil")', true)).toBe('HYPERLINK("http://evil")'); + expect(sanitizeCellText('=+HACK', true)).toBe('HACK'); + expect(sanitizeCellText('++cmd', true)).toBe('cmd'); + }); + + 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@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'); + }); +}); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 584126eb..6c0d808e 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { json, urlencoded } from 'express'; import helmet from 'helmet'; import compression from 'compression'; import { AppModule } from './app.module'; @@ -8,7 +9,12 @@ import { runMigrationsOnStartup } from './migration-runner'; async function bootstrap() { await runMigrationsOnStartup(); - const app = await NestFactory.create(AppModule); + // 默认 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(json()); + app.use(urlencoded({ extended: true })); // 全局 DTO 校验:对带 class-validator 装饰器的 DTO 生效。 // 注意:不开 whitelist/forbidNonWhitelisted,避免把无装饰器的裸 body(如 { ids: number[] })剥空。 app.useGlobalPipes(new ValidationPipe({ transform: true }));