fix(server): 花名册预览与提交匹配归一化一致,并发导入计数修正

- 预览二次校验改 LOWER(TRIM) 归一化,与主查询/commit 路径一致,避免预览判可新建而提交判冲突
- 并发创建同标识学生重查改用锁定读刷新可见性(REPEATABLE READ 旧快照问题)
- 批量成员保存撞唯一索引后的恢复:复活行不计入并发跳过,仅新增行且已 active 才算已在本班
- 重试保存逐行执行,避免多行 INSERT 原子失败静默丢行
This commit is contained in:
2026-08-11 11:53:40 +08:00
parent 545d087b3d
commit bfc8175277

View File

@@ -1,10 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In } from 'typeorm';
import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entities';
import { DataSource, In, Not, Repository } from 'typeorm';
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';
import { sanitizeCellText } from './class-roster-import';
import type { ClassRosterCreateRow, ClassRosterImportRow } from './class-roster-import';
import { escapeLike } from '../common/like-escape';
import dayjs from '../common/dayjs';
@@ -15,6 +17,129 @@ interface AgentClassRow {
studentCount: string | number;
}
/** 判断是否为 MySQL 唯一键冲突1062 / ER_DUP_ENTRY兼容 TypeORM QueryFailedError 包装。 */
export function isDuplicateEntryError(error: unknown): boolean {
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
);
}
/** 标识归一化:去空白 + 小写,与 SQL 侧 LOWER(TRIM(col)) 保持一致。 */
function normalizeIdentifier(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase();
}
export type RosterPreviewRowStatus = 'matched' | 'unmatched' | 'in-class' | 'conflict';
export interface RosterPreviewRow {
rowNumber: number;
name: string;
phone?: string;
idNumber?: string;
studentNo?: string;
status: RosterPreviewRowStatus;
student?: { id: number; name: string; studentNo: string | null };
reason?: string;
}
export interface RosterPreviewResult {
rows: RosterPreviewRow[];
summary: {
total: number;
matched: number;
unmatched: number;
inClass: number;
conflict: number;
};
}
export interface RosterCommitResult {
added: number;
created: number;
skipped: number;
conflicts: number;
message: string;
}
/**
* 校验导入行与已匹配学生是否自洽:仅当学生已存储对应字段且与行内值不一致时返回原因。
* 用于避免「手机号是 A、姓名/身份证/学号却是 B」的错位数据被静默关联。
*/
function inconsistentFieldReason(
row: { name?: string; phone?: string; idNumber?: string; studentNo?: string },
student: Student,
): string | null {
// 与 buildStudentIndexes / SQL LOWER(TRIM()) 的归一化保持一致
const storedName = normalizeIdentifier(student.name);
const storedPhone = normalizeIdentifier(student.phone);
const storedIdNumber = normalizeIdentifier(student.idNumber);
const storedStudentNo = normalizeIdentifier(student.studentNo);
if (row.name && storedName && normalizeIdentifier(row.name) !== storedName) {
return `姓名与匹配学生不一致(${student.name?.trim()}`;
}
if (row.phone && storedPhone && normalizeIdentifier(row.phone) !== storedPhone) {
return '手机号与匹配学生不一致';
}
if (row.idNumber && storedIdNumber && normalizeIdentifier(row.idNumber) !== storedIdNumber) {
return '身份证号与匹配学生不一致';
}
if (row.studentNo && storedStudentNo && normalizeIdentifier(row.studentNo) !== storedStudentNo) {
return '学号与匹配学生不一致';
}
// 行内带标识但匹配学生未登记该字段:无法确认一致性,按冲突提示,避免静默丢弃字段
if (row.phone && !storedPhone) return '手机号与匹配学生不一致(学生未登记手机号)';
if (row.idNumber && !storedIdNumber) return '身份证号与匹配学生不一致(学生未登记身份证号)';
if (row.studentNo && !storedStudentNo) return '学号与匹配学生不一致(学生未登记学号)';
return null;
}
/** 判断标识键下是否存在已归档或员工账号学生。 */
function identifierHasArchivedOrStaff(
indexes: StudentIndexes,
key: string,
which: 'byPhone' | 'byIdNumber' | 'byStudentNo',
): boolean {
return (indexes[which].get(normalizeIdentifier(key)) ?? []).some(
(student) => student.status === 'archived' || student.status === 'staff',
);
}
interface StudentIndexes {
byPhone: Map<string, Student[]>;
byIdNumber: Map<string, Student[]>;
byStudentNo: Map<string, Student[]>;
byName: Map<string, Student[]>;
}
function pushIndex(map: Map<string, Student[]>, key: string, student: Student) {
const list = map.get(key) ?? [];
list.push(student);
map.set(key, list);
}
/** 构建按 手机号/身份证号/学号/姓名 归一的查询索引;键值两侧均去空白,避免存储值带空格导致漏匹配。 */
function buildStudentIndexes(students: Student[]): StudentIndexes {
const indexes: StudentIndexes = {
byPhone: new Map(),
byIdNumber: new Map(),
byStudentNo: new Map(),
byName: new Map(),
};
for (const student of students) {
const phone = normalizeIdentifier(student.phone);
const idNumber = normalizeIdentifier(student.idNumber);
const studentNo = normalizeIdentifier(student.studentNo);
const name = normalizeIdentifier(student.name);
if (phone) pushIndex(indexes.byPhone, phone, student);
if (idNumber) pushIndex(indexes.byIdNumber, idNumber, student);
if (studentNo) pushIndex(indexes.byStudentNo, studentNo, student);
if (name) pushIndex(indexes.byName, name, student);
}
return indexes;
}
@Injectable()
export class ClassesQueriesService {
constructor(
@@ -113,7 +238,544 @@ export class ClassesQueriesService {
conflicts: synced.conflicts.length,
};
});
}
}
/** 班级花名册导入预览:解析行后按 手机号→身份证号→学号→唯一姓名 匹配已有学生,不写库。 */
async previewRosterImport(
classId: number,
rows: ClassRosterImportRow[],
): Promise<RosterPreviewResult> {
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
const memberships = await this.classStudentRepo.find({ where: { classId } });
const activeStudentIds = new Set(
memberships.filter((m) => m.status === 'active').map((m) => m.studentId),
);
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)),
];
// 只匹配未归档且非员工账号的学生,避免已归档学生被重新拉回在读班级;
// 用 LOWER(TRIM(col)) 与 Map 索引的归一化保持一致,避免存储值带空格/大小写差异导致漏匹配
let students: Student[] = [];
{
const qb = this.dataSource.getRepository(Student).createQueryBuilder('s');
const ors: string[] = [];
if (phones.length) {
ors.push('LOWER(TRIM(s.phone)) IN (:...phones)');
qb.setParameter('phones', phones);
}
if (idNumbers.length) {
ors.push('LOWER(TRIM(s.idNumber)) IN (:...idNumbers)');
qb.setParameter('idNumbers', idNumbers);
}
if (studentNos.length) {
ors.push('LOWER(TRIM(s.studentNo)) IN (:...studentNos)');
qb.setParameter('studentNos', studentNos);
}
if (names.length) {
ors.push('LOWER(TRIM(s.name)) IN (:...names)');
qb.setParameter('names', names);
}
if (ors.length) {
qb.where('s.status NOT IN (:...blockedStatuses)', {
blockedStatuses: ['archived', 'staff'],
}).andWhere(`(${ors.join(' OR ')})`);
students = await qb.getMany();
}
}
const { byPhone, byIdNumber, byStudentNo, byName } = buildStudentIndexes(students);
const previewRows: RosterPreviewRow[] = [];
for (const row of rows) {
const name = row.name?.trim() ?? '';
const phone = row.phone?.trim();
const idNumber = row.idNumber?.trim();
const studentNo = row.studentNo?.trim();
if (!name && !phone && !idNumber && !studentNo) {
previewRows.push({ ...row, name, status: 'conflict', reason: '缺少姓名和匹配信息' });
continue;
}
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)) ?? [] : []),
]) {
identifierMatches.set(match.id, match);
}
if (identifierMatches.size > 1) {
const sameIdentifierDuplicate =
(phone && (byPhone.get(normalizeIdentifier(phone))?.length ?? 0) > 1) ||
(idNumber && (byIdNumber.get(normalizeIdentifier(idNumber))?.length ?? 0) > 1) ||
(studentNo && (byStudentNo.get(normalizeIdentifier(studentNo))?.length ?? 0) > 1);
previewRows.push({
...row,
name,
status: 'conflict',
reason: sameIdentifierDuplicate
? '手机号/学号/身份证在系统中匹配到多条学生记录,请先整理学生目录'
: '手机号/学号/身份证匹配到不同学生',
});
continue;
}
if (identifierMatches.size === 1) {
const student = [...identifierMatches.values()][0];
const reason = inconsistentFieldReason({ name, phone, idNumber, studentNo }, student);
if (reason) {
previewRows.push({ ...row, name, status: 'conflict', reason });
continue;
}
previewRows.push({
...row,
name,
status: activeStudentIds.has(student.id) ? 'in-class' : 'matched',
student: { id: student.id, name: student.name, studentNo: student.studentNo },
});
continue;
}
const nameMatches = name ? byName.get(normalizeIdentifier(name)) ?? [] : [];
if (nameMatches.length > 1) {
previewRows.push({
...row,
name,
status: 'conflict',
reason: '姓名匹配到多名学生,请补充手机号/学号/身份证号',
});
continue;
}
if (nameMatches.length === 1) {
const student = nameMatches[0];
const reason = inconsistentFieldReason({ name, phone, idNumber, studentNo }, student);
if (reason) {
previewRows.push({ ...row, name, status: 'conflict', reason });
continue;
}
previewRows.push({
...row,
name,
status: activeStudentIds.has(student.id) ? 'in-class' : 'matched',
student: { id: student.id, name: student.name, studentNo: student.studentNo },
});
continue;
}
previewRows.push({ ...row, name, status: 'unmatched' });
}
// 二次校验:未匹配行的标识若命中已归档/员工账号,标记为冲突而非可新建,
// 避免在创建时静默产生与归档学生同手机号/身份证/学号的重复学生。
// 与主查询/commit 路径统一走 LOWER(TRIM()) 归一化,避免存储值带空格/大小写差异时
// 预览判为可新建而 commit 判为冲突,导致两个入口结果不一致。
const unmatchedRows = previewRows.filter((row) => row.status === 'unmatched');
const unmatchedPhones = [
...new Set(unmatchedRows.map((row) => normalizeIdentifier(row.phone)).filter((v) => !!v)),
];
const unmatchedIdNumbers = [
...new Set(unmatchedRows.map((row) => normalizeIdentifier(row.idNumber)).filter((v) => !!v)),
];
const unmatchedStudentNos = [
...new Set(unmatchedRows.map((row) => normalizeIdentifier(row.studentNo)).filter((v) => !!v)),
];
const blockedQb = this.dataSource.getRepository(Student).createQueryBuilder('s');
const blockedOrs: string[] = [];
if (unmatchedPhones.length) {
blockedOrs.push('LOWER(TRIM(s.phone)) IN (:...unmatchedPhones)');
blockedQb.setParameter('unmatchedPhones', unmatchedPhones);
}
if (unmatchedIdNumbers.length) {
blockedOrs.push('LOWER(TRIM(s.idNumber)) IN (:...unmatchedIdNumbers)');
blockedQb.setParameter('unmatchedIdNumbers', unmatchedIdNumbers);
}
if (unmatchedStudentNos.length) {
blockedOrs.push('LOWER(TRIM(s.studentNo)) IN (:...unmatchedStudentNos)');
blockedQb.setParameter('unmatchedStudentNos', unmatchedStudentNos);
}
const blockedStudents = blockedOrs.length
? await blockedQb.where(`(${blockedOrs.join(' OR ')})`).getMany()
: [];
if (blockedStudents.length) {
const blockedIndexes = buildStudentIndexes(blockedStudents);
for (const row of previewRows) {
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'));
if (blocked) {
row.status = 'conflict';
row.reason = '手机号/学号/身份证对应已归档或员工账号,无法导入';
}
}
}
const summary: RosterPreviewResult['summary'] = {
total: 0,
matched: 0,
unmatched: 0,
inClass: 0,
conflict: 0,
};
for (const row of previewRows) {
summary.total++;
if (row.status === 'matched') summary.matched++;
else if (row.status === 'unmatched') summary.unmatched++;
else if (row.status === 'in-class') summary.inClass++;
else summary.conflict++;
}
return { rows: previewRows, summary };
}
/** 班级花名册导入提交:加入选中学生 + 按勾选创建未匹配学生,事务内完成。 */
async commitRosterImport(
classId: number,
input: { addStudentIds?: number[]; createRows?: ClassRosterCreateRow[] },
): Promise<RosterCommitResult> {
if (!input.addStudentIds?.length && !input.createRows?.length) {
throw new BadRequestException('提交内容不能为空');
}
return this.dataSource.transaction(async (manager) => {
const classEntity = await manager.findOne(Class, { where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
const today = dayjs().utcOffset(8).format('YYYY-MM-DD');
const studentIdsToAdd = new Set<number>((input.addStudentIds || []).map(Number));
let createdCount = 0;
let conflicts = 0;
const createdStudents = new Map<number, Student>();
const createdByPhone = new Map<string, number>();
const createdByIdNumber = new Map<string, number>();
const createdByStudentNo = new Map<string, number>();
const createdByName = new Map<string, number>();
let hostOrganizationId: number | undefined;
let hostOrganizationResolved = false;
const createRows = input.createRows || [];
if (createRows.length) {
// 直接提交的 createRows 绕过上传解析,需同样剥离公式注入前缀后再匹配/落库
const sanitizedRows = createRows.map((row) => ({
name: sanitizeCellText(row.name?.trim() ?? ''),
phone: row.phone?.trim() ? sanitizeCellText(row.phone.trim(), true) : undefined,
idNumber: row.idNumber?.trim() ? sanitizeCellText(row.idNumber.trim()) : undefined,
studentNo: row.studentNo?.trim() ? sanitizeCellText(row.studentNo.trim()) : undefined,
}));
const phones = [
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.phone)).filter((v) => !!v)),
];
const idNumbers = [
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.idNumber)).filter((v) => !!v)),
];
const studentNos = [
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.studentNo)).filter((v) => !!v)),
];
const names = [
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.name)).filter((v) => !!v)),
];
// 在读/正常账号:作为可匹配候选;查询与 Map 索引统一走 LOWER(TRIM()) 归一化
const existingQb = manager.createQueryBuilder(Student, 's');
const existingOrs: string[] = [];
if (phones.length) {
existingOrs.push('LOWER(TRIM(s.phone)) IN (:...phones)');
existingQb.setParameter('phones', phones);
}
if (idNumbers.length) {
existingOrs.push('LOWER(TRIM(s.idNumber)) IN (:...idNumbers)');
existingQb.setParameter('idNumbers', idNumbers);
}
if (studentNos.length) {
existingOrs.push('LOWER(TRIM(s.studentNo)) IN (:...studentNos)');
existingQb.setParameter('studentNos', studentNos);
}
if (names.length) {
existingOrs.push('LOWER(TRIM(s.name)) IN (:...names)');
existingQb.setParameter('names', names);
}
const existingStudents = existingOrs.length
? await existingQb
.where(`(${existingOrs.join(' OR ')})`)
.andWhere('s.status NOT IN (:...blockedStatuses)', {
blockedStatuses: ['archived', 'staff'],
})
.getMany()
: [];
const existingIndexes = buildStudentIndexes(existingStudents);
// 全量状态查询(含已归档/员工):创建前检测标识是否已被非在读账号占用
const allStatusQb = manager.getRepository(Student).createQueryBuilder('s');
const allStatusOrs: string[] = [];
if (phones.length) {
allStatusOrs.push('LOWER(TRIM(s.phone)) IN (:...phones)');
allStatusQb.setParameter('phones', phones);
}
if (idNumbers.length) {
allStatusOrs.push('LOWER(TRIM(s.idNumber)) IN (:...idNumbers)');
allStatusQb.setParameter('idNumbers', idNumbers);
}
if (studentNos.length) {
allStatusOrs.push('LOWER(TRIM(s.studentNo)) IN (:...studentNos)');
allStatusQb.setParameter('studentNos', studentNos);
}
const allStatusStudents = allStatusOrs.length
? await allStatusQb.where(`(${allStatusOrs.join(' OR ')})`).getMany()
: [];
const allStatusIndexes = buildStudentIndexes(allStatusStudents);
for (const row of sanitizedRows) {
const name = row.name;
const phone = row.phone;
const idNumber = row.idNumber;
const studentNo = row.studentNo;
if (!name) {
conflicts++;
continue;
}
const candidates = new Map<number, Student>();
if (phone) {
const byBatch = createdByPhone.get(normalizeIdentifier(phone));
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
if (batchStudent) {
candidates.set(batchStudent.id, batchStudent);
} else {
const matches = existingIndexes.byPhone.get(normalizeIdentifier(phone)) ?? [];
if (matches.length === 1) candidates.set(matches[0].id, matches[0]);
else if (matches.length > 1) {
conflicts++;
continue;
}
}
}
if (idNumber) {
const byBatch = createdByIdNumber.get(normalizeIdentifier(idNumber));
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
if (batchStudent) {
candidates.set(batchStudent.id, batchStudent);
} else {
const matches = existingIndexes.byIdNumber.get(normalizeIdentifier(idNumber)) ?? [];
if (matches.length === 1) candidates.set(matches[0].id, matches[0]);
else if (matches.length > 1) {
conflicts++;
continue;
}
}
}
if (studentNo) {
const byBatch = createdByStudentNo.get(normalizeIdentifier(studentNo));
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
if (batchStudent) {
candidates.set(batchStudent.id, batchStudent);
} else {
const matches = existingIndexes.byStudentNo.get(normalizeIdentifier(studentNo)) ?? [];
if (matches.length === 1) candidates.set(matches[0].id, matches[0]);
else if (matches.length > 1) {
conflicts++;
continue;
}
}
}
// 无标识命中时按唯一姓名兜底,与预览逻辑保持一致,避免绕过预览产生重复学生
if (candidates.size === 0 && name) {
const byBatch = createdByName.get(normalizeIdentifier(name));
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
if (batchStudent) {
candidates.set(batchStudent.id, batchStudent);
} else {
const nameMatches = existingIndexes.byName.get(normalizeIdentifier(name)) ?? [];
if (nameMatches.length === 1) candidates.set(nameMatches[0].id, nameMatches[0]);
else if (nameMatches.length > 1) {
conflicts++;
continue;
}
}
}
if (candidates.size > 1) {
conflicts++;
continue;
}
let studentId: number;
if (candidates.size === 1) {
const student = [...candidates.values()][0];
const reason = inconsistentFieldReason({ name, phone, idNumber, studentNo }, student);
if (reason) {
conflicts++;
continue;
}
studentId = student.id;
} else {
const blocked =
(phone && identifierHasArchivedOrStaff(allStatusIndexes, phone, 'byPhone')) ||
(idNumber && identifierHasArchivedOrStaff(allStatusIndexes, idNumber, 'byIdNumber')) ||
(studentNo && identifierHasArchivedOrStaff(allStatusIndexes, studentNo, 'byStudentNo'));
if (blocked) {
conflicts++;
continue;
}
if (!hostOrganizationResolved) {
const host = await manager.findOne(Organization, {
where: { isHost: true, status: 'active' },
});
hostOrganizationId = host?.id;
hostOrganizationResolved = true;
}
if (!hostOrganizationId) {
conflicts++;
continue;
}
let created: Student;
try {
created = await manager.save(
manager.create(Student, {
name,
phone: phone || undefined,
idNumber: idNumber || undefined,
studentNo: studentNo || undefined,
status: 'active',
organizationId: hostOrganizationId,
}),
);
createdCount++;
} catch (error) {
// 并发创建同标识学生:按标识重查并复用已有档案,避免整批回滚。
// 仅复用未归档/非员工账号且与行内信息自洽的学生,否则按冲突处理。
// REPEATABLE READ 下普通一致读基于旧快照看不到并发提交的行,需用锁定读刷新可见性
if (!isDuplicateEntryError(error)) throw error;
const dup = await manager.findOne(Student, {
where: [
...(phone ? [{ phone, status: Not(In(['archived', 'staff'])) }] : []),
...(idNumber ? [{ idNumber, status: Not(In(['archived', 'staff'])) }] : []),
...(studentNo ? [{ studentNo, status: Not(In(['archived', 'staff'])) }] : []),
],
lock: { mode: 'pessimistic_read' },
order: { id: 'ASC' },
});
if (!dup || inconsistentFieldReason({ name, phone, idNumber, studentNo }, dup)) {
conflicts++;
continue;
}
created = dup;
}
studentId = created.id;
createdStudents.set(studentId, created);
if (phone) createdByPhone.set(normalizeIdentifier(phone), studentId);
if (idNumber) createdByIdNumber.set(normalizeIdentifier(idNumber), studentId);
if (studentNo) createdByStudentNo.set(normalizeIdentifier(studentNo), studentId);
createdByName.set(normalizeIdentifier(name), studentId);
}
studentIdsToAdd.add(studentId);
}
}
const ids = [...studentIdsToAdd];
let added = 0;
let skipped = 0;
if (ids.length) {
// 校验待加入学生仍存在(预览与提交之间可能被删除),缺失的按冲突跳过,避免外键失败整批回滚
const foundStudents = await manager.find(Student, {
where: { id: In(ids), status: Not(In(['archived', 'staff'])) },
});
const foundIds = new Set(foundStudents.map((student) => student.id));
const validIds = ids.filter((studentId) => foundIds.has(studentId));
conflicts += ids.length - validIds.length;
const existingMemberships = validIds.length
? await manager.find(ClassStudent, {
where: { classId, studentId: In(validIds) },
})
: [];
const existingByStudentId = new Map(
existingMemberships.map((m) => [m.studentId, m]),
);
const memberships = validIds.flatMap((studentId) => {
const current = existingByStudentId.get(studentId);
if (current?.status === 'active') {
skipped++;
return [];
}
if (current) {
current.status = 'active';
current.joinDate = today;
current.leaveDate = null;
added++;
return [current];
}
added++;
return [
manager.create(ClassStudent, {
classId,
studentId,
status: 'active',
joinDate: today,
}),
];
});
if (memberships.length) {
try {
await manager.save(ClassStudent, memberships);
} catch (error) {
// 并发导入同一学生时 class_student 唯一索引可能冲突InnoDB 下该语句回滚但事务仍可用。
// REPEATABLE READ 下普通读基于旧快照,需用锁定读刷新可见性后再跳过已存在成员
if (!isDuplicateEntryError(error)) throw error;
const existingAfter = await manager.find(ClassStudent, {
where: { classId, studentId: In(validIds) },
lock: { mode: 'pessimistic_read' },
});
// 本批复活行(已存在记录改为 active的 UPDATE 在批内已成功且对锁定读可见,
// 应保留在 added仅「新增行」且已 active 才视为并发事务已插入,计入 skipped
const reactivationIds = new Set(
memberships.filter((m) => m.id !== undefined).map((m) => m.studentId),
);
const activeAfterIds = new Set(
existingAfter.filter((m) => m.status === 'active').map((m) => m.studentId),
);
const remaining = memberships.filter((m) => !activeAfterIds.has(m.studentId));
const alreadyAdded = memberships.filter(
(m) => !reactivationIds.has(m.studentId) && activeAfterIds.has(m.studentId),
).length;
added -= alreadyAdded;
skipped += alreadyAdded;
if (remaining.length) {
// 逐行重试:多行 INSERT 是原子语句,任意一行撞唯一索引整批不落库;
// 逐行让已由并发事务插入的行跳过、其余新成员正常落库,计数准确
for (const membership of remaining) {
try {
await manager.save(ClassStudent, membership);
} catch (retryError) {
if (!isDuplicateEntryError(retryError)) throw retryError;
added -= 1;
skipped += 1;
}
}
}
}
}
}
const message =
`成功加入 ${added} 名学生` +
(createdCount > 0 ? `(其中新建 ${createdCount} 名)` : '') +
`,跳过 ${skipped} 名(已在本班)` +
(conflicts > 0 ? `,未处理 ${conflicts}` : '');
return { added, created: createdCount, skipped, conflicts, message };
});
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
const qb = this.scheduleRepo
@@ -137,7 +799,7 @@ export class ClassesQueriesService {
...s,
classroomName: (s.classroom as Classroom | undefined)?.name || null,
}));
}
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
const qb = this.attendanceRepo