fix(server): 班级花名册导入安全加固(zip 炸弹防护/公式注入/解析错误透出)

- assertRosterZipSafe 流式解压限流:单条目 8MB、总量 32MB 硬上限,超限即时中止
- 解压流超时兜底,防畸形 zip 挂起请求
- 工作表数量与行数在 ExcelJS 整体加载前流式预扫描拦截
- sanitizeCellText 补空格/换行公式触发符,keepPlus 支持括号且至少含一位数字
- 解析错误改 RosterParseError(400) 透出可操作提示,不再被 500 吞掉
- main.ts 仅对 /api/classes 路由放宽 JSON body 上限至 2mb
This commit is contained in:
2026-08-11 11:51:58 +08:00
parent 6f421c7bd6
commit 386c8dd5b3
5 changed files with 1203 additions and 2 deletions

View File

@@ -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 结构异常/内部错误(后者转通用文案)。
* 继承 BadRequestExceptionNest 默认异常过滤器对非 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<number, ColumnDef['key']> {
const headerIndex = new Map<number, ColumnDef['key']>();
const seenKeys = new Set<ColumnDef['key']>();
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<Pick<ClassRosterImportRow, 'name' | 'phone' | 'idNumber' | 'studentNo'>> = {};
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;
}

View File

@@ -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名'),
}),
);
});
});

View File

@@ -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<string, string> = {
[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<void> {
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 流式预扫描:按 `</row>` 计数(空行自闭合 `<row/>` 不计),
// 超限立即拒绝,避免 ExcelJS 物化整表后再由行数上限兜底
const isSheetEntry = /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name);
let sheetRowCount = 0;
let tail = '';
await new Promise<void>((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 的 `</row>` 也能被计数(尾缀可能重复计数,方向偏严)
const text = tail + chunk.toString('utf8');
tail = text.slice(-6);
sheetRowCount += text.split('</row>').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-opresult 为空):不写审计、不发通知,避免误导用户以为有新增
if (result.length === 0) return result;
await logAudit(this.logService, req, {
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
});

View File

@@ -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<unknown[]>) {
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<Record<string, string | number>>): 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<Record<string, unknown>> = [
{ id: 100, phone: '13800138000', idNumber: null, studentNo: 'S100', name: '已有学生' },
{ id: 200, name: '离班学生' },
{ id: 300, name: '在读学生' },
];
const createdStudents: Array<Record<string, unknown>> = [];
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<Record<string, unknown>>;
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<Record<string, unknown>> | 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<string, unknown>;
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');
});
});

View File

@@ -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 }));