fix(server): 班级创建事务化、花名册同名新生兜底修正、迁移超长科目备份
- create() 班级/学生/教师写入整体包事务,避免教师失败留下孤儿班级 - 花名册提交:行内带标识的同名新生不再复用本批姓名兜底(避免被误判冲突丢弃,与预览一致) - 迁移:超长科目截断前备份原值,且备份表确保存在(全新部署不因缺表启动失败) - workbook rels 解析真实工作表路径,防自定义文件名绕过 sheet/行数预扫描 - keepPlus 完整正则对齐注释意图(+ 86 前缀保留);formatDate 用 UTC getter - main.ts 大 JSON 路由前缀与限制提取为常量并注明耦合点
This commit is contained in:
@@ -68,9 +68,10 @@ function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown {
|
||||
}
|
||||
|
||||
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');
|
||||
// ExcelJS 日期单元格解码为 UTC 午夜 Date;用 UTC getter 保证输出与服务器时区无关
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
@@ -102,12 +103,11 @@ 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 (keepPlus && ch === '+' && start === 0 && /^\+[\d\s()-]*\d[\d\s()-]*$/.test(value)) {
|
||||
// 整体形如国际区号(+86 138...、+86 (138) 1234-5678、+ 86 138...)时整串保留;
|
||||
// 含 '.'/'#'/'x' 等分隔符或混入公式触发符('=HYPERLINK(...)')时回退到普通剥离
|
||||
// (去掉前导 '+'),避免整串丢弃丢失关键匹配标识。
|
||||
return value;
|
||||
}
|
||||
if (!FORMULA_LEADING_CHARS.has(ch)) break;
|
||||
start += 1;
|
||||
@@ -158,7 +158,8 @@ export function parseClassRosterWorkbook(workbook: ExcelJS.Workbook): ClassRoste
|
||||
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return;
|
||||
const parsed: Partial<Pick<ClassRosterImportRow, 'name' | 'phone' | 'idNumber' | 'studentNo'>> = {};
|
||||
const parsed: Partial<Pick<ClassRosterImportRow, 'name' | 'phone' | 'idNumber' | 'studentNo'>> =
|
||||
{};
|
||||
headerIndex.forEach((key, colNumber) => {
|
||||
const value = cellToText(row.getCell(colNumber));
|
||||
if (value) parsed[key] = value;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, Not, Repository } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassSchedule, AttendanceRecord, Organization, Student } from '../entities';
|
||||
import {
|
||||
Class,
|
||||
ClassStudent,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
Organization,
|
||||
Student,
|
||||
} from '../entities';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
|
||||
import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
|
||||
@@ -19,7 +26,8 @@ interface AgentClassRow {
|
||||
|
||||
/** 判断是否为 MySQL 唯一键冲突(1062 / ER_DUP_ENTRY),兼容 TypeORM QueryFailedError 包装。 */
|
||||
export function isDuplicateEntryError(error: unknown): boolean {
|
||||
const driver = (error as { driverError?: { code?: string; errno?: number } })?.driverError ?? error;
|
||||
const driver =
|
||||
(error as { driverError?: { code?: string; errno?: number } })?.driverError ?? error;
|
||||
return (
|
||||
(driver as { code?: string })?.code === 'ER_DUP_ENTRY' ||
|
||||
(driver as { errno?: number })?.errno === 1062
|
||||
@@ -146,7 +154,8 @@ export class ClassesQueriesService {
|
||||
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassStudent) private readonly classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(AttendanceRecord)
|
||||
private readonly attendanceRepo: Repository<AttendanceRecord>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -154,90 +163,102 @@ export class ClassesQueriesService {
|
||||
accessibleClassIds: number[] | undefined,
|
||||
query: { keyword?: string; status?: string; limit?: number },
|
||||
) {
|
||||
if (accessibleClassIds?.length === 0) return [];
|
||||
if (accessibleClassIds?.length === 0) return [];
|
||||
|
||||
const qb = this.classRepo
|
||||
.createQueryBuilder('class')
|
||||
.leftJoin(
|
||||
ClassStudent,
|
||||
'classStudent',
|
||||
'classStudent.classId = class.id AND classStudent.status = :activeStudent',
|
||||
{ activeStudent: 'active' },
|
||||
)
|
||||
.select('class.id', 'id');
|
||||
const classSelects = [
|
||||
['class.name', 'name'],
|
||||
['class.code', 'code'],
|
||||
['class.classType', 'classType'],
|
||||
['class.status', 'status'],
|
||||
['class.startDate', 'startDate'],
|
||||
['class.endDate', 'endDate'],
|
||||
['COUNT(classStudent.id)', 'studentCount'],
|
||||
] as const;
|
||||
for (const [column, alias] of classSelects) {
|
||||
qb.addSelect(column, alias);
|
||||
}
|
||||
qb.where('class.isArchived = :isArchived', { isArchived: false });
|
||||
if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${escapeLike(query.keyword)}%` });
|
||||
if (query.status) qb.andWhere('class.status = :status', { status: query.status });
|
||||
const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany<AgentClassRow>();
|
||||
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
|
||||
const qb = this.classRepo
|
||||
.createQueryBuilder('class')
|
||||
.leftJoin(
|
||||
ClassStudent,
|
||||
'classStudent',
|
||||
'classStudent.classId = class.id AND classStudent.status = :activeStudent',
|
||||
{ activeStudent: 'active' },
|
||||
)
|
||||
.select('class.id', 'id');
|
||||
const classSelects = [
|
||||
['class.name', 'name'],
|
||||
['class.code', 'code'],
|
||||
['class.classType', 'classType'],
|
||||
['class.status', 'status'],
|
||||
['class.startDate', 'startDate'],
|
||||
['class.endDate', 'endDate'],
|
||||
['COUNT(classStudent.id)', 'studentCount'],
|
||||
] as const;
|
||||
for (const [column, alias] of classSelects) {
|
||||
qb.addSelect(column, alias);
|
||||
}
|
||||
qb.where('class.isArchived = :isArchived', { isArchived: false });
|
||||
if (accessibleClassIds)
|
||||
qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
if (query.keyword)
|
||||
qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', {
|
||||
keyword: `%${escapeLike(query.keyword)}%`,
|
||||
});
|
||||
if (query.status) qb.andWhere('class.status = :status', { status: query.status });
|
||||
const rows = await qb
|
||||
.groupBy('class.id')
|
||||
.orderBy('class.name', 'ASC')
|
||||
.limit(query.limit ?? 20)
|
||||
.getRawMany<AgentClassRow>();
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: Number(row.id),
|
||||
studentCount: Number(row.studentCount || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async batchImportStudents(
|
||||
classId: number,
|
||||
users: Array<{ dingUserId: string; name: string; mobile?: string }>,
|
||||
): Promise<{ imported: number; skipped: number; conflicts: number }> {
|
||||
if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 };
|
||||
if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 };
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const classEntity = await manager.findOne(Class, { where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const classEntity = await manager.findOne(Class, { where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
const synced = await syncDingTalkStudents(manager, users);
|
||||
const studentIds = [...new Set(synced.studentIds.values())];
|
||||
if (studentIds.length === 0) {
|
||||
return { imported: 0, skipped: 0, conflicts: synced.conflicts.length };
|
||||
}
|
||||
|
||||
const existingClassStudents = await manager.find(ClassStudent, {
|
||||
where: { classId, studentId: In(studentIds) },
|
||||
});
|
||||
const existingByStudentId = new Map(
|
||||
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||
);
|
||||
const today = dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||||
let skipped = 0;
|
||||
const memberships = studentIds.flatMap((studentId) => {
|
||||
const existing = existingByStudentId.get(studentId);
|
||||
if (existing?.status === 'active') {
|
||||
skipped++;
|
||||
return [];
|
||||
const synced = await syncDingTalkStudents(manager, users);
|
||||
const studentIds = [...new Set(synced.studentIds.values())];
|
||||
if (studentIds.length === 0) {
|
||||
return { imported: 0, skipped: 0, conflicts: synced.conflicts.length };
|
||||
}
|
||||
if (existing) {
|
||||
existing.status = 'active';
|
||||
existing.joinDate = today;
|
||||
existing.leaveDate = null;
|
||||
return [existing];
|
||||
}
|
||||
return [
|
||||
manager.create(ClassStudent, {
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: today,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
if (memberships.length > 0) await manager.save(ClassStudent, memberships);
|
||||
return {
|
||||
imported: memberships.length,
|
||||
skipped,
|
||||
conflicts: synced.conflicts.length,
|
||||
};
|
||||
});
|
||||
const existingClassStudents = await manager.find(ClassStudent, {
|
||||
where: { classId, studentId: In(studentIds) },
|
||||
});
|
||||
const existingByStudentId = new Map(
|
||||
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||
);
|
||||
const today = dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||||
let skipped = 0;
|
||||
const memberships = studentIds.flatMap((studentId) => {
|
||||
const existing = existingByStudentId.get(studentId);
|
||||
if (existing?.status === 'active') {
|
||||
skipped++;
|
||||
return [];
|
||||
}
|
||||
if (existing) {
|
||||
existing.status = 'active';
|
||||
existing.joinDate = today;
|
||||
existing.leaveDate = null;
|
||||
return [existing];
|
||||
}
|
||||
return [
|
||||
manager.create(ClassStudent, {
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: today,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
if (memberships.length > 0) await manager.save(ClassStudent, memberships);
|
||||
return {
|
||||
imported: memberships.length,
|
||||
skipped,
|
||||
conflicts: synced.conflicts.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 班级花名册导入预览:解析行后按 手机号→身份证号→学号→唯一姓名 匹配已有学生,不写库。 */
|
||||
@@ -253,18 +274,14 @@ export class ClassesQueriesService {
|
||||
memberships.filter((m) => m.status === 'active').map((m) => m.studentId),
|
||||
);
|
||||
|
||||
const phones = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.phone)).filter((v) => !!v)),
|
||||
];
|
||||
const phones = [...new Set(rows.map((r) => normalizeIdentifier(r.phone)).filter((v) => !!v))];
|
||||
const idNumbers = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.idNumber)).filter((v) => !!v)),
|
||||
];
|
||||
const studentNos = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.studentNo)).filter((v) => !!v)),
|
||||
];
|
||||
const names = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.name)).filter((v) => !!v)),
|
||||
];
|
||||
const names = [...new Set(rows.map((r) => normalizeIdentifier(r.name)).filter((v) => !!v))];
|
||||
|
||||
// 只匹配未归档且非员工账号的学生,避免已归档学生被重新拉回在读班级;
|
||||
// 用 LOWER(TRIM(col)) 与 Map 索引的归一化保持一致,避免存储值带空格/大小写差异导致漏匹配
|
||||
@@ -312,9 +329,9 @@ export class ClassesQueriesService {
|
||||
|
||||
const identifierMatches = new Map<number, Student>();
|
||||
for (const match of [
|
||||
...(phone ? byPhone.get(normalizeIdentifier(phone)) ?? [] : []),
|
||||
...(idNumber ? byIdNumber.get(normalizeIdentifier(idNumber)) ?? [] : []),
|
||||
...(studentNo ? byStudentNo.get(normalizeIdentifier(studentNo)) ?? [] : []),
|
||||
...(phone ? (byPhone.get(normalizeIdentifier(phone)) ?? []) : []),
|
||||
...(idNumber ? (byIdNumber.get(normalizeIdentifier(idNumber)) ?? []) : []),
|
||||
...(studentNo ? (byStudentNo.get(normalizeIdentifier(studentNo)) ?? []) : []),
|
||||
]) {
|
||||
identifierMatches.set(match.id, match);
|
||||
}
|
||||
@@ -350,7 +367,7 @@ export class ClassesQueriesService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameMatches = name ? byName.get(normalizeIdentifier(name)) ?? [] : [];
|
||||
const nameMatches = name ? (byName.get(normalizeIdentifier(name)) ?? []) : [];
|
||||
if (nameMatches.length > 1) {
|
||||
previewRows.push({
|
||||
...row,
|
||||
@@ -416,8 +433,10 @@ export class ClassesQueriesService {
|
||||
if (row.status !== 'unmatched') continue;
|
||||
const blocked =
|
||||
(row.phone && identifierHasArchivedOrStaff(blockedIndexes, row.phone, 'byPhone')) ||
|
||||
(row.idNumber && identifierHasArchivedOrStaff(blockedIndexes, row.idNumber, 'byIdNumber')) ||
|
||||
(row.studentNo && identifierHasArchivedOrStaff(blockedIndexes, row.studentNo, 'byStudentNo'));
|
||||
(row.idNumber &&
|
||||
identifierHasArchivedOrStaff(blockedIndexes, row.idNumber, 'byIdNumber')) ||
|
||||
(row.studentNo &&
|
||||
identifierHasArchivedOrStaff(blockedIndexes, row.studentNo, 'byStudentNo'));
|
||||
if (blocked) {
|
||||
row.status = 'conflict';
|
||||
row.reason = '手机号/学号/身份证对应已归档或员工账号,无法导入';
|
||||
@@ -590,9 +609,14 @@ export class ClassesQueriesService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无标识命中时按唯一姓名兜底,与预览逻辑保持一致,避免绕过预览产生重复学生
|
||||
// 无标识命中时按唯一姓名兜底;仅当行内完全没有标识时才能复用本批新建的同名学生——
|
||||
// 同一批两个同名新生(不同手机号/学号)若复用本批姓名,第二个会被
|
||||
// inconsistentFieldReason 因标识不一致误判为冲突丢弃,且与预览「可新建」矛盾
|
||||
if (candidates.size === 0 && name) {
|
||||
const byBatch = createdByName.get(normalizeIdentifier(name));
|
||||
const byBatch =
|
||||
!phone && !idNumber && !studentNo
|
||||
? createdByName.get(normalizeIdentifier(name))
|
||||
: undefined;
|
||||
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
|
||||
if (batchStudent) {
|
||||
candidates.set(batchStudent.id, batchStudent);
|
||||
@@ -623,8 +647,10 @@ export class ClassesQueriesService {
|
||||
} else {
|
||||
const blocked =
|
||||
(phone && identifierHasArchivedOrStaff(allStatusIndexes, phone, 'byPhone')) ||
|
||||
(idNumber && identifierHasArchivedOrStaff(allStatusIndexes, idNumber, 'byIdNumber')) ||
|
||||
(studentNo && identifierHasArchivedOrStaff(allStatusIndexes, studentNo, 'byStudentNo'));
|
||||
(idNumber &&
|
||||
identifierHasArchivedOrStaff(allStatusIndexes, idNumber, 'byIdNumber')) ||
|
||||
(studentNo &&
|
||||
identifierHasArchivedOrStaff(allStatusIndexes, studentNo, 'byStudentNo'));
|
||||
if (blocked) {
|
||||
conflicts++;
|
||||
continue;
|
||||
@@ -656,7 +682,9 @@ export class ClassesQueriesService {
|
||||
} catch (error) {
|
||||
// 并发创建同标识学生:按标识重查并复用已有档案,避免整批回滚。
|
||||
// 仅复用未归档/非员工账号且与行内信息自洽的学生,否则按冲突处理。
|
||||
// REPEATABLE READ 下普通一致读基于旧快照看不到并发提交的行,需用锁定读刷新可见性
|
||||
// REPEATABLE READ 下普通一致读基于旧快照看不到并发提交的行,需用锁定读刷新可见性。
|
||||
// 注意:students 表当前对 phone/idNumber/studentNo 无唯一索引,此分支实际不会触发
|
||||
// (save 不抛 1062);若未来补唯一索引,需同时处理死锁重试(ER_LOCK_DEADLOCK)。
|
||||
if (!isDuplicateEntryError(error)) throw error;
|
||||
const dup = await manager.findOne(Student, {
|
||||
where: [
|
||||
@@ -700,9 +728,7 @@ export class ClassesQueriesService {
|
||||
where: { classId, studentId: In(validIds) },
|
||||
})
|
||||
: [];
|
||||
const existingByStudentId = new Map(
|
||||
existingMemberships.map((m) => [m.studentId, m]),
|
||||
);
|
||||
const existingByStudentId = new Map(existingMemberships.map((m) => [m.studentId, m]));
|
||||
const memberships = validIds.flatMap((studentId) => {
|
||||
const current = existingByStudentId.get(studentId);
|
||||
if (current?.status === 'active') {
|
||||
@@ -778,27 +804,27 @@ export class ClassesQueriesService {
|
||||
}
|
||||
|
||||
async getSchedule(classId: number, query: QueryClassScheduleDto) {
|
||||
const qb = this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.leftJoinAndSelect('cs.classroom', 'classroom')
|
||||
.where('cs.classId = :classId', { classId });
|
||||
const qb = this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.leftJoinAndSelect('cs.classroom', 'classroom')
|
||||
.where('cs.classId = :classId', { classId });
|
||||
|
||||
if (query.startDate) {
|
||||
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
|
||||
}
|
||||
if (query.endDate) {
|
||||
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
|
||||
}
|
||||
if (query.startDate) {
|
||||
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
|
||||
}
|
||||
if (query.endDate) {
|
||||
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
|
||||
}
|
||||
|
||||
const schedules = await qb
|
||||
.orderBy('cs.weekDay', 'ASC')
|
||||
.addOrderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
const schedules = await qb
|
||||
.orderBy('cs.weekDay', 'ASC')
|
||||
.addOrderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return schedules.map((s) => ({
|
||||
...s,
|
||||
classroomName: (s.classroom as Classroom | undefined)?.name || null,
|
||||
}));
|
||||
return schedules.map((s) => ({
|
||||
...s,
|
||||
classroomName: (s.classroom as Classroom | undefined)?.name || null,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
|
||||
|
||||
@@ -84,10 +84,19 @@ async function assertRosterZipSafe(buffer: Buffer): Promise<void> {
|
||||
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),
|
||||
// 工作表路径:ExcelJS 按 xl/_rels/workbook.xml.rels 定位 worksheet(文件名可被自定义绕过,
|
||||
// 仅匹配标准 sheetN.xml 会漏判),先解析 rels 拿到真实 part 路径,再叠加标准文件名兜底
|
||||
const sheetPaths = new Set<string>();
|
||||
const relsEntry = zip.files['xl/_rels/workbook.xml.rels'];
|
||||
if (relsEntry && !relsEntry.dir) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
const worksheetCount = entries.filter(
|
||||
(entry) => sheetPaths.has(entry.name) || /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name),
|
||||
).length;
|
||||
if (worksheetCount > ROSTER_IMPORT_MAX_SHEETS) {
|
||||
throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件');
|
||||
@@ -100,7 +109,8 @@ async function assertRosterZipSafe(buffer: Buffer): Promise<void> {
|
||||
let entryUncompressed = 0;
|
||||
// 工作表 XML 流式预扫描:按 `</row>` 计数(空行自闭合 `<row/>` 不计),
|
||||
// 超限立即拒绝,避免 ExcelJS 物化整表后再由行数上限兜底
|
||||
const isSheetEntry = /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name);
|
||||
const isSheetEntry =
|
||||
sheetPaths.has(entry.name) || /^xl\/worksheets\/sheet\d+\.xml$/.test(entry.name);
|
||||
let sheetRowCount = 0;
|
||||
let tail = '';
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -174,11 +184,7 @@ export class ClassesController {
|
||||
}
|
||||
|
||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||
return this.service.assertClassAccess(
|
||||
req.user.id,
|
||||
classId,
|
||||
this.canManageAllClasses(req),
|
||||
);
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllClasses(req));
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -207,7 +213,10 @@ export class ClassesController {
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
} catch (error) {
|
||||
this.logger.error(`班级花名册模板下载失败: ${(error as Error).message}`, (error as Error).stack);
|
||||
this.logger.error(
|
||||
`班级花名册模板下载失败: ${(error as Error).message}`,
|
||||
(error as Error).stack,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ statusCode: 500, message: '模板生成失败' });
|
||||
} else {
|
||||
@@ -250,7 +259,11 @@ export class ClassesController {
|
||||
async create(@Body() dto: CreateClassDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.create(dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`,
|
||||
module: '班级管理',
|
||||
action: '创建班级',
|
||||
targetId: result.id,
|
||||
targetType: 'class',
|
||||
detail: `班级${result.code} ${result.name}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -312,7 +325,10 @@ export class ClassesController {
|
||||
this.logger.warn(`班级花名册导入解析失败 classId=${id}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`班级花名册导入文件解析失败 classId=${id}: ${(error as Error).message}`, (error as Error).stack);
|
||||
this.logger.error(
|
||||
`班级花名册导入文件解析失败 classId=${id}: ${(error as Error).message}`,
|
||||
(error as Error).stack,
|
||||
);
|
||||
// zip 结构异常/内部错误等统一转通用文案,避免暴露内部细节
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
throw new BadRequestException('文件解析失败,请上传有效的 Excel 文件');
|
||||
@@ -331,7 +347,10 @@ export class ClassesController {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.commitRosterImport(+id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '批量导入学生', targetId: +id, targetType: 'class',
|
||||
module: '班级管理',
|
||||
action: '批量导入学生',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `成功加入${result.added}名(新建${result.created}名),跳过${result.skipped}名,冲突${result.conflicts}行`,
|
||||
});
|
||||
return result;
|
||||
@@ -355,11 +374,19 @@ export class ClassesController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('class:edit')
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) {
|
||||
async update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateClassDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
|
||||
module: '班级管理',
|
||||
action: '编辑班级',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: JSON.stringify(dto),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -370,7 +397,10 @@ export class ClassesController {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.remove(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
|
||||
module: '班级管理',
|
||||
action: '归档班级',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -381,7 +411,11 @@ export class ClassesController {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.purge(+id);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
|
||||
module: '班级管理',
|
||||
action: '永久删除班级',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: '物理删除,不可恢复',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -438,11 +472,19 @@ export class ClassesController {
|
||||
|
||||
@Post(':id/students')
|
||||
@RequirePermission('class:edit')
|
||||
async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) {
|
||||
async addStudents(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: AddStudentsDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.addStudents(+id, dto.studentIds);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
|
||||
module: '班级管理',
|
||||
action: '添加学生',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `新增${result.added}名学生`,
|
||||
});
|
||||
try {
|
||||
const cls = await this.service.findOne(+id);
|
||||
@@ -470,7 +512,11 @@ export class ClassesController {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.removeStudent(+id, +studentId);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`,
|
||||
module: '班级管理',
|
||||
action: '移除学生',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `移除学生${studentId}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -484,13 +530,21 @@ export class ClassesController {
|
||||
|
||||
@Post(':id/teachers')
|
||||
@RequirePermission('class:edit')
|
||||
async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) {
|
||||
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}`,
|
||||
module: '班级管理',
|
||||
action: '添加教师',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `教师${dto.userId} 角色${dto.roleType}`,
|
||||
});
|
||||
try {
|
||||
void this.notificationsService.create({
|
||||
@@ -515,7 +569,11 @@ export class ClassesController {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`,
|
||||
module: '班级管理',
|
||||
action: '移除教师角色',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `移除教师分配${assignmentId}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -530,7 +588,11 @@ export class ClassesController {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const result = await this.service.removeTeacher(+id, +userId);
|
||||
await logAudit(this.logService, req, {
|
||||
module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`,
|
||||
module: '班级管理',
|
||||
action: '移除教师',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `移除教师${userId}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource,
|
||||
Repository,
|
||||
In,
|
||||
Like } from 'typeorm';
|
||||
import { DataSource, EntityManager, In, Like, Repository } from 'typeorm';
|
||||
import { escapeLike } from '../common/like-escape';
|
||||
import {
|
||||
Class,
|
||||
@@ -232,71 +229,78 @@ export class ClassesService {
|
||||
}
|
||||
}
|
||||
|
||||
const cls = this.classRepo.create({
|
||||
...classData,
|
||||
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
|
||||
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
|
||||
});
|
||||
const saved = await this.classRepo.save(cls);
|
||||
// 班级/学生/教师写入整体包进事务:任一环节失败(并发唯一索引冲突、断连等)整体回滚,
|
||||
// 避免「班级与已加入学生已落库、教师写入失败」留下孤儿数据。
|
||||
// batchImportStudents(钉钉导入)自带事务,保持事务外调用,失败行为与原来一致。
|
||||
const savedClassId = await this.dataSource.transaction(async (manager) => {
|
||||
const cls = manager.create(Class, {
|
||||
...classData,
|
||||
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
|
||||
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
|
||||
});
|
||||
const saved = await manager.save(cls);
|
||||
|
||||
// add students
|
||||
if (studentIds?.length) {
|
||||
const entries = studentIds.map((sid: number) =>
|
||||
this.classStudentRepo.create({
|
||||
classId: saved.id,
|
||||
studentId: sid,
|
||||
joinDate: dayjs().utcOffset(8).format('YYYY-MM-DD'),
|
||||
}),
|
||||
);
|
||||
await this.classStudentRepo.save(entries);
|
||||
}
|
||||
// add students
|
||||
if (studentIds?.length) {
|
||||
const entries = studentIds.map((sid: number) =>
|
||||
manager.create(ClassStudent, {
|
||||
classId: saved.id,
|
||||
studentId: sid,
|
||||
joinDate: dayjs().utcOffset(8).format('YYYY-MM-DD'),
|
||||
}),
|
||||
);
|
||||
await manager.save(ClassStudent, entries);
|
||||
}
|
||||
|
||||
// add teachers:任课老师一行一科目;其他角色单行无科目
|
||||
if (teacherPlans.length) {
|
||||
const entries: ClassTeacher[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const plan of teacherPlans) {
|
||||
if (plan.roleType === 'subject_teacher') {
|
||||
for (const subject of plan.subjects) {
|
||||
// 唯一索引 collation 大小写不敏感,去重键统一小写避免 'Math'+'math' 撞 1062
|
||||
const key = `${plan.userId}:${plan.roleType}:${subject.toLowerCase()}`;
|
||||
// add teachers:任课老师一行一科目;其他角色单行无科目
|
||||
if (teacherPlans.length) {
|
||||
const entries: ClassTeacher[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const plan of teacherPlans) {
|
||||
if (plan.roleType === 'subject_teacher') {
|
||||
for (const subject of plan.subjects) {
|
||||
// 唯一索引 collation 大小写不敏感,去重键统一小写避免 'Math'+'math' 撞 1062
|
||||
const key = `${plan.userId}:${plan.roleType}:${subject.toLowerCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push(
|
||||
manager.create(ClassTeacher, {
|
||||
classId: saved.id,
|
||||
userId: plan.userId,
|
||||
roleType: plan.roleType,
|
||||
subject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const key = `${plan.userId}:${plan.roleType}:`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push(
|
||||
this.classTeacherRepo.create({
|
||||
manager.create(ClassTeacher, {
|
||||
classId: saved.id,
|
||||
userId: plan.userId,
|
||||
roleType: plan.roleType,
|
||||
subject,
|
||||
subject: '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const key = `${plan.userId}:${plan.roleType}:`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push(
|
||||
this.classTeacherRepo.create({
|
||||
classId: saved.id,
|
||||
userId: plan.userId,
|
||||
roleType: plan.roleType,
|
||||
subject: '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (entries.length) await this.classTeacherRepo.save(entries);
|
||||
if (entries.length) await manager.save(ClassTeacher, entries);
|
||||
|
||||
// sync head/life/academic teacher IDs
|
||||
await this.syncClassTeacherIds(saved.id);
|
||||
}
|
||||
// sync head/life/academic teacher IDs(事务内用 manager 保证同事务可见性)
|
||||
await this.syncClassTeacherIds(saved.id, manager);
|
||||
}
|
||||
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
// batch import students by dingUserIds
|
||||
if (users?.length) {
|
||||
await this.batchImportStudents(saved.id, users);
|
||||
await this.batchImportStudents(savedClassId, users);
|
||||
}
|
||||
|
||||
return this.findOne(saved.id);
|
||||
return this.findOne(savedClassId);
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateClassDto) {
|
||||
@@ -546,15 +550,22 @@ export class ClassesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async syncClassTeacherIds(classId: number) {
|
||||
const teachers = await this.classTeacherRepo.find({ where: { classId } });
|
||||
private async syncClassTeacherIds(classId: number, manager?: EntityManager) {
|
||||
const teachers = manager
|
||||
? await manager.find(ClassTeacher, { where: { classId } })
|
||||
: await this.classTeacherRepo.find({ where: { classId } });
|
||||
const head = teachers.find((t) => t.roleType === 'head_teacher');
|
||||
const life = teachers.find((t) => t.roleType === 'life_teacher');
|
||||
const academic = teachers.find((t) => t.roleType === 'academic_teacher');
|
||||
await this.classRepo.update(classId, {
|
||||
const patch = {
|
||||
headTeacherId: head?.userId ?? null,
|
||||
lifeTeacherId: life?.userId ?? null,
|
||||
academicTeacherId: academic?.userId ?? null,
|
||||
} as Partial<Class>);
|
||||
} as Partial<Class>;
|
||||
if (manager) {
|
||||
await manager.update(Class, classId, patch);
|
||||
} else {
|
||||
await this.classRepo.update(classId, patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ClassesService, normalizeTeacherSubjects } from './classes.service';
|
||||
import { ClassTeacher, TeacherRoleType } from '../entities';
|
||||
import { Class, ClassStudent, ClassTeacher, TeacherRoleType } from '../entities';
|
||||
|
||||
/** 模拟 TypeORM EntityManager:按实体类型分发 create/save/find/update,
|
||||
* 班级/学生/教师写入走各自 repo mock,供 create() 事务化后的测试使用。 */
|
||||
function entityAwareManager(
|
||||
classRepo: Record<string, jest.Mock>,
|
||||
classTeacherRepo: Record<string, jest.Mock>,
|
||||
classStudentRepo: Record<string, jest.Mock>,
|
||||
) {
|
||||
return {
|
||||
create: (entity: unknown, value: unknown) =>
|
||||
entity === ClassTeacher ? classTeacherRepo.create(value) : classRepo.create(value),
|
||||
save: async (entity: unknown, value?: unknown) => {
|
||||
if (entity === ClassTeacher) return classTeacherRepo.save(value);
|
||||
if (entity === ClassStudent) return value;
|
||||
return classRepo.save(entity);
|
||||
},
|
||||
find: (entity: unknown, opts: unknown) =>
|
||||
entity === ClassTeacher ? classTeacherRepo.find(opts) : classStudentRepo.find(opts),
|
||||
update: (_entity: unknown, id: unknown, patch: unknown) => classRepo.update(id, patch),
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
classRepo: Record<string, jest.Mock>,
|
||||
@@ -9,9 +30,14 @@ function createService(
|
||||
dataSource: Record<string, unknown> = {},
|
||||
) {
|
||||
const ds = {
|
||||
// 默认把事务回调的 manager 代理到 classTeacherRepo 的 save/create,保持既有断言语义
|
||||
// 默认把事务回调的 manager 代理到 repo mock,保持既有断言语义
|
||||
transaction: async (fn: (manager: Record<string, jest.Mock>) => unknown) =>
|
||||
fn({ save: classTeacherRepo.save, create: classTeacherRepo.create }),
|
||||
fn({
|
||||
save: classTeacherRepo.save,
|
||||
create: classTeacherRepo.create,
|
||||
find: classTeacherRepo.find ?? jest.fn().mockResolvedValue([]),
|
||||
update: classRepo.update ?? jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
...dataSource,
|
||||
};
|
||||
return new ClassesService(
|
||||
@@ -246,7 +272,10 @@ describe('ClassesService — teacher multi-subject (one row per subject)', () =>
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const service = createService(classRepo, classTeacherRepo, classStudentRepo);
|
||||
const service = createService(classRepo, classTeacherRepo, classStudentRepo, {
|
||||
transaction: async (fn: (m: unknown) => unknown) =>
|
||||
fn(entityAwareManager(classRepo, classTeacherRepo, classStudentRepo)),
|
||||
});
|
||||
|
||||
await service.create({
|
||||
name: '高三1班',
|
||||
@@ -308,7 +337,10 @@ describe('ClassesService — teacher multi-subject (one row per subject)', () =>
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) });
|
||||
const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) }, {
|
||||
transaction: async (fn: (m: unknown) => unknown) =>
|
||||
fn(entityAwareManager(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) })),
|
||||
});
|
||||
|
||||
await service.create({
|
||||
name: '高三1班',
|
||||
|
||||
@@ -338,7 +338,9 @@ export class CommitRosterImportDto {
|
||||
createRows?: RosterCreateRowDto[];
|
||||
}
|
||||
|
||||
/** 校验 addStudentIds/createRows 至少一项且合计不超过文件解析上限。 */
|
||||
/** 校验 addStudentIds/createRows 合计不超过文件解析上限。
|
||||
* 挂在 addStudentIds 上:@IsOptional 使该字段缺失时(如仅 createRows 或空 body)此校验器不运行,
|
||||
* 空 body 由 service 抛「提交内容不能为空」兜底;双数组都提交时(唯一需要合计限制的场景)本校验器生效。 */
|
||||
function ValidateRosterCommitLimits(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
|
||||
@@ -435,6 +435,20 @@ 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`);
|
||||
await runner.query(`UPDATE class_teacher SET subject = LEFT(subject, 50)
|
||||
WHERE CHAR_LENGTH(subject) > 50`);
|
||||
|
||||
|
||||
@@ -6,13 +6,19 @@ import compression from 'compression';
|
||||
import { AppModule } from './app.module';
|
||||
import { runMigrationsOnStartup } from './migration-runner';
|
||||
|
||||
// 与 app.setGlobalPrefix('api') + ClassesController('classes') 保持同步:
|
||||
// 花名册提交/批量导入走这里,放宽 JSON 上限;改前缀或控制器路径时需同步修改。
|
||||
// 注意:Express 按前缀匹配,/api/classes 开头的路由都会放宽到 2mb。
|
||||
const LARGE_BODY_ROUTES_PREFIX = '/api/classes';
|
||||
const LARGE_BODY_LIMIT = '2mb';
|
||||
|
||||
async function bootstrap() {
|
||||
await runMigrationsOnStartup();
|
||||
|
||||
// 默认 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(LARGE_BODY_ROUTES_PREFIX, json({ limit: LARGE_BODY_LIMIT }));
|
||||
app.use(json());
|
||||
app.use(urlencoded({ extended: true }));
|
||||
// 全局 DTO 校验:对带 class-validator 装饰器的 DTO 生效。
|
||||
|
||||
Reference in New Issue
Block a user