From f50301148d01faaa651b8f1e62a3125e93925e1f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 9 Aug 2026 21:29:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(correctness):=20=E5=B9=B6=E5=8F=91/?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1/=E5=AE=9E=E4=BD=93/=E6=97=B6=E5=8C=BA/?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=80=E8=87=B4=E6=80=A7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页 - financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等 - 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time) - rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项 Reviewed-by: OCR (open-codereview.ai) --- .../src/agent-tools/agent-tool.registry.ts | 9 +- .../ai-a2ui-submissions.service.spec.ts | 38 ++ .../ai-chat/ai-a2ui-submissions.service.ts | 40 +- apps/server/src/app.module.ts | 3 +- .../attendance/attendance-calendar.service.ts | 4 +- .../attendance-generation.service.spec.ts | 91 +++ .../attendance-generation.service.ts | 92 +-- .../attendance-import.service.spec.ts | 263 +++++++- .../attendance/attendance-import.service.ts | 235 +++++-- .../attendance-report.service.spec.ts | 76 +++ .../attendance/attendance-report.service.ts | 77 ++- .../attendance/attendance.boundaries.spec.ts | 18 +- .../bills/bills-generation.service.spec.ts | 441 +++++++++++++ .../src/bills/bills-generation.service.ts | 224 ++++--- .../server/src/bills/bills.controller.spec.ts | 196 ++++++ apps/server/src/bills/bills.controller.ts | 297 +++++---- apps/server/src/bills/bills.service.spec.ts | 43 +- apps/server/src/bills/bills.service.ts | 5 +- apps/server/src/bills/dto/bill.dto.ts | 25 +- .../classroom-rentals.service.spec.ts | 161 ++++- .../rental-schedule.service.ts | 215 ++++--- apps/server/src/common/china-time.spec.ts | 38 ++ apps/server/src/common/china-time.ts | 39 ++ .../src/deposits/deposits.purge.spec.ts | 15 +- apps/server/src/deposits/deposits.service.ts | 26 +- apps/server/src/entities/bill.entity.ts | 45 +- .../src/entities/class-student.entity.ts | 2 +- .../entities/ding-attendance-raw.entity.ts | 4 +- .../src/entities/ding-leave-raw.entity.ts | 4 +- .../src/entities/learning-record.entity.ts | 7 - .../src/entities/student-profile.entity.ts | 7 - .../src/entities/student-wallet.entity.ts | 8 +- apps/server/src/exams/exams.purge.spec.ts | 13 +- apps/server/src/exams/exams.service.spec.ts | 16 +- apps/server/src/exams/exams.service.ts | 52 +- .../expense-types/expense-types.service.ts | 6 +- .../financial-operations.service.ts | 34 +- .../src/imports/imports.commit.service.ts | 26 +- .../src/imports/imports.preview.service.ts | 18 +- .../src/imports/imports.service.spec.ts | 72 +++ .../occupancies/occupancies.service.spec.ts | 2 + .../src/occupancies/occupancies.service.ts | 13 +- .../occupancy-operations.service.ts | 28 +- apps/server/src/rbac/rbac-presets.ts | 6 +- apps/server/src/rbac/rbac-seed.service.ts | 72 ++- apps/server/src/rbac/rbac.seed.spec.ts | 71 +++ apps/server/src/rooms/rooms.service.ts | 38 +- .../students/students.import.service.spec.ts | 558 ++++++++++++++++ .../src/students/students.import.service.ts | 594 +++++++++++++----- apps/server/src/sync/schedule-sync.service.ts | 14 +- apps/server/src/sync/sync-runner.ts | 10 +- apps/server/src/sync/sync.service.ts | 11 +- .../src/wallets/wallets.service.spec.ts | 88 ++- apps/server/src/wallets/wallets.service.ts | 75 ++- 54 files changed, 3843 insertions(+), 722 deletions(-) create mode 100644 apps/server/src/attendance/attendance-report.service.spec.ts create mode 100644 apps/server/src/bills/bills-generation.service.spec.ts create mode 100644 apps/server/src/bills/bills.controller.spec.ts create mode 100644 apps/server/src/common/china-time.spec.ts create mode 100644 apps/server/src/common/china-time.ts create mode 100644 apps/server/src/students/students.import.service.spec.ts diff --git a/apps/server/src/agent-tools/agent-tool.registry.ts b/apps/server/src/agent-tools/agent-tool.registry.ts index a121f62..59b3feb 100644 --- a/apps/server/src/agent-tools/agent-tool.registry.ts +++ b/apps/server/src/agent-tools/agent-tool.registry.ts @@ -9,12 +9,11 @@ export class AgentToolRegistry { /** Register a tool (called once at module init). */ register(tool: ToolDef): void { - const idx = this.tools.findIndex((t) => t.name === tool.name); - if (idx >= 0) { - this.tools[idx] = tool; - } else { - this.tools.push(tool); + if (this.tools.some((t) => t.name === tool.name)) { + // 重复 key 会在运行时造成工具覆盖/歧义,注册阶段直接失败更明确。 + throw new Error(`Agent tool 名称重复: ${tool.name}`); } + this.tools.push(tool); } /** diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts index 560439f..d34df55 100644 --- a/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts @@ -55,6 +55,44 @@ describe('A2uiSubmissionsService', () => { expect(repo.save).not.toHaveBeenCalled(); }); + it('并发重复提交遇到唯一约束冲突时返回既有记录而不是抛 500', async () => { + const dupError = Object.assign(new Error('Duplicate entry'), { + code: 'ER_DUP_ENTRY', + errno: 1062, + }); + const { service } = createService({ + findOne: jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(submission), + save: jest.fn().mockRejectedValue(dupError), + }); + const result = await service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: '{"ok":false}', + }); + expect(result.created).toBe(false); + expect(result.submission).toEqual(submission); + }); + + it('非唯一约束错误仍然原样抛给调用方', async () => { + const dbError = new Error('db down'); + const { service } = createService({ + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn().mockRejectedValue(dbError), + }); + await expect( + service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: submission.resultJson, + }), + ).rejects.toBe(dbError); + }); + it('findSubmission 透传查询条件', async () => { const { service, repo } = createService({ findOne: jest.fn().mockResolvedValue(submission), diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts index ad2a706..c137a8f 100644 --- a/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts @@ -10,6 +10,18 @@ export interface RecordSubmissionInput { resultJson?: string | null; } +/** 判断是否为唯一约束冲突(MySQL ER_DUP_ENTRY / PostgreSQL 23505) */ +const isDuplicateKeyError = (error: unknown): boolean => { + const err = error as { + code?: string; + errno?: number; + driverError?: { code?: string; errno?: number }; + }; + const code = err.driverError?.code ?? err.code; + const errno = err.driverError?.errno ?? err.errno; + return code === 'ER_DUP_ENTRY' || code === '23505' || errno === 1062; +}; + /** * A2UI 提交幂等服务。 */ @@ -26,15 +38,25 @@ export class A2uiSubmissionsService { }> { const existing = await this.findSubmission(input.artifactId, input.clientRequestId); if (existing) return { created: false, submission: existing }; - const submission = await this.submissions.save( - this.submissions.create({ - artifactId: input.artifactId, - clientRequestId: input.clientRequestId, - status: input.status, - resultJson: input.resultJson ?? null, - }), - ); - return { created: true, submission }; + try { + const submission = await this.submissions.save( + this.submissions.create({ + artifactId: input.artifactId, + clientRequestId: input.clientRequestId, + status: input.status, + resultJson: input.resultJson ?? null, + }), + ); + return { created: true, submission }; + } catch (error) { + // 并发下两个请求同时通过 findSubmission 时,唯一索引 uk_ai_a2ui_submissions_artifact_client + // 保证只有一个插入成功;另一个捕获唯一约束冲突后返回既有结果,而不是抛 500 + if (isDuplicateKeyError(error)) { + const concurrent = await this.findSubmission(input.artifactId, input.clientRequestId); + if (concurrent) return { created: false, submission: concurrent }; + } + throw error; + } } async findSubmission( diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 66dad62..351cebf 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -165,7 +165,8 @@ import { IntegrationConfigModule } from './integration/config/config.module'; database: config.get('DB_DATABASE', 'dorm_billing'), entities: allEntities, migrations: allMigrations, - synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', + // 生产安全:仅当显式 DB_SYNCHRONIZE=true 时才自动同步表结构 + synchronize: config.get('DB_SYNCHRONIZE') === 'true', charset: 'utf8mb4', }; }, diff --git a/apps/server/src/attendance/attendance-calendar.service.ts b/apps/server/src/attendance/attendance-calendar.service.ts index 81f3962..22d559d 100644 --- a/apps/server/src/attendance/attendance-calendar.service.ts +++ b/apps/server/src/attendance/attendance-calendar.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { getWeekDayFromDateOnly } from '../common/china-time'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between } from 'typeorm'; import { AttendanceRecord, ClassSchedule } from '../entities'; @@ -31,8 +32,7 @@ export class AttendanceCalendarService { } private getWeekDayForDate(date: string): number { - const day = new Date(`${date}T00:00:00+08:00`).getUTCDay(); - return day === 0 ? 7 : day; + return getWeekDayFromDateOnly(date); } async getScheduleOptionsForAttendance(classId: number, date: string) { diff --git a/apps/server/src/attendance/attendance-generation.service.spec.ts b/apps/server/src/attendance/attendance-generation.service.spec.ts index ac08d13..3141443 100644 --- a/apps/server/src/attendance/attendance-generation.service.spec.ts +++ b/apps/server/src/attendance/attendance-generation.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { AttendanceGenerationService } from './attendance-generation.service'; describe('AttendanceGenerationService — 默认周范围', () => { @@ -62,4 +63,94 @@ describe('AttendanceGenerationService — 默认周范围', () => { dateTo: '2026-08-31', }); }); + + it('rejects invalid date formats before generating', async () => { + const service = createService(); + await expect( + service.generateAttendanceFromSchedules({ + classId: 1, + dateFrom: '2026/08/01', + dateTo: '2026-08-07', + } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects the 9999-12-31 boundary that would overflow date iteration', async () => { + const service = createService(); + await expect( + service.generateAttendanceFromSchedules({ + classId: 1, + dateFrom: '2026-08-01', + dateTo: '9999-12-31', + } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('default afternoon period is labeled 下午', async () => { + const saved: Array> = []; + const periodConfigRepo = { + count: jest.fn().mockResolvedValue(0), + save: jest.fn(async (entities: Array>) => { + saved.push(...entities); + return entities; + }), + create: jest.fn((data: Record) => ({ ...data })), + find: jest.fn().mockResolvedValue([]), + }; + const service = new AttendanceGenerationService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + periodConfigRepo as never, + {} as never, + ); + + await service.getAttendancePeriodConfigs(); + + expect(saved.find((period) => period.periodKey === 'afternoon')).toEqual( + expect.objectContaining({ label: '下午', startTime: '14:00', endTime: '17:00' }), + ); + }); + + it('wraps clear + save of period configs in a single transaction', async () => { + const manager = { + clear: jest.fn().mockResolvedValue(undefined), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: unknown) => value), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const periodConfigRepo = { + count: jest.fn().mockResolvedValue(1), + clear: jest.fn(), + save: jest.fn(), + find: jest.fn().mockResolvedValue([]), + }; + const service = new AttendanceGenerationService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + periodConfigRepo as never, + dataSource as never, + ); + + await service.saveAttendancePeriodConfigs({ + periods: [ + { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true }, + { periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 2, enabled: true }, + ], + } as never); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(manager.clear).toHaveBeenCalledTimes(1); + expect(manager.save).toHaveBeenCalledWith(expect.any(Array)); + // 两段写都在事务内完成,不再直接走 repo 的 clear/save + expect(periodConfigRepo.clear).not.toHaveBeenCalled(); + expect(periodConfigRepo.save).not.toHaveBeenCalled(); + }); }); diff --git a/apps/server/src/attendance/attendance-generation.service.ts b/apps/server/src/attendance/attendance-generation.service.ts index b4799ea..bfdc6a9 100644 --- a/apps/server/src/attendance/attendance-generation.service.ts +++ b/apps/server/src/attendance/attendance-generation.service.ts @@ -13,13 +13,14 @@ import { import { toMinutes, isClassStudentActiveOnDate } from './attendance-time'; import dayjs from '../common/dayjs'; import type { BatchCreateAttendanceDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto } from './dto/attendance.dto'; +import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time'; @Injectable() export class AttendanceGenerationService { private readonly defaultAttendancePeriods = [ { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 }, { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 }, - { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 }, + { periodKey: 'afternoon', label: '下午', startTime: '14:00', endTime: '17:00', sortOrder: 3 }, { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 }, ] as const; @@ -62,6 +63,13 @@ export class AttendanceGenerationService { if (dateFrom > dateTo) { throw new BadRequestException('dateFrom must not be later than dateTo'); } + // 迭代前校验日期格式与范围:非法格式直接拒绝;9999-12-31 继续 +1 天会溢出归一化 + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) { + throw new BadRequestException('无效日期'); + } + if (dateFrom > '9999-12-30' || dateTo > '9999-12-30') { + throw new BadRequestException('日期超出合理范围(最大支持 9999-12-30)'); + } const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) { @@ -94,17 +102,19 @@ export class AttendanceGenerationService { existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`), ); + // 考勤时段配置只在循环外取一次,避免每 (date, schedule) 都触发 count+find 查询(N+1) + const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs(); + const entities: AttendanceRecord[] = []; - const end = new Date(dateTo); - for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) { - const dateStr = dayjs(d).utcOffset(8).format('YYYY-MM-DD'); - const weekDay = d.getDay() === 0 ? 7 : d.getDay(); + let dateStr = dateFrom; + while (dateStr <= dateTo) { + const weekDay = getWeekDayFromDateOnly(dateStr); for (const sched of schedules) { if (sched.weekDay !== weekDay) continue; if (dateStr < sched.startDate || dateStr > sched.endDate) continue; - const session = await this.mapScheduleTimeToSession(sched.startTime); + const session = await this.mapScheduleTimeToSession(sched.startTime, attendancePeriodConfigs); const classStudentsForDate = classStudents.filter((cs) => isClassStudentActiveOnDate(cs, dateStr), ); @@ -124,6 +134,7 @@ export class AttendanceGenerationService { existingKeys.add(key); } } + dateStr = addDaysToDateOnly(dateStr, 1); } const saved = await this.attendanceRepo.save(entities); @@ -152,23 +163,6 @@ export class AttendanceGenerationService { }); } - private toMinutes(time: string): number { - const [hour, minute] = time.split(':').map(Number); - return hour * 60 + minute; - } - - private getCourseClock(date: Date): { date: string; minutes: number } { - const c = dayjs(date).utcOffset(8); - return { - date: c.format('YYYY-MM-DD'), - minutes: c.hour() * 60 + c.minute(), - }; - } - - private shiftDate(date: string, days: number): string { - return dayjs.utc(`${date}T00:00:00.000Z`).add(days, 'day').format('YYYY-MM-DD'); - } - private async ensureAttendancePeriodConfigs() { const count = await this.attendancePeriodConfigRepo.count(); if (count === 0) { @@ -187,9 +181,8 @@ export class AttendanceGenerationService { } async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) { - const parsedDate = new Date(`${date}T00:00:00`); - if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期'); - const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequestException('无效日期'); + const weekDay = getWeekDayFromDateOnly(date); const qb = this.scheduleRepo .createQueryBuilder('schedule') .where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL }) @@ -209,9 +202,11 @@ export class AttendanceGenerationService { const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany(); if (!session) return schedules; + // 配置只取一次并传给每次 mapScheduleTimeToSession,避免循环内重复 count+find 查询 + const attendancePeriodConfigs = await this.ensureAttendancePeriodConfigs(); const matchedSchedules: ClassSchedule[] = []; for (const schedule of schedules) { - if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) { + if ((await this.mapScheduleTimeToSession(schedule.startTime, attendancePeriodConfigs)) === session) { matchedSchedules.push(schedule); } } @@ -251,30 +246,39 @@ export class AttendanceGenerationService { } } - await this.attendancePeriodConfigRepo.clear(); - await this.attendancePeriodConfigRepo.save( - normalized.map((period) => this.attendancePeriodConfigRepo.create(period)), - ); + // clear + save 两段写放进同一事务:中途失败整体回滚,避免出现空配置表 + await this.dataSource.transaction(async (manager) => { + await manager.clear(AttendancePeriodConfig); + await manager.save( + manager.create( + AttendancePeriodConfig, + normalized.map((period) => ({ ...period })), + ), + ); + }); return this.getAttendancePeriodConfigs(); } async resetAttendancePeriodConfigs() { - await this.attendancePeriodConfigRepo.clear(); - return this.ensureAttendancePeriodConfigs(); + // clear + 写入默认配置放进同一事务:失败整体回滚,避免残留空配置表 + await this.dataSource.transaction(async (manager) => { + await manager.clear(AttendancePeriodConfig); + await manager.save( + manager.create( + AttendancePeriodConfig, + this.defaultAttendancePeriods.map((period) => ({ ...period, enabled: true })), + ), + ); + }); + return this.getAttendancePeriodConfigs(); } - private mapLessonScheduleTimeToSession(startTime: string): string { - const hour = parseInt(startTime.slice(0, 2), 10); - if (hour < 8) return 'morning_reading'; - if (hour < 12) return 'morning'; - if (hour < 17) return 'afternoon'; - if (hour < 20) return 'evening_study'; - return 'night_check'; - } - - private async mapScheduleTimeToSession(startTime: string): Promise { + private async mapScheduleTimeToSession( + startTime: string, + configs?: AttendancePeriodConfig[], + ): Promise { const startMinutes = toMinutes(startTime); - const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled); + const periods = (configs ?? (await this.ensureAttendancePeriodConfigs())).filter((period) => period.enabled); const matched = periods.find((period) => { const periodStart = toMinutes(period.startTime); const periodEnd = toMinutes(period.endTime); diff --git a/apps/server/src/attendance/attendance-import.service.spec.ts b/apps/server/src/attendance/attendance-import.service.spec.ts index 1789c00..1161da4 100644 --- a/apps/server/src/attendance/attendance-import.service.spec.ts +++ b/apps/server/src/attendance/attendance-import.service.spec.ts @@ -9,7 +9,7 @@ describe('AttendanceImportService', () => { findOne: jest.fn(), save: jest.fn(), }; - const studentRepo = { findOne: jest.fn() }; + const studentRepo = { findOne: jest.fn(), find: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), @@ -17,17 +17,31 @@ describe('AttendanceImportService', () => { const attendanceService = { autoMatchDingRecords: jest.fn(), }; + let runner: { + connect: jest.Mock; + query: jest.Mock; + release: jest.Mock; + }; + const dataSource = { + createQueryRunner: jest.fn(() => runner), + }; let service: AttendanceImportService; beforeEach(() => { jest.clearAllMocks(); + runner = { + connect: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue([{ acquired: 1 }]), + release: jest.fn().mockResolvedValue(undefined), + }; service = new AttendanceImportService( dingRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, attendanceService as unknown as AttendanceService, + dataSource as never, ); }); @@ -118,6 +132,55 @@ describe('AttendanceImportService', () => { ); }); + it('writes checkInTime for OnDuty and checkOutTime for OffDuty only', async () => { + const onDuty = await (service as any).mapToEntity({ + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T08:00:00.000Z', + checkId: 'check-on', + checkType: 'OnDuty', + }); + const offDuty = await (service as any).mapToEntity({ + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T18:00:00.000Z', + checkId: 'check-off', + checkType: 'OffDuty', + }); + + expect(onDuty.checkInTime).toEqual(new Date('2026-07-01T08:00:00.000Z')); + expect(onDuty.checkOutTime).toBeUndefined(); + expect(offDuty.checkOutTime).toEqual(new Date('2026-07-01T18:00:00.000Z')); + expect(offDuty.checkInTime).toBeUndefined(); + }); + + it('does not write checkIn/checkOut times for a non-whitelisted checkType', async () => { + const entity = await (service as any).mapToEntity({ + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T08:00:00.000Z', + checkId: 'check-unknown', + checkType: 'OnDuty/OffDuty', + }); + + // 非法考勤类型不写任何时间,避免污染 checkOutTime/checkInTime;记录本身仍保留 + expect(entity.attendanceType).toBe('OnDuty/OffDuty'); + expect(entity.checkInTime).toBeUndefined(); + expect(entity.checkOutTime).toBeUndefined(); + }); + it('fills the student name from the DingTalk mapping when saving an imported record', async () => { dingTalkService.fetchAttendanceResults.mockResolvedValue([ { @@ -133,8 +196,8 @@ describe('AttendanceImportService', () => { }, ]); dingRawRepo.find.mockResolvedValue([]); - studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 }); - studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' }); + studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 3 }]); + studentRepo.find.mockResolvedValue([{ id: 3, name: '张三' }]); dingRawRepo.save.mockImplementation(async (entities) => entities); await service.importFromDingTalk({ @@ -148,6 +211,11 @@ describe('AttendanceImportService', () => { [expect.objectContaining({ userName: '张三' })], { chunk: 50 }, ); + // 批量预取:只查一次映射 + 一次学生,不再逐条 findOne + expect(studentDingMappingRepo.find).toHaveBeenCalledTimes(1); + expect(studentRepo.find).toHaveBeenCalledTimes(1); + expect(studentDingMappingRepo.findOne).not.toHaveBeenCalled(); + expect(studentRepo.findOne).not.toHaveBeenCalled(); }); @@ -305,4 +373,193 @@ describe('AttendanceImportService', () => { } }); + it('returns success false when a batch save fails', async () => { + dingTalkService.fetchAttendanceResults.mockResolvedValue([ + { + userId: 'ding-1', + userName: '张三', + workDate: '2026-07-01', + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '2026-07-01T08:00:00.000Z', + checkId: 'check-save-fail', + checkType: 'OnDuty', + }, + ]); + dingRawRepo.find.mockResolvedValue([]); + dingRawRepo.save.mockRejectedValue(new Error('database down')); + + const result = await service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + }); + + expect(result.success).toBe(false); + expect(result.imported).toBe(0); + expect(result.errors.some((message) => message.includes('Batch save error'))).toBe(true); + expect(service.running).toBe(false); + }); + + it('cleans up isRunning and importingUserId even when runner.release fails', async () => { + runner.release.mockRejectedValueOnce(new Error('release boom')); + dingTalkService.fetchAttendanceResults.mockResolvedValue([]); + + const result = await service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + userId: 7, + }); + + expect(result.success).toBe(true); + expect(service.running).toBe(false); + expect((service as any).importingUserId).toBeUndefined(); + expect(runner.release).toHaveBeenCalled(); + }); + + it('releases the query runner when connect fails to avoid pool leaks', async () => { + runner.connect.mockRejectedValueOnce(new Error('connect boom')); + + await expect( + service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + }), + ).rejects.toThrow('connect boom'); + + expect(runner.release).toHaveBeenCalledTimes(1); + expect(runner.query).not.toHaveBeenCalled(); + expect(service.running).toBe(false); + }); + + it('watchdog only warns and never releases the DB lock while the import is running', async () => { + jest.useFakeTimers(); + const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); + try { + let releaseImport: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseImport = resolve; + }); + dingTalkService.fetchAttendanceResults.mockImplementation(() => gate.then(() => [])); + + const importPromise = service.importFromDingTalk({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1'], + }); + + await jest.advanceTimersByTimeAsync(30 * 60 * 1000); + + // 看门狗只告警,不执行 RELEASE_LOCK(锁仍由导入结束时释放) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('30 分钟')); + expect( + runner.query.mock.calls.some(([sql]) => String(sql).includes('RELEASE_LOCK')), + ).toBe(false); + + releaseImport!(); + await importPromise; + } finally { + warnSpy.mockRestore(); + jest.useRealTimers(); + } + }); + + it('fetches user×date batches concurrently with a bounded concurrency of 4', async () => { + const userIds = Array.from({ length: 101 }, (_, index) => `user-${index + 1}`); // 3 user batches + // 3 user batches × 2 date ranges = 6 requests + let inFlight = 0; + let maxInFlight = 0; + dingTalkService.fetchAttendanceResults.mockImplementation(async (params) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + return [{ + userId: params.userIds[0], + userName: '', + workDate: params.startDate, + timeResult: 'Normal', + locationResult: '', + planCheckTime: '', + actualCheckTime: '', + checkId: `${params.startDate}-${params.userIds[0]}`, + checkType: 'OnDuty', + }]; + }); + + const results = await (service as any).fetchAllPages({ + startDate: '2026-07-01', + endDate: '2026-07-10', + userIds, + }); + + expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(6); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(4); + expect(results).toHaveLength(6); + // 结果顺序仍为(日期范围 × 用户批次)的原始顺序 + expect(results[0].checkId).toBe('2026-07-01-user-1'); + expect(results[1].checkId).toBe('2026-07-01-user-51'); + expect(results[3].checkId).toBe('2026-07-08-user-1'); + expect(results[4].checkId).toBe('2026-07-08-user-51'); + expect(results[5].checkId).toBe('2026-07-08-user-101'); + }); + + it('chunks dingId dedup lookups into blocks of at most 1000 and merges results', async () => { + const checkIds = Array.from({ length: 1001 }, (_, index) => `check-${index + 1}`); + // 输入含重复 id:先去重再分块,返回 Map 仍按 dingId 去重 + const results = [...checkIds, 'check-1', 'check-500'].map((checkId) => ({ checkId })); + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) => + inValues(where.dingId).map((dingId) => ({ dingId })), + ); + + const map = await (service as any).getExistingRecordsByDingId(results); + + // 1001 个去重后的 id 被切成 1000 + 1 两块并发查询 + expect(dingRawRepo.find).toHaveBeenCalledTimes(2); + const firstChunk = inValues( + (dingRawRepo.find.mock.calls[0]?.[0] as { where: { dingId: unknown } }).where.dingId, + ); + const secondChunk = inValues( + (dingRawRepo.find.mock.calls[1]?.[0] as { where: { dingId: unknown } }).where.dingId, + ); + expect(firstChunk).toHaveLength(1000); + expect(secondChunk).toHaveLength(1); + // 分块结果合并后仍能命中所有 id,且无重复 + expect(map.size).toBe(1001); + expect(map.get('check-1')?.dingId).toBe('check-1'); + expect(map.get('check-1001')?.dingId).toBe('check-1001'); + }); + + it('keeps a single query at the 1000-id chunk boundary', async () => { + const checkIds = Array.from({ length: 1000 }, (_, index) => `check-${index + 1}`); + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + dingRawRepo.find.mockImplementation(async ({ where }: { where: { dingId: unknown } }) => + inValues(where.dingId).map((dingId) => ({ dingId })), + ); + + const map = await (service as any).getExistingRecordsByDingId( + checkIds.map((checkId) => ({ checkId })), + ); + + expect(dingRawRepo.find).toHaveBeenCalledTimes(1); + expect(map.size).toBe(1000); + }); }); + diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts index d0e07cc..02be711 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { DataSource, Repository, In } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import dayjs from '../common/dayjs'; import { @@ -12,6 +12,19 @@ import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingta import { AttendanceService } from './attendance.service'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; +/** 考勤导入 DB 互斥锁名(GET_LOCK/RELEASE_LOCK 共用) */ +const ATTENDANCE_IMPORT_LOCK = 'gongxue:attendance-import'; + +/** + * 钉钉考勤类型白名单(与现有 attendance_type 取值一致)。 + * 只有 OnDuty/OffDuty 能区分签到/签退,才允许写入 checkInTime/checkOutTime; + * 其他类型(如 OnDuty/OffDuty、NotSigned 等)只保留原始值,不写打卡时间。 + */ +const ATTENDANCE_CHECK_TYPE_WHITELIST = ['OnDuty', 'OffDuty']; + +/** 单次 IN(dingIds) 查询最多携带的 id 数:超过该上限按块并发查询,避免无界 IN 列表。 */ +const MAX_DING_IDS_PER_QUERY = 1000; + /** * Service for importing DingTalk attendance data into the system. * @@ -26,7 +39,12 @@ import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dt export class AttendanceImportService { private readonly logger = new Logger(AttendanceImportService.name); - /** RxJS Subject emitting live progress during import */ + /** + * RxJS Subject emitting live progress during import。 + * 注意:这是进程内存态——多实例部署时每个实例各持有一份 Subject, + * SSE 只会收到触发导入的那个实例发出的事件(多实例下进度可能不准,如需准确 + * 应改用 Redis pub/sub 等共享通道,本轮不强改)。 + */ private progressSubject = new Subject(); private isRunning = false; /** ID of the user who triggered the current import (for SSE scoping) */ @@ -40,6 +58,7 @@ export class AttendanceImportService { private readonly studentDingMappingRepo: Repository, private readonly dingTalkService: DingTalkService, private readonly attendanceService: AttendanceService, + private readonly dataSource: DataSource, ) {} /** @@ -77,24 +96,81 @@ export class AttendanceImportService { throw new Error('An import is already in progress'); } - const startedAt = Date.now(); - this.isRunning = true; - this.importingUserId = params.userId; - - // Safety timeout: auto-reset isRunning after 30 minutes in case of - // an unhandled exception that bypasses the finally block (extremely rare). - const SAFETY_TIMEOUT_MS = 30 * 60 * 1000; - const safetyTimer = setTimeout(() => { - if (this.isRunning) { - this.logger.error('Import safety timeout triggered — force-resetting isRunning'); - this.isRunning = false; + // DB 层互斥:用 MySQL advisory lock(GET_LOCK)保证同一时刻只有一个导入在跑, + // 不再依赖内存计时器(计时器超时可能把仍在运行的导入误判为可再次启动)。 + // 锁绑定在同一个 query runner 连接上;进程异常退出时连接关闭会自动释放锁。 + const runner = this.dataSource.createQueryRunner(); + let lockAcquired = false; + let watchdog: NodeJS.Timeout | undefined; + try { + // connect 也放进 try/finally:连接失败时同样 release runner,避免连接池泄漏 + await runner.connect(); + const lockRows = (await runner.query( + `SELECT GET_LOCK('${ATTENDANCE_IMPORT_LOCK}', 0) AS acquired`, + )) as Array<{ acquired: number | string }> | undefined; + lockAcquired = + Array.isArray(lockRows) && + lockRows.length > 0 && + Number((lockRows[0] as { acquired?: unknown })?.acquired) === 1; + if (!lockAcquired) { + throw new Error('An import is already in progress'); } - }, SAFETY_TIMEOUT_MS); + this.isRunning = true; + this.importingUserId = params.userId; + // 兜底看门狗:只告警、不释放锁。若导入仍在运行就主动 RELEASE_LOCK, + // 会让另一个实例误以为锁空闲而并发导入,破坏多实例互斥。 + // 锁只由 finally 中的 RELEASE_LOCK 释放;进程异常退出时连接关闭,MySQL 会自动释放锁。 + watchdog = setTimeout(() => { + this.logger.warn( + '考勤导入超过 30 分钟仍未完成,请检查任务是否卡死(锁会在导入结束或进程退出时自动释放)', + ); + }, 30 * 60 * 1000); + return await this.runImport(params); + } finally { + if (watchdog) clearTimeout(watchdog); + if (lockAcquired) { + try { + await runner.query(`SELECT RELEASE_LOCK('${ATTENDANCE_IMPORT_LOCK}')`); + } catch (releaseError) { + this.logger.warn( + `Failed to release attendance import DB lock: ${String(releaseError)}`, + ); + } + } + // release 失败只告警,不影响 isRunning/importingUserId 的清理 + try { + await runner.release(); + } catch (releaseError) { + this.logger.warn( + `Failed to release attendance import query runner: ${String(releaseError)}`, + ); + } + // 只有成功抢到锁的调用才需要清理运行态;未抢到锁时这些字段从未被设置 + if (lockAcquired) { + this.isRunning = false; + this.importingUserId = undefined; + } + } + } + /** + * The actual import pipeline (fetch → parse → deduplicate → save → auto-match). + * Runs while the caller holds the DB-level attendance-import lock. + */ + private async runImport(params: { + startDate: string; + endDate: string; + userIds?: string[]; + autoMatch?: boolean; + userId?: number; + }): Promise { + const startedAt = Date.now(); const errors: string[] = []; let imported = 0; let skipped = 0; let matched = 0; + // 保存阶段失败的批次数:只要存在保存失败,整体结果即为失败 + let failedSaves = 0; try { this.emit('fetching', 0, 0, 'Fetching attendance results from DingTalk...'); @@ -117,10 +193,12 @@ export class AttendanceImportService { } this.emit('saving', 0, newRecords.length, `Saving ${newRecords.length} records...`); + // 批量预取 dingUserId → 学生姓名(映射+学生各查一次),避免逐条 mapToEntity 时 N+1 + const studentNameByDingUserId = await this.buildStudentNameByDingUserId(newRecords); const batchSize = 100; for (let i = 0; i < newRecords.length; i += batchSize) { const batch = newRecords.slice(i, i + batchSize); - const entities = await Promise.all(batch.map((record) => this.mapToEntity(record))); + const entities = batch.map((record) => this.mapToEntity(record, studentNameByDingUserId)); try { await this.dingRawRepo.save(entities, { chunk: 50 }); imported += entities.length; @@ -128,6 +206,7 @@ export class AttendanceImportService { } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); errors.push(`Batch save error at offset ${i}: ${msg}`); + failedSaves += 1; this.logger.error(`Batch save error: ${msg}`); } } @@ -140,18 +219,14 @@ export class AttendanceImportService { const duration = Date.now() - startedAt; this.emit('complete', imported, rawResults.length, `Import complete: ${imported} new, ${skipped} skipped, ${matched} matched (${duration}ms)`); - this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched`); - return { success: true, imported, skipped, matched, errors, duration }; + this.logger.log(`DingTalk attendance import done: ${imported} imported, ${skipped} skipped, ${matched} matched, ${failedSaves} failed batches`); + return { success: failedSaves === 0, imported, skipped, matched, errors, duration }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); errors.push(msg); this.emit('error', imported, 0, `Import failed: ${msg}`, msg); this.logger.error(`DingTalk attendance import failed: ${msg}`); return { success: false, imported, skipped, matched, errors, duration: Date.now() - startedAt }; - } finally { - clearTimeout(safetyTimer); - this.isRunning = false; - this.importingUserId = undefined; } } @@ -177,24 +252,34 @@ export class AttendanceImportService { const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7); const totalRequests = userBatches.length * dateRanges.length; let completedRequests = 0; + let fetchedCount = 0; - for (const range of dateRanges) { - for (const users of userBatches) { + // 用户×日期批次相互独立,按并发上限 4 批量 Promise.all 拉取; + // 结果仍按(日期范围 × 用户批次)的原始顺序收集,保持返回结构不变。 + const batches = dateRanges.flatMap((range) => + userBatches.map((users) => ({ range, users })), + ); + const results = await this.mapWithConcurrency( + batches, + 4, + async ({ range, users }) => { const batch = await this.dingTalkService.fetchAttendanceResults({ startDate: range.startDate, endDate: range.endDate, userIds: users, }); - allResults.push(...batch); completedRequests++; + fetchedCount += batch.length; this.emit( 'fetching', completedRequests, totalRequests, - `已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`, + `已完成 ${completedRequests}/${totalRequests} 批,获取 ${fetchedCount} 条记录`, ); - } - } + return batch; + }, + ); + for (const batch of results) allResults.push(...batch); return allResults; } @@ -244,17 +329,28 @@ export class AttendanceImportService { /** * Query which dingIds already exist to skip duplicates. + * + * dingIds 去重后按每块 ≤1000 个切成数组,用 Promise.all 并发查询各块再合并, + * 避免无界 IN(dingIds) 触发 MySQL 参数上限/包大小错误;返回 Map 仍按 dingId 去重。 */ private async getExistingRecordsByDingId( results: DingTalkAttendanceResult[], ): Promise> { - const dingIds = results.map((r) => r.checkId).filter(Boolean); + const dingIds = [...new Set(results.map((r) => r.checkId).filter(Boolean))]; if (dingIds.length === 0) return new Map(); - const existing = await this.dingRawRepo.find({ - where: { dingId: In(dingIds) }, - }); - return new Map(existing.map((entity) => [entity.dingId, entity])); + const chunks: string[][] = []; + for (let i = 0; i < dingIds.length; i += MAX_DING_IDS_PER_QUERY) { + chunks.push(dingIds.slice(i, i + MAX_DING_IDS_PER_QUERY)); + } + const chunkResults = await Promise.all( + chunks.map((chunk) => + this.dingRawRepo.find({ + where: { dingId: In(chunk) }, + }), + ), + ); + return new Map(chunkResults.flat().map((entity) => [entity.dingId, entity])); } private async refreshDuplicatePunchMetadata( @@ -288,11 +384,15 @@ export class AttendanceImportService { /** * Map a DingTalk API result to a DingAttendanceRaw entity. + * 学生姓名来自调用前批量预取的 map,避免逐条触发两次查询(N+1)。 */ - private async mapToEntity(r: DingTalkAttendanceResult): Promise { + private mapToEntity( + r: DingTalkAttendanceResult, + studentNameByDingUserId: Map, + ): DingAttendanceRaw { const entity = new DingAttendanceRaw(); entity.dingUserId = r.userId; - entity.userName = r.userName || await this.resolveStudentName(r.userId); + entity.userName = r.userName || studentNameByDingUserId.get(r.userId) || ''; entity.attendanceDate = r.workDate; entity.dingId = r.checkId; entity.attendanceType = r.checkType || 'OnDuty'; @@ -302,7 +402,9 @@ export class AttendanceImportService { entity.punchDeviceName = r.deviceName || null; entity.punchDeviceId = r.deviceId || null; - if (r.actualCheckTime) { + // checkType 白名单校验:非法类型无法确定是签到还是签退,不写时间, + // 避免污染 checkOutTime/checkInTime(记录本身仍保留并计入导入)。 + if (r.actualCheckTime && ATTENDANCE_CHECK_TYPE_WHITELIST.includes(r.checkType)) { const dt = new Date(r.actualCheckTime); if (!isNaN(dt.getTime())) { if (r.checkType === 'OnDuty') { @@ -318,13 +420,64 @@ export class AttendanceImportService { return entity; } - private async resolveStudentName(dingUserId: string): Promise { - const mapping = await this.studentDingMappingRepo.findOne({ - where: { dingUserId }, + /** + * 批量预取 dingUserId → 学生姓名:先一次性查 StudentDingMapping, + * 再一次性查 Student,最后组装成 Map。避免 mapToEntity 逐条两次查询(N+1)。 + */ + private async buildStudentNameByDingUserId( + results: DingTalkAttendanceResult[], + ): Promise> { + const dingUserIds = [ + ...new Set( + results + .filter((record) => !record.userName) + .map((record) => record.userId) + .filter(Boolean), + ), + ]; + if (dingUserIds.length === 0) return new Map(); + + const mappings = await this.studentDingMappingRepo.find({ + where: { dingUserId: In(dingUserIds) }, }); - if (!mapping) return ''; - const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } }); - return student?.name || ''; + if (mappings.length === 0) return new Map(); + + const studentIds = [...new Set(mappings.map((mapping) => mapping.studentId))]; + const students = await this.studentRepo.find({ + where: { id: In(studentIds) }, + }); + const studentNameById = new Map( + students.map((student) => [student.id, student.name]), + ); + const nameByDingUserId = new Map(); + for (const mapping of mappings) { + const name = studentNameById.get(mapping.studentId); + if (name) nameByDingUserId.set(mapping.dingUserId, name); + } + return nameByDingUserId; + } + + /** + * 以固定并发上限执行异步 mapper,返回结果保持输入顺序。 + */ + private async mapWithConcurrency( + items: T[], + limit: number, + mapper: (item: T) => Promise, + ): Promise { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(limit, items.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index]); + } + }), + ); + return results; } /** diff --git a/apps/server/src/attendance/attendance-report.service.spec.ts b/apps/server/src/attendance/attendance-report.service.spec.ts new file mode 100644 index 0000000..be2239d --- /dev/null +++ b/apps/server/src/attendance/attendance-report.service.spec.ts @@ -0,0 +1,76 @@ +import { AttendanceReportService } from './attendance-report.service'; + +describe('AttendanceReportService.getAlerts', () => { + function createService(records: unknown[]) { + const attendanceRepo = { + createQueryBuilder: jest.fn(() => { + const qb: any = { + leftJoinAndSelect: jest.fn(() => qb), + where: jest.fn(() => qb), + andWhere: jest.fn(() => qb), + orderBy: jest.fn(() => qb), + addOrderBy: jest.fn(() => qb), + getMany: jest.fn().mockResolvedValue(records), + }; + return qb; + }), + }; + return new AttendanceReportService(attendanceRepo as never, {} as never); + } + + const absent = (studentId: number, date: string) => ({ + studentId, + status: 'absent', + attendanceDate: date, + student: { name: `S${studentId}` }, + class: { name: '一班' }, + }); + + it('returns the most recent run when consecutive runs have equal length', async () => { + const service = createService([ + absent(1, '2026-07-01'), + absent(1, '2026-07-02'), + absent(1, '2026-07-05'), + absent(1, '2026-07-06'), + ]); + + const alerts = await service.getAlerts(30, 2); + + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ + studentId: 1, + studentName: 'S1', + className: '一班', + type: '缺勤', + count: 2, + lastDate: '2026-07-06', + }); + }); + + it('uses the latest date of the longest run as lastDate', async () => { + const service = createService([ + absent(2, '2026-07-01'), + absent(2, '2026-07-02'), + absent(2, '2026-07-03'), + absent(2, '2026-07-06'), + absent(2, '2026-07-07'), + ]); + + const alerts = await service.getAlerts(30, 2); + + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ count: 3, lastDate: '2026-07-03' }); + }); + + it('does not merge non-consecutive absences into one run', async () => { + const service = createService([ + absent(3, '2026-07-01'), + absent(3, '2026-07-03'), + absent(3, '2026-07-05'), + ]); + + const alerts = await service.getAlerts(30, 3); + + expect(alerts).toHaveLength(0); + }); +}); diff --git a/apps/server/src/attendance/attendance-report.service.ts b/apps/server/src/attendance/attendance-report.service.ts index 72108e3..e9e19d2 100644 --- a/apps/server/src/attendance/attendance-report.service.ts +++ b/apps/server/src/attendance/attendance-report.service.ts @@ -171,27 +171,72 @@ export class AttendanceReportService { lastDate: string; }> = []; - let current: (typeof alerts)[0] | null = null; + // 按「学生 + 状态」聚合记录日期,再做真实的连续日判断: + // 仅按状态/条数统计会把不连续的日子误算成连续缺勤/迟到。 + const grouped = new Map< + string, + { + studentId: number; + studentName: string; + className: string; + status: string; + dates: Set; + } + >(); for (const r of records) { - const name = r.student?.name || ''; - const className = r.class?.name || ''; - const status = r.status === 'absent' ? '缺勤' : '迟到'; - if (current && current.studentId === r.studentId && current.type === status) { - current.count++; - if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate; - } else { - if (current && current.count >= threshold) alerts.push({ ...current }); - current = { + const key = `${r.studentId}|${r.status}`; + let group = grouped.get(key); + if (!group) { + group = { studentId: r.studentId, - studentName: name, - className, - type: status, - count: 1, - lastDate: r.attendanceDate, + studentName: r.student?.name || '', + className: r.class?.name || '', + status: r.status, + dates: new Set(), }; + grouped.set(key, group); + } + group.dates.add(r.attendanceDate); + } + + for (const group of grouped.values()) { + // 按日期倒序聚合:长度相同的连续段取「最近一段」(先遇到者胜出), + // lastDate 指向该段内最新日期,保持重构前的原语义。 + const sortedDates = [...group.dates].sort((a, b) => (a < b ? 1 : -1)); + let bestRun = 0; + let bestRunLastDate = ''; + let run = 1; + let runLastDate = sortedDates[0] || ''; + for (let i = 1; i <= sortedDates.length; i++) { + const date = sortedDates[i]; + if ( + i < sortedDates.length && + dayjs.utc(sortedDates[i - 1]).diff(dayjs.utc(date), 'day') === 1 + ) { + // 倒序连续:runLastDate 保持该段内最早遇到(即最新)的日期 + run++; + } else { + if (run > bestRun) { + bestRun = run; + bestRunLastDate = runLastDate; + } + if (i < sortedDates.length) { + run = 1; + runLastDate = date; + } + } + } + if (bestRun >= threshold) { + alerts.push({ + studentId: group.studentId, + studentName: group.studentName, + className: group.className, + type: group.status === 'absent' ? '缺勤' : '迟到', + count: bestRun, + lastDate: bestRunLastDate, + }); } } - if (current && current.count >= threshold) alerts.push(current); return alerts; } } diff --git a/apps/server/src/attendance/attendance.boundaries.spec.ts b/apps/server/src/attendance/attendance.boundaries.spec.ts index 1dd35a7..7553b62 100644 --- a/apps/server/src/attendance/attendance.boundaries.spec.ts +++ b/apps/server/src/attendance/attendance.boundaries.spec.ts @@ -25,6 +25,22 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => { ...periodConfigRepoOverrides, }; + // saveAttendancePeriodConfigs/resetAttendancePeriodConfigs 现在在事务内写库: + // 事务 manager 的 clear/create/save 委托给同一个 periodConfigRepo mock, + // 保证原有断言(如 savedPeriods 捕获)仍然成立。 + const transactionManager = { + clear: jest.fn().mockImplementation(() => periodConfigRepo.clear()), + create: jest.fn().mockImplementation((_entity: unknown, data: unknown) => + Array.isArray(data) ? data : periodConfigRepo.create(data), + ), + save: jest.fn().mockImplementation((entities: unknown) => periodConfigRepo.save(entities)), + }; + const dataSource = { + transaction: jest.fn(async (cb: (manager: unknown) => Promise) => + cb(transactionManager), + ), + }; + return new AttendanceService( {} as never, // attendanceRepo {} as never, // dingRawRepo @@ -38,7 +54,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => { {} as never, // attendanceSessionRepo {} as never, // attendanceDeviceRepo periodConfigRepo as never, - {} as never, // dataSource + dataSource as never, ); } diff --git a/apps/server/src/bills/bills-generation.service.spec.ts b/apps/server/src/bills/bills-generation.service.spec.ts new file mode 100644 index 0000000..282090c --- /dev/null +++ b/apps/server/src/bills/bills-generation.service.spec.ts @@ -0,0 +1,441 @@ +import { BillsGenerationService } from './bills-generation.service'; +import type { GenerateBillsDto } from './dto/bill.dto'; +import { Bill, RoomExpense, PersonalExpense, Occupancy } from '../entities'; + +function mockQueryBuilder(results: T[]) { + return { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(results), + }; +} + +function createService(existingPeriodBills: Array> = []) { + const lockQb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(existingPeriodBills), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(lockQb), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: Record) => ({ id: 1, ...value })), + // advisory lock:默认首请求可拿到锁,RELEASE_LOCK 直接成功 + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), + }; + const dataSource = { transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)) }; + const roomExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const occRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const personalExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const service = new BillsGenerationService( + roomExpRepo as never, + personalExpRepo as never, + occRepo as never, + dataSource as never, + { debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never, + ); + return { service, dataSource, manager, lockQb, roomExpRepo, occRepo, personalExpRepo }; +} + +const PERIOD = { periodStart: '2026-06-01', periodEnd: '2026-06-30' } as GenerateBillsDto; + +describe('BillsGenerationService — 周期内重复生成并发防重', () => { + it('rejects duplicate generation inside the transaction with a locked re-check', async () => { + const { service, dataSource, manager, lockQb } = createService([ + { id: 1, periodStart: '2026-06-01', periodEnd: '2026-06-30' }, + ]); + + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成'); + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write'); + expect(lockQb.where).toHaveBeenCalledWith('b.periodStart = :periodStart', { + periodStart: '2026-06-01', + }); + expect(lockQb.andWhere).toHaveBeenCalledWith('b.periodEnd = :periodEnd', { + periodEnd: '2026-06-30', + }); + expect(lockQb.getMany).toHaveBeenCalledTimes(1); + expect(manager.save).not.toHaveBeenCalled(); + }); + + it('still performs the locked re-check and generates bills when the period is free', async () => { + const { service, dataSource, manager, lockQb, roomExpRepo, occRepo } = createService(); + (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ); + (occRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]), + ); + + const result = await service.generateBillsOnce(PERIOD); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(lockQb.setLock).toHaveBeenCalledWith('pessimistic_write'); + expect(lockQb.getMany).toHaveBeenCalledTimes(1); + expect(manager.save).toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + message: '成功生成 1 条账单', + count: 1, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + }), + ); + }); + + it('loads occupancies for all rooms in one batched query (no per-room N+1)', async () => { + const { service, manager, occRepo, roomExpRepo } = createService(); + (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + { + id: 2, + roomId: 2, + expenseType: 'water', + amount: 200, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ); + const batchQb = mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + { + id: 2, + roomId: 2, + studentId: 11, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]); + // 第 1 次:roomIds 聚合查询(长租 active 入住);第 2 次:全房间批量入住查询 + (occRepo.createQueryBuilder as jest.Mock) + .mockReturnValueOnce(mockQueryBuilder([])) + .mockReturnValueOnce(batchQb); + + const result = await service.generateBillsOnce(PERIOD); + + // 入住查询只发两次:roomIds 聚合 1 次 + roomIds 批量查询 1 次(不再按房间循环 N+1) + expect(occRepo.createQueryBuilder).toHaveBeenCalledTimes(2); + expect(batchQb.where).toHaveBeenCalledWith('o.roomId IN (:...roomIds)', { + roomIds: [1, 2], + }); + expect(batchQb.andWhere).toHaveBeenCalledWith('o.billingStartDate <= :periodEnd', { + periodEnd: '2026-06-30', + }); + expect(batchQb.andWhere).toHaveBeenCalledWith( + '(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', + { periodStart: '2026-06-01' }, + ); + // 内存分组后仍按原逻辑为每个房间的学生生成账单 + expect(manager.save).toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + message: '成功生成 2 条账单', + count: 2, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + }), + ); + }); + + it('only one of two concurrent requests succeeds; the second sees committed bills and rejects', async () => { + const committed: Array> = []; + const lockQb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockImplementation(async () => committed), + }; + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(lockQb), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: Record) => { + if ('studentId' in value && 'periodStart' in value) { + const saved = { id: committed.length + 1, ...value }; + committed.push(saved); + return saved; + } + return { id: 1, ...value }; + }), + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const roomExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ), + }; + const occRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]), + ), + }; + const personalExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const service = new BillsGenerationService( + roomExpRepo as never, + personalExpRepo as never, + occRepo as never, + dataSource as never, + { debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never, + ); + + const first = await service.generateBillsOnce(PERIOD); + expect(first.count).toBe(1); + expect(committed).toHaveLength(1); + + // 第二个并发请求在锁释放后能看到第一个请求已提交的账单,直接抛「账单已生成」 + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('账单已生成'); + expect(committed).toHaveLength(1); + }); + + it('throws ConflictException when the advisory lock cannot be acquired in time', async () => { + const { service, manager } = createService(); + (manager.query as jest.Mock).mockResolvedValue([{ acquired: 0 }]); + + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成'); + expect(manager.query).toHaveBeenCalledWith( + "SELECT GET_LOCK('gongxue:bills-gen:2026-06-01-2026-06-30', 5) AS acquired", + ); + // 未拿到锁:不进入重复检查与写入 + expect(manager.save).not.toHaveBeenCalled(); + }); + + it('only one of two truly concurrent first-time requests succeeds (advisory lock)', async () => { + const committed: Array> = []; + let lockHeld = false; + let markSecondArrived!: () => void; + const secondArrived = new Promise((resolve) => { + markSecondArrived = resolve; + }); + let releaseFirstSave!: () => void; + + const lockQb = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockImplementation(async () => committed), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(lockQb), + create: jest.fn((_entity: unknown, value: unknown) => value), + save: jest.fn(async (value: Record) => { + if ('studentId' in value && 'periodStart' in value) { + // 第一个请求已持有锁并开始写账单,此时放第二个并发请求进来抢同一把锁 + markSecondArrived(); + await new Promise((resolve) => { + releaseFirstSave = resolve; + }); + const saved = { id: committed.length + 1, ...value }; + committed.push(saved); + return saved; + } + return { id: 1, ...value }; + }), + query: jest.fn(async (sql: string) => { + if (sql.includes('GET_LOCK')) { + if (lockHeld) return [{ acquired: 0 }]; + lockHeld = true; + return [{ acquired: 1 }]; + } + if (sql.includes('RELEASE_LOCK')) { + lockHeld = false; + return [{ released: 1 }]; + } + return []; + }), + }; + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb(manager)), + }; + const roomExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + expenseType: 'water', + amount: 100, + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + } as RoomExpense, + ]), + ), + }; + const occRepo = { + createQueryBuilder: jest.fn().mockReturnValue( + mockQueryBuilder([ + { + id: 1, + roomId: 1, + studentId: 10, + stayType: 'short', + billingStartDate: '2026-06-01', + billingEndDate: '2026-06-30', + } as Occupancy, + ]), + ), + }; + const personalExpRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + }; + const service = new BillsGenerationService( + roomExpRepo as never, + personalExpRepo as never, + occRepo as never, + dataSource as never, + { debitBill: jest.fn(async (_manager: unknown, bill: Bill) => bill) } as never, + ); + + const firstPromise = service.generateBillsOnce(PERIOD); + await secondArrived; + + // 第二个并发请求在第一个请求持有锁期间尝试获取同一把锁 → 超时/被占用 → ConflictException + await expect(service.generateBillsOnce(PERIOD)).rejects.toThrow('正在生成'); + expect(committed).toHaveLength(0); + + releaseFirstSave(); + const first = await firstPromise; + expect(first.count).toBe(1); + expect(committed).toHaveLength(1); + expect(lockHeld).toBe(false); + }); + + it('saves all BillItems of a bill in one batched save', async () => { + const { service, manager, personalExpRepo } = createService(); + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 9, + studentId: 10, + roomId: 1, + expenseType: 'meal', + description: '三餐', + amount: 50, + expenseDate: '2026-06-05', + status: 'active', + } as PersonalExpense, + ]), + ); + + await service.generateBillsOnce(PERIOD); + + const arraySaves = (manager.save as jest.Mock).mock.calls.filter( + ([value]) => Array.isArray(value), + ); + expect(arraySaves).toHaveLength(1); + expect(arraySaves[0][0]).toHaveLength(1); + expect(arraySaves[0][0][0]).toEqual( + expect.objectContaining({ + billId: 1, + personalExpenseId: 9, + expenseType: 'meal', + studentAmount: 50, + }), + ); + }); + + it('rounds personalAmount to two decimals before saving', async () => { + const { service, manager, personalExpRepo } = createService(); + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( + mockQueryBuilder([ + { + id: 10, + studentId: 10, + roomId: 1, + expenseType: 'meal', + description: '三餐', + amount: 0.30000000000000004, + expenseDate: '2026-06-05', + status: 'active', + } as PersonalExpense, + ]), + ); + + await service.generateBillsOnce(PERIOD); + + const billSave = (manager.save as jest.Mock).mock.calls.find( + ([value]) => !Array.isArray(value) && value?.studentId === 10, + ); + expect(billSave).toBeDefined(); + expect(billSave![0]).toEqual( + expect.objectContaining({ + personalAmount: 0.3, + totalAmount: 0.3, + }), + ); + }); +}); diff --git a/apps/server/src/bills/bills-generation.service.ts b/apps/server/src/bills/bills-generation.service.ts index 5861fd2..2069644 100644 --- a/apps/server/src/bills/bills-generation.service.ts +++ b/apps/server/src/bills/bills-generation.service.ts @@ -1,20 +1,44 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; -import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities'; +import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy } from '../entities'; import { WalletsService } from '../wallets/wallets.service'; import type { GenerateBillsDto } from './dto/bill.dto'; import dayjs from '../common/dayjs'; +/** 把 TypeORM 可能 hydrate 成 Date 的 date-only 字段归一化为 YYYY-MM-DD 字符串。 */ +function toDateOnly(value: Date | string | null | undefined): string { + if (value == null) return ''; + if (value instanceof Date) { + const y = value.getFullYear(); + const m = String(value.getMonth() + 1).padStart(2, '0'); + const d = String(value.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + return String(value); +} + +/** YYYY-MM-DD 字符串(字典序即时间序)取较大/较小者。 */ +function maxDateOnly(a: string, b: string): string { + return a > b ? a : b; +} +function minDateOnly(a: string, b: string): string { + return a < b ? a : b; +} + +/** YYYY-MM-DD 字符串相差的天数(a 晚于 b 返回负值)。 */ +function daysBetweenDateOnly(a: string, b: string): number { + const [ay, am, ad] = a.split('-').map(Number); + const [by, bm, bd] = b.split('-').map(Number); + return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000); +} + @Injectable() export class BillsGenerationService { constructor( - @InjectRepository(Bill) private billRepo: Repository, - @InjectRepository(BillItem) private itemRepo: Repository, @InjectRepository(RoomExpense) private roomExpRepo: Repository, @InjectRepository(PersonalExpense) private personalExpRepo: Repository, @InjectRepository(Occupancy) private occRepo: Repository, - @InjectRepository(Room) private roomRepo: Repository, private dataSource: DataSource, private walletsService: WalletsService, ) {} @@ -26,14 +50,6 @@ export class BillsGenerationService { if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { throw new BadRequestException('账单周期无效,结束日期不能早于开始日期'); } - const pStart = new Date(`${periodStart}T00:00:00Z`); - const pEnd = new Date(`${periodEnd}T00:00:00Z`); - const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); - if (existingBills.length > 0) { - throw new BadRequestException( - `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`, - ); - } const roomExpenses = await this.roomExpRepo .createQueryBuilder('e') .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { @@ -42,7 +58,16 @@ export class BillsGenerationService { }) .andWhere('e.status = :status', { status: 'active' }) .getMany(); - const longTermOccupancies: Occupancy[] = []; + // 长租入住:即使本周期没有费用记录,也按长租计费纳入开票范围 + const longTermOccupancies: Occupancy[] = await this.occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('o.stayType = :stayType', { stayType: 'long' }) + .andWhere('o.status = :status', { status: 'active' }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) + .getMany(); const roomExpMap = new Map(); for (const expense of roomExpenses) { const expenses = roomExpMap.get(expense.roomId) || []; @@ -60,16 +85,36 @@ export class BillsGenerationService { { shared: number; items: Array> } >(); + // 一次性按 roomIds In(...) 查询本周期内全部在住/曾住的入住记录,内存按 roomId 分组, + // 避免对每个房间循环 createQueryBuilder(...).getMany()(N+1)。 + // 有意不按 o.status 过滤:这里要查这些房间在本周期内全部在住/曾住的入住记录来分摊费用, + // 退宿后归档的入住记录只要 billingStartDate/billingEndDate 覆盖本周期仍应参与分摊 + // (与 occupancy-operations.getRoomOccupanciesInPeriod 的计费语义一致)。 + // status='active' 过滤只用于上面 roomIds 聚合(决定哪些房间进入开票范围),不用于分摊查询。 + const roomIdList = [...roomIds]; + const allOccupancies: Occupancy[] = + roomIdList.length > 0 + ? await this.occRepo + .createQueryBuilder('o') + .leftJoinAndSelect('o.student', 'student') + .leftJoinAndSelect('o.room', 'room') + .where('o.roomId IN (:...roomIds)', { roomIds: roomIdList }) + .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) + .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { + periodStart, + }) + .getMany() + : []; + const occupanciesByRoom = new Map(); + for (const occupancy of allOccupancies) { + const list = occupanciesByRoom.get(occupancy.roomId) || []; + list.push(occupancy); + occupanciesByRoom.set(occupancy.roomId, list); + } + for (const roomId of roomIds) { const expenses = roomExpMap.get(roomId) || []; - const occupancies = await this.occRepo - .createQueryBuilder('o') - .leftJoinAndSelect('o.student', 'student') - .leftJoinAndSelect('o.room', 'room') - .where('o.roomId = :roomId', { roomId }) - .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) - .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) - .getMany(); + const occupancies = occupanciesByRoom.get(roomId) || []; const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long'); const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long'); @@ -95,21 +140,21 @@ export class BillsGenerationService { studentBillData.set(occupancy.studentId, data); } + // 日期统一用 YYYY-MM-DD 字符串比较/运算,避免 TypeORM 把 date 列 hydrate 成 Date 后比较不可靠 const studentDays = shortTermOccs.map((occupancy) => { - const start = new Date( - Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()), - ); - const end = occupancy.billingEndDate - ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime())) - : pEnd; - const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); + const startStr = toDateOnly(occupancy.billingStartDate); + const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd; + if (!startStr) return { studentId: occupancy.studentId, days: 0 }; + const start = maxDateOnly(startStr, periodStart); + const end = minDateOnly(endStr, periodEnd); + const days = end < start ? 0 : daysBetweenDateOnly(start, end) + 1; return { studentId: occupancy.studentId, days }; }); const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); if (totalDays === 0) continue; + const eligibleDays = studentDays.filter((entry) => entry.days > 0); for (const expense of expenses) { - const eligibleDays = studentDays.filter((entry) => entry.days > 0); const expenseTotal = Number(Number(expense.amount).toFixed(2)); let allocated = 0; for (const [index, entry] of eligibleDays.entries()) { @@ -167,44 +212,81 @@ export class BillsGenerationService { const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); const bills = await this.dataSource.transaction(async (manager) => { - const generated: Bill[] = []; - for (const studentId of allStudentIds) { - const shared = studentBillData.get(studentId)?.shared || 0; - const personal = personalMap.get(studentId) || 0; - const total = Number((shared + personal).toFixed(2)); - let bill = await manager.save( - manager.create(Bill, { - studentId, - periodStart, - periodEnd, - sharedAmount: Number(shared.toFixed(2)), - personalAmount: personal, - totalAmount: total, - source: 'batch', - paidAmount: 0, - outstandingAmount: total, - status: 'unpaid', - }), + // 首次生成的并发防护:FOR UPDATE 对不存在的行不加锁,两个并发请求都能通过检查; + // 这里在事务内先拿按周期 key 的 MySQL advisory lock(GET_LOCK 对不存在的 key 同样生效), + // 失败/超时直接抛 ConflictException,重复检查放进锁内。financialOperations.run 只覆盖 + // 带 operationId 的幂等场景,无 operationId 的并发首次生成靠这把锁兜底。 + const lockName = `gongxue:bills-gen:${periodStart}-${periodEnd}`; + const lockRows = (await manager.query( + `SELECT GET_LOCK('${lockName}', 5) AS acquired`, + )) as unknown as Array<{ acquired?: unknown }> | undefined; + if (Number(lockRows?.[0]?.acquired ?? 0) !== 1) { + throw new ConflictException( + `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单正在生成中,请勿重复提交`, ); - const items = [ - ...(studentBillData.get(studentId)?.items || []), - ...(personalItems.get(studentId) || []), - ]; - for (const item of items) - await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); - const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); - if (includedPersonal.length) { - await manager - .createQueryBuilder() - .update(PersonalExpense) - .set({ billId: bill.id }) - .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) - .execute(); - } - bill = await this.walletsService.debitBill(manager, bill); - generated.push(bill); } - return generated; + try { + // 周期内重复生成检查移进事务内,并对同一 (periodStart, periodEnd) 加锁复查: + // 并发请求会在锁释放后看到已提交的账单并抛「账单已生成」,保证只有一个请求成功。 + const existingBills = await manager + .createQueryBuilder(Bill, 'b') + .setLock('pessimistic_write') + .where('b.periodStart = :periodStart', { periodStart }) + .andWhere('b.periodEnd = :periodEnd', { periodEnd }) + .getMany(); + if (existingBills.length > 0) { + throw new BadRequestException( + `${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`, + ); + } + const generated: Bill[] = []; + for (const studentId of allStudentIds) { + const shared = studentBillData.get(studentId)?.shared || 0; + const personal = personalMap.get(studentId) || 0; + const total = Number((shared + personal).toFixed(2)); + let bill = await manager.save( + manager.create(Bill, { + studentId, + periodStart, + periodEnd, + sharedAmount: Number(shared.toFixed(2)), + personalAmount: Number(personal.toFixed(2)), + totalAmount: total, + source: 'batch', + paidAmount: 0, + outstandingAmount: total, + status: 'unpaid', + }), + ); + const items = [ + ...(studentBillData.get(studentId)?.items || []), + ...(personalItems.get(studentId) || []), + ]; + // 逐条 save 改为批量一次保存,减少往返 + if (items.length > 0) { + await manager.save( + manager.create( + BillItem, + items.map((item) => ({ ...item, billId: bill.id })), + ), + ); + } + const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); + if (includedPersonal.length) { + await manager + .createQueryBuilder() + .update(PersonalExpense) + .set({ billId: bill.id }) + .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) + .execute(); + } + bill = await this.walletsService.debitBill(manager, bill); + generated.push(bill); + } + return generated; + } finally { + await manager.query(`SELECT RELEASE_LOCK('${lockName}')`); + } }); return { message: `成功生成 ${bills.length} 条账单`, @@ -222,12 +304,10 @@ export class BillsGenerationService { periodEnd: string, monthlyRate: number, ) { - const activeStart = - occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart; - const activeEnd = - occupancy.billingEndDate && occupancy.billingEndDate < periodEnd - ? occupancy.billingEndDate - : periodEnd; + const startStr = toDateOnly(occupancy.billingStartDate); + const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd; + const activeStart = startStr > periodStart ? startStr : periodStart; + const activeEnd = endStr && endStr < periodEnd ? endStr : periodEnd; if (activeEnd < activeStart || monthlyRate <= 0) return 0; const [startYear, startMonth] = activeStart.split('-').map(Number); const [endYear, endMonth] = activeEnd.split('-').map(Number); diff --git a/apps/server/src/bills/bills.controller.spec.ts b/apps/server/src/bills/bills.controller.spec.ts new file mode 100644 index 0000000..00fd058 --- /dev/null +++ b/apps/server/src/bills/bills.controller.spec.ts @@ -0,0 +1,196 @@ +import { BillsController } from './bills.controller'; +import { NotificationType } from '../entities/notification.entity'; + +describe('BillsController — 通知批量预取学生', () => { + function createController(options: { + bills?: Array>; + students?: Array>; + } = {}) { + const { bills = [], students = [] } = options; + const service = { + generateBills: jest.fn().mockResolvedValue({ + periodStart: '2026-06-01', + periodEnd: '2026-06-30', + count: bills.length, + bills, + }), + batchUpdateStatus: jest.fn().mockResolvedValue({}), + }; + const studentRepo = { + find: jest.fn().mockResolvedValue(students), + findOne: jest.fn(), + }; + const billRepo = { findBy: jest.fn().mockResolvedValue(bills) }; + const notificationsService = { + create: jest.fn().mockResolvedValue(undefined), + }; + const logService = { log: jest.fn().mockResolvedValue(undefined) }; + const controller = new BillsController( + service as never, + {} as never, + logService as never, + notificationsService as never, + studentRepo as never, + billRepo as never, + ); + return { controller, service, studentRepo, billRepo, notificationsService }; + } + + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + + it('batches student lookup before sending bill_generated notifications', async () => { + const { controller, studentRepo, notificationsService } = createController({ + bills: [ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 10, totalAmount: 80, status: 'unpaid' }, + { id: 3, studentId: 11, totalAmount: 50, status: 'unpaid' }, + ], + students: [ + { id: 10, userId: 100 }, + { id: 11, userId: null }, + ], + }); + + await controller.generateBills({} as never, req as never); + + expect(studentRepo.find).toHaveBeenCalledTimes(1); + const where = (studentRepo.find as jest.Mock).mock.calls[0][0].where; + expect(where.id.value).toEqual([10, 11]); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + expect(notificationsService.create).toHaveBeenCalledTimes(2); + expect(notificationsService.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + recipientIds: [100], + type: NotificationType.BILL_GENERATED, + title: '新账单', + }), + ); + }); + + it('notifies only paid bills in batch status update with a single student query', async () => { + const { controller, studentRepo, notificationsService, billRepo } = createController({ + bills: [ + { id: 1, studentId: 10, totalAmount: 100, status: 'paid' }, + { id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' }, + { id: 3, studentId: 10, totalAmount: 80, status: 'paid' }, + ], + students: [{ id: 10, userId: 100 }], + }); + // 更新前状态:1/2/3 都未付款;更新后 1/3 变为 paid → 只通知 1/3(幂等重确认不重复通知) + billRepo.findBy.mockResolvedValueOnce([ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 11, totalAmount: 50, status: 'unpaid' }, + { id: 3, studentId: 10, totalAmount: 80, status: 'unpaid' }, + ]); + + await controller.batchUpdateStatus({ ids: [1, 2, 3], status: 'paid' }, req as never); + + expect(studentRepo.find).toHaveBeenCalledTimes(1); + expect(notificationsService.create).toHaveBeenCalledTimes(2); + expect(notificationsService.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + recipientIds: [100], + type: NotificationType.BILL_PAID, + title: '账单已确认', + content: expect.stringContaining('#1'), + }), + ); + expect(notificationsService.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ content: expect.stringContaining('#3') }), + ); + }); + + it('a notification failure does not stop notifications for other bills', async () => { + let call = 0; + const notificationsService = { + create: jest.fn().mockImplementation(() => { + call += 1; + if (call === 1) return Promise.reject(new Error('notify down')); + return Promise.resolve(undefined); + }), + }; + const studentRepo = { + find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]), + findOne: jest.fn(), + }; + const billRepo = { + findBy: jest.fn() + .mockResolvedValueOnce([ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' }, + ]) + .mockResolvedValue([ + { id: 1, studentId: 10, totalAmount: 100, status: 'paid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'paid' }, + ]), + }; + const service = { + batchUpdateStatus: jest.fn().mockResolvedValue({}), + generateBills: jest.fn(), + }; + const controller = new BillsController( + service as never, + {} as never, + { log: jest.fn().mockResolvedValue(undefined) } as never, + notificationsService as never, + studentRepo as never, + billRepo as never, + ); + + await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never); + + expect(notificationsService.create).toHaveBeenCalledTimes(2); + }); + + it('logs notification failures instead of swallowing them silently', async () => { + let call = 0; + const notificationsService = { + create: jest.fn().mockImplementation(() => { + call += 1; + if (call === 1) return Promise.reject(new Error('notify down')); + return Promise.resolve(undefined); + }), + }; + const studentRepo = { + find: jest.fn().mockResolvedValue([{ id: 10, userId: 100 }]), + findOne: jest.fn(), + }; + const billRepo = { + findBy: jest.fn() + .mockResolvedValueOnce([ + { id: 1, studentId: 10, totalAmount: 100, status: 'unpaid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'unpaid' }, + ]) + .mockResolvedValue([ + { id: 1, studentId: 10, totalAmount: 100, status: 'paid' }, + { id: 2, studentId: 10, totalAmount: 50, status: 'paid' }, + ]), + }; + const service = { + batchUpdateStatus: jest.fn().mockResolvedValue({}), + generateBills: jest.fn(), + }; + const controller = new BillsController( + service as never, + {} as never, + { log: jest.fn().mockResolvedValue(undefined) } as never, + notificationsService as never, + studentRepo as never, + billRepo as never, + ); + const warnSpy = jest.spyOn((controller as any).logger, 'warn').mockImplementation(() => undefined); + + try { + await controller.batchUpdateStatus({ ids: [1, 2], status: 'paid' }, req as never); + + // Promise.allSettled:失败通知不阻断其他通知,且失败被记录到日志 + expect(notificationsService.create).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('notify down')); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index 746cdc7..f8e748b 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -12,6 +12,7 @@ import { Res, Req, ParseIntPipe, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; @@ -21,7 +22,13 @@ import { NotificationType } from '../entities/notification.entity'; import { Student } from '../entities/student.entity'; import { Bill } from '../entities/bill.entity'; import { BillsExportService } from './bills-export.service'; -import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; +import { + BatchIdsBodyDto, + CancelBillDto, + GenerateBillsDto, + UpdateBillStatusDto, +} from './dto/bill.dto'; +import { BatchIdsDto } from '../common/batch-ids.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; @@ -38,6 +45,8 @@ interface AuthenticatedRequest { @UseGuards(JwtAuthGuard) @Controller('bills') export class BillsController { + private readonly logger = new Logger(BillsController.name); + constructor( private service: BillsService, private exportService: BillsExportService, @@ -56,18 +65,20 @@ export class BillsController { }); // Send bill_generated notifications try { - for (const bill of result.bills) { - const student = await this.studentRepo.findOne({ where: { id: bill.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: NotificationType.BILL_GENERATED, - title: '新账单', - content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`, - }); - } - } - } catch (_) { /* don't block response */ } + await this.sendPaidNotifications( + this.studentRepo, + this.notificationsService, + result.bills, + (bill) => ({ + type: NotificationType.BILL_GENERATED, + title: '新账单', + content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`, + }), + ); + } catch (err) { + // 预取/通知失败不影响主流程,但记录日志避免静默吞错 + this.logger.warn(`账单生成通知发送失败: ${String(err)}`); + } return result; } @@ -87,113 +98,6 @@ export class BillsController { }); } - @Get(':id') - @RequirePermission('bill:view') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.service.findOne(id); - } - - @Put(':id/status') - @RequirePermission('bill:confirm') - async updateStatus( - @Param('id', ParseIntPipe) id: number, - @Body() dto: UpdateBillStatusDto, - @Request() req: AuthenticatedRequest, - ) { - const result = await this.service.updateStatus(id, dto); - await logAudit(this.logService, req, { - module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill', - }); - // Send bill_paid notification - try { - const student = await this.studentRepo.findOne({ where: { id: result.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: NotificationType.BILL_PAID, - title: '账单已确认', - content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`, - }); - } - } catch (_) { /* don't block response */ } - return result; - } - - @Put('batch/status') - @RequirePermission('bill:confirm') - async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: AuthenticatedRequest) { - const result = await this.service.batchUpdateStatus(body.ids, body.status); - await logAudit(this.logService, req, { - module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`, - }); - // Send bill_paid notifications (batch) - try { - const bills = await this.billRepo.findBy({ id: In(body.ids) }); - for (const bill of bills) { - const student = await this.studentRepo.findOne({ where: { id: bill.studentId } }); - if (student?.userId) { - void this.notificationsService.create({ - recipientIds: [student.userId], - type: NotificationType.BILL_PAID, - title: '账单已确认', - content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`, - }); - } - } - } catch (_) { /* don't block response */ } - return result; - } - - @Post(':id/cancel') - @RequirePermission('bill:delete') - async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) { - const result = await this.service.cancel(id, dto, req.user?.id); - await logAudit(this.logService, req, { - module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason, - }); - return result; - } - - @Delete(':id') - @RequirePermission('bill:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const result = await this.service.remove(id); - await logAudit(this.logService, req, { - module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', - }); - return result; - } - - @Delete(':id/permanent') - @RequirePermission('bill:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { - const result = await this.service.purge(id); - await logAudit(this.logService, req, { - module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', - }); - return result; - } - - @Post('batch-permanent-delete') - @RequirePermission('bill:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { - const result = await this.service.batchPurge(body.ids || []); - await logAudit(this.logService, req, { - module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, - }); - return result; - } - - @Post('batch/delete') - @RequirePermission('bill:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { - const result = await this.service.batchRemove(body.ids); - await logAudit(this.logService, req, { - module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`, - }); - return result; - } - @Get('export/excel') @RequirePermission('bill:export-excel') async exportExcel( @@ -226,4 +130,157 @@ export class BillsController { }); return this.exportService.exportStudentPdf(id, res); } + + @Get(':id') + @RequirePermission('bill:view') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.service.findOne(id); + } + + @Put('batch/status') + @RequirePermission('bill:confirm') + async batchUpdateStatus(@Body() body: BatchIdsBodyDto, @Request() req: AuthenticatedRequest) { + const ids = body.ids || []; + // 更新前先取当前状态:只对「本次从非 paid 变为 paid」的账单发通知,幂等重确认不重复打扰 + const before = await this.billRepo.findBy({ id: In(ids) }); + const paidBefore = new Set( + before.filter((bill) => bill.status === 'paid').map((bill) => bill.id), + ); + const result = await this.service.batchUpdateStatus(ids, body.status); + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', detail: `IDs: ${ids.join(',')}`, + }); + // Send bill_paid notifications (batch):DB 查询失败正常抛出,只有通知失败被吞(记日志) + const bills = await this.billRepo.findBy({ id: In(ids) }); + const paidBills = bills.filter((bill) => bill.status === 'paid' && !paidBefore.has(bill.id)); + try { + await this.sendPaidNotifications(this.studentRepo, this.notificationsService, paidBills); + } catch (err) { + this.logger.warn(`账单确认通知发送失败: ${String(err)}`); + } + return result; + } + + @Put(':id/status') + @RequirePermission('bill:confirm') + async updateStatus( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateBillStatusDto, + @Request() req: AuthenticatedRequest, + ) { + const prior = await this.billRepo.findOne({ where: { id } }); + const result = await this.service.updateStatus(id, dto); + await logAudit(this.logService, req, { + module: '账单管理', action: '确认账单', targetId: id, targetType: 'bill', + }); + // 仅当本次从非 paid 变为 paid 才发通知(幂等),复用批量通知 helper + if (result.status === 'paid' && prior?.status !== 'paid') { + try { + await this.sendPaidNotifications(this.studentRepo, this.notificationsService, [result]); + } catch (err) { + this.logger.warn(`账单已确认通知发送失败: ${String(err)}`); + } + } + return result; + } + + @Post(':id/cancel') + @RequirePermission('bill:delete') + async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.cancel(id, dto, req.user?.id); + await logAudit(this.logService, req, { + module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', + detail: Array.from(dto.reason ?? '') + .map((ch) => (ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127 ? ' ' : ch)) + .join('') + .trim(), + }); + return result; + } + + @Delete(':id') + @RequirePermission('bill:delete') + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + const result = await this.service.remove(id); + await logAudit(this.logService, req, { + module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', + }); + return result; + } + + @Delete(':id/permanent') + @RequirePermission('bill:purge') + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + const result = await this.service.purge(id); + await logAudit(this.logService, req, { + module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', + }); + return result; + } + + @Post('batch-permanent-delete') + @RequirePermission('bill:purge') + async batchPurge(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.batchPurge(body.ids || []); + await logAudit(this.logService, req, { + module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, + }); + return result; + } + + @Post('batch/delete') + @RequirePermission('bill:delete') + async batchRemove(@Body() body: BatchIdsDto, @Request() req: AuthenticatedRequest) { + const ids = body.ids || []; + const result = await this.service.batchRemove(ids); + await logAudit(this.logService, req, { + module: '账单管理', action: '批量归档账单', detail: `IDs: ${ids.join(',')}`, + }); + return result; + } + + /** + * 批量预取学生并并发发送账单通知(generateBills/batchUpdateStatus 共用)。 + * 内部用 Promise.allSettled 并发发送:单个通知失败只记录日志、不影响其他通知与主流程。 + * notificationFor 缺省时发送「已确认收款」通知(batchUpdateStatus 场景)。 + */ + private async sendPaidNotifications( + studentRepo: Repository, + notificationsService: NotificationsService, + bills: Bill[], + notificationFor: ( + bill: Bill, + ) => { type: NotificationType; title: string; content: string } = (bill) => ({ + type: NotificationType.BILL_PAID, + title: '账单已确认', + content: `账单 #${bill.id} 已确认收款,金额: ¥${bill.totalAmount}`, + }), + ): Promise { + if (bills.length === 0) return; + // 批量预取学生(一次 In 查询),避免每张账单 studentRepo.findOne(N+1) + const studentIds = [...new Set(bills.map((bill) => bill.studentId))]; + const students = await studentRepo.find({ where: { id: In(studentIds) } }); + const userByStudentId = new Map( + students.filter((student) => student.userId).map((student) => [student.id, student.userId]), + ); + const results = await Promise.allSettled( + bills.map((bill) => { + const userId = userByStudentId.get(bill.studentId); + if (!userId) return Promise.resolve(undefined); + const notification = notificationFor(bill); + return notificationsService.create({ + recipientIds: [userId], + type: notification.type, + title: notification.title, + content: notification.content, + }); + }), + ); + for (const result of results) { + if (result.status === 'rejected') { + // 不吞错:失败记录到日志,通知失败不影响主流程 + this.logger.warn(`账单通知发送失败: ${String(result.reason)}`); + } + } + } } diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index 435c2ab..bcfde9a 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -71,7 +71,11 @@ describe('BillsService — generateBills', () => { await (itemRepo.save as jest.Mock)(value); return { id: value.id || 1, ...value }; }), - createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })), + createQueryBuilder: jest.fn(() => ({ setLock: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })), + // advisory lock:事务内 GET_LOCK/RELEASE_LOCK 直接成功 + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), })), query: jest.fn().mockResolvedValue([]), }; @@ -390,26 +394,27 @@ describe('BillsService — generateBills', () => { ]), ); - // Use sequential query builder returns: first call → room 1 occs, second → room 2 occs + // Use sequential query builder returns: + // call 1 → 本周期长租入住(该测试无长租,返回空) + // call 2 → roomIds 批量入住查询(room 1 + room 2 全部在住记录) // NOTE: mock order depends on internal service call sequence; if refactored, update callCount indices let callCount = 0; (occRepo.createQueryBuilder as jest.Mock).mockImplementation(() => { callCount++; if (callCount === 1) { - return mockQueryBuilder([ - { - id: 1, studentId: 10, roomId: 1, - billingStartDate: '2026-06-01', billingEndDate: '2026-06-10', - stayType: 'short', room: undefined, - } as Occupancy, - { - id: 2, studentId: 11, roomId: 1, - billingStartDate: '2026-06-01', billingEndDate: '2026-06-20', - stayType: 'short', room: undefined, - } as Occupancy, - ]); + return mockQueryBuilder([]); } return mockQueryBuilder([ + { + id: 1, studentId: 10, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-10', + stayType: 'short', room: undefined, + } as Occupancy, + { + id: 2, studentId: 11, roomId: 1, + billingStartDate: '2026-06-01', billingEndDate: '2026-06-20', + stayType: 'short', room: undefined, + } as Occupancy, { id: 3, studentId: 12, roomId: 2, billingStartDate: '2026-06-01', billingEndDate: '2026-06-30', @@ -562,21 +567,25 @@ describe('BillsService — allocation rounding boundary', () => { create: (_entity: unknown, value: any) => value, save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })), createQueryBuilder: jest.fn(() => ({ + setLock: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }), })), + // advisory lock:事务内 GET_LOCK/RELEASE_LOCK 直接成功 + query: jest.fn(async (sql: string) => + sql.includes('GET_LOCK') ? [{ acquired: 1 }] : [{ released: 1 }], + ), })), }; const walletsService = { debitBill: jest.fn(async (_manager, bill) => bill) } as any; const generation = new BillsGenerationService( - billRepo as any, - itemRepo as any, roomExpRepo as any, personalExpRepo as any, occRepo as any, - roomRepo as any, dataSource as any, walletsService, ); diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index feab198..3715244 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -12,6 +12,7 @@ import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill import { WalletsService } from '../wallets/wallets.service'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; import { BillsGenerationService } from './bills-generation.service'; +import { escapeLike } from '../common/like-escape'; interface AgentBillRow { billId: string | number; @@ -143,11 +144,11 @@ export class BillsService { const billId = Number(query.keyword); if (Number.isInteger(billId) && billId > 0) { qb.andWhere('(student.name LIKE :keyword OR bill.id = :billId)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, billId, }); } else { - qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` }); + qb.andWhere('student.name LIKE :keyword', { keyword: `%${escapeLike(query.keyword)}%` }); } } if (query.periodStart) diff --git a/apps/server/src/bills/dto/bill.dto.ts b/apps/server/src/bills/dto/bill.dto.ts index 55e7333..a5fff53 100644 --- a/apps/server/src/bills/dto/bill.dto.ts +++ b/apps/server/src/bills/dto/bill.dto.ts @@ -1,4 +1,14 @@ -import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; export class GenerateBillsDto { @IsOptional() @@ -7,7 +17,7 @@ export class GenerateBillsDto { operationId?: string; @IsString() - @Matches(/^\d{4}-\d{2}$/) + @Matches(/^\d{4}-(0[1-9]|1[0-2])$/) billingMonth: string; @IsOptional() @@ -36,3 +46,14 @@ export class CancelBillDto { @MaxLength(300) reason: string; } + +/** 批量确认账单请求体:ids 校验为整数数组且最多 500 条。 */ +export class BatchIdsBodyDto { + @IsArray() + @IsInt({ each: true }) + @ArrayMaxSize(500) + ids: number[]; + + @IsIn(['unpaid', 'partially_paid', 'paid']) + status: 'unpaid' | 'partially_paid' | 'paid'; +} diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts index d35abc3..2d6d0dd 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -201,7 +201,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { let scheduleRepo: jest.Mocked< Pick< Repository, - 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' + 'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; @@ -226,17 +226,30 @@ describe('ClassroomRentalsService — rental schedule sync', () => { Pick, 'findOne'> >; + // syncScheduleFromRental 现在把 read+write 放进 scheduleRepo.manager 事务, + // 事务内通过 manager.getRepository(ClassSchedule) 拿到绑定事务的同一 mock。 + const scheduleManager = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ + getRepository: jest.fn((entity: unknown) => + entity === ClassSchedule ? scheduleRepo : undefined, + ), + }), + ), + }; scheduleRepo = { + find: jest.fn(), findOne: jest.fn(), save: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn(), + manager: scheduleManager, } as jest.Mocked< Pick< Repository, - 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' + 'find' | 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; @@ -265,8 +278,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { const dto: CreateRentalDto = { classroomId: 1, lesseeOrganizationId: 2, - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-09-01', + endDate: '2026-09-30', }; const classroom = { id: 1, status: 'available' } as Classroom; const hostOrganization = { @@ -294,7 +307,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { ); rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); - scheduleRepo.findOne.mockResolvedValue(null); + scheduleRepo.find.mockResolvedValue([]); scheduleRepo.create.mockImplementation( (entity) => ({ ...(entity as object) }) as ClassSchedule, ); @@ -310,8 +323,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { classroomId: 1, lessorOrganizationId: 1, lesseeOrganizationId: 2, - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-09-01', + endDate: '2026-09-30', status: 'active', }), ); @@ -321,8 +334,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { classId: null, startTime: '00:00', endTime: '23:59', - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-09-01', + endDate: '2026-09-30', subject: 'Organization A 租赁', teacherId: null, scheduleType: 'RENTAL', @@ -332,6 +345,63 @@ describe('ClassroomRentalsService — rental schedule sync', () => { ); expect(scheduleRepo.save).toHaveBeenCalled(); }); + + it('creates one RENTAL schedule row per weekday for a multi-day rental', async () => { + const dto: CreateRentalDto = { + classroomId: 1, + lesseeOrganizationId: 2, + startDate: '2026-09-01', // 周二 + endDate: '2026-09-05', // 周六 + }; + classroomRepo.findOne.mockResolvedValue({ id: 1, status: 'available' } as Classroom); + organizationRepo.findOne + .mockResolvedValueOnce({ + id: 1, + name: 'Host', + isHost: true, + status: 'active', + } as Organization) + .mockResolvedValueOnce({ + id: 2, + name: 'Organization A', + isHost: false, + status: 'active', + } as Organization); + rentalRepo.create.mockImplementation( + (entity) => ({ ...(entity as object) }) as ClassroomRental, + ); + rentalRepo.save.mockImplementation((entity) => + Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental), + ); + rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.find.mockResolvedValue([]); + scheduleRepo.create.mockImplementation( + (entity) => ({ ...(entity as object) }) as ClassSchedule, + ); + scheduleRepo.save.mockImplementation((entity) => + Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule), + ); + + await service.create(dto); + + expect(scheduleRepo.create).toHaveBeenCalledTimes(5); + const createdWeekDays = (scheduleRepo.create as jest.Mock).mock.calls + .map((call) => (call[0] as ClassSchedule).weekDay) + .sort((a, b) => a - b); + expect(createdWeekDays).toEqual([2, 3, 4, 5, 6]); + for (const call of (scheduleRepo.create as jest.Mock).mock.calls) { + expect(call[0]).toEqual( + expect.objectContaining({ + rentalId: 1, + scheduleType: 'RENTAL', + startDate: '2026-09-01', + endDate: '2026-09-05', + status: 'active', + }), + ); + } + }); }); describe('update()', () => { @@ -352,17 +422,20 @@ describe('ClassroomRentalsService — rental schedule sync', () => { startDate: '2026-08-01', endDate: '2099-04-30', }; - const existingSchedule = { - id: 50, + // 多日租赁已按周几展开为 7 行排课(weekDay 1..7) + const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({ + id: 49 + weekDay, rentalId: 1, scheduleType: 'RENTAL', classroomId: 1, - } as ClassSchedule; + weekDay, + status: 'active', + })) as ClassSchedule[]; rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental); rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); - scheduleRepo.findOne.mockResolvedValue(existingSchedule); + scheduleRepo.find.mockResolvedValue(existingSchedules); const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' }; await service.update(1, dto); @@ -387,6 +460,55 @@ describe('ClassroomRentalsService — rental schedule sync', () => { expect(scheduleRepo.delete).not.toHaveBeenCalled(); }); + it('cancels RENTAL schedule rows for weekdays no longer covered after the rental is shortened', async () => { + const existingRental = { + id: 1, + classroomId: 1, + lesseeOrganizationId: 2, + startDate: '2026-09-01', + endDate: '2026-09-07', + status: 'active', + notes: '', + lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, + classroom: { id: 1 } as Classroom, + } as ClassroomRental; + const shortenedRental = { ...existingRental, endDate: '2026-09-02' }; + const existingSchedules = [1, 2, 3, 4, 5, 6, 7].map((weekDay) => ({ + id: 49 + weekDay, + rentalId: 1, + scheduleType: 'RENTAL', + classroomId: 1, + weekDay, + status: 'active', + })) as ClassSchedule[]; + + rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(shortenedRental); + rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.find.mockResolvedValue(existingSchedules); + + const dto: UpdateRentalDto = { endDate: '2026-09-02' }; + await service.update(1, dto); + + // 2026-09-01(周二)=2、2026-09-02(周三)=3 保留为 active,其余周几行置为 cancelled + expect(scheduleRepo.update).toHaveBeenCalledWith( + 50, + expect.objectContaining({ status: 'cancelled' }), + ); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 54, + expect.objectContaining({ status: 'cancelled' }), + ); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 51, + expect.objectContaining({ status: 'active', weekDay: 2 }), + ); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 52, + expect.objectContaining({ status: 'active', weekDay: 3 }), + ); + }); + it('deactivates the RENTAL schedule row when the rental is cancelled', async () => { const rental = { id: 1, @@ -423,7 +545,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { } as ClassroomRental; const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental; rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended); - scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule); + scheduleRepo.find.mockResolvedValue([{ id: 50, weekDay: 3, status: 'active' } as ClassSchedule]); await service.end(1); @@ -431,7 +553,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => { 1, expect.objectContaining({ status: 'ended' }), ); - expect(scheduleRepo.update).toHaveBeenCalled(); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 50, + expect.objectContaining({ status: 'cancelled' }), + ); }); it('rejects ending a future rental', async () => { @@ -492,12 +617,18 @@ describe('ClassroomRentalsService — organization roles', () => { .mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }), } as any; const scheduleRepo = { + find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), save: jest.fn(async (value) => ({ ...value, id: 100 })), create: jest.fn((value) => value), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), + manager: { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ getRepository: jest.fn((entity: unknown) => (entity === ClassSchedule ? scheduleRepo : undefined)) }), + ), + }, } as any; const scheduleService = new RentalScheduleService(rentalRepo, classroomRepo, scheduleRepo); diff --git a/apps/server/src/classroom-rentals/rental-schedule.service.ts b/apps/server/src/classroom-rentals/rental-schedule.service.ts index 15cf14f..c1e0267 100644 --- a/apps/server/src/classroom-rentals/rental-schedule.service.ts +++ b/apps/server/src/classroom-rentals/rental-schedule.service.ts @@ -1,9 +1,23 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -import { ClassroomRental, Classroom, ClassSchedule, ClassroomStatus } from '../entities'; +import { + ClassroomRental, + Classroom, + ClassSchedule, + ClassroomStatus, + ScheduleType, +} from '../entities'; import { ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import dayjs from '../common/dayjs'; +import { addDaysToDateOnly, getWeekDayFromDateOnly } from '../common/china-time'; + +// TODO: matrix 格子整体暂保持 any;统计过滤时用最小显式形状避免 unsafe any 访问 +interface ScheduleMatrixCell { + scheduleType?: string; + status?: string; + [key: string]: unknown; +} const COLOR_PALETTE = [ "#5B8FF9", @@ -43,8 +57,9 @@ export class RentalScheduleService { this.scheduleRepo.find({ where: { classroomId, - status: ClassroomRentalStatus.ACTIVE, - scheduleType: 'INTERNAL', + // ClassSchedule.status 实体类型为 string,保留字面量 + status: 'active', + scheduleType: ScheduleType.INTERNAL, startDate: LessThanOrEqual(monthEnd), endDate: MoreThanOrEqual(monthStart), }, @@ -85,8 +100,9 @@ export class RentalScheduleService { const scheduleCandidates = await this.scheduleRepo .createQueryBuilder('cs') .where('cs.classroomId = :cid', { cid: classroomId }) + // ClassSchedule.status 实体类型为 string,保留字面量 .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' }) + .andWhere('cs.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL }) .andWhere('cs.startDate <= :end', { end: endDate }) .andWhere('cs.endDate >= :start', { start: startDate }) .getMany(); @@ -118,25 +134,21 @@ export class RentalScheduleService { const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; if (overlapStart > overlapEnd) return false; - const startUtc = this.toUtcDate(overlapStart); - const endUtc = this.toUtcDate(overlapEnd); - const startWeekDay = startUtc.getUTCDay() || 7; - const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7; - startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence); - return startUtc <= endUtc; - } - - private toUtcDate(date: string): Date { - const [year, month, day] = date.split('-').map(Number); - return new Date(Date.UTC(year, month - 1, day)); + // YYYY-MM-DD 字典序即时间序;weekDay 取中国日历星期(1=周一 … 7=周日), + // 统一走 common/china-time,避免本地时区 getters 造成偏移。 + const startWeekDay = getWeekDayFromDateOnly(overlapStart); + const firstOccurrence = addDaysToDateOnly( + overlapStart, + (schedule.weekDay - startWeekDay + 7) % 7, + ); + return firstOccurrence <= overlapEnd; } private addDateRange(dates: Set, startDate: string, endDate: string) { - const current = this.toUtcDate(startDate); - const end = this.toUtcDate(endDate); - while (current <= end) { - dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD')); - current.setUTCDate(current.getUTCDate() + 1); + let current = startDate; + while (current <= endDate) { + dates.add(current); + current = addDaysToDateOnly(current, 1); } } @@ -150,13 +162,11 @@ export class RentalScheduleService { const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; if (overlapStart > overlapEnd) return; - const current = this.toUtcDate(overlapStart); - const end = this.toUtcDate(overlapEnd); - const startWeekDay = current.getUTCDay() || 7; - current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); - while (current <= end) { - dates.add(dayjs(current).utcOffset(8).format('YYYY-MM-DD')); - current.setUTCDate(current.getUTCDate() + 7); + const startWeekDay = getWeekDayFromDateOnly(overlapStart); + let current = addDaysToDateOnly(overlapStart, (schedule.weekDay - startWeekDay + 7) % 7); + while (current <= overlapEnd) { + dates.add(current); + current = addDaysToDateOnly(current, 7); } } @@ -180,6 +190,7 @@ export class RentalScheduleService { .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) .getMany(); + // TODO: 收敛为显式类型(matrix 格子含 scheduleType/status 等字段,涉及面较大暂保持 any) const organizationMap = new Map(); const matrix: Record> = {}; const summary: Record< @@ -193,12 +204,9 @@ export class RentalScheduleService { } for (const rental of rentals) { - const start = new Date(rental.startDate); - const end = new Date(rental.endDate); - const monthStart = new Date(first); - const monthEnd = new Date(last); - const effStart = start < monthStart ? monthStart : start; - const effEnd = end > monthEnd ? monthEnd : end; + // YYYY-MM-DD 字典序即时间序,直接字符串比较,避免本地时区解析偏移 + const effStart = rental.startDate > first ? rental.startDate : first; + const effEnd = rental.endDate < last ? rental.endDate : last; if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) { organizationMap.set(rental.lesseeOrganization.id, { id: rental.lesseeOrganization.id, @@ -208,11 +216,13 @@ export class RentalScheduleService { COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length], }); } - for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { - const day = d.getDate(); - if (!matrix[rental.classroomId]) continue; + let d = effStart; + while (d <= effEnd) { + const day = Number(d.slice(8, 10)); + if (matrix[rental.classroomId]) { matrix[rental.classroomId][day] = { - scheduleType: 'RENTAL', + scheduleType: ScheduleType.RENTAL, + status: rental.status, rentalId: rental.id, organizationId: rental.lesseeOrganizationId, organizationName: rental.lesseeOrganization?.name || '未知', @@ -221,6 +231,8 @@ export class RentalScheduleService { COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length], hasContract: !!rental.contractPath, }; + } + d = addDaysToDateOnly(d, 1); } } @@ -229,26 +241,25 @@ export class RentalScheduleService { .createQueryBuilder('s') .leftJoinAndSelect('s.class', 'class') .leftJoinAndSelect('s.teacher', 'teacher') + // ClassSchedule.status 实体类型为 string,保留字面量 .where('s.status = :active', { active: 'active' }) - .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.scheduleType = :type', { type: ScheduleType.INTERNAL }) .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) .getMany(); for (const sched of schedules) { if (!sched.classroomId) continue; - const schedStart = new Date( - Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()), - ); - const schedEnd = new Date( - Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()), - ); - for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { - const dow = d.getDay() === 0 ? 7 : d.getDay(); - if (dow !== sched.weekDay) continue; - const day = d.getDate(); - if (!matrix[sched.classroomId]) continue; + const schedStart = sched.startDate > first ? sched.startDate : first; + const schedEnd = sched.endDate < last ? sched.endDate : last; + let d = schedStart; + while (d <= schedEnd) { + const dow = getWeekDayFromDateOnly(d); + const day = Number(d.slice(8, 10)); + if (dow === sched.weekDay && matrix[sched.classroomId]) { matrix[sched.classroomId][day] = { - scheduleType: 'INTERNAL', + scheduleType: ScheduleType.INTERNAL, + // ClassSchedule.status 实体类型为 string,保留字面量 + status: 'active', scheduleId: sched.id, className: (sched.class as { name?: string } | null)?.name || '', subject: sched.subject, @@ -257,11 +268,17 @@ export class RentalScheduleService { endTime: sched.endTime, color: '#52c41a', }; + } + d = addDaysToDateOnly(d, 1); } } - // 统计 + // 统计:只统计 RENTAL 且非 cancelled 的天数——内部排课覆盖格(INTERNAL)与 + // cancelled 行不计入租赁占用(matrix 每格带 scheduleType/status,按需过滤)。 for (const cls of classrooms) { - const rented = Object.keys(matrix[cls.id]).length; + const rented = Object.values(matrix[cls.id]).filter((cell) => { + const typed = cell as ScheduleMatrixCell | undefined; + return typed?.scheduleType === ScheduleType.RENTAL && typed?.status !== 'cancelled'; + }).length; summary[cls.id].rentedDays = rented; summary[cls.id].idleDays = lastDay - rented; summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0; @@ -299,38 +316,78 @@ export class RentalScheduleService { async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) { const name = organizationName || rental.lesseeOrganization?.name || '承租机构'; - const weekDay = this.dateToWeekDay(rental.startDate); - let schedule = await this.scheduleRepo.findOne({ - where: { rentalId: rental.id, scheduleType: 'RENTAL' }, - }); - const data = { - classroomId: rental.classroomId, - classId: null, - weekDay, - startTime: '00:00', - endTime: '23:59', - startDate: rental.startDate, - endDate: rental.endDate, - subject: `${name} 租赁`, - teacherId: null, - scheduleType: 'RENTAL', - rentalId: rental.id, - status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active', - notes: rental.notes, - }; - if (schedule) { - await this.scheduleRepo.update(schedule.id, data); - } else { - schedule = this.scheduleRepo.create(data); - await this.scheduleRepo.save(schedule); + // 已结束的租赁(状态为 ENDED,或 ACTIVE 但 endDate 早于今天)与已取消一致, + // 不再写入/更新为 active 排课,统一映射为 cancelled。 + const today = dayjs().utcOffset(8).format('YYYY-MM-DD'); + const isInactive = + rental.status === ClassroomRentalStatus.CANCELLED || + rental.status === ClassroomRentalStatus.ENDED || + (rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today); + + // 多日租赁:按 startDate..endDate 覆盖的每一天(用 addDaysToDateOnly 迭代)展开成周几, + // 每个工作日对应一条 RENTAL 排课,weekDay 取当天星期(1=周一 … 7=周日), + // 与 getSchedule/findConflicts 按 weekDay 匹配的语义一致。 + const coveredWeekDays = new Set(); + for ( + let day = rental.startDate; + day <= rental.endDate; + day = addDaysToDateOnly(day, 1) + ) { + coveredWeekDays.add(this.dateToWeekDay(day)); } + + // read-then-multiple-write(cancel 旧行 / update 覆盖行 / insert 新行)放进同一事务, + // 任一写入失败则整体回滚,避免残留半同步状态。 + await this.scheduleRepo.manager.transaction(async (manager) => { + // 事务内使用 manager.getRepository 获得绑定到该事务的排课仓库 + const scheduleRepo = manager.getRepository(ClassSchedule); + const existingSchedules = await scheduleRepo.find({ + where: { rentalId: rental.id, scheduleType: ScheduleType.RENTAL }, + }); + const existingByWeekDay = new Map(); + for (const schedule of existingSchedules) { + existingByWeekDay.set(schedule.weekDay, schedule); + } + + // 租赁缩短/换教室后不再覆盖的周几,旧排课行统一映射为 cancelled,避免残留旧占位 + // (ClassSchedule.status 实体类型为 string,保留 'cancelled' 字面量) + for (const schedule of existingSchedules) { + if (!coveredWeekDays.has(schedule.weekDay) && schedule.status !== 'cancelled') { + await scheduleRepo.update(schedule.id, { status: 'cancelled' }); + } + } + + for (const weekDay of coveredWeekDays) { + const data = { + classroomId: rental.classroomId, + classId: null, + weekDay, + startTime: '00:00', + endTime: '23:59', + startDate: rental.startDate, + endDate: rental.endDate, + subject: `${name} 租赁`, + teacherId: null, + scheduleType: ScheduleType.RENTAL, + rentalId: rental.id, + // ClassSchedule.status 实体类型为 string,保留 'active'/'cancelled' 字面量 + status: isInactive ? 'cancelled' : 'active', + notes: rental.notes, + }; + const schedule = existingByWeekDay.get(weekDay); + if (schedule) { + await scheduleRepo.update(schedule.id, data); + } else { + const created = scheduleRepo.create(data); + await scheduleRepo.save(created); + } + } + }); } dateToWeekDay(date: string): number { - const d = new Date(date); - const day = d.getDay(); - return day === 0 ? 7 : day; + return getWeekDayFromDateOnly(date); } } diff --git a/apps/server/src/common/china-time.spec.ts b/apps/server/src/common/china-time.spec.ts new file mode 100644 index 0000000..501004a --- /dev/null +++ b/apps/server/src/common/china-time.spec.ts @@ -0,0 +1,38 @@ +import { + CHINA_UTC_OFFSET, + addDaysToDateOnly, + getWeekDayFromDateOnly, + isConsecutiveDates, + parseChinaDateOnly, +} from './china-time'; + +describe('china-time', () => { + it('exports UTC+8 offset', () => { + expect(CHINA_UTC_OFFSET).toBe(8); + }); + + it('addDaysToDateOnly crosses month/year boundaries', () => { + expect(addDaysToDateOnly('2026-02-28', 1)).toBe('2026-03-01'); + expect(addDaysToDateOnly('2026-12-31', 1)).toBe('2027-01-01'); + expect(addDaysToDateOnly('2026-01-01', -1)).toBe('2025-12-31'); + expect(addDaysToDateOnly('2026-08-09', 7)).toBe('2026-08-16'); + }); + + it('getWeekDayFromDateOnly is timezone independent', () => { + // 2026-08-09 是周日 → 7 + expect(getWeekDayFromDateOnly('2026-08-09')).toBe(7); + // 2026-08-10 是周一 → 1 + expect(getWeekDayFromDateOnly('2026-08-10')).toBe(1); + }); + + it('parseChinaDateOnly anchors to UTC+8 midnight', () => { + const d = parseChinaDateOnly('2026-08-09'); + expect(d.toISOString()).toBe('2026-08-08T16:00:00.000Z'); + }); + + it('isConsecutiveDates detects adjacent dates', () => { + expect(isConsecutiveDates('2026-08-09', '2026-08-10')).toBe(true); + expect(isConsecutiveDates('2026-08-09', '2026-08-11')).toBe(false); + expect(isConsecutiveDates('2026-08-09', '2026-08-09')).toBe(false); + }); +}); diff --git a/apps/server/src/common/china-time.ts b/apps/server/src/common/china-time.ts new file mode 100644 index 0000000..054a478 --- /dev/null +++ b/apps/server/src/common/china-time.ts @@ -0,0 +1,39 @@ +import dayjs from './dayjs'; + +/** + * 中国时区(Asia/Shanghai)常量与日期工具。 + * 服务端/生产统一按 UTC+8 处理「业务日期」,避免与服务器本地时区混用导致 off-by-one。 + */ +export const CHINA_UTC_OFFSET = 8; + +/** 当前时刻的中国日期(YYYY-MM-DD)。 */ +export function getChinaDate(date: Date = new Date()): string { + return dayjs(date).utcOffset(CHINA_UTC_OFFSET).format('YYYY-MM-DD'); +} + +/** 把 'YYYY-MM-DD' 按 UTC+8 午夜解析为 Date(用于需要绝对时刻的场合)。 */ +export function parseChinaDateOnly(dateOnly: string): Date { + return new Date(`${dateOnly}T00:00:00+08:00`); +} + +/** 纯日期字符串加减天数,避免本地时区 getters 造成的偏移。 */ +export function addDaysToDateOnly(dateOnly: string, days: number): string { + const [y, m, d] = dateOnly.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1, d + days)); + const yy = dt.getUTCFullYear(); + const mm = String(dt.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(dt.getUTCDate()).padStart(2, '0'); + return `${yy}-${mm}-${dd}`; +} + +/** 返回 dateOnly 在中国日历下的星期(1=周一 … 7=周日),与本地时区无关。 */ +export function getWeekDayFromDateOnly(dateOnly: string): number { + const [y, m, d] = dateOnly.split('-').map(Number); + const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); + return day === 0 ? 7 : day; +} + +/** 两个 YYYY-MM-DD 是否按中国日历相邻(差 1 天)。 */ +export function isConsecutiveDates(a: string, b: string): boolean { + return addDaysToDateOnly(a, 1) === b || addDaysToDateOnly(b, 1) === a; +} diff --git a/apps/server/src/deposits/deposits.purge.spec.ts b/apps/server/src/deposits/deposits.purge.spec.ts index e8ab93f..55abc87 100644 --- a/apps/server/src/deposits/deposits.purge.spec.ts +++ b/apps/server/src/deposits/deposits.purge.spec.ts @@ -13,7 +13,11 @@ describe('DepositsService.purge', () => { ...overrides?.deposit, }; const repo = { - findOne: jest.fn().mockResolvedValue(deposit), + // purge 现在按 id + 已归档状态查询;无状态条件时(fallback)返回记录本体 + findOne: jest.fn(async ({ where }: { where: { id?: number; status?: string } }) => { + if (where?.status === 'archived') return deposit.status === 'archived' ? deposit : null; + return deposit; + }), delete: jest.fn().mockResolvedValue({ affected: 1 }), }; const installmentRepo = { count: jest.fn().mockResolvedValue(0) }; @@ -55,11 +59,18 @@ describe('DepositsService.purge', () => { expect(repo.delete).not.toHaveBeenCalled(); }); + it('does not delete when the deposit is no longer archived by delete time', async () => { + const { service, repo } = createService(); + repo.delete.mockResolvedValue({ affected: 0 }); + await expect(service.purge(1)).rejects.toBeInstanceOf(BadRequestException); + expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' }); + }); + it('deletes an archived deposit with no paid history', async () => { const { service, repo } = createService(); await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除押金(不可恢复)', }); - expect(repo.delete).toHaveBeenCalledWith(1); + expect(repo.delete).toHaveBeenCalledWith({ id: 1, status: 'archived' }); }); }); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index b4d4bce..6de71d0 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -1,12 +1,13 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; import { Deposit } from '../entities/deposit.entity'; import { Student } from '../entities/student.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; +import { escapeLike } from '../common/like-escape'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); @@ -195,7 +196,7 @@ export class DepositsService { if (query?.keyword) { qb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', - { keyword: `%${query.keyword}%` }, + { keyword: `%${escapeLike(query.keyword)}%` }, ); } if (query?.status && query.status !== 'archived') { @@ -236,7 +237,10 @@ export class DepositsService { } if (amount <= 0) throw new BadRequestException('收取金额必须大于0'); - const existing = await this.repo.findOne({ where: { studentId: dto.studentId } }); + // 只匹配未归档押金:避免给学生新建押金时错误地复活/修改已归档记录 + const existing = await this.repo.findOne({ + where: { studentId: dto.studentId, status: Not('archived') }, + }); if (existing) { existing.amount = money(Number(existing.amount || 0) + amount); existing.paidDate = dto.paidDate; @@ -326,10 +330,12 @@ export class DepositsService { } async purge(id: number) { - const deposit = await this.repo.findOne({ where: { id } }); - if (!deposit) throw new NotFoundException('押金记录不存在'); - if (deposit.status !== 'archived') { - throw new BadRequestException('仅已归档押金可以永久删除,请先归档'); + // 明确按已归档状态查询:避免 findOne 匹配到被 create() 复活/修改的新押金记录 + const deposit = await this.repo.findOne({ where: { id, status: 'archived' } }); + if (!deposit) { + const existing = await this.repo.findOne({ where: { id } }); + if (existing) throw new BadRequestException('仅已归档押金可以永久删除,请先归档'); + throw new NotFoundException('押金记录不存在'); } if (Number(deposit.refundAmount || 0) > 0) { throw new BadRequestException('该押金已有退款金额,无法永久删除'); @@ -343,7 +349,11 @@ export class DepositsService { if (paidInstallments > 0) { throw new BadRequestException('该押金存在已支付分期,无法永久删除'); } - await this.repo.delete(id); + // 条件删除:即使读取后状态被并发修改(例如被 create() 复活),也只删除仍处于已归档的记录 + const result = await this.repo.delete({ id, status: 'archived' }); + if (!result.affected) { + throw new BadRequestException('押金状态已变化,请刷新后重试'); + } return { message: '已永久删除押金(不可恢复)' }; } diff --git a/apps/server/src/entities/bill.entity.ts b/apps/server/src/entities/bill.entity.ts index 841509c..c956540 100644 --- a/apps/server/src/entities/bill.entity.ts +++ b/apps/server/src/entities/bill.entity.ts @@ -24,22 +24,57 @@ export class Bill { @Column({ name: 'period_end', type: 'date' }) periodEnd: string; - @Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'shared_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) sharedAmount: number; - @Column({ name: 'personal_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'personal_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) personalAmount: number; - @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'total_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) totalAmount: number; @Column({ type: 'varchar', length: 30, default: 'batch' }) source: 'batch' | 'student_utility'; - @Column({ name: 'paid_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'paid_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) paidAmount: number; - @Column({ name: 'outstanding_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + @Column({ + name: 'outstanding_amount', + type: 'decimal', + precision: 10, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) outstandingAmount: number; @Column({ type: 'varchar', length: 20, default: 'unpaid' }) diff --git a/apps/server/src/entities/class-student.entity.ts b/apps/server/src/entities/class-student.entity.ts index 7a6ca73..9306941 100644 --- a/apps/server/src/entities/class-student.entity.ts +++ b/apps/server/src/entities/class-student.entity.ts @@ -26,7 +26,7 @@ export class ClassStudent { @Column({ name: 'student_id', type: 'integer' }) studentId: number; - @ManyToOne(() => Student) + @ManyToOne(() => Student, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'student_id' }) student: Student; diff --git a/apps/server/src/entities/ding-attendance-raw.entity.ts b/apps/server/src/entities/ding-attendance-raw.entity.ts index f97958a..e89157d 100644 --- a/apps/server/src/entities/ding-attendance-raw.entity.ts +++ b/apps/server/src/entities/ding-attendance-raw.entity.ts @@ -57,11 +57,11 @@ export class DingAttendanceRaw { matchStatus: string; @Column({ name: 'matched_student_id', type: 'integer', nullable: true }) - matchedStudentId: number; + matchedStudentId: number | null; @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'matched_student_id' }) - matchedStudent: Student; + matchedStudent: Student | null; @Column({ name: 'raw_data', type: 'text', nullable: true }) rawData: string; diff --git a/apps/server/src/entities/ding-leave-raw.entity.ts b/apps/server/src/entities/ding-leave-raw.entity.ts index f348ff5..b2cf829 100644 --- a/apps/server/src/entities/ding-leave-raw.entity.ts +++ b/apps/server/src/entities/ding-leave-raw.entity.ts @@ -54,11 +54,11 @@ export class DingLeaveRaw { matchStatus: string; @Column({ name: 'matched_student_id', type: 'integer', nullable: true }) - matchedStudentId: number; + matchedStudentId: number | null; @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'matched_student_id' }) - matchedStudent: Student; + matchedStudent: Student | null; @Column({ name: 'raw_data', type: 'text', nullable: true }) rawData: string; diff --git a/apps/server/src/entities/learning-record.entity.ts b/apps/server/src/entities/learning-record.entity.ts index 8fb544e..4b72f82 100644 --- a/apps/server/src/entities/learning-record.entity.ts +++ b/apps/server/src/entities/learning-record.entity.ts @@ -4,10 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, - ManyToOne, - JoinColumn, } from 'typeorm'; -import { Student } from './student.entity'; @Entity('learning_records') export class LearningRecord { @@ -17,10 +14,6 @@ export class LearningRecord { @Column({ name: 'student_id', type: 'integer' }) studentId: number; - @ManyToOne(() => Student, { eager: true }) - @JoinColumn({ name: 'student_id' }) - student: Student; - @Column({ name: 'record_date', type: 'date', nullable: true }) recordDate: string; diff --git a/apps/server/src/entities/student-profile.entity.ts b/apps/server/src/entities/student-profile.entity.ts index 7b687b4..f9de49d 100644 --- a/apps/server/src/entities/student-profile.entity.ts +++ b/apps/server/src/entities/student-profile.entity.ts @@ -4,10 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, - ManyToOne, - JoinColumn, } from 'typeorm'; -import { Student } from './student.entity'; @Entity('student_profiles') export class StudentProfile { @@ -17,10 +14,6 @@ export class StudentProfile { @Column({ name: 'student_id', type: 'integer', unique: true }) studentId: number; - @ManyToOne(() => Student, { eager: true }) - @JoinColumn({ name: 'student_id' }) - student: Student; - @Column({ name: 'target_college', length: 100, nullable: true }) targetCollege: string; diff --git a/apps/server/src/entities/student-wallet.entity.ts b/apps/server/src/entities/student-wallet.entity.ts index 1e67900..cb50e73 100644 --- a/apps/server/src/entities/student-wallet.entity.ts +++ b/apps/server/src/entities/student-wallet.entity.ts @@ -17,7 +17,13 @@ export class StudentWallet { @Column({ name: 'student_id', type: 'integer', unique: true }) studentId: number; - @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + @Column({ + type: 'decimal', + precision: 12, + scale: 2, + default: 0, + transformer: { to: (v: number) => v, from: (v: string | null) => (v == null ? v : Number(v)) }, + }) balance: number; @CreateDateColumn({ name: 'created_at' }) diff --git a/apps/server/src/exams/exams.purge.spec.ts b/apps/server/src/exams/exams.purge.spec.ts index 54af2f2..4949033 100644 --- a/apps/server/src/exams/exams.purge.spec.ts +++ b/apps/server/src/exams/exams.purge.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { ExamsService } from './exams.service'; +import { Exam } from '../entities'; describe('ExamsService.purge', () => { const createService = (overrides?: { exam?: Record }) => { @@ -10,13 +11,23 @@ describe('ExamsService.purge', () => { find: jest.fn().mockResolvedValue([exam]), }; const classTeacherRepo = { findOne: jest.fn().mockResolvedValue({}) }; + const dataSource = { + transaction: jest.fn(async (cb: (manager: any) => unknown) => + cb({ + getRepository: jest.fn((entity: unknown) => { + if (entity === Exam) return examRepo; + throw new Error(`Unexpected repository: ${String(entity)}`); + }), + }), + ), + }; const service = new ExamsService( examRepo as never, {} as never, {} as never, {} as never, classTeacherRepo as never, - {} as never, + dataSource as never, ); return { service, examRepo, classTeacherRepo }; }; diff --git a/apps/server/src/exams/exams.service.spec.ts b/apps/server/src/exams/exams.service.spec.ts index e633bd9..b205377 100644 --- a/apps/server/src/exams/exams.service.spec.ts +++ b/apps/server/src/exams/exams.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common'; -import { ExamScore } from '../entities'; +import { Exam, ExamScore } from '../entities'; import { QueryExamDto } from './dto/exam.dto'; import { ExamsService } from './exams.service'; @@ -36,6 +36,16 @@ function updateQb(affected = 1) { return qb; } +function runWithExamRepo(examRepo: Record) { + return async (run: (manager: any) => Promise) => + run({ + getRepository: jest.fn((entity: unknown) => { + if (entity === Exam) return examRepo; + throw new Error(`Unexpected repository: ${String(entity)}`); + }), + }); +} + describe('ExamsService', () => { it('creates score rows from the active class roster snapshot', async () => { const members = [ @@ -260,7 +270,7 @@ describe('ExamsService', () => { createQueryBuilder: jest.fn(() => qb), }; const scoreRepo = { update: jest.fn(), save: jest.fn() }; - const service = createService(async () => undefined, { examRepo, scoreRepo }); + const service = createService(runWithExamRepo(examRepo), { examRepo, scoreRepo }); await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({ message: '已批量归档 1 场考试', @@ -283,7 +293,7 @@ describe('ExamsService', () => { ]), createQueryBuilder: jest.fn(() => qb), }; - const service = createService(async () => undefined, { examRepo }); + const service = createService(runWithExamRepo(examRepo), { examRepo }); await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({ message: '已批量恢复 1 场考试', diff --git a/apps/server/src/exams/exams.service.ts b/apps/server/src/exams/exams.service.ts index 8f543ed..04ed1d5 100644 --- a/apps/server/src/exams/exams.service.ts +++ b/apps/server/src/exams/exams.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, In, Like, Repository } from 'typeorm'; import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities'; import { CreateExamDto, QueryExamDto } from './dto/exam.dto'; +import { escapeLike } from '../common/like-escape'; @Injectable() export class ExamsService { @@ -31,7 +32,7 @@ export class ExamsService { const where: Record = { status: query.isArchived ? 'archived' : 'active', }; - if (query.keyword) where.examName = Like(`%${query.keyword}%`); + if (query.keyword) where.examName = Like(`%${escapeLike(query.keyword)}%`); if (query.examType) where.examType = query.examType; if (query.classId) where.classId = query.classId; if (accessibleClassIds) { @@ -205,27 +206,33 @@ export class ExamsService { async batchPurge(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '永久删除'); - const deleted: number[] = []; - const skipped: string[] = []; - for (const exam of exams) { - if (exam.status !== 'archived') { - skipped.push(`${exam.examName}(未归档)`); - continue; + // 循环内逐条删除放进同一事务:任一删除失败则整批回滚(全有或全无)。 + return this.dataSource.transaction(async (manager) => { + const examRepo = manager.getRepository(Exam); + const deleted: number[] = []; + const skipped: string[] = []; + for (const exam of exams) { + if (exam.status !== 'archived') { + skipped.push(`${exam.examName}(未归档)`); + continue; + } + await examRepo.delete(exam.id); + deleted.push(exam.id); } - await this.examRepo.delete(exam.id); - deleted.push(exam.id); - } - const message = - skipped.length > 0 - ? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` - : `已永久删除 ${deleted.length} 场考试(不可恢复)`; - return { message, deleted: deleted.length, skipped: skipped.length }; + const message = + skipped.length > 0 + ? `已永久删除 ${deleted.length} 场考试;${skipped.length} 场被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` + : `已永久删除 ${deleted.length} 场考试(不可恢复)`; + return { message, deleted: deleted.length, skipped: skipped.length }; + }); } async batchArchive(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '归档'); const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id); - const archived = await this.updateBatchStatus(targetIds, 'archived'); + const archived = await this.dataSource.transaction(async (manager) => + this.updateBatchStatus(manager, targetIds, 'archived'), + ); return { message: `已批量归档 ${archived} 场考试`, archived, @@ -236,7 +243,9 @@ export class ExamsService { async batchRestore(ids: number[], userId: number, canManageAll: boolean) { const exams = await this.findBatchExams(ids, userId, canManageAll, '恢复'); const targetIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id); - const restored = await this.updateBatchStatus(targetIds, 'active'); + const restored = await this.dataSource.transaction(async (manager) => + this.updateBatchStatus(manager, targetIds, 'active'), + ); return { message: `已批量恢复 ${restored} 场考试`, restored, @@ -263,9 +272,14 @@ export class ExamsService { return exams; } - private async updateBatchStatus(ids: number[], status: 'active' | 'archived') { + private async updateBatchStatus( + manager: EntityManager, + ids: number[], + status: 'active' | 'archived', + ) { if (ids.length === 0) return 0; - const result = await this.examRepo + const result = await manager + .getRepository(Exam) .createQueryBuilder() .update() .set({ status }) diff --git a/apps/server/src/expense-types/expense-types.service.ts b/apps/server/src/expense-types/expense-types.service.ts index ecd8dae..2615395 100644 --- a/apps/server/src/expense-types/expense-types.service.ts +++ b/apps/server/src/expense-types/expense-types.service.ts @@ -53,7 +53,11 @@ export class ExpenseTypesService { async create(dto: CreateExpenseTypeDto): Promise { const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() }; - const exists = await this.repo.findOne({ where: { code: normalized.code } }); + // 注意:ExpenseType.code 在数据库有唯一索引(全表唯一),唯一性检查必须与之一致, + // 否则软删行会让 DB 层抛重复键错误。软删后重建同名类型需另行放开唯一索引(需迁移)。 + const exists = await this.repo.findOne({ + where: { code: normalized.code }, + }); if (exists) throw new ConflictException('费用类型代码已存在'); return this.repo.save(this.repo.create(normalized)); } diff --git a/apps/server/src/financial-operations/financial-operations.service.ts b/apps/server/src/financial-operations/financial-operations.service.ts index 99c5964..a5ce7fc 100644 --- a/apps/server/src/financial-operations/financial-operations.service.ts +++ b/apps/server/src/financial-operations/financial-operations.service.ts @@ -17,7 +17,7 @@ export class FinancialOperationsService { const existing = await this.repo.findOne({ where: { operationId } }); if (existing) { if (existing.type !== type) throw new ConflictException('operationId 已用于其他操作'); - if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T; + if (existing.status === 'completed' && existing.resultJson) return this.parseResult(existing.resultJson); if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交'); } @@ -28,21 +28,38 @@ export class FinancialOperationsService { } catch (error) { const concurrent = await this.repo.findOne({ where: { operationId } }); if (concurrent?.status === 'completed' && concurrent.resultJson) { - return JSON.parse(concurrent.resultJson) as T; + return this.parseResult(concurrent.resultJson); } throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error }); } } else { + // 失败重试认领:条件状态更新,affected=1 才算认领成功, + // 避免并发下多个请求同时把 failed 改成 running 后重复执行 work() + const claim = await this.repo + .createQueryBuilder() + .update(FinancialOperation) + .set({ status: 'running', errorMessage: null, resultJson: null }) + .where('id = :id', { id: operation.id }) + .andWhere( + "(status IN ('failed') OR (status = 'completed' AND resultJson IS NULL) OR (status = 'running' AND updatedAt < DATE_SUB(NOW(), INTERVAL 15 MINUTE)))", + ) + .execute(); + if (claim.affected !== 1) { + const concurrent = await this.repo.findOne({ where: { operationId } }); + if (concurrent?.status === 'completed' && concurrent.resultJson) { + return this.parseResult(concurrent.resultJson); + } + throw new ConflictException('该操作正在处理中,请勿重复提交'); + } operation.status = 'running'; operation.errorMessage = null; operation.resultJson = null; - await this.repo.save(operation); } try { const result = await work(); operation.status = 'completed'; - operation.resultJson = JSON.stringify(result); + operation.resultJson = JSON.stringify(result ?? null); await this.repo.save(operation); return result; } catch (error) { @@ -52,4 +69,13 @@ export class FinancialOperationsService { throw error; } } + + /** 解析缓存的结果 JSON,损坏数据按失败处理而不是抛 500。 */ + private parseResult(resultJson: string | null | undefined): T { + try { + return JSON.parse(resultJson ?? 'null') as T; + } catch { + throw new ConflictException('该操作的结果数据已损坏,请重试'); + } + } } diff --git a/apps/server/src/imports/imports.commit.service.ts b/apps/server/src/imports/imports.commit.service.ts index 9a2be99..fb585d2 100644 --- a/apps/server/src/imports/imports.commit.service.ts +++ b/apps/server/src/imports/imports.commit.service.ts @@ -99,10 +99,28 @@ export class ImportCommitService { } } - run.status = 'committing'; - step.status = 'committing'; - await this.runs.save(run); - await this.steps.save(step); + // 原子抢占:只有 run/step 仍处于可提交状态时才迁移到 committing, + // 避免两个并发请求同时通过校验并重复写入(affected=0 表示已被他人抢占)。 + await this.dataSource.transaction(async (manager) => { + const runClaim = await manager.update( + ImportRun, + { id: run.id, status: In(['ready', 'preparing']) }, + { status: 'committing' }, + ); + if ((runClaim.affected ?? 0) !== 1) { + throw new ConflictException('导入任务正在提交中或状态已变化,请刷新后重试'); + } + const stepClaim = await manager.update( + ImportStep, + { id: step.id, status: 'ready' }, + { status: 'committing' }, + ); + if ((stepClaim.affected ?? 0) !== 1) { + throw new ConflictException( + `阶段「${IMPORT_STEP_LABELS[stepKey]}」正在提交中或状态已变化,请刷新后重试`, + ); + } + }); const counts = { created: 0, updated: 0, skipped: 0, failed: 0 }; try { diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts index 1b27894..bc3d352 100644 --- a/apps/server/src/imports/imports.preview.service.ts +++ b/apps/server/src/imports/imports.preview.service.ts @@ -80,7 +80,6 @@ export class ImportPreviewService { suggestMapping(sheetsData[0]?.headers ?? [], stepKey)); assertMapping(stepKey, mapping, sheetsData, usedSheets); - await this.rows.delete({ stepId: step.id }); const rowEntities: ImportRow[] = []; const summary: StepPreviewSummary = { total: 0, @@ -153,12 +152,17 @@ export class ImportPreviewService { } } - await this.rows.save(rowEntities); - step.sheetsJson = JSON.stringify(usedSheets); - step.mappingJson = JSON.stringify(mapping); - step.status = 'ready'; - step.summaryJson = JSON.stringify(summary); - await this.steps.save(step); + // 同一批次的写入(清空旧预览行、写新预览行、更新阶段状态)放进同一事务, + // 避免预览过程中失败留下半成品数据。 + await this.dataSource.transaction(async (manager) => { + await manager.delete(ImportRow, { stepId: step.id }); + await manager.save(ImportRow, rowEntities); + step.sheetsJson = JSON.stringify(usedSheets); + step.mappingJson = JSON.stringify(mapping); + step.status = 'ready'; + step.summaryJson = JSON.stringify(summary); + await manager.save(ImportStep, step); + }); const headers = sheetsData.find((s) => s.name === usedSheets[0])?.headers ?? []; const rows = rowEntities.map((entity) => ({ diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 6809fd5..f321c48 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -256,6 +256,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -316,6 +325,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -495,6 +513,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -559,6 +586,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -635,6 +671,15 @@ describe('ImportsService', () => { } return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -696,6 +741,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -767,6 +821,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, @@ -838,6 +901,15 @@ describe('ImportsService', () => { if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) }; }), + transaction: jest.fn(async (cb: (manager: Record) => unknown) => + cb({ + delete: rowsRepo.delete, + save: jest.fn(async (...args: unknown[]) => { + const value = args.length >= 2 ? args[1] : args[0]; + return rowsRepo.save(value as never); + }), + }), + ), }; const service = new ImportsService( makeRunsRepo(run) as never, diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index d5254fd..e74977a 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -54,6 +54,7 @@ function createCheckInManager(options?: { update: jest.fn(), }; const queryResults = [ + options?.student ?? { id: 3, organizationId: 7 }, options?.existingOccupancy ?? null, options?.room ?? { id: 2, capacity: 4, status: 'available' }, ...(options?.bed !== undefined ? [options.bed] : []), @@ -99,6 +100,7 @@ function createCheckOutManager(occupancy: Occupancy | null) { createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(occupancy)), save: jest.fn(async (value) => value), update: jest.fn(), + count: jest.fn().mockResolvedValue(0), }; return manager; } diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 96717a2..0df6bbc 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -72,6 +72,16 @@ export class OccupanciesService { async checkIn(dto: CheckInDto, userId?: number) { this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); return this.dataSource.transaction(async (manager) => { + // 先锁学生行:对「不存在的入住记录」做 SELECT ... FOR UPDATE 无法防止 + // 两个并发入住同时通过检查后插入重复记录;锁学生行可让同一学生的 + // 并发入住串行化,后到者在拿到锁后能看到先到者已提交的入住记录。 + const student = await this.withPessimisticWriteLock( + manager + .createQueryBuilder(Student, 'student') + .where('student.id = :studentId', { studentId: dto.studentId }), + ).getOne(); + if (!student) throw new NotFoundException('学生不存在'); + const existing = await this.withPessimisticWriteLock( manager .createQueryBuilder(Occupancy, 'occupancy') @@ -91,9 +101,6 @@ export class OccupanciesService { where: { roomId: dto.roomId, checkOutDate: IsNull() }, }); if (count >= (room.capacity ?? 0)) throw new BadRequestException('宿舍已满'); - const student = await manager.findOne(Student, { where: { id: dto.studentId } }); - if (!student) throw new NotFoundException('学生不存在'); - if (dto.bedId) { const bed = await this.withPessimisticWriteLock( manager.createQueryBuilder(Bed, 'bed').where('bed.id = :bedId AND bed.roomId = :roomId', { diff --git a/apps/server/src/occupancies/occupancy-operations.service.ts b/apps/server/src/occupancies/occupancy-operations.service.ts index 77896ef..d598c6b 100644 --- a/apps/server/src/occupancies/occupancy-operations.service.ts +++ b/apps/server/src/occupancies/occupancy-operations.service.ts @@ -75,7 +75,14 @@ export class OccupancyOperationsService { await manager.save(occ); if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' }); if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' }); - await manager.update(Room, occ.roomId, { status: 'available' }); + // 多床位房间不能因为单条退宿就置为 available: + // 只有该房间不再有任何在住记录时才更新房间状态。 + const remainingActive = await manager.count(Occupancy, { + where: { roomId: occ.roomId, checkOutDate: IsNull() }, + }); + if (remainingActive === 0) { + await manager.update(Room, occ.roomId, { status: 'available' }); + } return occ; }); } @@ -113,7 +120,14 @@ export class OccupancyOperationsService { if (oldOcc.lockerId) { await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); } - await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); + // 多床位房间不能因为单次换房就置为 available: + // 只有原房间不再有任何在住记录时才更新房间状态。 + const oldRoomRemaining = await runner.manager.count(Occupancy, { + where: { roomId: oldOcc.roomId, checkOutDate: IsNull() }, + }); + if (oldRoomRemaining === 0) { + await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); + } // 检查新房容量 const newRoom = await withPessimisticWriteLock( runner.manager @@ -365,12 +379,18 @@ export class OccupancyOperationsService { occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; occ.checkOutReason = dto.checkOutReason || ''; await runner.manager.save(occ); - // 更新房间状态 - await runner.manager.update(Room, occ.roomId, { status: 'available' }); // 释放床位/柜子 if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); if (occ.lockerId) await runner.manager.update(Locker, occ.lockerId, { status: 'available' }); + // 多床位房间不能因为单条退宿就置为 available: + // 只有该房间不再有任何在住记录时才更新房间状态。 + const remainingActive = await runner.manager.count(Occupancy, { + where: { roomId: occ.roomId, checkOutDate: IsNull() }, + }); + if (remainingActive === 0) { + await runner.manager.update(Room, occ.roomId, { status: 'available' }); + } success++; } await runner.commitTransaction(); diff --git a/apps/server/src/rbac/rbac-presets.ts b/apps/server/src/rbac/rbac-presets.ts index e4b0ac0..2a47c5b 100644 --- a/apps/server/src/rbac/rbac-presets.ts +++ b/apps/server/src/rbac/rbac-presets.ts @@ -12,6 +12,9 @@ export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: stri { code: 'student:import', name: '导入学生', group: 'student' }, { code: 'student:export', name: '导出学生', group: 'student' }, { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, + { code: 'exam:create', name: '创建考试', group: 'exam' }, + { code: 'exam:edit', name: '编辑/归档考试与录入成绩', group: 'exam' }, + { code: 'exam:delete', name: '删除考试', group: 'exam' }, { code: 'room:view', name: '查看宿舍', group: 'room' }, { code: 'room:inspect', name: '宿舍查寝', group: 'room' }, { code: 'room:create', name: '新增宿舍', group: 'room' }, @@ -104,9 +107,6 @@ export const DEPRECATED_PERMISSION_CODES = [ 'learning:create', 'learning:edit', 'learning:delete', - 'exam:create', - 'exam:edit', - 'exam:delete', 'department:view', 'department:edit', 'department:delete', diff --git a/apps/server/src/rbac/rbac-seed.service.ts b/apps/server/src/rbac/rbac-seed.service.ts index 402081e..29feb73 100644 --- a/apps/server/src/rbac/rbac-seed.service.ts +++ b/apps/server/src/rbac/rbac-seed.service.ts @@ -39,17 +39,6 @@ export class RbacService { return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); } - private async resolvePermissions(permissionIds: number[]): Promise { - const uniqueIds = [...new Set(permissionIds)]; - const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; - if (permissions.length !== uniqueIds.length) { - const foundIds = new Set(permissions.map((permission) => permission.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - throw new Error(`权限不存在: ${missingIds.join(',')}`); - } - return permissions; - } - async getTeacherWorkspace(userId: number) { // Find all classes where this user is a teacher const teacherAssignments = await this.classTeacherRepo.find({ @@ -139,11 +128,16 @@ export class RbacSeedService { } async seedData(): Promise { - const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true }); - if (restoredLegacyUsers.affected) { + // 仅当角色表为空(首次初始化)或显式设置 SEED_ROLES=true 时才播种; + // 否则跳过,避免每次启动都无条件重激活角色/权限,覆盖管理员的自定义调整。 + const forceSeed = process.env.SEED_ROLES === 'true'; + const roleCount = await this.roleRepo.count(); + if (roleCount > 0 && !forceSeed) { this.logger.log( - `已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`, + `角色表已有 ${roleCount} 条记录,跳过种子数据重激活(如需强制播种请设置 SEED_ROLES=true)`, ); + await this.ensureDefaultAdmin(); + return; } for (const p of PRESET_PERMISSIONS) { @@ -273,26 +267,40 @@ export class RbacSeedService { } } - const count = await this.userRepo.count(); - if (count === 0) { - const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; - const hash = await bcrypt.hash(adminPassword, 10); - const adminUser = this.userRepo.create({ - username: 'admin', - passwordHash: hash, - name: '管理员', - }); - const superAdminRole = allRoles.find((r) => r.code === 'super_admin'); - if (superAdminRole) { - adminUser.roles = [superAdminRole]; - } - await this.userRepo.save(adminUser); - this.logger.log( - `已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`, - ); - } + await this.ensureDefaultAdmin(); this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); } + /** + * 首次初始化时创建默认管理员。 + * 即使跳过角色/权限播种(角色表已有数据)也会执行,避免出现「有角色但无管理员」的状态。 + */ + private async ensureDefaultAdmin(): Promise { + const count = await this.userRepo.count(); + if (count > 0) return; + const adminPassword = process.env.ADMIN_PASSWORD; + // 生产环境禁止使用默认弱口令 + if (!adminPassword) { + if (process.env.NODE_ENV === 'production') { + throw new Error('生产环境必须设置 ADMIN_PASSWORD 后再初始化默认管理员'); + } + this.logger.warn('ADMIN_PASSWORD 未设置,开发环境使用默认密码 admin123'); + } + const password = adminPassword || 'admin123'; + const hash = await bcrypt.hash(password, 10); + const adminUser = this.userRepo.create({ + username: 'admin', + passwordHash: hash, + name: '管理员', + }); + const superAdminRole = await this.roleRepo.findOne({ where: { code: 'super_admin' } }); + if (superAdminRole) { + adminUser.roles = [superAdminRole]; + } + await this.userRepo.save(adminUser); + // 不打印密码,避免凭据落入日志 + this.logger.log('已创建默认管理员: admin(请妥善保管密码)'); + } + } diff --git a/apps/server/src/rbac/rbac.seed.spec.ts b/apps/server/src/rbac/rbac.seed.spec.ts index 42fef73..a75c237 100644 --- a/apps/server/src/rbac/rbac.seed.spec.ts +++ b/apps/server/src/rbac/rbac.seed.spec.ts @@ -28,6 +28,7 @@ describe('RbacService seedData', () => { remove: jest.fn(async (value: any) => value), }; const roleRepo = { + count: jest.fn(async () => 0), findOne: jest.fn(async ({ where }: any) => where.code === 'system_admin' || where.name === '系统管理员' ? systemAdminRole : null, ), @@ -99,6 +100,7 @@ describe('RbacService seedData', () => { remove: jest.fn(async (value) => value), }; const roleRepo = { + count: jest.fn(async () => 0), findOne: jest.fn(async ({ where }: any) => where.code === 'teacher' || where.name === '老师' ? teacherRole : null, ), @@ -187,6 +189,7 @@ describe('RbacService legacy role consolidation', () => { remove: jest.fn(async (value) => value), }; const roleRepo = { + count: jest.fn(async () => 0), findOne: jest.fn(async () => targetRole), create: jest.fn((value) => ({ ...value, permissions: [] })), save: jest.fn(async (value) => value), @@ -217,3 +220,71 @@ describe('RbacService legacy role consolidation', () => { expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole); }); }); + +describe('RbacSeedService startup gating', () => { + const makeService = (roleCount: number) => { + const permRepo = { + findOne: jest.fn(async () => null), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(async () => []), + remove: jest.fn(), + }; + const roleRepo = { + count: jest.fn(async () => roleCount), + findOne: jest.fn(async () => null), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(async () => []), + remove: jest.fn(), + }; + const userRepo = { + update: jest.fn(async () => ({ affected: 0 })), + count: jest.fn(), + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + }; + const service = new RbacService( + permRepo as never, + roleRepo as never, + userRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + return { service, permRepo, roleRepo, userRepo }; + }; + + it('skips re-seeding (no reactivation) when roles exist and SEED_ROLES is not set', async () => { + const original = process.env.SEED_ROLES; + delete process.env.SEED_ROLES; + try { + const { service, permRepo, roleRepo, userRepo } = makeService(3); + await service.seedData(); + expect(roleRepo.count).toHaveBeenCalled(); + expect(permRepo.findOne).not.toHaveBeenCalled(); + expect(roleRepo.save).not.toHaveBeenCalled(); + expect(userRepo.update).not.toHaveBeenCalled(); + } finally { + if (original === undefined) delete process.env.SEED_ROLES; + else process.env.SEED_ROLES = original; + } + }); + + it('seeds when SEED_ROLES=true even if roles already exist', async () => { + const original = process.env.SEED_ROLES; + process.env.SEED_ROLES = 'true'; + try { + const { service, permRepo, roleRepo } = makeService(3); + await service.seedData(); + expect(permRepo.findOne).toHaveBeenCalled(); + expect(roleRepo.save).toHaveBeenCalled(); + } finally { + if (original === undefined) delete process.env.SEED_ROLES; + else process.env.SEED_ROLES = original; + } + }); +}); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 5232bbd..6c35366 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -92,17 +92,22 @@ export class RoomsService { } async create(dto: CreateRoomDto) { - const parsed = RoomsService.parseRoomNumber(dto.roomNumber); - const entity = this.repo.create({ - ...dto, - building: dto.building ?? parsed.building, - floor: dto.floor ?? parsed.floor, - roomType: dto.roomType ?? parsed.roomType, - capacity: dto.capacity ?? parsed.capacity, + // 宿舍 + 默认床位创建放进同一事务,避免宿舍已建、床位失败留下半成品。 + return this.dataSource.transaction(async (manager) => { + const roomRepo = manager.getRepository(Room); + const bedRepo = manager.getRepository(Bed); + const parsed = RoomsService.parseRoomNumber(dto.roomNumber); + const entity = roomRepo.create({ + ...dto, + building: dto.building ?? parsed.building, + floor: dto.floor ?? parsed.floor, + roomType: dto.roomType ?? parsed.roomType, + capacity: dto.capacity ?? parsed.capacity, + }); + const room = await roomRepo.save(entity); + await this.createDefaultBedsWithManager(bedRepo, room.id, room.capacity); + return room; }); - const room = await this.repo.save(entity); - await this.createDefaultBeds(room.id, room.capacity); - return room; } async update(id: number, dto: UpdateRoomDto) { @@ -311,6 +316,19 @@ export class RoomsService { return this.queries.createDefaultBeds(roomId, capacity); } + private async createDefaultBedsWithManager( + bedRepo: Repository, + roomId: number, + capacity: number, + ): Promise { + const count = Math.max(capacity ?? 0, 0); + if (count === 0) return; + const beds = Array.from({ length: count }, (_, index) => + bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), + ); + await bedRepo.save(beds); + } + private getNextBedNumber(beds: Pick[]): number { return this.bedOps.getNextBedNumber(beds); } diff --git a/apps/server/src/students/students.import.service.spec.ts b/apps/server/src/students/students.import.service.spec.ts new file mode 100644 index 0000000..3b87d41 --- /dev/null +++ b/apps/server/src/students/students.import.service.spec.ts @@ -0,0 +1,558 @@ +import { BadRequestException } from '@nestjs/common'; +import { EntityManager, Repository } from 'typeorm'; +import { StudentsImportService } from './students.import.service'; +import { Student } from '../entities/student.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; +import { Organization } from '../entities/organization.entity'; + +describe('StudentsImportService.batchImport', () => { + const makeManager = (options?: { + existingByPhone?: Record>; + existingByIdNumber?: Record>; + hostOrganization?: Organization | null; + throwOnOrganization?: boolean; + }): { + manager: EntityManager; + studentRepo: Repository; + organizationRepo: Repository; + archiveRepo: Record; + savedProfiles: any[]; + savedEnrollments: any[]; + savedExamScores: any[]; + savedLearningRecords: any[]; + } => { + const existingByPhone = options?.existingByPhone ?? {}; + const existingByIdNumber = options?.existingByIdNumber ?? {}; + const savedProfiles: any[] = []; + const savedEnrollments: any[] = []; + const savedExamScores: any[] = []; + const savedLearningRecords: any[] = []; + let nextStudentId = 1; + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + const studentRepo = { + // 批量预取后不再逐行调用 findOne:保留 mock 用于断言已被替代 + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn(async ({ where }: any) => { + if (where?.phone) { + return inValues(where.phone) + .map((phone) => existingByPhone[phone]) + .filter((student): student is Student => Boolean(student)); + } + if (where?.idNumber) { + return inValues(where.idNumber) + .map((idNumber) => existingByIdNumber[idNumber]) + .filter((student): student is Student => Boolean(student)); + } + return []; + }), + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: any) => { + const id = value?.id ?? nextStudentId++; + return { ...value, id }; + }), + update: jest.fn(), + } as unknown as Repository; + const organizationRepo = { + findOne: jest.fn(async () => { + if (options?.throwOnOrganization) throw new BadRequestException('尚未配置本机构'); + return options?.hostOrganization ?? { id: 7, isHost: true, status: 'active' }; + }), + } as unknown as Repository; + const archiveRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: any) => { + savedProfiles.push(value); + return { ...value, id: value?.id ?? 10 }; + }), + }; + const manager = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return studentRepo; + if (entity === Organization) return organizationRepo; + if (entity === StudentProfile) return archiveRepo; + if (entity === ResultArchive) return archiveRepo; + if (entity === StudentEnrollment) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedEnrollments.push(v); + return { ...v, id: v?.id ?? 20 }; + }), + }; + } + if (entity === ExamScore) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedExamScores.push(v); + return { ...v, id: v?.id ?? 30 }; + }), + }; + } + if (entity === LearningRecord) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedLearningRecords.push(v); + return { ...v, id: v?.id ?? 40 }; + }), + }; + } + throw new Error(`Unexpected entity ${String(entity)}`); + }), + } as unknown as EntityManager; + return { + manager, + studentRepo, + organizationRepo, + archiveRepo, + savedProfiles, + savedEnrollments, + savedExamScores, + savedLearningRecords, + }; + }; + + const createService = (options?: Parameters[0]) => { + const mocks = makeManager(options); + const repo = { + manager: { + transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(mocks.manager)), + }, + } as unknown as Repository; + const service = new StudentsImportService(repo); + return { service, repo, ...mocks }; + }; + + it('imports students inside one transaction and returns the existing shape', async () => { + const { service, repo } = createService(); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '李四', phone: '13900139000' }, + ]); + + expect(result).toEqual({ + message: '成功导入 2 名学生,导入档案相关记录 0 条,跳过 0 条(重复或空行)', + imported: 2, + archiveImported: 0, + skipped: 0, + invalidDateSkipped: 0, + }); + expect(repo.manager.transaction).toHaveBeenCalledTimes(1); + }); + + it('skips duplicate and empty rows without aborting the batch', async () => { + const { service } = createService({ + existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student }, + }); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '', phone: '13900139000' }, + { name: '王五', phone: '13700137000' }, + ]); + + expect(result).toEqual({ + message: '成功导入 1 名学生,导入档案相关记录 0 条,跳过 2 条(重复或空行)', + imported: 1, + archiveImported: 0, + skipped: 2, + invalidDateSkipped: 0, + }); + }); + + it('keeps fail-fast semantics: a row failure rejects and rolls the batch back', async () => { + const { service, repo, manager } = createService({ throwOnOrganization: true }); + await expect( + service.batchImport([{ name: '张三', phone: '13800138000' }]), + ).rejects.toBeInstanceOf(BadRequestException); + // The transaction callback threw, so nothing was committed. + expect(repo.manager.transaction).toHaveBeenCalledTimes(1); + expect(manager.getRepository).toHaveBeenCalledWith(Student); + }); + + it('queries the host organization only once across the whole batch', async () => { + const { service, organizationRepo } = createService(); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '李四', phone: '13900139000' }, + { name: '王五', phone: '13700137000' }, + ]); + + expect(result.imported).toBe(3); + expect(organizationRepo.findOne).toHaveBeenCalledTimes(1); + }); + + it('routes archive rows to the right student by phone', async () => { + const { service, savedEnrollments, savedExamScores, savedLearningRecords } = createService(); + const result = await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000' }, + { name: '李四', phone: '13900139000' }, + ], + enrollments: [ + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + { phone: '13900139000', courseCategory: 'art', classType: 'small_class', className: '周末班' }, + ], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90 }, + { phone: '13900139000', examType: 'final', subject: '数学', score: 88 }, + ], + learningRecords: [ + { phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: 'ok' }, + { phone: '13900139000', recordDate: '2024-10-21', recordType: 'study_feedback', content: 'good' }, + ], + }); + + expect(result.imported).toBe(2); + expect(result.archiveImported).toBe(6); + expect(savedEnrollments.map((e) => e.studentId)).toEqual([1, 2]); + expect(savedExamScores.map((e) => e.studentId)).toEqual([1, 2]); + expect(savedLearningRecords.map((e) => e.studentId)).toEqual([1, 2]); + }); + + it('deduplicates identical enrollment rows within one import (in-memory upsert)', async () => { + const { service, savedEnrollments } = createService(); + await service.batchImport({ + students: [{ name: '张三', phone: '13800138000' }], + enrollments: [ + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + ], + examScores: [], + learningRecords: [], + }); + + // 同一行被命中两次并更新保存(与原有“find 后 save”的 upsert 语义一致), + // 但始终落在同一条记录上,不会新建重复行。 + expect(savedEnrollments).toHaveLength(2); + expect(new Set(savedEnrollments.map((e) => e.id)).size).toBe(1); + }); + + it('skips invalid dates, records the count, and still imports the rest of the row', async () => { + const { service, savedProfiles } = createService(); + const result = await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024/9/1' }, + ], + enrollments: [], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: 'not-a-date' }, + ], + learningRecords: [ + { phone: '13800138000', recordDate: '2024/10/20', recordType: 'study_feedback', content: 'ok' }, + ], + }); + + // profile + exam score imported(非法日期字段被跳过);learning record 因必填日期非法整条跳过 + expect(result.imported).toBe(1); + expect(result.archiveImported).toBe(2); + expect(result.invalidDateSkipped).toBe(3); + expect(savedProfiles[0]).not.toHaveProperty('profileDate'); + }); + + it('treats impossible calendar dates as invalid while accepting real leap days', async () => { + const { service, savedProfiles, savedExamScores } = createService(); + const result = await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-02-31' }, + ], + enrollments: [], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2023-02-29' }, + { phone: '13800138000', examType: 'final', subject: '数学', score: 88, examDate: '2024-02-29' }, + ], + learningRecords: [], + }); + + // 2024-02-31 非法(2 月没有 31 号);2023 非闰年 2/29 非法;2024 闰年 2/29 合法 + expect(result.invalidDateSkipped).toBe(2); + expect(savedProfiles[0]).not.toHaveProperty('profileDate'); + expect(savedExamScores[0].examDate).toBeUndefined(); + expect(savedExamScores[1].examDate).toBe('2024-02-29'); + }); + + it('writes well-formed dates unchanged', async () => { + const { service, savedProfiles } = createService(); + await service.batchImport({ + students: [ + { name: '张三', phone: '13800138000', targetCollege: '北大', profileDate: '2024-09-01' }, + ], + enrollments: [], + examScores: [ + { phone: '13800138000', examType: 'monthly', subject: '语文', score: 90, examDate: '2024-10-15' }, + ], + learningRecords: [ + { phone: '13800138000', recordDate: '2024-10-20', recordType: 'study_feedback', content: '状态稳定' }, + ], + }); + + expect(savedProfiles[0].profileDate).toBe('2024-09-01'); + }); + + it('prefetches existing students with two IN queries instead of per-row findOne', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, name: '张三', phone: '13800138000' } as Student }, + existingByIdNumber: { + '110101199001011234': { id: 2, name: '李四', idNumber: '110101199001011234' } as Student, + }, + }); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '李四', idNumber: '110101199001011234' }, + { name: '王五', phone: '13900139000' }, + ]); + + // 批量预取:phone + idNumber 各一次 IN 查询,不再逐行 findOne + expect(studentRepo.find).toHaveBeenCalledTimes(2); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(2); + }); + + it('keeps in-batch dedup by phone/idNumber after saving a new student', async () => { + const { service, studentRepo } = createService(); + const result = await service.batchImport([ + { name: '张三', phone: '13800138000' }, + { name: '张三副本', phone: '13800138000' }, + { name: '李四', phone: '13900139000', idNumber: '110101199001011234' }, + { name: '李四副本', idNumber: '110101199001011234' }, + ]); + + // 新保存学生同步进内存索引:后续相同 phone/idNumber 行视为重复跳过 + expect(result.imported).toBe(2); + expect(result.skipped).toBe(2); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + }); +}); + +describe('StudentsImportService.matchImport', () => { + const createService = (options?: { + existingByPhone?: Record>; + existingByIdNumber?: Record>; + }) => { + const existingByPhone = options?.existingByPhone ?? {}; + const existingByIdNumber = options?.existingByIdNumber ?? {}; + const savedProfiles: any[] = []; + const savedEnrollments: any[] = []; + const savedExamScores: any[] = []; + const savedLearningRecords: any[] = []; + const inValues = (value: unknown): string[] => { + if (Array.isArray(value)) return value as string[]; + if (value && typeof value === 'object' && '_value' in value) { + return (value as { _value: unknown })._value as string[]; + } + return []; + }; + const studentRepo = { + // 批量预取后不再逐行调用 findOne:保留 mock 用于断言已被替代 + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn(async ({ where }: any) => { + if (where?.phone) { + return inValues(where.phone) + .map((phone) => existingByPhone[phone]) + .filter((student): student is Student => Boolean(student)); + } + if (where?.idNumber) { + return inValues(where.idNumber) + .map((idNumber) => existingByIdNumber[idNumber]) + .filter((student): student is Student => Boolean(student)); + } + return []; + }), + update: jest.fn(), + } as unknown as Repository; + const archiveRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: any) => { + savedProfiles.push(value); + return { ...value, id: value?.id ?? 10 }; + }), + }; + const manager = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return studentRepo; + if (entity === StudentProfile) return archiveRepo; + if (entity === ResultArchive) return archiveRepo; + if (entity === StudentEnrollment) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedEnrollments.push(v); + return { ...v, id: v?.id ?? 20 }; + }), + }; + } + if (entity === ExamScore) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedExamScores.push(v); + return { ...v, id: v?.id ?? 30 }; + }), + }; + } + if (entity === LearningRecord) { + return { + find: jest.fn().mockResolvedValue([]), + create: jest.fn((v: unknown) => v), + save: jest.fn(async (v: any) => { + savedLearningRecords.push(v); + return { ...v, id: v?.id ?? 40 }; + }), + }; + } + throw new Error(`Unexpected entity ${String(entity)}`); + }), + } as unknown as EntityManager; + const repo = { + manager: { + transaction: jest.fn(async (cb: (m: EntityManager) => unknown) => cb(manager)), + }, + } as unknown as Repository; + const service = new StudentsImportService(repo); + return { service, studentRepo, savedProfiles, savedEnrollments, savedExamScores, savedLearningRecords }; + }; + + it('updates a matched student with writable fields', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + }); + const result = await service.matchImport([ + { phone: '13800138000', name: '张三', gender: '男' }, + ]); + + expect(studentRepo.update).toHaveBeenCalledWith(1, { name: '张三', gender: '男' }); + expect(result).toEqual({ + message: '更新已有学生资料 1 人,导入档案相关记录 0 条,跳过 0 条(无匹配/无更新字段/双键冲突)', + matched: 1, + archiveImported: 0, + skipped: 0, + skippedNoFields: 0, + skippedConflict: 0, + invalidDateSkipped: 0, + }); + }); + + it('skips matched rows without writable fields instead of calling an empty update', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + }); + const result = await service.matchImport([{ phone: '13800138000' }]); + + expect(studentRepo.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + message: '更新已有学生资料 0 人,导入档案相关记录 0 条,跳过 1 条(无匹配/无更新字段/双键冲突)', + matched: 0, + archiveImported: 0, + skipped: 1, + skippedNoFields: 1, + skippedConflict: 0, + invalidDateSkipped: 0, + }); + }); + + it('skips rows whose phone and idNumber match different students (conflict)', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + existingByIdNumber: { '440111200001010011': { id: 2, idNumber: '440111200001010011' } as Student }, + }); + const result = await service.matchImport([ + { phone: '13800138000', idNumber: '440111200001010011', name: '张三' }, + ]); + + // 双键命中不同学生:不得更新任何一方,行计入 skippedConflict + expect(studentRepo.update).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.skippedConflict).toBe(1); + expect(result.matched).toBe(0); + }); + + it('matches by idNumber and can still update phone when it differs', async () => { + const { service, studentRepo } = createService({ + existingByIdNumber: { '110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student }, + }); + const result = await service.matchImport([ + { idNumber: '110101199001011234', phone: '13900139000' }, + ]); + + expect(studentRepo.update).toHaveBeenCalledWith(1, { phone: '13900139000' }); + expect(result.matched).toBe(1); + expect(result.skippedNoFields).toBe(0); + }); + + it('imports archive rows for a matched student even when the student row has no fields', async () => { + const { service, studentRepo, savedEnrollments } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + }); + const result = await service.matchImport({ + students: [{ phone: '13800138000' }], + enrollments: [ + { phone: '13800138000', courseCategory: 'culture', classType: 'one_on_one', className: '冲刺班' }, + ], + examScores: [], + learningRecords: [], + }); + + expect(studentRepo.update).not.toHaveBeenCalled(); + expect(savedEnrollments).toHaveLength(1); + expect(result.matched).toBe(1); + expect(result.archiveImported).toBe(1); + expect(result.skipped).toBe(0); + expect(result.skippedNoFields).toBe(0); + }); + + it('prefetches existing students with two IN queries instead of per-row findOne', async () => { + const { service, studentRepo } = createService({ + existingByPhone: { '13800138000': { id: 1, phone: '13800138000' } as Student }, + existingByIdNumber: { + '110101199001011234': { id: 2, idNumber: '110101199001011234' } as Student, + }, + }); + const result = await service.matchImport([ + { phone: '13800138000', name: '张三' }, + { idNumber: '110101199001011234', name: '李四' }, + { phone: '13900139000', name: '王五' }, + ]); + + // 批量预取:phone + idNumber 各一次 IN 查询,不再逐行 findOne + expect(studentRepo.find).toHaveBeenCalledTimes(2); + expect(studentRepo.findOne).not.toHaveBeenCalled(); + expect(result.matched).toBe(2); + expect(result.skipped).toBe(1); + }); + + it('keeps matching by updated phone within the same batch', async () => { + const { service, studentRepo } = createService({ + existingByIdNumber: { + '110101199001011234': { id: 1, idNumber: '110101199001011234', phone: '13800138000' } as Student, + }, + }); + const result = await service.matchImport([ + { idNumber: '110101199001011234', phone: '13900139000', name: '张三' }, + { phone: '13900139000', name: '张三2' }, + ]); + + // 第一行通过 idNumber 命中并把 phone 改为 13900139000, + // 第二行按更新后的 phone 仍能命中同一学生(与同一事务内 findOne 语义一致) + expect(studentRepo.update).toHaveBeenCalledTimes(2); + expect(result.matched).toBe(2); + expect(result.skipped).toBe(0); + }); +}); diff --git a/apps/server/src/students/students.import.service.ts b/apps/server/src/students/students.import.service.ts index ef89cf4..d71b1b4 100644 --- a/apps/server/src/students/students.import.service.ts +++ b/apps/server/src/students/students.import.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, In, Repository } from 'typeorm'; import { Student } from '../entities/student.entity'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; @@ -16,115 +16,296 @@ import type { StudentWorkbookImport, } from './student-import'; import { getHostOrganizationId } from './students.organization'; +import { addDaysToDateOnly } from '../common/china-time'; + +/** 学生查重/匹配的内存索引:phone 与 idNumber 各一张 Map(key 为去空白后的值)。 */ +type StudentLookupIndex = { + byPhone: Map; + byIdNumber: Map; +}; @Injectable() export class StudentsImportService { + // 档案/报读/成绩等写入统一走 manager.getRepository(见下方 helpers), + // 这里只注入 Student repo(查重、更新、开启事务)。 constructor( @InjectRepository(Student) private readonly repo: Repository, - @InjectRepository(StudentProfile) private readonly profileRepo: Repository, - @InjectRepository(StudentEnrollment) - private readonly enrollmentRepo: Repository, - @InjectRepository(ExamScore) private readonly examScoreRepo: Repository, - @InjectRepository(LearningRecord) - private readonly learningRecordRepo: Repository, - @InjectRepository(ResultArchive) private readonly resultRepo: Repository, - @InjectRepository(Organization) private readonly organizationRepo: Repository, ) {} async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); - let imported = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - if (!row.name || !row.name.trim()) { - skipped++; - continue; + // 整批导入放进同一事务:查重、建学生、写档案要么全部成功,要么全部回滚。 + return this.repo.manager.transaction(async (manager) => { + const studentRepo = manager.getRepository(Student); + const organizationRepo = manager.getRepository(Organization); + // 按手机号预建报读/成绩/回访行分组(手机号是导入行与学生档案之间的关联键, + // 等价于按学生分组),避免逐学生循环内对 data.* 全量 filter。 + const enrollmentsByPhone = this.groupByPhone(data.enrollments); + const examScoresByPhone = this.groupByPhone(data.examScores); + const learningRecordsByPhone = this.groupByPhone(data.learningRecords); + // 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询), + // 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。 + const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students); + let imported = 0; + let skipped = 0; + let archiveImported = 0; + let invalidDateSkipped = 0; + // 本机构 id 循环外懒加载缓存一次,避免每个学生行都查询 + let hostOrganizationId: number | undefined; + for (const row of data.students) { + if (!row.name || !row.name.trim()) { + skipped++; + continue; + } + // 按 phone/idNumber 查重:先 phone 后 idNumber,命中即视为已存在 + if (this.matchStudentByIndexes(existingIndexes, row)) { + skipped++; + continue; + } + if (!row.organizationId && hostOrganizationId === undefined) { + hostOrganizationId = await getHostOrganizationId(organizationRepo); + } + const student = await studentRepo.save( + studentRepo.create({ + name: row.name.trim(), + studentNo: row.studentNo?.trim() || undefined, + phone: row.phone?.trim() || undefined, + idNumber: row.idNumber?.trim() || undefined, + gender: row.gender || undefined, + ethnicity: row.ethnicity || undefined, + emergencyContact: row.emergencyContact || undefined, + emergencyPhone: row.emergencyPhone || undefined, + supervisor: row.supervisor || undefined, + organizationId: row.organizationId || hostOrganizationId, + }), + ); + // 新保存的学生同步进内存索引:本批后续相同 phone/idNumber 行视为重复跳过, + // 保持与逐行 findOne 一致的 upsert/去重语义。 + this.indexStudent(existingIndexes, student); + const archive = await this.importArchiveData(manager, student.id, row, { + enrollments: enrollmentsByPhone, + examScores: examScoresByPhone, + learningRecords: learningRecordsByPhone, + }); + archiveImported += archive.imported; + invalidDateSkipped += archive.invalidDateSkipped; + imported++; } - const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); - if (exists) { - skipped++; - continue; - } - const student = await this.repo.save( - this.repo.create({ - name: row.name.trim(), - studentNo: row.studentNo?.trim() || undefined, - phone: row.phone?.trim() || undefined, - idNumber: row.idNumber?.trim() || undefined, - gender: row.gender || undefined, - ethnicity: row.ethnicity || undefined, - emergencyContact: row.emergencyContact || undefined, - emergencyPhone: row.emergencyPhone || undefined, - supervisor: row.supervisor || undefined, - organizationId: row.organizationId || (await getHostOrganizationId(this.organizationRepo)), - }), - ); - archiveImported += await this.importArchiveData(student.id, row, data); - imported++; - } - return { - message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, - imported, - archiveImported, - skipped, - }; + return { + message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, + imported, + archiveImported, + skipped, + invalidDateSkipped, + }; + }); } async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); - let matched = 0; - let skipped = 0; - let archiveImported = 0; - for (const row of data.students) { - // Match by phone first, then idNumber - let student = row.phone?.trim() - ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) - : null; - if (!student && row.idNumber?.trim()) { - student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); + // 整批匹配写入放进同一事务:更新学生资料与写档案要么全部成功,要么全部回滚。 + return this.repo.manager.transaction(async (manager) => { + const studentRepo = manager.getRepository(Student); + // 与 batchImport 一致:按手机号预建分组,避免逐学生循环内全量 filter。 + const enrollmentsByPhone = this.groupByPhone(data.enrollments); + const examScoresByPhone = this.groupByPhone(data.examScores); + const learningRecordsByPhone = this.groupByPhone(data.learningRecords); + // 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询), + // 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。 + const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students); + let matched = 0; + let skipped = 0; + let skippedNoFields = 0; + let skippedConflict = 0; + let archiveImported = 0; + let invalidDateSkipped = 0; + for (const row of data.students) { + // 双键命中不同学生:跳过该行,避免把 idNumber 写到错误的 student 上 + if (this.hasIndexConflict(existingIndexes, row)) { + skipped++; + skippedConflict++; + continue; + } + // Match by phone first, then idNumber + const student = this.matchStudentByIndexes(existingIndexes, row); + if (!student) { + skipped++; + continue; + } + const updates: Partial< + Pick< + Student, + | 'name' + | 'studentNo' + | 'phone' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + | 'supervisor' + | 'organizationId' + > + > = {}; + if (row.name?.trim()) updates.name = row.name.trim(); + if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); + // phone/idNumber 是匹配键:与命中学生一致时视为无变更(避免空 set 更新), + // 不一致时仍允许通过另一个键命中后更新。 + if (row.phone?.trim() && row.phone.trim() !== student.phone) updates.phone = row.phone.trim(); + if (row.idNumber?.trim() && row.idNumber.trim() !== student.idNumber) updates.idNumber = row.idNumber.trim(); + if (row.gender) updates.gender = row.gender; + if (row.ethnicity) updates.ethnicity = row.ethnicity; + if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; + if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; + if (row.supervisor) updates.supervisor = row.supervisor; + if (row.organizationId) updates.organizationId = row.organizationId; + // 按 phone/idNumber 命中已有学生但本行没有任何可写字段时, + // 跳过该学生的更新(避免 TypeORM 对空 set 报错),保留匹配/去重语义。 + const hasWritableFields = Object.keys(updates).length > 0; + const archive = await this.importArchiveData(manager, student.id, row, { + enrollments: enrollmentsByPhone, + examScores: examScoresByPhone, + learningRecords: learningRecordsByPhone, + }); + archiveImported += archive.imported; + invalidDateSkipped += archive.invalidDateSkipped; + if (!hasWritableFields && archive.imported === 0 && archive.invalidDateSkipped === 0) { + // 整行无任何可写字段:计入 skipped(不抛错) + skippedNoFields++; + skipped++; + continue; + } + if (hasWritableFields) { + await studentRepo.update(student.id, updates); + // 更新后的 phone/idNumber 同步进内存索引:后续行按新值仍可命中, + // 与逐行 findOne 在同一事务内能看到本批已更新记录的语义一致。 + this.reindexStudent(existingIndexes, student, updates); + } + matched++; } - if (!student) { - skipped++; - continue; - } - const updates: Partial< - Pick< - Student, - | 'name' - | 'studentNo' - | 'phone' - | 'idNumber' - | 'gender' - | 'ethnicity' - | 'emergencyContact' - | 'emergencyPhone' - | 'supervisor' - | 'organizationId' - > - > = {}; - if (row.name?.trim()) updates.name = row.name.trim(); - if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); - if (row.phone?.trim()) updates.phone = row.phone.trim(); - if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); - if (row.gender) updates.gender = row.gender; - if (row.ethnicity) updates.ethnicity = row.ethnicity; - if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; - if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; - if (row.supervisor) updates.supervisor = row.supervisor; - if (row.organizationId) updates.organizationId = row.organizationId; - await this.repo.update(student.id, updates); - archiveImported += await this.importArchiveData(student.id, row, data); - matched++; - } + return { + message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配/无更新字段/双键冲突)`, + matched, + archiveImported, + skipped, + skippedNoFields, + skippedConflict, + invalidDateSkipped, + }; + }); + } + + /** + * 批量预取本批导入行涉及的所有 phone / idNumber 对应的已有学生: + * 只发两条 IN 查询(phone In(...)、idNumber In(...)),内存建索引供逐行匹配, + * 替代逐行 findOne(N+1)。某类 key 为空时跳过对应查询。 + */ + private async prefetchStudentIndexes( + studentRepo: Repository, + rows: StudentImportRow[], + ): Promise { + const phones = [ + ...new Set( + rows + .map((row) => row.phone?.trim()) + .filter((phone): phone is string => Boolean(phone)), + ), + ]; + const idNumbers = [ + ...new Set( + rows + .map((row) => row.idNumber?.trim()) + .filter((idNumber): idNumber is string => Boolean(idNumber)), + ), + ]; + const [byPhoneList, byIdNumberList] = await Promise.all([ + phones.length > 0 + ? studentRepo.find({ where: { phone: In(phones) } }) + : Promise.resolve([] as Student[]), + idNumbers.length > 0 + ? studentRepo.find({ where: { idNumber: In(idNumbers) } }) + : Promise.resolve([] as Student[]), + ]); return { - message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, - matched, - archiveImported, - skipped, + byPhone: this.buildIndex(byPhoneList, (student) => student.phone), + byIdNumber: this.buildIndex(byIdNumberList, (student) => student.idNumber), }; } + /** 从查询结果构建 phone / idNumber → Student 的 Map(key 去空白,空值不建索引)。 */ + private buildIndex( + students: Student[], + keyOf: (student: Student) => string | null | undefined, + ): Map { + const index = new Map(); + for (const student of students) { + const key = keyOf(student)?.trim(); + if (key) index.set(key, student); + } + return index; + } + + /** + * 双键冲突检测:同一行 phone 与 idNumber 分别命中不同的学生时,不能更新任何一个, + * 否则会把一个学生的 idNumber 写到另一个学生身上(数据错乱)。冲突行按 skipped 处理。 + */ + private hasIndexConflict(indexes: StudentLookupIndex, row: StudentImportRow): boolean { + const phone = row.phone?.trim(); + const idNumber = row.idNumber?.trim(); + if (!phone || !idNumber) return false; + const byPhone = indexes.byPhone.get(phone); + const byIdNumber = indexes.byIdNumber.get(idNumber); + return !!byPhone && !!byIdNumber && byPhone.id !== byIdNumber.id; + } + + /** 逐行匹配已有学生:优先 phone,未命中再按 idNumber(与 matchImport 原有语义一致)。 */ + private matchStudentByIndexes( + indexes: StudentLookupIndex, + row: StudentImportRow, + ): Student | undefined { + const phone = row.phone?.trim(); + if (phone) { + const byPhone = indexes.byPhone.get(phone); + if (byPhone) return byPhone; + } + const idNumber = row.idNumber?.trim(); + if (idNumber) { + const byIdNumber = indexes.byIdNumber.get(idNumber); + if (byIdNumber) return byIdNumber; + } + return undefined; + } + + /** 新保存的学生同步进内存索引(batchImport 批次内去重)。 */ + private indexStudent(indexes: StudentLookupIndex, student: Student): void { + if (student.phone) indexes.byPhone.set(student.phone.trim(), student); + if (student.idNumber) indexes.byIdNumber.set(student.idNumber.trim(), student); + } + + /** + * matchImport 更新学生资料后同步内存索引:phone/idNumber 被改写时删除旧 key、 + * 注册新 key,并让内存实体与已更新值保持一致(后续行按新值仍可命中)。 + */ + private reindexStudent( + indexes: StudentLookupIndex, + student: Student, + updates: Partial>, + ): void { + const oldPhone = student.phone?.trim(); + const newPhone = updates.phone?.trim(); + const oldIdNumber = student.idNumber?.trim(); + const newIdNumber = updates.idNumber?.trim(); + Object.assign(student, updates); + if (newPhone && newPhone !== oldPhone) { + if (oldPhone) indexes.byPhone.delete(oldPhone); + indexes.byPhone.set(newPhone, student); + } + if (newIdNumber && newIdNumber !== oldIdNumber) { + if (oldIdNumber) indexes.byIdNumber.delete(oldIdNumber); + indexes.byIdNumber.set(newIdNumber, student); + } + } + private normalizeImportData( importData: StudentWorkbookImport | StudentImportRow[], ): StudentWorkbookImport { @@ -142,6 +323,30 @@ export class StudentsImportService { return String(left ?? '').trim() === String(right ?? '').trim(); } + /** 按手机号预建行分组(手机号去空白后作为 key,空手机号的行不参与匹配)。 */ + private groupByPhone(rows: T[]): Map { + const byPhone = new Map(); + for (const row of rows) { + const phone = this.normalizePhone(row.phone); + if (!phone) continue; + const list = byPhone.get(phone); + if (list) list.push(row); + else byPhone.set(phone, [row]); + } + return byPhone; + } + + /** + * 日期字段写入前的真实日历校验:先检查 YYYY-MM-DD 形状, + * 再用 common/china-time 的 addDaysToDateOnly 往返判断月/日是否真实存在 + * (Date.UTC 会把溢出日期归一化,如 2024-02-31 → 2024-03-02,往返不一致即非法)。 + */ + private isValidDateOnly(value: string): boolean { + const dateOnly = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return false; + return addDaysToDateOnly(dateOnly, 0) === dateOnly; + } + private hasProfileData(row: StudentImportRow) { return [ row.targetCollege, @@ -166,89 +371,161 @@ export class StudentsImportService { } private async importArchiveData( + manager: EntityManager, studentId: number, row: StudentImportRow, - data: StudentWorkbookImport, - ) { + data: { + enrollments: Map; + examScores: Map; + learningRecords: Map; + }, + ): Promise<{ imported: number; invalidDateSkipped: number }> { const phone = this.normalizePhone(row.phone); let imported = 0; + let invalidDateSkipped = 0; if (this.hasProfileData(row)) { - await this.upsertProfileFromImport(studentId, row); + // 非法日期不写入,跳过该字段并计数 + if (row.profileDate?.trim() && !this.isValidDateOnly(row.profileDate)) { + invalidDateSkipped++; + } + await this.upsertProfileFromImport(manager, studentId, row); imported++; } if (this.hasResultData(row)) { - await this.upsertResultFromImport(studentId, row); + await this.upsertResultFromImport(manager, studentId, row); imported++; } - if (!phone) return imported; + if (!phone) return { imported, invalidDateSkipped }; + + const enrollmentRows = data.enrollments.get(phone) ?? []; + const examRows = data.examScores.get(phone) ?? []; + const learningRows = data.learningRecords.get(phone) ?? []; + + // 按 studentId 批量预取既有档案行,内存匹配(保持 upsert 语义), + // 避免每个档案行单独 find 查询;新建保存后同步加入内存列表供后续行匹配。 + let existingEnrollments: StudentEnrollment[] = []; + let existingExamScores: ExamScore[] = []; + let existingLearningRecords: LearningRecord[] = []; + if (enrollmentRows.length > 0) { + existingEnrollments = await manager.getRepository(StudentEnrollment).find({ + where: { studentId }, + }); + } + if (examRows.length > 0) { + existingExamScores = await manager.getRepository(ExamScore).find({ + where: { studentId }, + }); + } + if (learningRows.length > 0) { + existingLearningRecords = await manager.getRepository(LearningRecord).find({ + where: { studentId }, + }); + } const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { - const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); + for (const enrollmentRow of enrollmentRows) { + const enrollment = await this.upsertEnrollmentFromImport( + manager, + studentId, + enrollmentRow, + existingEnrollments, + ); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } - for (const examRow of data.examScores.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { - if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { + for (const examRow of examRows) { + // 非法日期不写入,跳过该字段并计数 + if (examRow.examDate?.trim() && !this.isValidDateOnly(examRow.examDate)) { + invalidDateSkipped++; + } + if ( + await this.upsertExamScoreFromImport( + manager, + studentId, + examRow, + enrollmentByClassName, + existingExamScores, + ) + ) { imported++; } } - for (const learningRow of data.learningRecords.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { - if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { + for (const learningRow of learningRows) { + // recordDate 必填:非法日期视为缺省,跳过整条并计数 + if (learningRow.recordDate?.trim() && !this.isValidDateOnly(learningRow.recordDate)) { + invalidDateSkipped++; + continue; + } + if (await this.upsertLearningRecordFromImport(manager, studentId, learningRow, existingLearningRecords)) { imported++; } } - return imported; + return { imported, invalidDateSkipped }; } - private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { + private async upsertProfileFromImport( + manager: EntityManager, + studentId: number, + row: StudentImportRow, + ) { + const profileRepo = manager.getRepository(StudentProfile); const entity = - (await this.profileRepo.findOne({ where: { studentId } })) || - this.profileRepo.create({ studentId }); + (await profileRepo.findOne({ where: { studentId } })) || + profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); if (row.grade?.trim()) entity.grade = row.grade.trim(); - if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); + if (row.profileDate?.trim() && this.isValidDateOnly(row.profileDate)) { + entity.profileDate = row.profileDate.trim(); + } if (row.notes?.trim()) entity.notes = row.notes.trim(); - await this.profileRepo.save(entity); + await profileRepo.save(entity); } - private async upsertResultFromImport(studentId: number, row: StudentImportRow) { + private async upsertResultFromImport( + manager: EntityManager, + studentId: number, + row: StudentImportRow, + ) { + const resultRepo = manager.getRepository(ResultArchive); const entity = - (await this.resultRepo.findOne({ where: { studentId } })) || - this.resultRepo.create({ studentId }); + (await resultRepo.findOne({ where: { studentId } })) || + resultRepo.create({ studentId }); if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); - await this.resultRepo.save(entity); + await resultRepo.save(entity); } - private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { + private async upsertEnrollmentFromImport( + manager: EntityManager, + studentId: number, + row: StudentEnrollmentImportRow, + existing: StudentEnrollment[], + ) { if (!row.courseCategory?.trim() || !row.classType?.trim()) { return null; } - const existing = await this.enrollmentRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.courseCategory, row.courseCategory) && - this.sameValue(item.classType, row.classType) && - this.sameValue(item.className, row.className) && - this.sameValue(item.startDate, row.startDate), - ) || this.enrollmentRepo.create({ studentId }); + const enrollmentRepo = manager.getRepository(StudentEnrollment); + let entity = existing.find( + (item) => + this.sameValue(item.courseCategory, row.courseCategory) && + this.sameValue(item.classType, row.classType) && + this.sameValue(item.className, row.className) && + this.sameValue(item.startDate, row.startDate), + ); + if (!entity) { + entity = enrollmentRepo.create({ studentId }); + // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 + existing.push(entity); + } entity.courseCategory = row.courseCategory.trim(); entity.classType = row.classType.trim(); if (row.className?.trim()) entity.className = row.className.trim(); @@ -258,57 +535,74 @@ export class StudentsImportService { if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); if (row.status?.trim()) entity.status = row.status.trim(); else if (!entity.status) entity.status = 'active'; - return this.enrollmentRepo.save(entity); + return enrollmentRepo.save(entity); } private async upsertExamScoreFromImport( + manager: EntityManager, studentId: number, row: ExamScoreImportRow, enrollmentByClassName: Map, + existing: ExamScore[], ) { if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; - const existing = await this.examScoreRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.examType, row.examType) && - this.sameValue(item.examName, row.examName) && - this.sameValue(item.subject, row.subject) && - this.sameValue(item.examDate, row.examDate), - ) || this.examScoreRepo.create({ studentId }); + const examScoreRepo = manager.getRepository(ExamScore); + let entity = existing.find( + (item) => + this.sameValue(item.examType, row.examType) && + this.sameValue(item.examName, row.examName) && + this.sameValue(item.subject, row.subject) && + this.sameValue(item.examDate, row.examDate), + ); + if (!entity) { + entity = examScoreRepo.create({ studentId }); + // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 + existing.push(entity); + } entity.examType = row.examType.trim(); entity.subject = row.subject.trim(); entity.score = row.score; if (row.examName?.trim()) entity.examName = row.examName.trim(); if (row.classAvg !== undefined) entity.classAvg = row.classAvg; if (row.rank !== undefined) entity.rank = row.rank; - if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); + if (row.examDate?.trim() && this.isValidDateOnly(row.examDate)) { + entity.examDate = row.examDate.trim(); + } if (row.enrollmentName?.trim()) { const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); if (enrollment) entity.enrollmentId = enrollment.id; } if (!entity.status) entity.status = 'active'; - await this.examScoreRepo.save(entity); + await examScoreRepo.save(entity); return true; } - private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { + private async upsertLearningRecordFromImport( + manager: EntityManager, + studentId: number, + row: LearningRecordImportRow, + existing: LearningRecord[], + ) { if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; - const existing = await this.learningRecordRepo.find({ where: { studentId } }); - const entity = - existing.find( - (item) => - this.sameValue(item.recordDate, row.recordDate) && - this.sameValue(item.recordType, row.recordType) && - this.sameValue(item.content, row.content), - ) || this.learningRecordRepo.create({ studentId }); + const learningRecordRepo = manager.getRepository(LearningRecord); + let entity = existing.find( + (item) => + this.sameValue(item.recordDate, row.recordDate) && + this.sameValue(item.recordType, row.recordType) && + this.sameValue(item.content, row.content), + ); + if (!entity) { + entity = learningRecordRepo.create({ studentId }); + // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 + existing.push(entity); + } entity.recordDate = row.recordDate.trim(); entity.recordType = row.recordType.trim(); entity.content = row.content.trim(); if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); if (!entity.status) entity.status = 'active'; - await this.learningRecordRepo.save(entity); + await learningRecordRepo.save(entity); return true; } } diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index 837b311..95953fa 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -77,6 +77,13 @@ export class ScheduleSyncService { const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); const classNameMap = await loadClassNames(this.classRepo, classIds); + // 同名班级会串组:统计本次参与同步的班级名出现次数, + // 重名时在考勤组名/组标识中带上 classId 以避免共享同一个考勤组。 + const classNameCounts = new Map(); + for (const name of classNameMap.values()) { + classNameCounts.set(name, (classNameCounts.get(name) || 0) + 1); + } + // ── Step 3: 将每天的多节课合并成一个钉钉班次 ── // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为 // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。 @@ -203,8 +210,11 @@ export class ScheduleSyncService { continue; } - // 创建/匹配该班级的考勤组 - const groupName = `排课_${className}`; + // 创建/匹配该班级的考勤组;重名班级时追加 classId 后缀避免串组 + const groupName = + (classNameCounts.get(className) ?? 0) > 1 + ? `排课_${classId}_${className}` + : `排课_${className}`; let attendanceGroupId: number; try { const cached = groupByName.get(groupName); diff --git a/apps/server/src/sync/sync-runner.ts b/apps/server/src/sync/sync-runner.ts index 38893d2..d06326b 100644 --- a/apps/server/src/sync/sync-runner.ts +++ b/apps/server/src/sync/sync-runner.ts @@ -41,7 +41,15 @@ export class SyncRunner { this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined); throw error; } finally { - await this.releaseLease(platform, runId); + // releaseLease 自身兜底:释放租约失败不能掩盖原始的同步结果/错误 + try { + await this.releaseLease(platform, runId); + } catch (error) { + this.logger.error( + `${platform} sync lease release failed`, + error instanceof Error ? error.stack : undefined, + ); + } } } diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 7f609ba..1f03d9f 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -189,6 +189,7 @@ export class SyncService { let matched = 0; let created = 0; + let skippedNoFields = 0; await this.dataSource.transaction(async (manager) => { const orgs = await manager.query>( 'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1', @@ -221,6 +222,11 @@ export class SyncService { ); if (decision.action === 'match' && decision.matchStudentId) { + // 所有映射字段均为空时跳过更新,避免用空对象覆盖已有学生数据。 + if (Object.keys(mappedValues).length === 0) { + skippedNoFields++; + continue; + } await manager.update(Student, decision.matchStudentId, mappedValues); matched++; } else if (decision.action === 'create') { @@ -242,7 +248,10 @@ export class SyncService { return { recordsCount: matched + created, status: 'success', - message: `匹配 ${matched} 人,新增 ${created} 人`, + message: + skippedNoFields > 0 + ? `匹配 ${matched} 人,新增 ${created} 人,跳过无更新字段 ${skippedNoFields} 条` + : `匹配 ${matched} 人,新增 ${created} 人`, }; }); } diff --git a/apps/server/src/wallets/wallets.service.spec.ts b/apps/server/src/wallets/wallets.service.spec.ts index 740aac2..a0e9e55 100644 --- a/apps/server/src/wallets/wallets.service.spec.ts +++ b/apps/server/src/wallets/wallets.service.spec.ts @@ -5,15 +5,29 @@ import { Bill } from '../entities/bill.entity'; const manager = (walletBalance: number) => { const wallet = { id: 1, studentId: 10, balance: walletBalance }; const saved: any[] = []; + const updateQuery = { + update: jest.fn(), + set: jest.fn(), + setParameter: jest.fn(), + where: jest.fn(), + andWhere: jest.fn(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + updateQuery.update.mockReturnValue(updateQuery); + updateQuery.set.mockReturnValue(updateQuery); + updateQuery.setParameter.mockReturnValue(updateQuery); + updateQuery.where.mockReturnValue(updateQuery); + updateQuery.andWhere.mockReturnValue(updateQuery); return { wallet, saved, + updateQuery, value: { findOne: jest.fn(async () => wallet), findOneByOrFail: jest.fn(async () => wallet), save: jest.fn(async (value: any) => { saved.push(value); return value; }), create: jest.fn((_entity: unknown, value: unknown) => value), - createQueryBuilder: jest.fn(), + createQueryBuilder: jest.fn(() => updateQuery), }, }; }; @@ -91,6 +105,8 @@ describe('WalletsService financial boundaries', () => { it('does not issue a second refund for an already cancelled bill', async () => { const ctx = manager(10); + // 模拟条件 UPDATE(status <> 'cancelled')未命中:已取消账单不产生任何退款 + ctx.updateQuery.execute.mockResolvedValueOnce({ affected: 0 }); const service = new WalletsService({} as any, {} as any, {} as any, {} as any); const bill = { id: 12, @@ -107,6 +123,25 @@ describe('WalletsService financial boundaries', () => { expect(ctx.saved).toHaveLength(0); }); + it('throws 余额不足 when a concurrent debit drained the wallet', async () => { + const ctx = manager(40); + const service = new WalletsService({} as any, {} as any, {} as any, {} as any); + const bill = { + id: 13, + studentId: 10, + totalAmount: 100, + paidAmount: 0, + outstandingAmount: 100, + status: 'unpaid', + } as Bill; + ctx.updateQuery.execute.mockResolvedValue({ affected: 0 }); + + await expect(service.debitBill(ctx.value as any, bill, 1)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(ctx.saved.some((row) => row.type === 'bill_payment')).toBe(false); + }); + it('rejects an amount that rounds to zero before opening a transaction', async () => { const dataSource = { transaction: jest.fn() }; const service = new WalletsService({} as any, {} as any, {} as any, dataSource as any); @@ -152,3 +187,54 @@ describe('WalletsService wallet locking', () => { expect(ctx.query.setLock).toHaveBeenCalledWith('pessimistic_write'); }); }); + +describe('WalletsService transaction history', () => { + it('limits transactions to the latest 200 rows', async () => { + const transactionRepo = { find: jest.fn().mockResolvedValue([]) }; + const service = new WalletsService({} as any, transactionRepo as any, {} as any, {} as any); + + await service.findTransactions(10); + + expect(transactionRepo.find).toHaveBeenCalledWith({ + where: { studentId: 10 }, + order: { createdAt: 'DESC' }, + take: 200, + }); + }); +}); + +describe('WalletsService batch operation key length', () => { + it('caps operation_id at 64 chars while keeping the :studentId suffix', async () => { + const wallet = { id: 1, studentId: 10, balance: 2 }; + const saved: any[] = []; + const txManager = { + findOne: jest.fn(async () => wallet), + findOneByOrFail: jest.fn(async () => wallet), + save: jest.fn(async (value: any) => { saved.push(value); return value; }), + create: jest.fn((_entity: unknown, value: unknown) => value), + }; + const dataSource = { + transaction: jest.fn(async (work: (manager: any) => Promise) => work(txManager)), + }; + const service = new WalletsService( + {} as any, + {} as any, + { findOne: jest.fn(async () => ({ id: 10 })) } as any, + dataSource as any, + ); + const longId = 'x'.repeat(70); + + await service.batchChangeBalance({ + operationId: longId, + studentIds: [10], + amount: -1, + type: 'adjustment', + description: '批量调账', + }); + + const tx = saved.find((row: any) => row.type === 'adjustment'); + expect(tx.operationId).toBe(`${longId.slice(0, 50)}:10`); + expect(tx.operationId.length).toBeLessThanOrEqual(64); + expect(tx.operationId.endsWith(':10')).toBe(true); + }); +}); diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 009ae64..741ef70 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; import { Room } from '../entities/room.entity'; +import { escapeLike } from '../common/like-escape'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); @@ -30,7 +31,7 @@ export class WalletsService { if (query?.keyword) { qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { - keyword: `%${query.keyword}%`, + keyword: `%${escapeLike(query.keyword)}%`, }); } if (query?.roomType) { @@ -99,7 +100,12 @@ export class WalletsService { } async findTransactions(studentId: number) { - return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }); + // 只返回最近 200 条流水,避免无分页全量返回拖垮接口 + return this.transactionRepo.find({ + where: { studentId }, + order: { createdAt: 'DESC' }, + take: 200, + }); } async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { @@ -163,6 +169,11 @@ export class WalletsService { const uniqueStudentIds = Array.from(new Set(batch.studentIds)); const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = []; for (const studentId of uniqueStudentIds) { + // operation_id 为 varchar(64):operationId 最长 64,直接拼 :studentId 会溢出。 + // 截断 operationId 前缀并整体兜底截到 64,保证不超长且尽量保留 :studentId。 + const opKey = operationId + ? `${operationId.slice(0, 50)}:${studentId}`.slice(0, 64) + : undefined; results.push( await this.changeBalanceOnce( { @@ -172,7 +183,7 @@ export class WalletsService { description: batch.description, }, recordedBy, - operationId ? `${operationId}:${studentId}` : undefined, + opKey, manager, ), ); @@ -206,11 +217,24 @@ export class WalletsService { return manager.save(bill); } + // 原子扣款:由数据库在同一 UPDATE 中扣减并校验余额,避免并发下 check-then-act 导致 double-spend + const debit = await manager + .createQueryBuilder() + .update(StudentWallet) + .set({ balance: () => 'balance - :amount' }) + .setParameter('amount', amount) + .where('id = :id', { id: wallet.id }) + .andWhere('balance >= :amount', { amount }) + .execute(); + if (debit.affected !== 1) { + throw new BadRequestException('余额不足'); + } + // balanceAfter 为事务内估算值(内存旧值 ± amount):原子 UPDATE 已由数据库完成, + // 并发下流水余额可能与最终 DB 余额略有偏差,但金额本身由数据库条件更新保证一致。 wallet.balance = money(Number(wallet.balance) - amount); bill.paidAmount = money(paid + amount); bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount))); bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid'; - await manager.save(wallet); await manager.save(bill); await manager.save( manager.create(WalletTransaction, { @@ -227,13 +251,38 @@ export class WalletsService { } async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) { - if (bill.status === 'cancelled') return bill; + // 幂等:只有成功把账单从未取消 → 取消的那一次调用才执行退款。 + // 条件 UPDATE 与调用方事务内的悲观锁是双保险,并发重复退款不会重复冲正。 + const claimed = await manager + .createQueryBuilder() + .update(Bill) + .set({ status: 'cancelled', cancelledAt: () => 'NOW()', cancelReason: reason }) + .where('id = :id', { id: bill.id }) + .andWhere('status <> :cancelled', { cancelled: 'cancelled' }) + .execute(); + if (claimed.affected !== 1) return bill; // 已被其他请求取消,直接返回(幂等) + + bill.status = 'cancelled'; + bill.cancelledAt = new Date(); + bill.cancelReason = reason; const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount))); if (paid > 0) { const wallet = await this.getOrCreateWallet(manager, bill.studentId); + // 原子退款:余额累加由数据库完成,避免并发覆盖 + const credit = await manager + .createQueryBuilder() + .update(StudentWallet) + .set({ balance: () => 'balance + :amount' }) + .setParameter('amount', paid) + .where('id = :id', { id: wallet.id }) + .execute(); + if (credit.affected !== 1) { + throw new BadRequestException('学生钱包不存在,退款失败'); + } + // balanceAfter 为事务内估算值(内存旧值 + paid):原子 UPDATE 已由数据库完成, + // 并发下流水余额可能与最终 DB 余额略有偏差,但冲正金额本身由数据库累加保证一致。 wallet.balance = money(Number(wallet.balance) + paid); - await manager.save(wallet); await manager.save( manager.create(WalletTransaction, { studentId: bill.studentId, @@ -246,11 +295,8 @@ export class WalletsService { }), ); } - bill.status = 'cancelled'; bill.paidAmount = 0; bill.outstandingAmount = 0; - bill.cancelledAt = new Date(); - bill.cancelReason = reason; return manager.save(bill); } @@ -269,9 +315,14 @@ export class WalletsService { .getMany(); const settled: Bill[] = []; for (const bill of bills) { - const wallet = await manager.findOne(StudentWallet, { where: { studentId } }); - if (!wallet || money(wallet.balance) <= 0) break; - settled.push(await this.debitBill(manager, bill, recordedBy)); + const paidBefore = money(bill.paidAmount); + const result = await this.debitBill(manager, bill, recordedBy); + if (money(result.paidAmount) > paidBefore) { + settled.push(result); + } else { + // 余额已不足以支付后续账单,停止结算 + break; + } } return settled; }