fix(server): 迁移超长科目备份移到截断前、keepPlus/rels 解析加固、钉钉导入失败补偿
All checks were successful
CI / check (pull_request) Successful in 4m26s
Dependency Check / check (pull_request) Successful in 2m48s

- 迁移:超长科目原值备份移到 step 2 拆分 UPDATE 之前(原位置截断后按长度查不到原值,备份无效)
- keepPlus:'+' 后紧跟公式触发符(- + = @ 等)不保留前缀,写回 Excel 不会被当公式
- workbook rels:逐元素解析任意属性顺序/引号,声明大小超限拒绝,防文件名绕过与内存放大
- create():钉钉导入失败时补偿删除刚建班级(成员外键 CASCADE),避免孤儿班级
This commit is contained in:
2026-08-11 12:46:12 +08:00
parent 66c039ee87
commit 257c2065b8
5 changed files with 124 additions and 61 deletions

View File

@@ -103,8 +103,15 @@ export function sanitizeCellText(value: string, keepPlus = false): string {
let start = 0; let start = 0;
while (start < value.length) { while (start < value.length) {
const ch = value[start]; const ch = value[start];
if (keepPlus && ch === '+' && start === 0 && /^\+[\d\s()-]*\d[\d\s()-]*$/.test(value)) { if (
// 整体形如国际区号(+86 138...、+86 (138) 1234-5678、+ 86 138...)时整串保留; 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(...)')时回退到普通剥离 // 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离
// (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。 // (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。
return value; return value;

View File

@@ -75,6 +75,8 @@ const ROSTER_IMPORT_MAX_SHEETS = 5;
// 单工作表行数预算:合法花名册 ≤2000 数据行 + 表头,留出空白行余量; // 单工作表行数预算:合法花名册 ≤2000 数据行 + 表头,留出空白行余量;
// ExcelJS 会把整表物化为对象图后才执行行数上限,此处先流式预扫描拦截超限文件 // ExcelJS 会把整表物化为对象图后才执行行数上限,此处先流式预扫描拦截超限文件
const ROSTER_IMPORT_MAX_ROWS_PER_SHEET = 2500; 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'; const ROSTER_IMPORT_TEMPLATE_FILENAME = '班级花名册导入模板.xlsx';
/** 校验上传的 xlsx zip 结构:条目数与真实解压总大小超限即拒绝(在 ExcelJS 整体解压前执行)。 */ /** 校验上传的 xlsx zip 结构:条目数与真实解压总大小超限即拒绝(在 ExcelJS 整体解压前执行)。 */
@@ -89,10 +91,24 @@ async function assertRosterZipSafe(buffer: Buffer): Promise<void> {
const sheetPaths = new Set<string>(); const sheetPaths = new Set<string>();
const relsEntry = zip.files['xl/_rels/workbook.xml.rels']; const relsEntry = zip.files['xl/_rels/workbook.xml.rels'];
if (relsEntry && !relsEntry.dir) { 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'); const relsXml = await relsEntry.async('string');
for (const m of relsXml.matchAll(/Type="[^"]*\/worksheet"[^>]*\bTarget="([^"]+)"/g)) { // XML 属性顺序任意、单双引号皆合法,逐元素提取 Type/Target避免仅匹配 Type 在前的写法被绕过)
const target = (m[1] ?? '').replace(/^\/+/, ''); for (const m of relsXml.matchAll(/<Relationship\b([^>]*)>/g)) {
if (target) sheetPaths.add(target.startsWith('xl/') ? target : `xl/${target}`); 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( const worksheetCount = entries.filter(

View File

@@ -1,6 +1,10 @@
import * as ExcelJS from 'exceljs'; import * as ExcelJS from 'exceljs';
import { ClassesQueriesService } from './classes-queries.service'; 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'; import { Class, ClassStudent, Organization, Student } from '../entities';
/** 模拟 TypeORM QueryBuilder 链getMany 委托给传入的 rows 获取函数andWhere 表示带 status 过滤。 */ /** 模拟 TypeORM QueryBuilder 链getMany 委托给传入的 rows 获取函数andWhere 表示带 status 过滤。 */
@@ -75,9 +79,7 @@ describe('class roster import workbook', () => {
]; ];
ws.addRow({ name: '张三', phone: '13800138000' }); ws.addRow({ name: '张三', phone: '13800138000' });
ws.getCell(2, 2).value = { error: '#DIV/0!' } as never; ws.getCell(2, 2).value = { error: '#DIV/0!' } as never;
expect(parseClassRosterWorkbook(workbook)).toEqual([ expect(parseClassRosterWorkbook(workbook)).toEqual([{ rowNumber: 2, name: '张三' }]);
{ rowNumber: 2, name: '张三' },
]);
}); });
it('strips formula-leading characters from text cells', () => { it('strips formula-leading characters from text cells', () => {
@@ -109,7 +111,9 @@ describe('class roster import workbook', () => {
const ws = workbook.addWorksheet('其他表'); const ws = workbook.addWorksheet('其他表');
ws.addRow(['字段A', '字段B']); ws.addRow(['字段A', '字段B']);
ws.addRow(['值1', '值2']); ws.addRow(['值1', '值2']);
expect(() => parseClassRosterWorkbook(workbook)).toThrow('未识别到班级花名册工作表,请使用下载的导入模板'); expect(() => parseClassRosterWorkbook(workbook)).toThrow(
'未识别到班级花名册工作表,请使用下载的导入模板',
);
}); });
it('throws when the required name column is missing', () => { it('throws when the required name column is missing', () => {
@@ -135,7 +139,9 @@ describe('ClassesQueriesService.previewRosterImport', () => {
const dataSource = { const dataSource = {
getRepository: jest.fn().mockReturnValue({ getRepository: jest.fn().mockReturnValue({
...studentRepo, ...studentRepo,
createQueryBuilder: jest.fn().mockImplementation(() => makeQueryBuilder(() => studentRepo.find())), createQueryBuilder: jest
.fn()
.mockImplementation(() => makeQueryBuilder(() => studentRepo.find())),
}), }),
transaction: jest.fn(), transaction: jest.fn(),
}; };
@@ -241,7 +247,14 @@ describe('ClassesQueriesService.previewRosterImport', () => {
studentRepo.find studentRepo.find
.mockResolvedValueOnce([]) .mockResolvedValueOnce([])
.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, [ const result = await queries.previewRosterImport(1, [
{ rowNumber: 2, name: '归档生', phone: '13600000000' }, { rowNumber: 2, name: '归档生', phone: '13600000000' },
@@ -396,10 +409,7 @@ describe('ClassesQueriesService.commitRosterImport', () => {
it('deduplicates name-only create rows within the same batch', async () => { it('deduplicates name-only create rows within the same batch', async () => {
const { queries } = buildManager(); const { queries } = buildManager();
const result = await queries.commitRosterImport(1, { const result = await queries.commitRosterImport(1, {
createRows: [ createRows: [{ name: '甲' }, { name: '甲' }],
{ name: '甲' },
{ name: '甲' },
],
}); });
expect(result.created).toBe(1); expect(result.created).toBe(1);
@@ -445,7 +455,14 @@ describe('ClassesQueriesService.commitRosterImport', () => {
// 全量状态查询(无 status 条件)返回已归档学生;在读查询返回空 // 全量状态查询(无 status 条件)返回已归档学生;在读查询返回空
if (Array.isArray(where) && where[0]?.status === undefined) { if (Array.isArray(where) && where[0]?.status === undefined) {
return [ return [
{ id: 98, phone: '13600000000', idNumber: null, studentNo: null, name: '归档生', status: 'archived' }, {
id: 98,
phone: '13600000000',
idNumber: null,
studentNo: null,
name: '归档生',
status: 'archived',
},
]; ];
} }
return []; return [];
@@ -453,11 +470,30 @@ describe('ClassesQueriesService.commitRosterImport', () => {
if (entity === ClassStudent) return []; if (entity === ClassStudent) return [];
return []; return [];
}), }),
create: jest.fn().mockImplementation((_entity: unknown, value: object) => ({ id: 2000, ...value })), create: jest
save: jest.fn().mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])),
createQueryBuilder: jest
.fn() .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) => makeQueryBuilder((hasStatusFilter) =>
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)) }; const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) };
@@ -542,9 +559,10 @@ describe('ClassesQueriesService.commitRosterImport', () => {
}); });
expect(result.created).toBe(1); expect(result.created).toBe(1);
const created = manager.create.mock.calls.find( const created = manager.create.mock.calls.find((call) => call[0] === Student)?.[1] as Record<
(call) => call[0] === Student, string,
)?.[1] as Record<string, unknown>; unknown
>;
expect(created.name).toBe('1+1'); expect(created.name).toBe('1+1');
expect(created.phone).toBe('HACK'); expect(created.phone).toBe('HACK');
expect(created.idNumber).toBe('ID'); expect(created.idNumber).toBe('ID');
@@ -566,7 +584,9 @@ describe('ClassesQueriesService.commitRosterImport', () => {
{} as never, {} as never,
dataSource 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 () => { 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', () => { it('strips the + prefix when formula characters follow, keeping the remainder', () => {
// 非纯号码前缀:回退到普通剥离(去掉前导 +),触发符不再位于首位, // 非纯号码前缀:回退到普通剥离(去掉前导 +),触发符不再位于首位,
// 写回 Excel/CSV 时不会被解释为公式,同时不整串丢弃合法国际号码分隔符 // 写回 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@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'); 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');
}); });
}); });

View File

@@ -295,9 +295,20 @@ export class ClassesService {
return saved.id; return saved.id;
}); });
// batch import students by dingUserIds // batch import students by dingUserIds(自带事务,事务外调用)
if (users?.length) { 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); return this.findOne(savedClassId);

View File

@@ -396,6 +396,22 @@ async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promi
// 2) 历史多科目值拆分:按内容判断(含分隔符或超长)而非列类型——标准升级路径 // 2) 历史多科目值拆分:按内容判断(含分隔符或超长)而非列类型——标准升级路径
// varchar(50) NULL下同样可能存在 '语文、数学' 这类合并值。 // 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( const splitCandidates = (await runner.query(
`SELECT 1 FROM class_teacher WHERE role_type = 'subject_teacher' `SELECT 1 FROM class_teacher WHERE role_type = 'subject_teacher'
AND (CHAR_LENGTH(subject) > 50 OR subject REGEXP '[、,,;]') LIMIT 1`, 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。 // 兜底:其余任何 >50 字符的值(如单个超长科目)先截断,避免 ALTER 报 Data too long。
// 截断前先把原值备份到审计表(任课老师的超长科目不在 1b 的备份范围内,否则截断后原值永久丢失) // 原值已在上方 step 2 之前备份到 class_teacher_legacy_subject_backup
// 备份表在 1b 才创建(收敛 schema 下 needsLegacyCleanup=false此处无条件执行需先确保表存在
await runner.query(`CREATE TABLE IF NOT EXISTS class_teacher_legacy_subject_backup (
id INT NOT NULL PRIMARY KEY,
class_id INT NOT NULL,
user_id INT NOT NULL,
role_type VARCHAR(30) NOT NULL,
subject TEXT NULL,
backed_up_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB`);
await runner.query(`INSERT IGNORE INTO class_teacher_legacy_subject_backup
(id, class_id, user_id, role_type, subject)
SELECT id, class_id, user_id, role_type, subject FROM class_teacher
WHERE CHAR_LENGTH(subject) > 50`);
await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50) await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50)
WHERE CHAR_LENGTH(subject) > 50`); WHERE CHAR_LENGTH(subject) > 50`);