diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts index b6aa96d3..df840cd0 100644 --- a/apps/server/src/classes/classes-queries.service.ts +++ b/apps/server/src/classes/classes-queries.service.ts @@ -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; + byIdNumber: Map; + byStudentNo: Map; + byName: Map; +} + +function pushIndex(map: Map, 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 { + 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(); + 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 { + 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((input.addStudentIds || []).map(Number)); + let createdCount = 0; + let conflicts = 0; + const createdStudents = new Map(); + const createdByPhone = new Map(); + const createdByIdNumber = new Map(); + const createdByStudentNo = new Map(); + const createdByName = new Map(); + 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(); + 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