fix(server): 迁移超长科目备份移到截断前、keepPlus/rels 解析加固、钉钉导入失败补偿
- 迁移:超长科目原值备份移到 step 2 拆分 UPDATE 之前(原位置截断后按长度查不到原值,备份无效) - keepPlus:'+' 后紧跟公式触发符(- + = @ 等)不保留前缀,写回 Excel 不会被当公式 - workbook rels:逐元素解析任意属性顺序/引号,声明大小超限拒绝,防文件名绕过与内存放大 - create():钉钉导入失败时补偿删除刚建班级(成员外键 CASCADE),避免孤儿班级
This commit is contained in:
@@ -103,8 +103,15 @@ export function sanitizeCellText(value: string, keepPlus = false): string {
|
||||
let start = 0;
|
||||
while (start < value.length) {
|
||||
const ch = value[start];
|
||||
if (keepPlus && ch === '+' && start === 0 && /^\+[\d\s()-]*\d[\d\s()-]*$/.test(value)) {
|
||||
// 整体形如国际区号(+86 138...、+86 (138) 1234-5678、+ 86 138...)时整串保留;
|
||||
if (
|
||||
keepPlus &&
|
||||
ch === '+' &&
|
||||
start === 0 &&
|
||||
// '+' 后不允许公式触发符(= - + @ 制表/换行),'+-86'、'+(1-2)' 这类开头仍会被
|
||||
// Excel 当公式解释,回退到剥离;空格合法(+ 86 138... 也是国际区号写法)
|
||||
/^\+[^=\-+@\t\r\n][\d\s()-]*\d[\d\s()-]*$/.test(value)
|
||||
) {
|
||||
// 整体形如国际区号(+86 138...、+86 (138) 1234-5678)时整串保留;
|
||||
// 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离
|
||||
// (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。
|
||||
return value;
|
||||
|
||||
@@ -75,6 +75,8 @@ const ROSTER_IMPORT_MAX_SHEETS = 5;
|
||||
// 单工作表行数预算:合法花名册 ≤2000 数据行 + 表头,留出空白行余量;
|
||||
// ExcelJS 会把整表物化为对象图后才执行行数上限,此处先流式预扫描拦截超限文件
|
||||
const ROSTER_IMPORT_MAX_ROWS_PER_SHEET = 2500;
|
||||
// workbook.xml.rels 正常远小于此值;超阈值视为异常(防压缩比放大后整体解压进内存)
|
||||
const ROSTER_IMPORT_MAX_RELS_BYTES = 1024 * 1024;
|
||||
const ROSTER_IMPORT_TEMPLATE_FILENAME = '班级花名册导入模板.xlsx';
|
||||
|
||||
/** 校验上传的 xlsx zip 结构:条目数与真实解压总大小超限即拒绝(在 ExcelJS 整体解压前执行)。 */
|
||||
@@ -89,10 +91,24 @@ async function assertRosterZipSafe(buffer: Buffer): Promise<void> {
|
||||
const sheetPaths = new Set<string>();
|
||||
const relsEntry = zip.files['xl/_rels/workbook.xml.rels'];
|
||||
if (relsEntry && !relsEntry.dir) {
|
||||
// rels 文件极小(正常 <10KB),但声明大小不可信:超阈值直接拒绝,避免先整体解压进内存
|
||||
const declaredSize = (relsEntry as unknown as { _data?: { uncompressedSize?: number } })._data
|
||||
?.uncompressedSize;
|
||||
if (typeof declaredSize === 'number' && declaredSize > ROSTER_IMPORT_MAX_RELS_BYTES) {
|
||||
throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件');
|
||||
}
|
||||
const relsXml = await relsEntry.async('string');
|
||||
for (const m of relsXml.matchAll(/Type="[^"]*\/worksheet"[^>]*\bTarget="([^"]+)"/g)) {
|
||||
const target = (m[1] ?? '').replace(/^\/+/, '');
|
||||
if (target) sheetPaths.add(target.startsWith('xl/') ? target : `xl/${target}`);
|
||||
// XML 属性顺序任意、单双引号皆合法,逐元素提取 Type/Target(避免仅匹配 Type 在前的写法被绕过)
|
||||
for (const m of relsXml.matchAll(/<Relationship\b([^>]*)>/g)) {
|
||||
const attrs = m[1] ?? '';
|
||||
const type = attrs.match(/Type\s*=\s*["']([^"']+)["']/)?.[1];
|
||||
const target = attrs.match(/Target\s*=\s*["']([^"']+)["']/)?.[1];
|
||||
if (type?.endsWith('/worksheet') && target) {
|
||||
const normalized = target.replace(/^\/+/, '');
|
||||
if (normalized) {
|
||||
sheetPaths.add(normalized.startsWith('xl/') ? normalized : `xl/${normalized}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const worksheetCount = entries.filter(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { ClassesQueriesService } from './classes-queries.service';
|
||||
import { createClassRosterTemplateWorkbook, parseClassRosterWorkbook, sanitizeCellText } from './class-roster-import';
|
||||
import {
|
||||
createClassRosterTemplateWorkbook,
|
||||
parseClassRosterWorkbook,
|
||||
sanitizeCellText,
|
||||
} from './class-roster-import';
|
||||
import { Class, ClassStudent, Organization, Student } from '../entities';
|
||||
|
||||
/** 模拟 TypeORM QueryBuilder 链:getMany 委托给传入的 rows 获取函数;andWhere 表示带 status 过滤。 */
|
||||
@@ -75,9 +79,7 @@ describe('class roster import workbook', () => {
|
||||
];
|
||||
ws.addRow({ name: '张三', phone: '13800138000' });
|
||||
ws.getCell(2, 2).value = { error: '#DIV/0!' } as never;
|
||||
expect(parseClassRosterWorkbook(workbook)).toEqual([
|
||||
{ rowNumber: 2, name: '张三' },
|
||||
]);
|
||||
expect(parseClassRosterWorkbook(workbook)).toEqual([{ rowNumber: 2, name: '张三' }]);
|
||||
});
|
||||
|
||||
it('strips formula-leading characters from text cells', () => {
|
||||
@@ -109,7 +111,9 @@ describe('class roster import workbook', () => {
|
||||
const ws = workbook.addWorksheet('其他表');
|
||||
ws.addRow(['字段A', '字段B']);
|
||||
ws.addRow(['值1', '值2']);
|
||||
expect(() => parseClassRosterWorkbook(workbook)).toThrow('未识别到班级花名册工作表,请使用下载的导入模板');
|
||||
expect(() => parseClassRosterWorkbook(workbook)).toThrow(
|
||||
'未识别到班级花名册工作表,请使用下载的导入模板',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the required name column is missing', () => {
|
||||
@@ -135,7 +139,9 @@ describe('ClassesQueriesService.previewRosterImport', () => {
|
||||
const dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue({
|
||||
...studentRepo,
|
||||
createQueryBuilder: jest.fn().mockImplementation(() => makeQueryBuilder(() => studentRepo.find())),
|
||||
createQueryBuilder: jest
|
||||
.fn()
|
||||
.mockImplementation(() => makeQueryBuilder(() => studentRepo.find())),
|
||||
}),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
@@ -241,7 +247,14 @@ describe('ClassesQueriesService.previewRosterImport', () => {
|
||||
studentRepo.find
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 99, name: '归档生', phone: '13600000000', idNumber: null, studentNo: null, status: 'archived' },
|
||||
{
|
||||
id: 99,
|
||||
name: '归档生',
|
||||
phone: '13600000000',
|
||||
idNumber: null,
|
||||
studentNo: null,
|
||||
status: 'archived',
|
||||
},
|
||||
]);
|
||||
const result = await queries.previewRosterImport(1, [
|
||||
{ rowNumber: 2, name: '归档生', phone: '13600000000' },
|
||||
@@ -396,10 +409,7 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
||||
it('deduplicates name-only create rows within the same batch', async () => {
|
||||
const { queries } = buildManager();
|
||||
const result = await queries.commitRosterImport(1, {
|
||||
createRows: [
|
||||
{ name: '甲' },
|
||||
{ name: '甲' },
|
||||
],
|
||||
createRows: [{ name: '甲' }, { name: '甲' }],
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
@@ -445,7 +455,14 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
||||
// 全量状态查询(无 status 条件)返回已归档学生;在读查询返回空
|
||||
if (Array.isArray(where) && where[0]?.status === undefined) {
|
||||
return [
|
||||
{ id: 98, phone: '13600000000', idNumber: null, studentNo: null, name: '归档生', status: 'archived' },
|
||||
{
|
||||
id: 98,
|
||||
phone: '13600000000',
|
||||
idNumber: null,
|
||||
studentNo: null,
|
||||
name: '归档生',
|
||||
status: 'archived',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
@@ -453,11 +470,30 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
||||
if (entity === ClassStudent) return [];
|
||||
return [];
|
||||
}),
|
||||
create: jest.fn().mockImplementation((_entity: unknown, value: object) => ({ id: 2000, ...value })),
|
||||
save: jest.fn().mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])),
|
||||
createQueryBuilder: jest
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
.mockImplementation((_entity: unknown, value: object) => ({ id: 2000, ...value })),
|
||||
save: jest
|
||||
.fn()
|
||||
.mockImplementation(async (...args: unknown[]) => (args.length === 1 ? args[0] : args[1])),
|
||||
createQueryBuilder: jest.fn().mockImplementation(() =>
|
||||
makeQueryBuilder((hasStatusFilter) =>
|
||||
hasStatusFilter
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 98,
|
||||
phone: '13600000000',
|
||||
idNumber: null,
|
||||
studentNo: null,
|
||||
name: '归档生',
|
||||
status: 'archived',
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
getRepository: jest.fn().mockReturnValue({
|
||||
createQueryBuilder: jest.fn().mockImplementation(() =>
|
||||
makeQueryBuilder((hasStatusFilter) =>
|
||||
hasStatusFilter
|
||||
? []
|
||||
@@ -473,25 +509,6 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
||||
],
|
||||
),
|
||||
),
|
||||
getRepository: jest.fn().mockReturnValue({
|
||||
createQueryBuilder: jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
makeQueryBuilder((hasStatusFilter) =>
|
||||
hasStatusFilter
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 98,
|
||||
phone: '13600000000',
|
||||
idNumber: null,
|
||||
studentNo: null,
|
||||
name: '归档生',
|
||||
status: 'archived',
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
}),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn().mockImplementation((work) => work(manager)) };
|
||||
@@ -542,9 +559,10 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
||||
});
|
||||
|
||||
expect(result.created).toBe(1);
|
||||
const created = manager.create.mock.calls.find(
|
||||
(call) => call[0] === Student,
|
||||
)?.[1] as Record<string, unknown>;
|
||||
const created = manager.create.mock.calls.find((call) => call[0] === Student)?.[1] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(created.name).toBe('1+1');
|
||||
expect(created.phone).toBe('HACK');
|
||||
expect(created.idNumber).toBe('ID');
|
||||
@@ -566,7 +584,9 @@ describe('ClassesQueriesService.commitRosterImport', () => {
|
||||
{} as never,
|
||||
dataSource as never,
|
||||
);
|
||||
await expect(queries.commitRosterImport(1, { addStudentIds: [1] })).rejects.toThrow('班级不存在');
|
||||
await expect(queries.commitRosterImport(1, { addStudentIds: [1] })).rejects.toThrow(
|
||||
'班级不存在',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an entirely empty commit payload', async () => {
|
||||
@@ -606,10 +626,16 @@ describe('sanitizeCellText formula injection guard', () => {
|
||||
it('strips the + prefix when formula characters follow, keeping the remainder', () => {
|
||||
// 非纯号码前缀:回退到普通剥离(去掉前导 +),触发符不再位于首位,
|
||||
// 写回 Excel/CSV 时不会被解释为公式,同时不整串丢弃合法国际号码分隔符
|
||||
expect(sanitizeCellText('+86=HYPERLINK("http://evil")', true)).toBe('86=HYPERLINK("http://evil")');
|
||||
expect(sanitizeCellText('+86=HYPERLINK("http://evil")', true)).toBe(
|
||||
'86=HYPERLINK("http://evil")',
|
||||
);
|
||||
expect(sanitizeCellText('+86@cmd', true)).toBe('86@cmd');
|
||||
expect(sanitizeCellText('+86 138 0013 8000', true)).toBe('+86 138 0013 8000');
|
||||
expect(sanitizeCellText('+86-138-0013-8000', true)).toBe('+86-138-0013-8000');
|
||||
expect(sanitizeCellText('+86 138.0013.8000', true)).toBe('86 138.0013.8000');
|
||||
// '+' 后紧跟公式触发符(- + = @ 等)不保留前缀:'+-' 开头写回 Excel 仍会被当公式
|
||||
expect(sanitizeCellText('+-86 138', true)).toBe('86 138');
|
||||
expect(sanitizeCellText('++86 138', true)).toBe('86 138');
|
||||
expect(sanitizeCellText('+ 86 138', true)).toBe('+ 86 138');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,9 +295,20 @@ export class ClassesService {
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
// batch import students by dingUserIds
|
||||
// batch import students by dingUserIds(自带事务,事务外调用)
|
||||
if (users?.length) {
|
||||
await this.batchImportStudents(savedClassId, users);
|
||||
try {
|
||||
await this.batchImportStudents(savedClassId, users);
|
||||
} catch (error) {
|
||||
// 钉钉导入失败会抛错,班级已在事务中提交——补偿删除刚创建的班级(学生/教师成员
|
||||
// 外键均 CASCADE),避免留下孤儿班级且前端重试会重复建班
|
||||
try {
|
||||
await this.classRepo.delete(savedClassId);
|
||||
} catch {
|
||||
// 补偿删除失败:保留班级,优先抛原始错误
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return this.findOne(savedClassId);
|
||||
|
||||
@@ -396,6 +396,22 @@ async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promi
|
||||
|
||||
// 2) 历史多科目值拆分:按内容判断(含分隔符或超长)而非列类型——标准升级路径
|
||||
// (varchar(50) NULL)下同样可能存在 '语文、数学' 这类合并值。
|
||||
// 任何 >50 字符的值(如单个超长科目)都会在拆分/兜底中截断,截断前先把原值备份到审计表
|
||||
// (任课老师的超长科目不在 1b 的备份范围内,否则截断后原值永久丢失)。
|
||||
// 必须在本步骤的破坏性 UPDATE 之前执行,否则截断后按 CHAR_LENGTH(subject) > 50 查不到原值。
|
||||
// 备份表在 1b 才创建(收敛 schema 下 needsLegacyCleanup=false),此处无条件执行需先确保表存在。
|
||||
await runner.query(`CREATE TABLE IF NOT EXISTS class_teacher_legacy_subject_backup (
|
||||
id INT NOT NULL PRIMARY KEY,
|
||||
class_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
role_type VARCHAR(30) NOT NULL,
|
||||
subject TEXT NULL,
|
||||
backed_up_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB`);
|
||||
await runner.query(`INSERT IGNORE INTO class_teacher_legacy_subject_backup
|
||||
(id, class_id, user_id, role_type, subject)
|
||||
SELECT id, class_id, user_id, role_type, subject FROM class_teacher
|
||||
WHERE CHAR_LENGTH(subject) > 50`);
|
||||
const splitCandidates = (await runner.query(
|
||||
`SELECT 1 FROM class_teacher WHERE role_type = 'subject_teacher'
|
||||
AND (CHAR_LENGTH(subject) > 50 OR subject REGEXP '[、,,;;]') LIMIT 1`,
|
||||
@@ -435,20 +451,7 @@ async function ensureClassTeacherSubjectUniqueLocked(runner: QueryRunner): Promi
|
||||
}
|
||||
|
||||
// 兜底:其余任何 >50 字符的值(如单个超长科目)先截断,避免 ALTER 报 Data too long。
|
||||
// 截断前先把原值备份到审计表(任课老师的超长科目不在 1b 的备份范围内,否则截断后原值永久丢失)。
|
||||
// 备份表在 1b 才创建(收敛 schema 下 needsLegacyCleanup=false),此处无条件执行需先确保表存在
|
||||
await runner.query(`CREATE TABLE IF NOT EXISTS class_teacher_legacy_subject_backup (
|
||||
id INT NOT NULL PRIMARY KEY,
|
||||
class_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
role_type VARCHAR(30) NOT NULL,
|
||||
subject TEXT NULL,
|
||||
backed_up_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB`);
|
||||
await runner.query(`INSERT IGNORE INTO class_teacher_legacy_subject_backup
|
||||
(id, class_id, user_id, role_type, subject)
|
||||
SELECT id, class_id, user_id, role_type, subject FROM class_teacher
|
||||
WHERE CHAR_LENGTH(subject) > 50`);
|
||||
// 原值已在上方 step 2 之前备份到 class_teacher_legacy_subject_backup。
|
||||
await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50)
|
||||
WHERE CHAR_LENGTH(subject) > 50`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user