From b1f35f9d1a14147aada0f5f39319b281fb88ae73 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 15 Jul 2026 00:03:55 +0800 Subject: [PATCH 1/4] test: harden business boundary conditions --- .../src/archive/archive.boundaries.spec.ts | 102 ++++++++++++ apps/server/src/archive/archive.service.ts | 47 ++++-- apps/server/src/archive/dto/archive.dto.ts | 24 +-- .../attendance.lesson-session.spec.ts | 35 +++++ .../src/attendance/attendance.service.ts | 23 ++- apps/server/src/auth/auth.service.spec.ts | 27 +++- apps/server/src/auth/auth.service.ts | 4 +- .../src/auth/strategies/jwt.strategy.spec.ts | 17 ++ .../src/auth/strategies/jwt.strategy.ts | 4 +- .../server/src/bills/bills.boundaries.spec.ts | 77 +++++++++ apps/server/src/bills/bills.service.spec.ts | 49 ++++++ apps/server/src/bills/bills.service.ts | 111 ++++++++----- apps/server/src/bills/dto/bill.dto.ts | 4 +- .../src/classes/classes.boundaries.spec.ts | 69 ++++++++ apps/server/src/classes/classes.scope.spec.ts | 5 +- apps/server/src/classes/classes.service.ts | 11 ++ apps/server/src/classes/dto/class.dto.ts | 147 +++++++++++++----- .../classroom-rentals/dto/rental.dto.spec.ts | 42 +++++ .../src/classroom-rentals/dto/rental.dto.ts | 18 ++- .../src/dashboard/dashboard.controller.ts | 23 +-- .../src/dashboard/dashboard.scope.spec.ts | 42 +++++ .../server/src/dashboard/dashboard.service.ts | 35 ++++- .../dashboard/dto/dashboard-query.dto.spec.ts | 28 ++++ .../src/dashboard/dto/dashboard-query.dto.ts | 20 +++ .../src/deposits/deposits.boundaries.spec.ts | 53 +++++++ apps/server/src/deposits/deposits.service.ts | 25 ++- .../src/expense-types/dto/expense-type.dto.ts | 13 +- .../expense-types.service.spec.ts | 68 ++++++++ .../expense-types/expense-types.service.ts | 7 +- .../src/expenses/dto/expense.dto.spec.ts | 31 ++++ apps/server/src/expenses/dto/expense.dto.ts | 38 ++++- .../src/expenses/expenses.boundaries.spec.ts | 107 +++++++++++++ apps/server/src/expenses/expenses.service.ts | 63 +++++++- .../config/integration-config.service.spec.ts | 50 ++++++ .../config/integration-config.service.ts | 29 ++-- .../src/integration/dingtalk.service.spec.ts | 38 +++++ .../src/integration/dingtalk.service.ts | 9 +- .../dto/notification.dto.spec.ts | 20 ++- .../src/notifications/dto/notification.dto.ts | 15 +- .../notifications.service.spec.ts | 61 ++++++++ .../notifications/notifications.service.ts | 24 +-- .../src/occupancies/dto/occupancy.dto.spec.ts | 27 ++++ .../src/occupancies/dto/occupancy.dto.ts | 39 +++-- .../occupancies/occupancies.service.spec.ts | 78 +++++++++- .../src/occupancies/occupancies.service.ts | 95 +++++++++-- .../operation-logs/dto/operation-log.dto.ts | 74 +++++++++ .../operation-logs.controller.ts | 21 +-- .../operation-logs.service.spec.ts | 52 +++++++ .../operation-logs/operation-logs.service.ts | 5 +- apps/server/src/rbac/dto/rbac.dto.ts | 24 ++- apps/server/src/rbac/rbac.boundary.spec.ts | 95 +++++++++++ apps/server/src/rbac/rbac.service.ts | 36 ++++- .../src/schedules/dto/schedule.dto.spec.ts | 27 ++++ apps/server/src/schedules/dto/schedule.dto.ts | 35 +++-- .../src/schedules/schedules.service.spec.ts | 38 +++++ .../server/src/schedules/schedules.service.ts | 16 ++ .../src/students/students.lifecycle.spec.ts | 43 +++++ .../src/sync/dto/schedule-sync.dto.spec.ts | 35 +++++ apps/server/src/sync/dto/schedule-sync.dto.ts | 21 +++ .../src/sync/schedule-sync.service.spec.ts | 60 +++++++ apps/server/src/sync/schedule-sync.service.ts | 28 ++-- apps/server/src/sync/sync.controller.spec.ts | 11 +- apps/server/src/sync/sync.controller.ts | 13 +- .../src/wallets/wallets.service.spec.ts | 67 ++++++++ apps/server/src/wallets/wallets.service.ts | 49 ++++-- 65 files changed, 2311 insertions(+), 293 deletions(-) create mode 100644 apps/server/src/archive/archive.boundaries.spec.ts create mode 100644 apps/server/src/bills/bills.boundaries.spec.ts create mode 100644 apps/server/src/classes/classes.boundaries.spec.ts create mode 100644 apps/server/src/classroom-rentals/dto/rental.dto.spec.ts create mode 100644 apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts create mode 100644 apps/server/src/dashboard/dto/dashboard-query.dto.ts create mode 100644 apps/server/src/deposits/deposits.boundaries.spec.ts create mode 100644 apps/server/src/expense-types/expense-types.service.spec.ts create mode 100644 apps/server/src/expenses/dto/expense.dto.spec.ts create mode 100644 apps/server/src/expenses/expenses.boundaries.spec.ts create mode 100644 apps/server/src/notifications/notifications.service.spec.ts create mode 100644 apps/server/src/operation-logs/dto/operation-log.dto.ts create mode 100644 apps/server/src/operation-logs/operation-logs.service.spec.ts create mode 100644 apps/server/src/rbac/rbac.boundary.spec.ts create mode 100644 apps/server/src/students/students.lifecycle.spec.ts create mode 100644 apps/server/src/sync/dto/schedule-sync.dto.spec.ts create mode 100644 apps/server/src/sync/dto/schedule-sync.dto.ts diff --git a/apps/server/src/archive/archive.boundaries.spec.ts b/apps/server/src/archive/archive.boundaries.spec.ts new file mode 100644 index 0000000..13ed5e7 --- /dev/null +++ b/apps/server/src/archive/archive.boundaries.spec.ts @@ -0,0 +1,102 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ArchiveService } from './archive.service'; + +function createService(repos: Partial>> = {}) { + return new ArchiveService( + (repos.student ?? {}) as never, + (repos.profile ?? {}) as never, + (repos.enrollment ?? {}) as never, + (repos.exam ?? {}) as never, + (repos.learning ?? {}) as never, + (repos.result ?? {}) as never, + (repos.attachment ?? {}) as never, + (repos.attendance ?? {}) as never, + {} as never, + ); +} + +describe('ArchiveService — resource and relationship boundaries', () => { + it('rejects adding archive records for a missing student', async () => { + const student = { findOne: jest.fn().mockResolvedValue(null) }; + const service = createService({ student }); + + await expect( + service.addEnrollment(404, { courseCategory: '文化', classType: '冲刺' }), + ).rejects.toBeInstanceOf(NotFoundException); + await expect( + service.addLearningRecord(404, { + recordDate: '2026-07-14', + recordType: '沟通', + content: '内容', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects linking an exam score to another student enrollment', async () => { + const exam = { create: jest.fn(), save: jest.fn() }; + const service = createService({ + student: { findOne: jest.fn().mockResolvedValue({ id: 7 }) }, + enrollment: { findOne: jest.fn().mockResolvedValue(null) }, + exam, + }); + + await expect( + service.addExamScore(7, { + examType: '月考', + subject: '语文', + score: 90, + enrollmentId: 99, + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(exam.save).not.toHaveBeenCalled(); + }); + + it('rejects moving an existing exam score to another student enrollment', async () => { + const exam = { + findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, enrollmentId: 1 }), + save: jest.fn(), + }; + const service = createService({ + enrollment: { findOne: jest.fn().mockResolvedValue(null) }, + exam, + }); + + await expect(service.updateExamScore(3, { enrollmentId: 99 })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(exam.save).not.toHaveBeenCalled(); + }); + + it('rejects a missing attachment upload before writing to disk', async () => { + const service = createService({ student: { findOne: jest.fn() } }); + await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('rejects attachment path traversal', async () => { + const service = createService({ + attachment: { + findOne: jest.fn().mockResolvedValue({ + id: 1, + studentId: 7, + filePath: '../../etc/passwd', + }), + }, + }); + await expect(service.getAttachmentFile(7, 1)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('returns not found for update/delete of absent child records', async () => { + const service = createService({ + enrollment: { findOne: jest.fn().mockResolvedValue(null) }, + exam: { findOne: jest.fn().mockResolvedValue(null) }, + learning: { findOne: jest.fn().mockResolvedValue(null) }, + attachment: { findOne: jest.fn().mockResolvedValue(null) }, + }); + await expect(service.updateEnrollment(1, {})).rejects.toBeInstanceOf(NotFoundException); + await expect(service.deleteExamScore(1)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.deleteLearningRecord(1)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.deleteAttachment(1)).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index ce68b02..3b13383 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -61,20 +61,27 @@ export class ArchiveService { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); - const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] = - await Promise.all([ - this.profileRepo.findOne({ where: { studentId } }), - this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), - this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }), - this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), - this.resultRepo.findOne({ where: { studentId } }), - this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), - this.attendanceRepo.find({ - where: { studentId }, - relations: ['schedule', 'class'], - order: { attendanceDate: 'DESC', punchTime: 'DESC' }, - }), - ]); + const [ + profileRaw, + enrollments, + examScores, + learningRecords, + resultArchive, + attachments, + attendances, + ] = await Promise.all([ + this.profileRepo.findOne({ where: { studentId } }), + this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), + this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }), + this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), + this.resultRepo.findOne({ where: { studentId } }), + this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), + this.attendanceRepo.find({ + where: { studentId }, + relations: ['schedule', 'class'], + order: { attendanceDate: 'DESC', punchTime: 'DESC' }, + }), + ]); return { student, @@ -123,9 +130,18 @@ export class ArchiveService { return { message: '已删除' }; } + private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) { + if (enrollmentId === undefined) return; + const enrollment = await this.enrollmentRepo.findOne({ + where: { id: enrollmentId, studentId }, + }); + if (!enrollment) throw new BadRequestException('报名记录不属于该学生'); + } + async addExamScore(studentId: number, dto: CreateExamScoreDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); + await this.assertEnrollmentBelongsToStudent(studentId, dto.enrollmentId); const entity = this.examScoreRepo.create({ ...dto, studentId }); return this.examScoreRepo.save(entity); @@ -134,6 +150,7 @@ export class ArchiveService { async updateExamScore(id: number, dto: UpdateExamScoreDto) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); + await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId); Object.assign(entity, dto); return this.examScoreRepo.save(entity); } @@ -181,6 +198,8 @@ export class ArchiveService { } async addAttachment(studentId: number, file: Express.Multer.File, category: string) { + if (!file?.buffer || !file.originalname) throw new BadRequestException('请选择附件文件'); + const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); diff --git a/apps/server/src/archive/dto/archive.dto.ts b/apps/server/src/archive/dto/archive.dto.ts index 85fef07..83f3d5d 100644 --- a/apps/server/src/archive/dto/archive.dto.ts +++ b/apps/server/src/archive/dto/archive.dto.ts @@ -1,5 +1,5 @@ import { PartialType } from '@nestjs/mapped-types'; -import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator'; +import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'class-validator'; export class UpsertProfileDto { @IsOptional() @IsString() targetCollege?: string; @@ -11,8 +11,8 @@ export class UpsertProfileDto { } export class CreateEnrollmentDto { - @IsString() courseCategory: string; - @IsString() classType: string; + @IsString() @IsNotEmpty() courseCategory: string; + @IsString() @IsNotEmpty() classType: string; @IsOptional() @IsString() className?: string; @IsOptional() @IsString() headTeacher?: string; @IsOptional() @IsString() subjectTeacher?: string; @@ -24,12 +24,12 @@ export class CreateEnrollmentDto { export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {} export class CreateExamScoreDto { - @IsString() examType: string; + @IsString() @IsNotEmpty() examType: string; @IsOptional() @IsString() examName?: string; - @IsString() subject: string; - @IsNumber() score: number; - @IsOptional() @IsNumber() classAvg?: number; - @IsOptional() @IsNumber() rank?: number; + @IsString() @IsNotEmpty() subject: string; + @IsNumber() @Min(0) score: number; + @IsOptional() @IsNumber() @Min(0) classAvg?: number; + @IsOptional() @IsNumber() @Min(1) rank?: number; @IsOptional() @IsDateString() examDate?: string; @IsOptional() @IsNumber() enrollmentId?: number; } @@ -38,8 +38,8 @@ export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {} export class CreateLearningRecordDto { @IsDateString() recordDate: string; - @IsString() recordType: string; - @IsString() content: string; + @IsString() @IsNotEmpty() recordType: string; + @IsString() @IsNotEmpty() content: string; @IsOptional() @IsString() followUpMethod?: string; @IsOptional() @IsString() nextStep?: string; } @@ -47,8 +47,8 @@ export class CreateLearningRecordDto { export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {} export class UpsertResultDto { - @IsOptional() @IsNumber() cultureFinalScore?: number; - @IsOptional() @IsNumber() professionalFinalScore?: number; + @IsOptional() @IsNumber() @Min(0) cultureFinalScore?: number; + @IsOptional() @IsNumber() @Min(0) professionalFinalScore?: number; @IsOptional() @IsString() admissionStatus?: string; @IsOptional() @IsString() admittedCollege?: string; @IsOptional() @IsString() admittedMajor?: string; diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts index ffeb36a..7fbebb2 100644 --- a/apps/server/src/attendance/attendance.lesson-session.spec.ts +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -593,3 +593,38 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => { expect(result.source).toBe('manual'); }); }); + +describe('AttendanceService — attendance window boundaries', () => { + it('crosses calendar boundaries only when the window requires it', () => { + const { service } = createService(); + expect(service.getLessonAttendanceImportDateRange( + { startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13', + )).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' }); + expect(service.getLessonAttendanceImportDateRange( + { startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13', + )).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' }); + expect(service.getLessonAttendanceImportDateRange( + { startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13', + )).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' }); + }); + + it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => { + const originalTz = process.env.TZ; + process.env.TZ = 'UTC'; + jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z')); + try { + const { service, scheduleRepo, sessionRepo, attendanceRepo } = createService(); + scheduleRepo.findOne.mockResolvedValue({ + ...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00', + startDate: '2026-07-13', endDate: '2026-07-13', + }); + sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' }); + attendanceRepo.find.mockResolvedValue([]); + await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21)) + .resolves.toMatchObject({ records: [] }); + } finally { + jest.useRealTimers(); + process.env.TZ = originalTz; + } + }); +}); diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index f4ba5fd..859893e 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -276,16 +276,13 @@ export class AttendanceService { ) { const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate); const now = new Date(); - const today = [ - now.getFullYear(), - String(now.getMonth() + 1).padStart(2, '0'), - String(now.getDate()).padStart(2, '0'), - ].join('-'); + const courseClock = this.getCourseClock(now); + const today = courseClock.date; if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤'); if (lessonDate === today) { const [hour, minute] = schedule.startTime.split(':').map(Number); const startMinute = hour * 60 + minute; - const currentMinute = now.getHours() * 60 + now.getMinutes(); + const currentMinute = courseClock.minutes; if (currentMinute < startMinute) { throw new BadRequestException('课程尚未开始,不能拉取考勤'); } @@ -676,6 +673,20 @@ export class AttendanceService { return hour * 60 + minute; } + private getCourseClock(date: Date): { date: string; minutes: number } { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hourCycle: 'h23', + }).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]), + ); + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + minutes: Number(parts.hour) * 60 + Number(parts.minute), + }; + } + private shiftDate(date: string, days: number): string { const shifted = new Date(`${date}T00:00:00.000Z`); shifted.setUTCDate(shifted.getUTCDate() + days); diff --git a/apps/server/src/auth/auth.service.spec.ts b/apps/server/src/auth/auth.service.spec.ts index 6894792..0c676e4 100644 --- a/apps/server/src/auth/auth.service.spec.ts +++ b/apps/server/src/auth/auth.service.spec.ts @@ -1,7 +1,7 @@ import * as bcrypt from 'bcryptjs'; import { AuthService } from './auth.service'; -describe('AuthService — super admin identity', () => { +describe('AuthService — authentication boundaries', () => { it('marks the preset 超管 role as super admin in the JWT payload', async () => { const userRepo = { findOne: jest.fn().mockResolvedValue({ @@ -22,8 +22,29 @@ describe('AuthService — super admin identity', () => { await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1'); - expect(jwtService.sign).toHaveBeenCalledWith( - expect.objectContaining({ isSuperAdmin: true }), + expect(jwtService.sign).toHaveBeenCalledWith(expect.objectContaining({ isSuperAdmin: true })); + }); + it('rejects an archived user even when the password is valid', async () => { + const userRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 2, + username: 'archived', + passwordHash: await bcrypt.hash('secret', 4), + isActive: true, + isArchived: true, + roles: [], + }), + save: jest.fn(), + }; + const service = new AuthService( + userRepo as never, + { sign: jest.fn() } as never, + { getUserPermissions: jest.fn() } as never, ); + + await expect( + service.login({ username: 'archived', password: 'secret' }, '192.0.2.10'), + ).rejects.toThrow('账号已失效'); + expect(userRepo.save).not.toHaveBeenCalled(); }); }); diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index b3d9047..3de488d 100644 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -38,7 +38,9 @@ export class AuthService { this.recordFailedAttempt(attemptKey); throw new UnauthorizedException('用户名或密码错误'); } - if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员'); + if (!user.isActive || user.isArchived) { + throw new UnauthorizedException('账号已失效,请联系管理员'); + } const valid = await bcrypt.compare(dto.password, user.passwordHash); if (!valid) { this.recordFailedAttempt(attemptKey); diff --git a/apps/server/src/auth/strategies/jwt.strategy.spec.ts b/apps/server/src/auth/strategies/jwt.strategy.spec.ts index 6255c3e..1443ab0 100644 --- a/apps/server/src/auth/strategies/jwt.strategy.spec.ts +++ b/apps/server/src/auth/strategies/jwt.strategy.spec.ts @@ -33,6 +33,23 @@ describe('JwtStrategy', () => { }); }); + it('recognizes the canonical super_admin role code even when the display name changes', async () => { + const userRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 1, + username: 'admin', + isActive: true, + isArchived: false, + roles: [{ name: '系统管理员', code: 'super_admin', status: 1, permissions: [] }], + }), + }; + const strategy = new JwtStrategy(config as never, userRepo as never); + + await expect(strategy.validate({ sub: 1 })).resolves.toEqual( + expect.objectContaining({ isSuperAdmin: true }), + ); + }); + it.each([ [{ id: 7, isActive: false, isArchived: false, roles: [] }], [{ id: 7, isActive: true, isArchived: true, roles: [] }], diff --git a/apps/server/src/auth/strategies/jwt.strategy.ts b/apps/server/src/auth/strategies/jwt.strategy.ts index 0236950..ca967a1 100644 --- a/apps/server/src/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/auth/strategies/jwt.strategy.ts @@ -47,7 +47,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { for (const role of user.roles ?? []) { if (role.status !== 1) continue; roles.push(role.name); - if (role.name === '超管' || role.name === 'super_admin') isSuperAdmin = true; + if (role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin') { + isSuperAdmin = true; + } for (const permission of role.permissions ?? []) permissions.add(permission.code); } diff --git a/apps/server/src/bills/bills.boundaries.spec.ts b/apps/server/src/bills/bills.boundaries.spec.ts new file mode 100644 index 0000000..6795fd2 --- /dev/null +++ b/apps/server/src/bills/bills.boundaries.spec.ts @@ -0,0 +1,77 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BillsService } from './bills.service'; +import { Bill } from '../entities/bill.entity'; + +function queryBuilder() { + return { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; +} + +function createService(bills: Partial[] = []) { + const billRepo = { + find: jest.fn().mockResolvedValue(bills), + findOne: jest.fn().mockResolvedValue(bills[0] ?? null), + save: jest.fn(async (value) => value), + createQueryBuilder: jest.fn(() => queryBuilder()), + }; + const manager = { + delete: jest.fn(), + update: jest.fn(), + }; + const dataSource = { transaction: jest.fn(async (callback) => callback(manager)) }; + const service = new BillsService( + billRepo as any, + { delete: jest.fn() } as any, + {} as any, + { update: jest.fn() } as any, + {} as any, + {} as any, + dataSource as any, + {} as any, + ); + return { service, billRepo, dataSource, manager }; +} + +describe('BillsService state and batch boundaries', () => { + it('rejects an empty batch status update', async () => { + const { service, billRepo } = createService(); + await expect(service.batchUpdateStatus([], 'paid')).rejects.toBeInstanceOf(BadRequestException); + expect(billRepo.find).not.toHaveBeenCalled(); + }); + + it('rejects a batch status update when some ids do not exist', async () => { + const { service, billRepo } = createService([{ id: 1, paidAmount: 0, outstandingAmount: 10 }]); + await expect(service.batchUpdateStatus([1, 2], 'unpaid')).rejects.toBeInstanceOf(NotFoundException); + expect(billRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('rejects marking a partially paid bill unpaid', async () => { + const { service, billRepo } = createService([{ id: 1, paidAmount: 10, outstandingAmount: 90, status: 'partially_paid' }]); + await expect(service.updateStatus(1, { status: 'unpaid' })).rejects.toBeInstanceOf(BadRequestException); + expect(billRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects an empty batch delete', async () => { + const { service, dataSource } = createService(); + await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('rejects a batch delete when some ids do not exist', async () => { + const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]); + await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('deletes a bill and its links in one transaction', async () => { + const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]); + await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' }); + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(manager.delete).toHaveBeenCalledTimes(2); + expect(manager.update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index 6af004a..eee388a 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -544,3 +544,52 @@ describe('BillsService — generateBills', () => { expect(result.count).toBe(0); }); }); + +describe('BillsService — allocation rounding boundary', () => { + it('keeps allocated cents equal to the original expense total', async () => { + const billRepo = mockRepo(); + const itemRepo = mockRepo(); + const roomExpRepo = mockRepo(); + const personalExpRepo = mockRepo(); + const occRepo = mockRepo(); + const roomRepo = mockRepo(); + let nextBillId = 0; + const dataSource = { + query: jest.fn().mockResolvedValue([]), + transaction: jest.fn(async (callback) => callback({ + create: (_entity: unknown, value: any) => value, + save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })), + createQueryBuilder: jest.fn(() => ({ + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + })), + })), + }; + const service = new BillsService( + billRepo as any, + itemRepo as any, + roomExpRepo as any, + personalExpRepo as any, + occRepo as any, + roomRepo as any, + dataSource as any, + { debitBill: jest.fn(async (_manager, bill) => bill) } as any, + ); + (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: 1, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy, + { id: 2, roomId: 1, studentId: 2, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy, + { id: 3, roomId: 1, studentId: 3, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy, + ])); + (personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder([])); + + const result = await service.generateBills({ periodStart: '2026-06-01', periodEnd: '2026-06-30' } as any); + + expect(result.bills.map((bill) => Number(bill.totalAmount))).toEqual([33.33, 33.33, 33.34]); + expect(result.bills.reduce((sum, bill) => sum + Number(bill.totalAmount), 0)).toBe(100); + }); +}); diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index d0e1080..d22f32c 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -32,8 +32,11 @@ export class BillsService { const { periodStart, periodEnd } = dto.billingMonth ? this.resolveBillingPeriod(dto.billingMonth) : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; - const pStart = new Date(periodStart); - const pEnd = new Date(periodEnd); + 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) { @@ -139,11 +142,16 @@ export class BillsService { if (totalDays === 0) continue; - // 对每项费用进行分摊 + // 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。 for (const expense of expenses) { - for (const sd of studentDays) { - if (sd.days === 0) continue; - const amount = Number(((sd.days / totalDays) * Number(expense.amount)).toFixed(2)); + const eligibleDays = studentDays.filter((sd) => sd.days > 0); + const expenseTotal = Number(Number(expense.amount).toFixed(2)); + let allocated = 0; + for (const [index, sd] of eligibleDays.entries()) { + const amount = index === eligibleDays.length - 1 + ? Number((expenseTotal - allocated).toFixed(2)) + : Number(((sd.days / totalDays) * expenseTotal).toFixed(2)); + allocated = Number((allocated + amount).toFixed(2)); if (!studentBillData.has(sd.studentId)) { studentBillData.set(sd.studentId, { shared: 0, items: [] }); } @@ -189,16 +197,14 @@ export class BillsService { } - // 合并所有涉及的学生 + // 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。 const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); - // 生成账单 - const bills: 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)); - - const savedBill = await this.dataSource.transaction(async (manager) => { + 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, @@ -230,14 +236,20 @@ export class BillsService { .execute(); } bill = await this.walletsService.debitBill(manager, bill); - return bill; - }); - bills.push(savedBill); - } + generated.push(bill); + } + return generated; + }); return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd }; } + private isValidDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + } + private resolveBillingPeriod(billingMonth: string) { const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); @@ -344,34 +356,36 @@ export class BillsService { async updateStatus(id: number, dto: UpdateBillStatusDto) { const bill = await this.billRepo.findOne({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) { - throw new BadRequestException('存在未付金额,不能直接标记为已支付'); - } + this.assertStatusMatchesAmounts(bill, dto.status); bill.status = dto.status; return this.billRepo.save(bill); } async batchUpdateStatus(ids: number[], status: string) { - const bills = await this.billRepo.find({ where: { id: In(ids) } }); - if (status === 'paid' && bills.some((bill) => Number(bill.outstandingAmount) > 0)) { - throw new BadRequestException('选中账单存在未付金额,不能直接标记为已支付'); - } + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单'); + if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效'); + const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); + if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); + for (const bill of bills) this.assertStatusMatchesAmounts(bill, status); await this.billRepo .createQueryBuilder() .update() .set({ status }) - .where('id IN (:...ids)', { ids }) + .where('id IN (:...ids)', { ids: uniqueIds }) .execute(); - return { message: `成功更新 ${ids.length} 条账单状态` }; + return { message: `成功更新 ${uniqueIds.length} 条账单状态` }; } async cancel(id: number, dto: CancelBillDto, recordedBy?: number) { + const reason = dto.reason?.trim(); + if (!reason) throw new BadRequestException('取消原因不能为空'); return this.dataSource.transaction(async (manager) => { const bill = await manager.findOne(Bill, { where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); await manager.update(PersonalExpense, { billId: id }, { billId: null }); - return this.walletsService.refundBill(manager, bill, dto.reason, recordedBy); + return this.walletsService.refundBill(manager, bill, reason, recordedBy); }); } @@ -381,25 +395,38 @@ export class BillsService { if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') { throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单'); } - await this.itemRepo.delete({ billId: id }); - await this.personalExpRepo.update({ billId: id }, { billId: null }); - await this.billRepo.delete(id); + await this.dataSource.transaction(async (manager) => { + await manager.delete(BillItem, { billId: id }); + await manager.update(PersonalExpense, { billId: id }, { billId: null }); + await manager.delete(Bill, id); + }); return { message: '账单已删除' }; } async batchRemove(ids: number[]) { - const bills = await this.billRepo.find({ where: { id: In(ids) } }); + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单'); + const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); + if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) { throw new BadRequestException('选中账单包含资金流水,不能批量删除'); } - await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute(); - await this.personalExpRepo - .createQueryBuilder() - .update() - .set({ billId: null }) - .where('billId IN (:...ids)', { ids }) - .execute(); - await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute(); - return { message: `成功删除 ${ids.length} 条账单` }; + await this.dataSource.transaction(async (manager) => { + await manager.delete(BillItem, { billId: In(uniqueIds) }); + await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null }); + await manager.delete(Bill, uniqueIds); + }); + return { message: `成功删除 ${uniqueIds.length} 条账单` }; + } + + private assertStatusMatchesAmounts(bill: Bill, status: string) { + const paid = Number(bill.paidAmount || 0); + const outstanding = Number(bill.outstandingAmount || 0); + const matches = status === 'paid' + ? outstanding <= 0 + : status === 'partially_paid' + ? paid > 0 && outstanding > 0 + : status === 'unpaid' && paid <= 0 && outstanding > 0; + if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致'); } } diff --git a/apps/server/src/bills/dto/bill.dto.ts b/apps/server/src/bills/dto/bill.dto.ts index a15cfd3..c4355c1 100644 --- a/apps/server/src/bills/dto/bill.dto.ts +++ b/apps/server/src/bills/dto/bill.dto.ts @@ -1,4 +1,4 @@ -import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; export class GenerateBillsDto { @IsString() @@ -21,6 +21,8 @@ export class UpdateBillStatusDto { export class CancelBillDto { @IsString() + @IsNotEmpty() + @Matches(/\S/) @MaxLength(300) reason: string; } diff --git a/apps/server/src/classes/classes.boundaries.spec.ts b/apps/server/src/classes/classes.boundaries.spec.ts new file mode 100644 index 0000000..959e712 --- /dev/null +++ b/apps/server/src/classes/classes.boundaries.spec.ts @@ -0,0 +1,69 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ClassesService } from './classes.service'; + +function createService(classRepo: Record, classTeacherRepo = {}) { + return new ClassesService( + classRepo as never, + {} as never, + classTeacherRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); +} + +describe('ClassesService — archive and teacher boundaries', () => { + it('rejects repeated archive and restore operations', async () => { + await expect( + createService({ findOne: jest.fn().mockResolvedValue({ isArchived: true }) }).archive(1), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + createService({ findOne: jest.fn().mockResolvedValue({ isArchived: false }) }).restore(1), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects assigning a teacher to a missing class', async () => { + const classTeacherRepo = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() }; + await expect( + createService({ findOne: jest.fn().mockResolvedValue(null) }, classTeacherRepo).addTeacher( + 9, + { + userId: 2, + roleType: 'head_teacher', + }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + expect(classTeacherRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects duplicate teacher roles', async () => { + const classTeacherRepo = { + findOne: jest.fn().mockResolvedValue({ id: 3 }), + create: jest.fn(), + save: jest.fn(), + }; + await expect( + createService( + { findOne: jest.fn().mockResolvedValue({ id: 1 }) }, + classTeacherRepo, + ).addTeacher(1, { + userId: 2, + roleType: 'head_teacher', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects removing a teacher or assignment that is not in the class', async () => { + const classTeacherRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + delete: jest.fn(), + }; + const service = createService({}, classTeacherRepo); + await expect(service.removeTeacher(1, 2)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.removeTeacherAssignment(1, 3)).rejects.toBeInstanceOf(NotFoundException); + expect(classTeacherRepo.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/classes/classes.scope.spec.ts b/apps/server/src/classes/classes.scope.spec.ts index 24c29e3..a0bae02 100644 --- a/apps/server/src/classes/classes.scope.spec.ts +++ b/apps/server/src/classes/classes.scope.spec.ts @@ -41,7 +41,10 @@ describe('ClassesService — teacher data scope', () => { it('clears denormalized teacher ids when the last teacher for that role is removed', async () => { const classRepo = { update: jest.fn() }; const classTeacherRepo = { - find: jest.fn().mockResolvedValue([]), + find: jest + .fn() + .mockResolvedValueOnce([{ id: 1, classId: 8, userId: 21 }]) + .mockResolvedValueOnce([]), delete: jest.fn().mockResolvedValue({ affected: 1 }), }; const service = new ClassesService( diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index 36451a6..66d1ba2 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -286,6 +286,7 @@ export class ClassesService { async archive(id: number) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); + if (cls.isArchived) throw new BadRequestException('班级已归档'); await this.classRepo.update(id, { isArchived: true }); return { success: true }; } @@ -294,6 +295,7 @@ export class ClassesService { async restore(id: number) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); + if (!cls.isArchived) throw new BadRequestException('班级未归档'); await this.classRepo.update(id, { isArchived: false }); return { success: true }; } @@ -392,6 +394,9 @@ export class ClassesService { } async addTeacher(classId: number, dto: AddTeacherDto) { + const cls = await this.classRepo.findOne({ where: { id: classId } }); + if (!cls) throw new NotFoundException('班级不存在'); + const existing = await this.classTeacherRepo.findOne({ where: { classId, userId: dto.userId, roleType: dto.roleType }, }); @@ -410,12 +415,18 @@ export class ClassesService { } async removeTeacher(classId: number, userId: number) { + const assignments = await this.classTeacherRepo.find({ where: { classId, userId } }); + if (assignments.length === 0) throw new NotFoundException('教师未分配到该班级'); await this.classTeacherRepo.delete({ classId, userId }); await this.syncClassTeacherIds(classId); return { success: true }; } async removeTeacherAssignment(classId: number, assignmentId: number) { + const assignment = await this.classTeacherRepo.findOne({ + where: { id: assignmentId, classId }, + }); + if (!assignment) throw new NotFoundException('教师角色分配不存在'); await this.classTeacherRepo.delete({ id: assignmentId, classId }); await this.syncClassTeacherIds(classId); return { success: true }; diff --git a/apps/server/src/classes/dto/class.dto.ts b/apps/server/src/classes/dto/class.dto.ts index 67c597b..9df9088 100644 --- a/apps/server/src/classes/dto/class.dto.ts +++ b/apps/server/src/classes/dto/class.dto.ts @@ -1,43 +1,81 @@ -import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator'; +import { + IsOptional, + IsString, + IsNotEmpty, + IsInt, + IsArray, + IsDateString, + IsEnum, + ArrayNotEmpty, + ValidateNested, + Min, +} from 'class-validator'; import { Type, Transform } from 'class-transformer'; import { ClassType, ClassStatus, TeacherRoleType } from '../../entities'; +export class ClassTeacherItemDto { + @IsInt() + userId: number; + + @IsEnum(TeacherRoleType) + roleType: string; + + @IsOptional() + @IsString() + subject?: string; +} + export class CreateClassDto { - @IsString() @IsNotEmpty() + @IsString() + @IsNotEmpty() name: string; - @IsString() @IsNotEmpty() + @IsString() + @IsNotEmpty() code: string; - - @IsEnum(ClassType) @IsString() @IsNotEmpty() + @IsEnum(ClassType) + @IsString() + @IsNotEmpty() classType: string; - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() startDate?: string; - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() endDate?: string; - @IsEnum(ClassStatus) @IsOptional() @IsString() + @IsEnum(ClassStatus) + @IsOptional() + @IsString() status?: string; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() headTeacherId?: number; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() lifeTeacherId?: number; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() academicTeacherId?: number; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() + @Min(0) maxStudents?: number; - @IsOptional() @IsString() + @IsOptional() + @IsString() notes?: string; - @IsOptional() @IsArray() + @IsOptional() + @IsArray() + @IsInt({ each: true }) studentIds?: number[]; @IsOptional() @@ -46,55 +84,73 @@ export class CreateClassDto { @Type(() => ImportUserItem) users?: ImportUserItem[]; - @IsOptional() @IsArray() - teachers?: Array<{ userId: number; roleType: string; subject?: string }>; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ClassTeacherItemDto) + teachers?: ClassTeacherItemDto[]; } export class UpdateClassDto { - @IsOptional() @IsString() + @IsOptional() + @IsString() name?: string; - @IsOptional() @IsString() + @IsOptional() + @IsString() code?: string; - - @IsEnum(ClassType) @IsOptional() @IsString() + @IsEnum(ClassType) + @IsOptional() + @IsString() classType?: string; - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() startDate?: string; - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() endDate?: string; - @IsEnum(ClassStatus) @IsOptional() @IsString() + @IsEnum(ClassStatus) + @IsOptional() + @IsString() status?: string; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() headTeacherId?: number; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() lifeTeacherId?: number; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() academicTeacherId?: number; - @IsOptional() @IsInt() + @IsOptional() + @IsInt() + @Min(0) maxStudents?: number; - @IsOptional() @IsString() + @IsOptional() + @IsString() notes?: string; } export class QueryClassDto { - - @IsOptional() @IsString() + @IsOptional() + @IsString() status?: string; - @IsOptional() @IsString() + @IsOptional() + @IsString() classType?: string; - @IsOptional() @IsString() + @IsOptional() + @IsString() keyword?: string; @IsOptional() @@ -108,7 +164,8 @@ export class QueryClassDto { } export class AddStudentsDto { - @IsArray() @IsInt({ each: true }) + @IsArray() + @IsInt({ each: true }) studentIds: number[]; } @@ -119,23 +176,28 @@ export class AddTeacherDto { @IsEnum(TeacherRoleType) roleType: string; - @IsOptional() @IsString() + @IsOptional() + @IsString() subject?: string; } export class QueryClassScheduleDto { - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() startDate?: string; - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() endDate?: string; } export class QueryClassAttendanceSummaryDto { - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() startDate?: string; - @IsOptional() @IsDateString() + @IsOptional() + @IsDateString() endDate?: string; } export class BatchImportStudentsDto { @@ -147,12 +209,15 @@ export class BatchImportStudentsDto { } export class ImportUserItem { - @IsString() @IsNotEmpty() + @IsString() + @IsNotEmpty() dingUserId: string; - @IsString() @IsNotEmpty() + @IsString() + @IsNotEmpty() name: string; - @IsOptional() @IsString() + @IsOptional() + @IsString() mobile?: string; } diff --git a/apps/server/src/classroom-rentals/dto/rental.dto.spec.ts b/apps/server/src/classroom-rentals/dto/rental.dto.spec.ts new file mode 100644 index 0000000..88b7243 --- /dev/null +++ b/apps/server/src/classroom-rentals/dto/rental.dto.spec.ts @@ -0,0 +1,42 @@ +import { validate } from 'class-validator'; +import { CreateRentalDto } from './rental.dto'; + +const createRental = (overrides: Partial = {}) => + Object.assign(new CreateRentalDto(), { + classroomId: 1, + lesseeOrganizationId: 2, + startDate: '2026-08-01', + endDate: '2026-08-31', + ...overrides, + }); + +describe('classroom rental DTO boundaries', () => { + it.each(['2026-02-31', '2026-08-01T00:00:00Z', '2026-8-1'])( + 'rejects invalid or non-date-only value %s', + async (startDate) => { + const errors = await validate(createRental({ startDate })); + expect(errors.some((error) => error.property === 'startDate')).toBe(true); + }, + ); + + it.each([ + ['dailyRate', 0], + ['totalAmount', -1], + ] as const)('rejects non-positive %s', async (field, value) => { + const errors = await validate(createRental({ [field]: value })); + expect(errors.some((error) => error.property === field)).toBe(true); + }); + + it('accepts positive amounts and a leap-day date', async () => { + await expect( + validate( + createRental({ + startDate: '2028-02-29', + endDate: '2028-02-29', + dailyRate: 0.01, + totalAmount: 0.01, + }), + ), + ).resolves.toHaveLength(0); + }); +}); diff --git a/apps/server/src/classroom-rentals/dto/rental.dto.ts b/apps/server/src/classroom-rentals/dto/rental.dto.ts index 573c4fd..61f08c6 100644 --- a/apps/server/src/classroom-rentals/dto/rental.dto.ts +++ b/apps/server/src/classroom-rentals/dto/rental.dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator'; +import { IsOptional, IsString, IsInt, IsNumber, IsISO8601, Matches, Min } from 'class-validator'; export class CreateRentalDto { @IsInt() @@ -11,18 +11,22 @@ export class CreateRentalDto { @IsInt() lesseeOrganizationId: number; - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) startDate: string; - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) endDate: string; @IsOptional() @IsNumber() + @Min(0.01) dailyRate?: number; @IsOptional() @IsNumber() + @Min(0.01) totalAmount?: number; @IsOptional() @@ -44,19 +48,23 @@ export class UpdateRentalDto { lesseeOrganizationId?: number; @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) startDate?: string; @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) endDate?: string; @IsOptional() @IsNumber() + @Min(0.01) dailyRate?: number; @IsOptional() @IsNumber() + @Min(0.01) totalAmount?: number; @IsOptional() diff --git a/apps/server/src/dashboard/dashboard.controller.ts b/apps/server/src/dashboard/dashboard.controller.ts index 6fc5034..0ede964 100644 --- a/apps/server/src/dashboard/dashboard.controller.ts +++ b/apps/server/src/dashboard/dashboard.controller.ts @@ -7,6 +7,7 @@ import { SubjectName, } from '../authorization'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dto/dashboard-query.dto'; interface RequestUser { id: number; @@ -43,28 +44,18 @@ export class DashboardController { } @Get('gantt') - getGanttData( - @Query('periodStart') periodStart?: string, - @Query('periodEnd') periodEnd?: string, - @Query('building') building?: string, - ) { - return this.service.getGanttData({ periodStart, periodEnd, building }); + getGanttData(@Query() query: DashboardGanttQueryDto) { + return this.service.getGanttData(query); } @Get('expense-stats') - getExpenseStats( - @Query('periodStart') periodStart?: string, - @Query('periodEnd') periodEnd?: string, - ) { - return this.service.getExpenseStats(periodStart, periodEnd); + getExpenseStats(@Query() query: DashboardPeriodQueryDto) { + return this.service.getExpenseStats(query.periodStart, query.periodEnd); } @Get('room-ranking') - getRoomExpenseRanking( - @Query('periodStart') periodStart?: string, - @Query('periodEnd') periodEnd?: string, - ) { - return this.service.getRoomExpenseRanking(periodStart, periodEnd); + getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) { + return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd); } @Get('class-attendance-ranking') diff --git a/apps/server/src/dashboard/dashboard.scope.spec.ts b/apps/server/src/dashboard/dashboard.scope.spec.ts index 28cc85b..a76bba1 100644 --- a/apps/server/src/dashboard/dashboard.scope.spec.ts +++ b/apps/server/src/dashboard/dashboard.scope.spec.ts @@ -42,3 +42,45 @@ describe('DashboardService — teacher class scope', () => { }); }); }); + +describe('DashboardService — boundary conditions', () => { + it('uses a deny-all predicate instead of an empty SQL IN list', async () => { + const qb = createQb(); + const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; + const service = new DashboardService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + {} as never, attendanceRepo as never, {} as never, {} as never, {} as never, + {} as never, {} as never, + ); + + await (service as unknown as { + getAttendanceTrend: (today: string, classIds: number[]) => Promise; + }).getAttendanceTrend('2026-07-14', []); + + expect(qb.andWhere).toHaveBeenCalledWith('1 = 0'); + }); + + it.each([ + ['getGanttData', [{ periodStart: '2026-08-01', periodEnd: '2026-07-31' }]], + ['getExpenseStats', ['2026-08-01', '2026-07-31']], + ['getRoomExpenseRanking', ['2026-08-01', '2026-07-31']], + ] as const)('rejects a reversed period in %s', async (method, args) => { + const service = new DashboardService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + {} as never, + ); + await expect((service[method] as (...values: never[]) => Promise)(...(args as never[]))) + .rejects.toThrow('结束日期不能早于开始日期'); + }); + + it('uses the China calendar date when the server timezone is behind China', () => { + const service = new DashboardService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + {} as never, + ); + expect((service as unknown as { getChinaDate: (date: Date) => string }) + .getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14'); + }); +}); diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 02dfe05..fa7822d 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm'; import { Room } from '../entities/room.entity'; @@ -40,8 +40,7 @@ export class DashboardService { } async getStats(accessibleClassIds?: number[]) { - const today = new Date(); - const todayStr = today.toISOString().slice(0, 10); + const todayStr = this.getChinaDate(new Date()); const currentMonth = todayStr.slice(0, 7); // YYYY-MM const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } }); @@ -180,6 +179,10 @@ export class DashboardService { accessibleClassIds?: number[], ) { if (accessibleClassIds) { + if (accessibleClassIds.length === 0) { + qb.andWhere('1 = 0'); + return; + } qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds }); } } @@ -260,6 +263,7 @@ export class DashboardService { // 甘特图数据:每个宿舍的入住时间线 async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { + this.assertPeriodRange(query?.periodStart, query?.periodEnd); const qb = this.occRepo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') @@ -302,6 +306,7 @@ export class DashboardService { } // 费用统计 async getExpenseStats(periodStart?: string, periodEnd?: string) { + this.assertPeriodRange(periodStart, periodEnd); const qb = this.expRepo .createQueryBuilder('e') .select('e.expenseType', 'type') @@ -314,6 +319,7 @@ export class DashboardService { // 各宿舍费用排行 async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { + this.assertPeriodRange(periodStart, periodEnd); const qb = this.expRepo .createQueryBuilder('e') .leftJoin('e.room', 'room') @@ -368,7 +374,7 @@ export class DashboardService { where: { status: 'available' as const }, order: { building: 'ASC', name: 'ASC' }, }); - const today = new Date().toISOString().slice(0, 10); + const today = this.getChinaDate(new Date()); const schedQb = this.scheduleRepo .createQueryBuilder('s') .select('s.classroomId', 'classroomId') @@ -400,12 +406,31 @@ export class DashboardService { })); } + private assertPeriodRange(periodStart?: string, periodEnd?: string) { + if (periodStart && periodEnd && periodStart > periodEnd) { + throw new BadRequestException('结束日期不能早于开始日期'); + } + } + + private getChinaDate(date: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const values = Object.fromEntries( + parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]), + ); + return `${values.year}-${values.month}-${values.day}`; + } + async getClassroomUtilizationStats() { const totalClassrooms = await this.classroomRepo.count({ where: { status: 'available' as const }, }); - const today = new Date().toISOString().slice(0, 10); + const today = this.getChinaDate(new Date()); // Count classrooms with active schedules today const schedQb = this.scheduleRepo diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts new file mode 100644 index 0000000..ac7e6ea --- /dev/null +++ b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto'; + +describe('dashboard query boundaries', () => { + it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( + 'rejects invalid or non-date-only value %s', + async (periodStart) => { + const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart }); + expect((await validate(dto)).some((error) => error.property === 'periodStart')).toBe(true); + }, + ); + + it('accepts a valid date range and bounded building name', async () => { + const dto = plainToInstance(DashboardGanttQueryDto, { + periodStart: '2026-07-01', + periodEnd: '2026-07-31', + building: 'A座', + }); + expect(await validate(dto)).toEqual([]); + }); + + it('rejects an excessively long building name', async () => { + const dto = plainToInstance(DashboardGanttQueryDto, { building: 'A'.repeat(51) }); + expect((await validate(dto)).some((error) => error.property === 'building')).toBe(true); + }); +}); diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.ts new file mode 100644 index 0000000..5f2aafd --- /dev/null +++ b/apps/server/src/dashboard/dto/dashboard-query.dto.ts @@ -0,0 +1,20 @@ +import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; + +export class DashboardPeriodQueryDto { + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) + periodStart?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) + periodEnd?: string; +} + +export class DashboardGanttQueryDto extends DashboardPeriodQueryDto { + @IsOptional() + @IsString() + @MaxLength(50) + building?: string; +} diff --git a/apps/server/src/deposits/deposits.boundaries.spec.ts b/apps/server/src/deposits/deposits.boundaries.spec.ts new file mode 100644 index 0000000..4b765b5 --- /dev/null +++ b/apps/server/src/deposits/deposits.boundaries.spec.ts @@ -0,0 +1,53 @@ +import { BadRequestException } from '@nestjs/common'; +import { DepositsService } from './deposits.service'; +import { Deposit } from '../entities/deposit.entity'; + +function serviceWith(deposit?: Partial) { + const record = deposit ? ({ id: 1, studentId: 2, ...deposit } as Deposit) : null; + const repo = { + findOne: jest.fn(async (options: any) => options?.where?.studentId ? record : record), + create: jest.fn((value) => value), + save: jest.fn(async (value) => value), + }; + const installmentRepo = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => value), + }; + const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 2 }) }; + return { service: new DepositsService(repo as any, installmentRepo as any, studentRepo as any), repo, installmentRepo }; +} + +describe('DepositsService boundaries', () => { + it('rejects an installment amount that rounds to zero', async () => { + const { service, installmentRepo } = serviceWith({ amount: 500, status: 'paid' }); + + await expect(service.addInstallment(1, 0.004, '2026-08-01')).rejects.toBeInstanceOf(BadRequestException); + expect(installmentRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects a repeated full refund', async () => { + const { service, repo } = serviceWith({ amount: 0, status: 'refunded' }); + + await expect(service.refund(1, { refundDate: '2026-07-14' })).rejects.toBeInstanceOf(BadRequestException); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('rounds cumulative collections and clears stale refund audit fields', async () => { + const { service } = serviceWith({ + amount: 10.01, + status: 'refunded', + refundDate: '2026-07-01', + refundAmount: 5, + refundedBy: 9, + refundedAt: new Date(), + }); + + const result = await service.create({ studentId: 2, amount: 0.02, paidDate: '2026-07-14' }, 7); + + expect(result).toMatchObject({ amount: 10.03, status: 'paid', recordedBy: 7 }); + expect(result.refundDate).toBeNull(); + expect(result.refundAmount).toBeNull(); + expect(result.refundedBy).toBeNull(); + expect(result.refundedAt).toBeNull(); + }); +}); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 6b33eba..576bacf 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -7,6 +7,8 @@ import { DepositInstallment } from '../entities/deposit-installment.entity'; import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; +const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); + @Injectable() export class DepositsService { @@ -46,14 +48,22 @@ export class DepositsService { async create(dto: CreateDepositDto, userId?: number) { const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); - if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0'); + const amount = money(dto.amount); + if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { + throw new BadRequestException('收取金额最多保留两位小数'); + } + if (amount <= 0) throw new BadRequestException('收取金额必须大于0'); const existing = await this.repo.findOne({ where: { studentId: dto.studentId } }); if (existing) { - existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2)); + existing.amount = money(Number(existing.amount || 0) + amount); existing.paidDate = dto.paidDate; existing.status = 'paid'; existing.recordedBy = userId ?? null; + existing.refundDate = null as unknown as string; + existing.refundAmount = null as unknown as number; + existing.refundedBy = null; + existing.refundedAt = null; if (dto.notes) existing.notes = dto.notes; return this.repo.save(existing); } @@ -61,7 +71,7 @@ export class DepositsService { return this.repo.save( this.repo.create({ studentId: dto.studentId, - amount: dto.amount, + amount, paidDate: dto.paidDate, notes: dto.notes, status: 'paid', @@ -71,12 +81,17 @@ export class DepositsService { } async addInstallment(depositId: number, amount: number, dueDate: string) { + const normalizedAmount = money(amount); + if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { + throw new BadRequestException('分期金额最多保留两位小数'); + } + if (normalizedAmount <= 0) throw new BadRequestException('分期金额必须大于0'); const deposit = await this.repo.findOne({ where: { id: depositId } }); if (!deposit) throw new NotFoundException('押金记录不存在'); const installment = this.installmentRepo.create({ depositId, - amount, + amount: normalizedAmount, dueDate, status: 'pending', }); @@ -106,7 +121,7 @@ export class DepositsService { throw new BadRequestException('该学生当前没有可退押金'); } - const refundAmount = Number(deposit.amount); + const refundAmount = money(deposit.amount); deposit.refundDate = dto.refundDate; deposit.refundAmount = refundAmount; diff --git a/apps/server/src/expense-types/dto/expense-type.dto.ts b/apps/server/src/expense-types/dto/expense-type.dto.ts index fba2173..8d68438 100644 --- a/apps/server/src/expense-types/dto/expense-type.dto.ts +++ b/apps/server/src/expense-types/dto/expense-type.dto.ts @@ -1,10 +1,16 @@ -import { IsString, IsOptional, IsInt, IsBoolean, IsIn } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsBoolean, IsIn, IsNotEmpty, Matches, MaxLength, Min } from 'class-validator'; export class CreateExpenseTypeDto { @IsString() + @IsNotEmpty() + @MaxLength(30) + @Matches(/^[a-z][a-z0-9_]*$/) code: string; @IsString() + @IsNotEmpty() + @MaxLength(30) + @Matches(/\S/) name: string; @IsOptional() @@ -13,12 +19,16 @@ export class CreateExpenseTypeDto { @IsOptional() @IsInt() + @Min(0) sortOrder?: number; } export class UpdateExpenseTypeDto { @IsOptional() @IsString() + @IsNotEmpty() + @MaxLength(30) + @Matches(/\S/) name?: string; @IsOptional() @@ -27,6 +37,7 @@ export class UpdateExpenseTypeDto { @IsOptional() @IsInt() + @Min(0) sortOrder?: number; @IsOptional() diff --git a/apps/server/src/expense-types/expense-types.service.spec.ts b/apps/server/src/expense-types/expense-types.service.spec.ts new file mode 100644 index 0000000..51ac71e --- /dev/null +++ b/apps/server/src/expense-types/expense-types.service.spec.ts @@ -0,0 +1,68 @@ +import 'reflect-metadata'; +import { validate } from 'class-validator'; +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto'; +import { ExpenseTypesService } from './expense-types.service'; + +describe('ExpenseTypesService boundaries', () => { + const createService = () => { + const repo = { + findOne: jest.fn(), + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ id: 1, ...value })), + remove: jest.fn(), + }; + return { service: new ExpenseTypesService(repo as never), repo }; + }; + + it('normalizes code and name before duplicate detection and save', async () => { + const { service, repo } = createService(); + repo.findOne.mockResolvedValue(null); + await service.create({ code: ' water ', name: ' 水费 ' }); + expect(repo.findOne).toHaveBeenCalledWith({ where: { code: 'water' } }); + expect(repo.create).toHaveBeenCalledWith({ code: 'water', name: '水费' }); + }); + + it('rejects a normalized duplicate code', async () => { + const { service, repo } = createService(); + repo.findOne.mockResolvedValue({ id: 1, code: 'water' }); + await expect(service.create({ code: ' water ', name: '水费' })) + .rejects.toBeInstanceOf(ConflictException); + }); + + it('trims an updated name and preserves omitted fields', async () => { + const { service, repo } = createService(); + repo.findOne.mockResolvedValue({ id: 1, code: 'water', name: '旧名称', enabled: true }); + await service.update(1, { name: ' 新名称 ' }); + expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ + code: 'water', name: '新名称', enabled: true, + })); + }); + + it('rejects removal of a missing type', async () => { + const { service, repo } = createService(); + repo.findOne.mockResolvedValue(null); + await expect(service.remove(999)).rejects.toBeInstanceOf(NotFoundException); + expect(repo.remove).not.toHaveBeenCalled(); + }); +}); + +describe('expense type DTO boundaries', () => { + it.each(['Water', '1water', 'water-fee', '', 'a'.repeat(31)])('rejects code %j', async (code) => { + const dto = Object.assign(new CreateExpenseTypeDto(), { code, name: '水费' }); + expect((await validate(dto)).some((error) => error.property === 'code')).toBe(true); + }); + + it.each(['', ' '])('rejects blank name %j and negative sort order', async (name) => { + const dto = Object.assign(new CreateExpenseTypeDto(), { + code: 'water', name, sortOrder: -1, + }); + const properties = (await validate(dto)).map((error) => error.property); + expect(properties).toEqual(expect.arrayContaining(['name', 'sortOrder'])); + }); + + it('accepts zero sort order and boolean enabled update', async () => { + const dto = Object.assign(new UpdateExpenseTypeDto(), { sortOrder: 0, enabled: false }); + expect(await validate(dto)).toEqual([]); + }); +}); diff --git a/apps/server/src/expense-types/expense-types.service.ts b/apps/server/src/expense-types/expense-types.service.ts index 0cbfe46..544e360 100644 --- a/apps/server/src/expense-types/expense-types.service.ts +++ b/apps/server/src/expense-types/expense-types.service.ts @@ -52,14 +52,15 @@ export class ExpenseTypesService { } async create(dto: CreateExpenseTypeDto): Promise { - const exists = await this.repo.findOne({ where: { code: dto.code } }); + const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() }; + const exists = await this.repo.findOne({ where: { code: normalized.code } }); if (exists) throw new ConflictException('费用类型代码已存在'); - return this.repo.save(this.repo.create(dto)); + return this.repo.save(this.repo.create(normalized)); } async update(id: number, dto: UpdateExpenseTypeDto): Promise { const t = await this.findOne(id); - Object.assign(t, dto); + Object.assign(t, dto, dto.name === undefined ? {} : { name: dto.name.trim() }); return this.repo.save(t); } diff --git a/apps/server/src/expenses/dto/expense.dto.spec.ts b/apps/server/src/expenses/dto/expense.dto.spec.ts new file mode 100644 index 0000000..2db7053 --- /dev/null +++ b/apps/server/src/expenses/dto/expense.dto.spec.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { BatchRoomExpenseDto } from './expense.dto'; + +describe('BatchRoomExpenseDto boundaries', () => { + it.each([ + { expenses: [] }, + { expenses: [{ roomId: 1, expenseType: 'water', amount: 0 }] }, + { expenses: [{ roomId: 1, expenseType: 'water', amount: -1 }] }, + { expenses: [{ roomId: 1, expenseType: 'water', amount: 1.001 }] }, + { periodStart: '2026-02-31' }, + ])('rejects invalid batch payload %#', async (override) => { + const dto = plainToInstance(BatchRoomExpenseDto, { + periodStart: '2026-07-01', + periodEnd: '2026-07-31', + expenses: [{ roomId: 1, expenseType: 'water', amount: 10 }], + ...override, + }); + await expect(validate(dto)).resolves.not.toHaveLength(0); + }); + + it('accepts a valid batch payload', async () => { + const dto = plainToInstance(BatchRoomExpenseDto, { + periodStart: '2026-07-01', + periodEnd: '2026-07-31', + expenses: [{ roomId: 1, expenseType: 'water', amount: 10.25 }], + }); + await expect(validate(dto)).resolves.toHaveLength(0); + }); +}); diff --git a/apps/server/src/expenses/dto/expense.dto.ts b/apps/server/src/expenses/dto/expense.dto.ts index bd8a0e1..fc24711 100644 --- a/apps/server/src/expenses/dto/expense.dto.ts +++ b/apps/server/src/expenses/dto/expense.dto.ts @@ -1,4 +1,4 @@ -import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator'; import { PartialType } from '@nestjs/mapped-types'; import { Type } from 'class-transformer'; @@ -13,9 +13,13 @@ export class CreateRoomExpenseDto { @Min(0.01) amount: number; + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) @IsDateString() periodStart: string; + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) @IsDateString() periodEnd: string; @@ -39,6 +43,8 @@ export class CreatePersonalExpenseDto { @Min(0.01) amount: number; + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) @IsDateString() expenseDate: string; @@ -74,14 +80,38 @@ export class QueryPersonalExpenseDto { studentId?: number; } -export class BatchRoomExpenseDto { +export class BatchRoomExpenseItemDto { + @IsInt() + roomId: number; + @IsString() + expenseType: string; + + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0.01) + amount: number; + + @IsOptional() + @IsString() + description?: string; +} + +export class BatchRoomExpenseDto { + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) + @IsDateString() periodStart: string; - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) + @IsDateString() periodEnd: string; - expenses: { roomId: number; expenseType: string; amount: number; description?: string }[]; + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => BatchRoomExpenseItemDto) + expenses: BatchRoomExpenseItemDto[]; } diff --git a/apps/server/src/expenses/expenses.boundaries.spec.ts b/apps/server/src/expenses/expenses.boundaries.spec.ts new file mode 100644 index 0000000..4acd85c --- /dev/null +++ b/apps/server/src/expenses/expenses.boundaries.spec.ts @@ -0,0 +1,107 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ExpensesService } from './expenses.service'; +import { PersonalExpense } from '../entities/personal-expense.entity'; + +const qb = (affected = 1) => ({ + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected }), +}); + +function createService(options?: { + roomFind?: any[]; + personalFind?: any[]; + roomExpense?: any; + personalExpense?: any; +}) { + const roomExpRepo = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => value), + find: jest.fn().mockResolvedValue(options?.roomFind ?? []), + findOne: jest.fn().mockResolvedValue(options?.roomExpense ?? null), + createQueryBuilder: jest.fn(() => qb()), + }; + const personalExpRepo = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => value), + find: jest.fn().mockResolvedValue(options?.personalFind ?? []), + findOne: jest.fn().mockResolvedValue(options?.personalExpense ?? null), + createQueryBuilder: jest.fn(() => qb()), + delete: jest.fn(), + }; + const roomRepo = { + find: jest.fn().mockImplementation(async () => options?.roomFind ?? []), + findOne: jest.fn().mockResolvedValue({ id: 1 }), + }; + const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) }; + return { + service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any), + roomExpRepo, + personalExpRepo, + roomRepo, + }; +} + +describe('ExpensesService boundaries', () => { + it('rejects an empty room-expense batch', async () => { + const { service, roomExpRepo } = createService(); + await expect(service.batchCreateRoomExpenses({ + periodStart: '2026-07-01', + periodEnd: '2026-07-31', + expenses: [], + })).rejects.toBeInstanceOf(BadRequestException); + expect(roomExpRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects a batch when any room does not exist', async () => { + const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] }); + await expect(service.batchCreateRoomExpenses({ + periodStart: '2026-07-01', + periodEnd: '2026-07-31', + expenses: [ + { roomId: 1, expenseType: 'water', amount: 10 }, + { roomId: 2, expenseType: 'water', amount: 20 }, + ], + })).rejects.toBeInstanceOf(NotFoundException); + expect(roomExpRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects a reversed room-expense period', async () => { + const { service } = createService(); + await expect(service.createRoomExpense({ + roomId: 1, + expenseType: 'water', + amount: 10, + periodStart: '2026-08-01', + periodEnd: '2026-07-31', + })).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects a batch delete when only part of the ids exist', async () => { + const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] }); + await expect(service.batchDeleteRoomExpenses([1, 2])).rejects.toBeInstanceOf(NotFoundException); + expect(roomExpRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('does not edit or delete a personal expense already linked to a bill', async () => { + const linked = { id: 1, studentId: 1, amount: 20, billId: 9 } as PersonalExpense; + const { service, personalExpRepo } = createService({ personalExpense: linked }); + + await expect(service.updatePersonalExpense(1, { amount: 30 })).rejects.toBeInstanceOf(BadRequestException); + await expect(service.deletePersonalExpense(1)).rejects.toBeInstanceOf(BadRequestException); + expect(personalExpRepo.save).not.toHaveBeenCalled(); + expect(personalExpRepo.delete).not.toHaveBeenCalled(); + }); + + it('rejects a personal-expense batch delete containing billed records', async () => { + const { service, personalExpRepo } = createService({ + personalFind: [ + { id: 1, billId: null }, + { id: 2, billId: 9 }, + ], + }); + + await expect(service.batchDeletePersonalExpenses([1, 2])).rejects.toBeInstanceOf(BadRequestException); + expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 443785d..03d0ca0 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -42,6 +42,8 @@ export class ExpensesService { // 宿舍费用 async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) { + this.assertValidPeriod(dto.periodStart, dto.periodEnd); + this.assertPositiveAmount(dto.amount); const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId }); @@ -49,6 +51,12 @@ export class ExpensesService { } async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) { + this.assertValidPeriod(dto.periodStart, dto.periodEnd); + if (!dto.expenses?.length) throw new BadRequestException('请至少填写一条费用'); + dto.expenses.forEach((expense) => this.assertPositiveAmount(expense.amount)); + const roomIds = [...new Set(dto.expenses.map((expense) => expense.roomId))]; + const existingRooms = await this.roomRepo.find({ where: { id: In(roomIds) }, select: ['id'] }); + if (existingRooms.length !== roomIds.length) throw new NotFoundException('部分宿舍不存在'); const entities = dto.expenses.map((e) => { const entity = this.roomExpRepo.create({ roomId: e.roomId, @@ -83,11 +91,14 @@ export class ExpensesService { } async batchDeleteRoomExpenses(ids: number[]) { - if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录'); + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录'); + const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); const result = await this.roomExpRepo .createQueryBuilder() .delete() - .where('id IN (:...ids)', { ids }) + .where('id IN (:...ids)', { ids: uniqueIds }) .execute(); return { message: '批量删除成功', deleted: result.affected || 0 }; } @@ -95,12 +106,40 @@ export class ExpensesService { async updateRoomExpense(id: number, dto: Partial) { const e = await this.roomExpRepo.findOne({ where: { id } }); if (!e) throw new NotFoundException('费用记录不存在'); + const periodStart = dto.periodStart ?? e.periodStart; + const periodEnd = dto.periodEnd ?? e.periodEnd; + this.assertValidPeriod(periodStart, periodEnd); + if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount); + if (dto.roomId !== undefined && dto.roomId !== e.roomId) { + const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); + if (!room) throw new NotFoundException('宿舍不存在'); + } Object.assign(e, dto); return this.roomExpRepo.save(e); } + private assertPositiveAmount(amount: number) { + if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { + throw new BadRequestException('费用金额最多保留两位小数'); + } + if (amount <= 0) throw new BadRequestException('费用金额必须大于0'); + } + + private assertValidPeriod(periodStart: string, periodEnd: string) { + if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { + throw new BadRequestException('账期无效,结束日期不能早于开始日期'); + } + } + + private isValidDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; + } + async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) { - if (dto.periodEnd < dto.periodStart) throw new BadRequestException('账期结束日期不能早于开始日期'); + this.assertValidPeriod(dto.periodStart, dto.periodEnd); + this.assertPositiveAmount(dto.amount); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); const expense = await this.personalExpRepo.save( @@ -125,6 +164,7 @@ export class ExpensesService { // 个人附加费 async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { + this.assertPositiveAmount(dto.amount); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId }); @@ -144,16 +184,23 @@ export class ExpensesService { async deletePersonalExpense(id: number) { const e = await this.personalExpRepo.findOne({ where: { id } }); if (!e) throw new NotFoundException('费用记录不存在'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能删除,请先取消账单'); await this.personalExpRepo.delete(id); return { message: '删除成功' }; } async batchDeletePersonalExpenses(ids: number[]) { - if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录'); + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录'); + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + if (existing.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } const result = await this.personalExpRepo .createQueryBuilder() .delete() - .where('id IN (:...ids)', { ids }) + .where('id IN (:...ids)', { ids: uniqueIds }) .execute(); return { message: '批量删除成功', deleted: result.affected || 0 }; } @@ -161,6 +208,12 @@ export class ExpensesService { async updatePersonalExpense(id: number, dto: Partial) { const e = await this.personalExpRepo.findOne({ where: { id } }); if (!e) throw new NotFoundException('费用记录不存在'); + if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单'); + if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount); + if (dto.studentId !== undefined && dto.studentId !== e.studentId) { + const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + } Object.assign(e, dto); return this.personalExpRepo.save(e); } diff --git a/apps/server/src/integration/config/integration-config.service.spec.ts b/apps/server/src/integration/config/integration-config.service.spec.ts index 339524a..cb559cb 100644 --- a/apps/server/src/integration/config/integration-config.service.spec.ts +++ b/apps/server/src/integration/config/integration-config.service.spec.ts @@ -26,6 +26,7 @@ describe('IntegrationConfigService.testConnection', () => { }), }; global.fetch = jest.fn().mockResolvedValue({ + ok: true, json: jest.fn().mockResolvedValue({ accessToken: 'token' }), }) as never; @@ -47,3 +48,52 @@ describe('IntegrationConfigService.testConnection', () => { ); }); }); + +describe('IntegrationConfigService security boundaries', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('masks AppSecret without mutating the parsed source object', async () => { + const content = JSON.stringify({ + config: { corpId: 'corp', agentId: 'agent', appSecret: 'top-secret' }, + }); + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + }; + const detailRepo = { + find: jest.fn().mockResolvedValue([{ type: 'DINGTALK_SYNC', enable: true, content }]), + }; + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await expect(service.getThirdConfig()).resolves.toEqual([ + { + type: 'DINGTALK', + verify: true, + config: { corpId: 'corp', agentId: 'agent' }, + }, + ]); + expect(JSON.parse(content).config.appSecret).toBe('top-secret'); + }); + + it('treats a non-2xx DingTalk token response as a failed connection even if it contains a token field', async () => { + const configRepo = { findOne: jest.fn() }; + const detailRepo = { findOne: jest.fn() }; + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: jest.fn().mockResolvedValue({ accessToken: 'must-not-be-used' }), + }) as never; + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await expect( + service.testConnection('DINGTALK' as never, { + corpId: 'corp', + agentId: 'agent', + appSecret: 'secret', + }), + ).resolves.toBe(false); + }); +}); diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index 638d6d5..910d5c4 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -205,13 +205,21 @@ export class IntegrationConfigService { /** 调钉钉新版接口拿 access_token */ private async fetchDingTalkToken(appKey: string, appSecret: string): Promise { - const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ appKey, appSecret }), - }); - const body = (await res.json()) as { accessToken?: string; expireIn?: number }; - return body.accessToken || null; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appKey, appSecret }), + signal: controller.signal, + }); + if (!res.ok) return null; + const body = (await res.json()) as { accessToken?: string; expireIn?: number }; + return body.accessToken || null; + } finally { + clearTimeout(timeout); + } } /** 解析并脱敏:删掉 appSecret 后返回 config 对象 */ @@ -219,9 +227,10 @@ export class IntegrationConfigService { if (!content) return {}; try { const parsed = JSON.parse(content); - const cfg = parsed.config || parsed; - if (cfg.appSecret) delete cfg.appSecret; - return cfg; + const source = parsed.config || parsed; + if (!source || typeof source !== 'object' || Array.isArray(source)) return {}; + const { appSecret: _appSecret, ...masked } = source as Record; + return masked; } catch { return {}; } diff --git a/apps/server/src/integration/dingtalk.service.spec.ts b/apps/server/src/integration/dingtalk.service.spec.ts index fade60f..9c67bea 100644 --- a/apps/server/src/integration/dingtalk.service.spec.ts +++ b/apps/server/src/integration/dingtalk.service.spec.ts @@ -174,3 +174,41 @@ describe('DingTalkService — attendance machine only group', () => { })); }); }); + +describe('DingTalkService — department user pagination boundaries', () => { + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = undefined as unknown as typeof fetch; + }); + + it('stops when DingTalk says there is another page but omits the next cursor', async () => { + const service = new DingTalkService({} as never, {} as never); + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + errcode: 0, + errmsg: 'ok', + result: { + list: [{ userid: 'u1', name: 'Alice', mobile: '', dept_id_list: [1] }], + has_more: true, + }, + }), + }) as jest.MockedFunction; + + await expect((service as any).getDeptUsers('token', 1)).resolves.toHaveLength(1); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('stops when the next cursor repeats the current cursor', async () => { + const service = new DingTalkService({} as never, {} as never); + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + errcode: 0, + errmsg: 'ok', + result: { list: [], has_more: true, next_cursor: 0 }, + }), + }) as jest.MockedFunction; + + await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 91c6506..8e12afe 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -279,8 +279,13 @@ export class DingTalkService { if (body.errcode === 0 && body.result) { all.push(...body.result.list); hasMore = body.result.has_more; - if (hasMore && body.result.next_cursor !== undefined) { - cursor = body.result.next_cursor; + if (hasMore) { + if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) { + this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`); + hasMore = false; + } else { + cursor = body.result.next_cursor; + } } } else { hasMore = false; diff --git a/apps/server/src/notifications/dto/notification.dto.spec.ts b/apps/server/src/notifications/dto/notification.dto.spec.ts index d97d96e..235aa75 100644 --- a/apps/server/src/notifications/dto/notification.dto.spec.ts +++ b/apps/server/src/notifications/dto/notification.dto.spec.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { NotificationQueryDto } from './notification.dto'; +import { CreateNotificationDto, NotificationQueryDto } from './notification.dto'; describe('NotificationQueryDto', () => { it('converts numeric query-string values before integer validation', async () => { @@ -15,3 +15,21 @@ describe('NotificationQueryDto', () => { expect(dto.limit).toBe(20); }); }); + +describe('notification DTO boundaries', () => { + it('rejects an empty recipient set', async () => { + const dto = plainToInstance(CreateNotificationDto, { + recipientIds: [], + type: 'test', + title: '标题', + }); + await expect(validate(dto)).resolves.toEqual(expect.arrayContaining([expect.any(Object)])); + }); + + it('rejects non-positive cursors and page sizes outside 1-100', async () => { + for (const value of [{ after: '0' }, { limit: '0' }, { limit: '101' }]) { + const dto = plainToInstance(NotificationQueryDto, value); + expect(await validate(dto)).not.toEqual([]); + } + }); +}); diff --git a/apps/server/src/notifications/dto/notification.dto.ts b/apps/server/src/notifications/dto/notification.dto.ts index 10a545c..fc524ad 100644 --- a/apps/server/src/notifications/dto/notification.dto.ts +++ b/apps/server/src/notifications/dto/notification.dto.ts @@ -1,8 +1,18 @@ -import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt } from 'class-validator'; +import { + ArrayNotEmpty, + IsString, + IsNotEmpty, + IsOptional, + IsArray, + IsInt, + Max, + Min, +} from 'class-validator'; import { Type } from 'class-transformer'; export class CreateNotificationDto { @IsArray() + @ArrayNotEmpty() @IsInt({ each: true }) recipientIds: number[]; @@ -27,10 +37,13 @@ export class NotificationQueryDto { @IsOptional() @Type(() => Number) @IsInt() + @Min(1) after?: number; @IsOptional() @Type(() => Number) @IsInt() + @Min(1) + @Max(100) limit?: number; } diff --git a/apps/server/src/notifications/notifications.service.spec.ts b/apps/server/src/notifications/notifications.service.spec.ts new file mode 100644 index 0000000..575563f --- /dev/null +++ b/apps/server/src/notifications/notifications.service.spec.ts @@ -0,0 +1,61 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { NotificationsService } from './notifications.service'; + +function createQueryBuilder() { + return { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; +} + +describe('NotificationsService boundaries', () => { + it('rejects empty recipients and de-duplicates repeated recipients', async () => { + const repo = { + save: jest.fn().mockImplementation(async (rows) => rows), + }; + const service = new NotificationsService(repo as never, { emit: jest.fn() } as never); + + await expect( + service.create({ recipientIds: [], type: 'test', title: '标题' }), + ).rejects.toBeInstanceOf(BadRequestException); + + const saved = await service.create({ recipientIds: [1, 1, 2], type: 'test', title: '标题' }); + expect(saved).toHaveLength(2); + expect(repo.save).toHaveBeenCalledWith([ + expect.objectContaining({ recipientId: 1 }), + expect.objectContaining({ recipientId: 2 }), + ]); + }); + + it('clamps service-level page size to protect callers outside the controller', async () => { + const qb = createQueryBuilder(); + const service = new NotificationsService( + { createQueryBuilder: jest.fn().mockReturnValue(qb) } as never, + new EventEmitter2(), + ); + + await service.findByUser(7, undefined, 1000); + expect(qb.take).toHaveBeenCalledWith(100); + }); + + it('does not allow marking another user notification as read', async () => { + const repo = { findOne: jest.fn().mockResolvedValue(null), update: jest.fn() }; + const service = new NotificationsService(repo as never, new EventEmitter2()); + await expect(service.markRead(3, 7)).rejects.toBeInstanceOf(NotFoundException); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('treats marking an already-read notification as idempotent', async () => { + const repo = { + findOne: jest.fn().mockResolvedValue({ id: 3, recipientId: 7, isRead: true }), + update: jest.fn(), + }; + const service = new NotificationsService(repo as never, new EventEmitter2()); + await expect(service.markRead(3, 7)).resolves.toBeUndefined(); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/notifications/notifications.service.ts b/apps/server/src/notifications/notifications.service.ts index f9581ba..d663cc4 100644 --- a/apps/server/src/notifications/notifications.service.ts +++ b/apps/server/src/notifications/notifications.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Subject, Observable } from 'rxjs'; @@ -18,7 +18,10 @@ export class NotificationsService { ) {} async create(dto: CreateNotificationDto): Promise { - const notifications = dto.recipientIds.map((recipientId) => ({ + const recipientIds = [...new Set(dto.recipientIds)]; + if (recipientIds.length === 0) throw new BadRequestException('通知接收人不能为空'); + + const notifications = recipientIds.map((recipientId) => ({ recipientId, type: dto.type, title: dto.title, @@ -36,16 +39,13 @@ export class NotificationsService { return saved; } - async findByUser( - userId: number, - after?: number, - limit: number = 20, - ): Promise { + async findByUser(userId: number, after?: number, limit: number = 20): Promise { + const safeLimit = Math.min(Math.max(limit, 1), 100); const qb = this.repo .createQueryBuilder('n') .where('n.recipientId = :userId', { userId }) .orderBy('n.createdAt', 'DESC') - .take(limit); + .take(safeLimit); if (after !== undefined) { qb.andWhere('n.id < :after', { after }); @@ -61,10 +61,10 @@ export class NotificationsService { } async markRead(id: number, userId: number): Promise { - await this.repo.update( - { id, recipientId: userId }, - { isRead: true, readAt: new Date() }, - ); + const notification = await this.repo.findOne({ where: { id, recipientId: userId } }); + if (!notification) throw new NotFoundException('通知不存在'); + if (notification.isRead) return; + await this.repo.update({ id, recipientId: userId }, { isRead: true, readAt: new Date() }); } async markAllRead(userId: number): Promise { diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts index 24b7df8..2f1d255 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts @@ -71,3 +71,30 @@ describe('manual occupancy DTO bed requirements', () => { expect(errors.some((error) => error.property === 'newLockerId')).toBe(false); }); }); + +describe('occupancy date boundaries', () => { + it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( + 'rejects invalid or non-date-only check-in date %s', + async (checkInDate) => { + const dto = Object.assign(new CheckInDto(), { + studentId: 1, + roomId: 2, + checkInDate, + bedId: 3, + }); + + expect((await validate(dto)).some((error) => error.property === 'checkInDate')).toBe(true); + }, + ); + + it('accepts a leap-day date', async () => { + const dto = Object.assign(new CheckInDto(), { + studentId: 1, + roomId: 2, + checkInDate: '2028-02-29', + bedId: 3, + }); + + await expect(validate(dto)).resolves.toHaveLength(0); + }); +}); diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts index 1cc6dab..f2ffc22 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.ts @@ -1,4 +1,14 @@ -import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsInt, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Matches, + Min, +} from 'class-validator'; export class CheckInDto { @IsInt() @@ -7,11 +17,13 @@ export class CheckInDto { @IsInt() roomId: number; - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) checkInDate: string; // YYYY-MM-DD @IsOptional() - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) billingStartDate?: string; // 默认=checkInDate,可调整 @IsOptional() @@ -40,11 +52,13 @@ export class CheckInDto { } export class CheckOutDto { - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) checkOutDate: string; @IsOptional() - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) billingEndDate?: string; // 默认=checkOutDate @IsOptional() @@ -56,11 +70,13 @@ export class TransferRoomDto { @IsInt() newRoomId: number; - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) transferDate: string; // YYYY-MM-DD @IsOptional() - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate @IsInt() @@ -70,7 +86,8 @@ export class TransferRoomDto { @IsInt() newLockerId?: number; @IsOptional() - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日 @IsOptional() @@ -82,11 +99,13 @@ export class BatchCheckOutDto { @IsArray() ids: number[]; - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) checkOutDate: string; // YYYY-MM-DD @IsOptional() - @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) billingEndDate?: string; // 默认=checkOutDate @IsOptional() diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 0ce5fea..8667149 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -117,8 +117,9 @@ describe('OccupanciesService — manual check-in deposit', () => { expect(depositRepo.save).toHaveBeenCalledTimes(1); }); - it('does not create another paid deposit when one already exists', async () => { - const { service, depositRepo } = createService({ id: 99 } as Deposit); + it('adds the collected amount to the existing student deposit', async () => { + const existing = { id: 99, amount: 200, status: 'refunded' } as Deposit; + const { service, depositRepo } = createService(existing); await service.checkIn({ studentId: 3, @@ -130,7 +131,15 @@ describe('OccupanciesService — manual check-in deposit', () => { }); expect(depositRepo.create).not.toHaveBeenCalled(); - expect(depositRepo.save).not.toHaveBeenCalled(); + expect(depositRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: 99, + amount: 1000, + status: 'paid', + paidDate: '2026-07-14', + notes: '入住登记自动收取', + }), + ); }); }); @@ -274,3 +283,66 @@ describe('OccupanciesService — import student matching', () => { expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 })); }); }); + +describe('OccupanciesService — stay lifecycle boundaries', () => { + it('rejects check-out before check-in without releasing resources', async () => { + const occupancy = { + id: 1, + roomId: 2, + bedId: 3, + lockerId: 4, + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + checkOutDate: null, + } as Occupancy; + const occupancyRepo = { + findOne: jest.fn().mockResolvedValue(occupancy), + save: jest.fn(), + } as any as Repository; + const roomRepo = { update: jest.fn() } as any as Repository; + const bedRepo = { update: jest.fn() } as any as Repository; + const lockerRepo = { update: jest.fn() } as any as Repository; + const service = new OccupanciesService( + occupancyRepo, + roomRepo, + {} as Repository, + {} as Repository, + bedRepo, + lockerRepo, + {} as Repository, + {} as DataSource, + ); + + await expect(service.checkOut(1, { checkOutDate: '2026-07-09' })).rejects.toThrow( + '退宿日期不能早于入住日期', + ); + expect(occupancyRepo.save).not.toHaveBeenCalled(); + expect(bedRepo.update).not.toHaveBeenCalled(); + expect(lockerRepo.update).not.toHaveBeenCalled(); + expect(roomRepo.update).not.toHaveBeenCalled(); + }); + + it('rejects check-in to a maintenance room', async () => { + const occupancyRepo = { + findOne: jest.fn().mockResolvedValue(null), + count: jest.fn(), + } as any as Repository; + const service = new OccupanciesService( + occupancyRepo, + { + findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, status: 'maintenance' }), + } as any, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as DataSource, + ); + + await expect( + service.checkIn({ studentId: 1, roomId: 2, checkInDate: '2026-07-10', bedId: 3 }), + ).rejects.toThrow('该宿舍当前不可入住'); + expect(occupancyRepo.count).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 856d9b4..2e34067 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -47,6 +47,8 @@ export class OccupanciesService { } async checkIn(dto: CheckInDto, userId?: number) { + this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); + // 检查学生是否已有活跃入住 const existing = await this.repo.findOne({ where: { studentId: dto.studentId, checkOutDate: IsNull() }, @@ -56,6 +58,9 @@ export class OccupanciesService { // 检查宿舍容量 const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived' || room.status === 'maintenance') { + throw new BadRequestException('该宿舍当前不可入住'); + } const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } }); if (count >= room.capacity) throw new BadRequestException('宿舍已满'); @@ -138,6 +143,12 @@ export class OccupanciesService { const occ = await this.repo.findOne({ where: { id: occupancyId } }); if (!occ) throw new NotFoundException('入住记录不存在'); if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); + this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder( + occ.billingStartDate || occ.checkInDate, + dto.billingEndDate || dto.checkOutDate, + '计费截止日不能早于计费起始日', + ); occ.checkOutDate = dto.checkOutDate; occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; @@ -166,6 +177,14 @@ export class OccupanciesService { const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } }); if (!oldOcc) throw new NotFoundException('入住记录不存在'); if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); + if (oldOcc.roomId === dto.newRoomId) + throw new BadRequestException('目标宿舍不能与当前宿舍相同'); + this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期'); + this.assertDateOrder( + oldOcc.billingStartDate || oldOcc.checkInDate, + dto.oldBillingEndDate || dto.transferDate, + '原宿舍计费截止日不能早于计费起始日', + ); // 退旧房 oldOcc.checkOutDate = dto.transferDate; @@ -183,6 +202,9 @@ export class OccupanciesService { // 检查新房容量 const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } }); if (!newRoom) throw new NotFoundException('目标宿舍不存在'); + if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { + throw new BadRequestException('目标宿舍当前不可入住'); + } const count = await runner.manager.count(Occupancy, { where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, }); @@ -209,6 +231,11 @@ export class OccupanciesService { const nextDay = new Date(transferDate); nextDay.setDate(nextDay.getDate() + 1); const defaultBillingStart = nextDay.toISOString().split('T')[0]; + this.assertDateOrder( + dto.transferDate, + dto.newBillingStartDate || defaultBillingStart, + '新宿舍计费起始日不能早于换房日期', + ); // 入住新房 const newOcc = runner.manager.create(Occupancy, { @@ -321,6 +348,17 @@ export class OccupanciesService { errors.push(`${occ.student?.name || id}已退宿`); continue; } + try { + this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder( + occ.billingStartDate || occ.checkInDate, + dto.billingEndDate || dto.checkOutDate, + '计费截止日不能早于计费起始日', + ); + } catch (error) { + errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`); + continue; + } occ.checkOutDate = dto.checkOutDate; occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; occ.checkOutReason = dto.checkOutReason || ''; @@ -447,12 +485,25 @@ export class OccupanciesService { ); } - // 3. 检查是否已有活跃入住 + const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; + const checkOutDate = row.checkOutDate?.trim(); + const billingStartDate = row.billingStartDate?.trim() || checkInDate; + const isHistoricalRecord = Boolean(checkOutDate); + this.assertDateOnly(checkInDate, '入住日期'); + this.assertDateOnly(billingStartDate, '计费起始日'); + this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); + if (checkOutDate) { + this.assertDateOnly(checkOutDate, '退宿日期'); + this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); + } + + // 3. 检查是否已有活跃入住(历史记录不影响当前入住) const existing = await this.repo.findOne({ where: { studentId: student.id, checkOutDate: IsNull() }, relations: ['room'], }); - if (existing) { + if (existing && !isHistoricalRecord) { errors.push( `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, ); @@ -462,7 +513,7 @@ export class OccupanciesService { // 4. 检查宿舍容量 const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); - if (count >= room.capacity) { + if (!isHistoricalRecord && count >= room.capacity) { errors.push( `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`, ); @@ -471,7 +522,6 @@ export class OccupanciesService { } // 5. 匹配或创建床位、柜子,并校验是否可用 - const isHistoricalRecord = Boolean(row.checkOutDate?.trim()); let bed: Bed | null = null; if (row.bedNumber?.trim()) { const bedNumber = row.bedNumber.trim(); @@ -507,12 +557,11 @@ export class OccupanciesService { } // 6. 创建入住记录 - const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; const occData: any = { studentId: student.id, roomId: room.id, checkInDate, - billingStartDate: row.billingStartDate?.trim() || checkInDate, + billingStartDate, stayType: row.stayType || undefined, responsibleOrganizationId: student.organizationId, notes: row.notes || undefined, @@ -520,9 +569,9 @@ export class OccupanciesService { lockerId: locker?.id, }; // 如果有退宿日期,直接记录 - if (row.checkOutDate?.trim()) { - occData.checkOutDate = row.checkOutDate.trim(); - occData.billingEndDate = row.checkOutDate.trim(); + if (checkOutDate) { + occData.checkOutDate = checkOutDate; + occData.billingEndDate = checkOutDate; } await this.repo.save(this.repo.create(occData)); @@ -536,13 +585,15 @@ export class OccupanciesService { } // 9. 自动收取押金(仅对新入住且非历史记录的学生) - if (options?.autoDeposit && !row.checkOutDate?.trim()) { + if (options?.autoDeposit && !isHistoricalRecord) { const existingDeposit = await this.depositRepo.findOne({ where: { studentId: student.id }, }); if (existingDeposit) { existingDeposit.amount = Number( - (Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(2), + (Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed( + 2, + ), ); existingDeposit.status = 'paid'; existingDeposit.paidDate = checkInDate; @@ -579,4 +630,26 @@ export class OccupanciesService { errors: errors.length > 0 ? errors : undefined, }; } + + private assertDateOnly(value: string, label: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); + } + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() + 1 !== month || + date.getUTCDate() !== day + ) { + throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); + } + } + + private assertDateOrder(start: string, end: string | undefined, message: string): void { + this.assertDateOnly(start, '起始日期'); + if (!end) return; + this.assertDateOnly(end, '结束日期'); + if (end < start) throw new BadRequestException(message); + } } diff --git a/apps/server/src/operation-logs/dto/operation-log.dto.ts b/apps/server/src/operation-logs/dto/operation-log.dto.ts new file mode 100644 index 0000000..ff88f83 --- /dev/null +++ b/apps/server/src/operation-logs/dto/operation-log.dto.ts @@ -0,0 +1,74 @@ +import { Type } from 'class-transformer'; +import { + IsISO8601, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator'; + +export class QueryOperationLogsDto { + @IsOptional() + @IsString() + @MaxLength(50) + module?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + userId?: number; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) + startDate?: string; + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) + endDate?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + pageSize: number = 50; +} + +export class CreateAuditLogDto { + @IsString() + @IsNotEmpty() + @MaxLength(50) + module: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + action: string; + + @IsOptional() + @IsInt() + targetId?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + targetType?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + detail?: string; +} diff --git a/apps/server/src/operation-logs/operation-logs.controller.ts b/apps/server/src/operation-logs/operation-logs.controller.ts index 9200875..491ad0d 100644 --- a/apps/server/src/operation-logs/operation-logs.controller.ts +++ b/apps/server/src/operation-logs/operation-logs.controller.ts @@ -3,6 +3,7 @@ import { OperationLogsService } from './operation-logs.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { extractRequestInfo } from '../common/request-utils'; +import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto'; @UseGuards(JwtAuthGuard) @Controller('operation-logs') @@ -11,29 +12,15 @@ export class OperationLogsController { @Get() @RequirePermission('log:view') - findAll( - @Query('module') module?: string, - @Query('userId') userId?: string, - @Query('startDate') startDate?: string, - @Query('endDate') endDate?: string, - @Query('page') page?: string, - @Query('pageSize') pageSize?: string, - ) { - return this.service.findAll({ - module, - userId: userId ? +userId : undefined, - startDate, - endDate, - page: page ? +page : 1, - pageSize: pageSize ? +pageSize : 50, - }); + findAll(@Query() query: QueryOperationLogsDto) { + return this.service.findAll(query); } @Post('audit') @RequirePermission('log:create') async createAuditLog( - @Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string }, + @Body() body: CreateAuditLogDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); diff --git a/apps/server/src/operation-logs/operation-logs.service.spec.ts b/apps/server/src/operation-logs/operation-logs.service.spec.ts new file mode 100644 index 0000000..ec429f5 --- /dev/null +++ b/apps/server/src/operation-logs/operation-logs.service.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { QueryOperationLogsDto } from './dto/operation-log.dto'; +import { OperationLogsService } from './operation-logs.service'; + +const createQb = () => ({ + orderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), +}); + +describe('operation log query boundaries', () => { + it.each(['0', '-1', '1.5', 'abc'])('rejects invalid page %s', async (page) => { + const dto = plainToInstance(QueryOperationLogsDto, { page }); + expect((await validate(dto)).some((error) => error.property === 'page')).toBe(true); + }); + + it.each(['0', '201', '1.5', 'abc'])('rejects invalid page size %s', async (pageSize) => { + const dto = plainToInstance(QueryOperationLogsDto, { pageSize }); + expect((await validate(dto)).some((error) => error.property === 'pageSize')).toBe(true); + }); + + + it.each(['2026-02-31', '2026-07-13T00:00:00Z'])( + 'rejects invalid or non-date-only value %s', + async (startDate) => { + const dto = plainToInstance(QueryOperationLogsDto, { startDate }); + expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true); + }, + ); + + it('transforms valid pagination and applies its database window', async () => { + const dto = plainToInstance(QueryOperationLogsDto, { page: '2', pageSize: '20' }); + expect(await validate(dto)).toEqual([]); + const qb = createQb(); + const service = new OperationLogsService({ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never); + await service.findAll(dto); + expect(qb.skip).toHaveBeenCalledWith(20); + expect(qb.take).toHaveBeenCalledWith(20); + }); + + it('rejects a reversed period before opening a query', async () => { + const repo = { createQueryBuilder: jest.fn() }; + const service = new OperationLogsService(repo as never); + await expect(service.findAll({ startDate: '2026-08-01', endDate: '2026-07-31' })) + .rejects.toThrow('结束日期不能早于开始日期'); + expect(repo.createQueryBuilder).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/operation-logs/operation-logs.service.ts b/apps/server/src/operation-logs/operation-logs.service.ts index 9c9e494..f7b005d 100644 --- a/apps/server/src/operation-logs/operation-logs.service.ts +++ b/apps/server/src/operation-logs/operation-logs.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { OperationLog } from '../entities/operation-log.entity'; @@ -31,6 +31,9 @@ export class OperationLogsService { page?: number; pageSize?: number; }) { + if (query?.startDate && query?.endDate && query.startDate > query.endDate) { + throw new BadRequestException('结束日期不能早于开始日期'); + } const qb = this.repo.createQueryBuilder('log').orderBy('log.createdAt', 'DESC'); if (query?.module) qb.andWhere('log.module = :module', { module: query.module }); if (query?.userId) qb.andWhere('log.userId = :userId', { userId: query.userId }); diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts index 3373646..c75029e 100644 --- a/apps/server/src/rbac/dto/rbac.dto.ts +++ b/apps/server/src/rbac/dto/rbac.dto.ts @@ -1,4 +1,13 @@ -import { IsString, MinLength, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { + ArrayUnique, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Min, + MinLength, +} from 'class-validator'; export class CreateRoleDto { @IsString() @@ -10,6 +19,9 @@ export class CreateRoleDto { @IsOptional() @IsArray() + @ArrayUnique() + @IsInt({ each: true }) + @Min(1, { each: true }) permissionIds?: number[]; } @@ -24,6 +36,9 @@ export class UpdateRoleDto { @IsOptional() @IsArray() + @ArrayUnique() + @IsInt({ each: true }) + @Min(1, { each: true }) permissionIds?: number[]; } @@ -40,6 +55,9 @@ export class CreateUserDto { @IsOptional() @IsArray() + @ArrayUnique() + @IsInt({ each: true }) + @Min(1, { each: true }) roleIds?: number[]; } @@ -58,6 +76,9 @@ export class UpdateUserDto { @IsOptional() @IsArray() + @ArrayUnique() + @IsInt({ each: true }) + @Min(1, { each: true }) roleIds?: number[]; } @@ -79,4 +100,3 @@ export class UpdateProfileDto { @IsString() qualifications?: string; } - diff --git a/apps/server/src/rbac/rbac.boundary.spec.ts b/apps/server/src/rbac/rbac.boundary.spec.ts new file mode 100644 index 0000000..843e819 --- /dev/null +++ b/apps/server/src/rbac/rbac.boundary.spec.ts @@ -0,0 +1,95 @@ +import { ValidationPipe } from '@nestjs/common'; +import { RbacService } from './rbac.service'; +import { CreateRoleDto, CreateUserDto, UpdateUserDto } from './dto/rbac.dto'; + +function makeService(overrides?: { + permRepo?: Record; + roleRepo?: Record; + userRepo?: Record; +}) { + const permRepo = { + findByIds: jest.fn().mockResolvedValue([]), + ...(overrides?.permRepo ?? {}), + }; + const roleRepo = { + create: jest.fn((value) => ({ ...value })), + save: jest.fn(async (value) => value), + findByIds: jest.fn().mockResolvedValue([]), + findOneOrFail: jest.fn(), + ...(overrides?.roleRepo ?? {}), + }; + const userRepo = { + create: jest.fn((value) => ({ ...value })), + save: jest.fn(async (value) => value), + findOne: jest.fn().mockResolvedValue(null), + ...(overrides?.userRepo ?? {}), + }; + return { + service: new RbacService( + permRepo as never, + roleRepo as never, + userRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ), + permRepo, + roleRepo, + userRepo, + }; +} + +describe('RBAC mutation boundaries', () => { + it('rejects a role when any requested permission id does not exist', async () => { + const { service, roleRepo } = makeService({ + permRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 1, code: 'student:view' }]) }, + }); + + await expect(service.createRole({ name: 'partial', permissionIds: [1, 999] })).rejects.toThrow( + '权限不存在: 999', + ); + expect(roleRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects a user when any requested role id does not exist', async () => { + const { service, userRepo } = makeService({ + roleRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 2, name: '老师' }]) }, + }); + + await expect( + service.createUser({ + username: 'alice', + password: 'secret', + name: 'Alice', + roleIds: [2, 404], + }), + ).rejects.toThrow('角色不存在: 404'); + expect(userRepo.save).not.toHaveBeenCalled(); + }); + + it('allows explicitly clearing all roles from an existing user', async () => { + const user = { id: 7, username: 'alice', name: 'Alice', roles: [{ id: 2 }] }; + const { service, userRepo } = makeService({ + userRepo: { findOne: jest.fn().mockResolvedValue(user) }, + }); + + await expect(service.updateUser(7, { roleIds: [] })).resolves.toEqual({ message: '更新成功' }); + expect(user.roles).toEqual([]); + expect(userRepo.save).toHaveBeenCalledWith(user); + }); +}); + +describe('RBAC DTO id arrays', () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + + it.each([ + [CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }], + [CreateRoleDto, { name: 'role', permissionIds: [1, 1] }], + [CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice', roleIds: [0] }], + [UpdateUserDto, { roleIds: [1.5] }], + ])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => { + await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); + }); +}); diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 8c35e2f..9c620bf 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -301,7 +301,11 @@ export class RbacService { } } - if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) { + if ( + role.code !== preset.code || + role.name !== preset.name || + role.description !== preset.description + ) { role.code = preset.code; role.name = preset.name; role.description = preset.description; @@ -368,6 +372,28 @@ 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; + } + + private async resolveRoles(roleIds: number[]): Promise { + const uniqueIds = [...new Set(roleIds)]; + const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : []; + if (roles.length !== uniqueIds.length) { + const foundIds = new Set(roles.map((role) => role.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + throw new Error(`角色不存在: ${missingIds.join(',')}`); + } + return roles; + } + async createRole(dto: { name: string; description?: string; @@ -375,7 +401,7 @@ export class RbacService { }): Promise { const role = this.roleRepo.create({ name: dto.name, description: dto.description }); if (dto.permissionIds && dto.permissionIds.length > 0) { - role.permissions = await this.permRepo.findByIds(dto.permissionIds); + role.permissions = await this.resolvePermissions(dto.permissionIds); } return this.roleRepo.save(role); } @@ -392,7 +418,7 @@ export class RbacService { if (dto.description !== undefined) role.description = dto.description; if (dto.permissionIds !== undefined) { role.permissions = - dto.permissionIds.length > 0 ? await this.permRepo.findByIds(dto.permissionIds) : []; + dto.permissionIds.length > 0 ? await this.resolvePermissions(dto.permissionIds) : []; } return this.roleRepo.save(role); } @@ -472,7 +498,7 @@ export class RbacService { name: dto.name, }); if (dto.roleIds && dto.roleIds.length > 0) { - user.roles = await this.roleRepo.findByIds(dto.roleIds); + user.roles = await this.resolveRoles(dto.roleIds); } await this.userRepo.save(user); return { message: '用户创建成功' }; @@ -492,7 +518,7 @@ export class RbacService { if (dto.name !== undefined) user.name = dto.name; if (dto.isActive !== undefined) user.isActive = dto.isActive; if (dto.roleIds !== undefined) { - user.roles = dto.roleIds.length > 0 ? await this.roleRepo.findByIds(dto.roleIds) : []; + user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : []; } await this.userRepo.save(user); return { message: '更新成功' }; diff --git a/apps/server/src/schedules/dto/schedule.dto.spec.ts b/apps/server/src/schedules/dto/schedule.dto.spec.ts index 9ca4864..ab2ffb4 100644 --- a/apps/server/src/schedules/dto/schedule.dto.spec.ts +++ b/apps/server/src/schedules/dto/schedule.dto.spec.ts @@ -60,3 +60,30 @@ it('removes the retired departmentId field from create requests', async () => { expect(dto).not.toHaveProperty('departmentId'); }); + +describe('schedule boundary validation', () => { + it.each(['24:00', '09:60', '99:99', '9:00'])('rejects invalid time %s', async (time) => { + const dto = createSchedule(''); + dto.startTime = time; + expect((await validate(dto)).some((error) => error.property === 'startTime')).toBe(true); + }); + + it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( + 'rejects invalid or non-date-only value %s', + async (date) => { + const dto = createSchedule(''); + dto.startDate = date; + expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true); + }, + ); + + it('accepts inclusive attendance-window limits and a leap-day date', async () => { + const zero = createSchedule(''); + zero.attendanceAdvanceMinutes = 0; + zero.startDate = '2028-02-29'; + const fullDay = createSchedule(''); + fullDay.attendanceAdvanceMinutes = 1440; + expect(await validate(zero)).toEqual([]); + expect(await validate(fullDay)).toEqual([]); + }); +}); diff --git a/apps/server/src/schedules/dto/schedule.dto.ts b/apps/server/src/schedules/dto/schedule.dto.ts index b2351a7..0e0bc71 100644 --- a/apps/server/src/schedules/dto/schedule.dto.ts +++ b/apps/server/src/schedules/dto/schedule.dto.ts @@ -3,7 +3,8 @@ import { IsString, IsNotEmpty, IsInt, - IsDateString, + IsISO8601, + IsMilitaryTime, Matches, Min, Max, @@ -26,11 +27,11 @@ export class CreateScheduleDto { @IsNotEmpty() weekDay: number; - @Matches(/^\d{2}:\d{2}$/) + @IsMilitaryTime() @IsNotEmpty() startTime: string; - @Matches(/^\d{2}:\d{2}$/) + @IsMilitaryTime() @IsNotEmpty() endTime: string; @@ -40,11 +41,13 @@ export class CreateScheduleDto { @Max(1440) attendanceAdvanceMinutes?: number; - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) @IsNotEmpty() startDate: string; - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) @IsNotEmpty() endDate: string; @@ -87,11 +90,11 @@ export class UpdateScheduleDto { weekDay?: number; @IsOptional() - @Matches(/^\d{2}:\d{2}$/) + @IsMilitaryTime() startTime?: string; @IsOptional() - @Matches(/^\d{2}:\d{2}$/) + @IsMilitaryTime() endTime?: string; @IsOptional() @@ -101,11 +104,13 @@ export class UpdateScheduleDto { attendanceAdvanceMinutes?: number; @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) startDate?: string; @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) endDate?: string; @IsOptional() @@ -149,21 +154,25 @@ export class QueryScheduleDto { weekDay?: number; @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) startDate?: string; @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) endDate?: string; } export class WeeklyViewQueryDto { @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) startDate?: string; @IsOptional() - @IsDateString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) endDate?: string; @IsOptional() diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index b4ac268..76a9121 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -339,3 +339,41 @@ describe('SchedulesService — remove', () => { expect(scheduleRepo.remove).not.toHaveBeenCalled(); }); }); + +describe('SchedulesService — range boundaries', () => { + const makeService = () => { + const scheduleRepo = { create: jest.fn() }; + return { + service: new SchedulesService( + scheduleRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ), + scheduleRepo, + }; + }; + const valid = { + classId: 1, classroomId: 2, weekDay: 1, + startTime: '09:00', endTime: '10:00', + startDate: '2026-07-01', endDate: '2026-07-31', subject: '数学', + }; + + it('rejects zero-duration schedules before repository access', async () => { + const { service, scheduleRepo } = makeService(); + await expect(service.create({ ...valid, endTime: '09:00' })).rejects.toThrow( + '上课时间和下课时间不能相同', + ); + expect(scheduleRepo.create).not.toHaveBeenCalled(); + }); + + it('rejects reversed date ranges before repository access', async () => { + const { service, scheduleRepo } = makeService(); + await expect( + service.create({ ...valid, startDate: '2026-08-01', endDate: '2026-07-31' }), + ).rejects.toThrow('排课结束日期不能早于开始日期'); + expect(scheduleRepo.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index 376705a..396291b 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -179,7 +179,22 @@ export class SchedulesService { } } + private assertValidScheduleRange( + startTime: string, + endTime: string, + startDate: string, + endDate: string, + ) { + if (startTime === endTime) { + throw new BadRequestException('上课时间和下课时间不能相同'); + } + if (startDate > endDate) { + throw new BadRequestException('排课结束日期不能早于开始日期'); + } + } + async create(dto: CreateScheduleDto) { + this.assertValidScheduleRange(dto.startTime, dto.endTime, dto.startDate, dto.endDate); await this.assertClassroomAvailable(dto.classroomId); await this.normalizeTeacherForSchedule(dto); await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId); @@ -211,6 +226,7 @@ export class SchedulesService { const endTime = dto.endTime ?? existing.endTime; const startDate = dto.startDate ?? existing.startDate; const endDate = dto.endDate ?? existing.endDate; + this.assertValidScheduleRange(startTime, endTime, startDate, endDate); const normalized = await this.normalizeTeacherForSchedule({ ...dto, diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts new file mode 100644 index 0000000..7006344 --- /dev/null +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -0,0 +1,43 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { StudentsService } from './students.service'; + +function createService(repo: Record, organizationRepo = {}) { + return new StudentsService( + repo as never, + {} as never, + {} as never, + {} as never, + {} as never, + organizationRepo as never, + ); +} + +describe('StudentsService — archive lifecycle boundaries', () => { + it('rejects archiving an already archived student', async () => { + const repo = { findOne: jest.fn().mockResolvedValue({ id: 1, status: 'archived' }) }; + await expect(createService(repo).remove(1)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects restoring a student that is not archived', async () => { + const repo = { findOne: jest.fn().mockResolvedValue({ id: 1, status: 'active' }) }; + await expect(createService(repo).restore(1)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an empty batch archive', async () => { + await expect(createService({}).batchRemove([])).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects creating a student under a missing or archived organization', async () => { + const repo = { create: jest.fn(), save: jest.fn() }; + const organizationRepo = { findOne: jest.fn().mockResolvedValue(null) }; + await expect( + createService(repo, organizationRepo).create({ name: '张三', organizationId: 9 }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('returns not found for a missing student', async () => { + const repo = { findOne: jest.fn().mockResolvedValue(null) }; + await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/server/src/sync/dto/schedule-sync.dto.spec.ts b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts new file mode 100644 index 0000000..8cb598f --- /dev/null +++ b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { ScheduleSyncQueryDto } from './schedule-sync.dto'; + +describe('ScheduleSyncQueryDto', () => { + it('transforms valid query strings', async () => { + const dto = plainToInstance(ScheduleSyncQueryDto, { + dateFrom: '2026-07-13', + days: '7', + attendanceMachineOnly: 'true', + }); + expect(await validate(dto)).toEqual([]); + expect(dto).toMatchObject({ days: 7, attendanceMachineOnly: true }); + }); + + it.each(['0', '-1', '91', '7.5', 'abc'])('rejects invalid sync days %s', async (days) => { + const dto = plainToInstance(ScheduleSyncQueryDto, { days }); + expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true); + }); + + it.each(['not-a-date', '2026-02-31', '2026-07-13T00:00:00Z'])( + 'rejects invalid or non-date-only start date %s', + async (dateFrom) => { + const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom }); + expect((await validate(dto)).some((error) => error.property === 'dateFrom')).toBe(true); + }, + ); + + it('treats non-true boolean strings as false', async () => { + const dto = plainToInstance(ScheduleSyncQueryDto, { attendanceMachineOnly: 'false' }); + expect(await validate(dto)).toEqual([]); + expect(dto.attendanceMachineOnly).toBe(false); + }); +}); diff --git a/apps/server/src/sync/dto/schedule-sync.dto.ts b/apps/server/src/sync/dto/schedule-sync.dto.ts new file mode 100644 index 0000000..30c08f7 --- /dev/null +++ b/apps/server/src/sync/dto/schedule-sync.dto.ts @@ -0,0 +1,21 @@ +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsISO8601, IsInt, IsOptional, Matches, Max, Min } from 'class-validator'; + +export class ScheduleSyncQueryDto { + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + @IsISO8601({ strict: true }) + dateFrom?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(90) + days: number = 30; + + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + attendanceMachineOnly: boolean = false; +} diff --git a/apps/server/src/sync/schedule-sync.service.spec.ts b/apps/server/src/sync/schedule-sync.service.spec.ts index 7099fdf..aa87705 100644 --- a/apps/server/src/sync/schedule-sync.service.spec.ts +++ b/apps/server/src/sync/schedule-sync.service.spec.ts @@ -549,3 +549,63 @@ describe('ScheduleSyncService — multiple lessons per student per day', () => { }); }); + +describe('ScheduleSyncService — date and overnight boundaries', () => { + const createService = (schedule: ClassSchedule) => { + const scheduleUsers = jest.fn().mockResolvedValue(undefined); + const upsertShift = jest.fn().mockResolvedValue(501); + const service = new ScheduleSyncService( + { find: jest.fn().mockResolvedValue([schedule]) } as never, + { find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]) } as never, + { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]) } as never, + { find: jest.fn().mockResolvedValue([{ id: 10, name: '边界班' }]) } as never, + { + queryShifts: jest.fn().mockResolvedValue([]), upsertShift, + queryAttendanceGroups: jest.fn().mockResolvedValue([ + { group_id: 88, group_name: '排课_边界班', type: 'TURN', member_count: 1 }, + ]), + updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), + createAttendanceGroup: jest.fn(), scheduleUsers, + } as never, + ); + return { service, scheduleUsers, upsertShift }; + }; + + it('syncs exactly the requested number of calendar days', async () => { + const { service, scheduleUsers } = createService({ + id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00', + startDate: '2026-07-13', endDate: '2026-07-20', status: 'active', + } as ClassSchedule); + await service.syncAll('2026-07-13', 7); + expect(scheduleUsers.mock.calls.flatMap((call) => call[1])).toHaveLength(1); + }); + + it('marks an overnight lesson off-duty time as next-day', async () => { + const { service, upsertShift } = createService({ + id: 1, classId: 10, weekDay: 1, startTime: '22:00', endTime: '01:00', + startDate: '2026-07-13', endDate: '2026-07-13', status: 'active', + } as ClassSchedule); + await service.syncAll('2026-07-13', 1); + expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({ + sections: [expect.objectContaining({ times: expect.arrayContaining([ + expect.objectContaining({ check_type: 'OnDuty', across: 0 }), + expect.objectContaining({ check_type: 'OffDuty', across: 1 }), + ]) })], + })); + }); + + it('does not shift the requested date when the server timezone is behind China', async () => { + const originalTz = process.env.TZ; + process.env.TZ = 'America/Los_Angeles'; + try { + const { service, scheduleUsers } = createService({ + id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00', + startDate: '2026-07-13', endDate: '2026-07-13', status: 'active', + } as ClassSchedule); + await service.syncAll('2026-07-13', 1); + expect(scheduleUsers).toHaveBeenCalledTimes(1); + } finally { + process.env.TZ = originalTz; + } + }); +}); diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index d7fc710..00acf8b 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -94,7 +94,8 @@ export class ScheduleSyncService { attendanceMachineOnly = false, ): Promise { const startDate = dateFrom || new Date().toISOString().slice(0, 10); - const endDate = this.addDays(startDate, days); + const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30; + const endDate = this.addDays(startDate, normalizedDays - 1); const empty: ScheduleSyncResult = { scheduleCount: 0, @@ -170,7 +171,7 @@ export class ScheduleSyncService { }, { check_type: 'OffDuty' as const, - across: 0, + across: this.toMinutes(period.endTime) <= this.toMinutes(period.startTime) ? 1 : 0, check_time: `1970-01-01 ${period.endTime}:00`, free_check: false, }, @@ -377,12 +378,12 @@ export class ScheduleSyncService { syncTo: string, ): DailySchedulePlan[] { const periodMapByClassDate = new Map>(); - const fromDate = new Date(syncFrom); - const toDate = new Date(syncTo); + const fromDate = new Date(`${syncFrom}T00:00:00.000Z`); + const toDate = new Date(`${syncTo}T00:00:00.000Z`); - for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) { + for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) { const dateStr = date.toISOString().slice(0, 10); - const weekDay = date.getDay() === 0 ? 7 : date.getDay(); + const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay(); for (const schedule of schedules) { if (schedule.classId == null || schedule.weekDay !== weekDay) continue; @@ -450,18 +451,21 @@ export class ScheduleSyncService { return items; } + private toMinutes(time: string): number { + const [hour, minute] = time.split(':').map(Number); + return hour * 60 + minute; + } + private minutesBetween(startTime: string, endTime: string): number { - const [startHour, startMinute] = startTime.split(':').map(Number); - const [endHour, endMinute] = endTime.split(':').map(Number); - const start = startHour * 60 + startMinute; - let end = endHour * 60 + endMinute; + const start = this.toMinutes(startTime); + let end = this.toMinutes(endTime); if (end <= start) end += 24 * 60; return end - start; } private addDays(dateStr: string, days: number): string { - const d = new Date(dateStr); - d.setDate(d.getDate() + days); + const d = new Date(`${dateStr}T00:00:00.000Z`); + d.setUTCDate(d.getUTCDate() + days); return d.toISOString().slice(0, 10); } diff --git a/apps/server/src/sync/sync.controller.spec.ts b/apps/server/src/sync/sync.controller.spec.ts index 961894c..18810e9 100644 --- a/apps/server/src/sync/sync.controller.spec.ts +++ b/apps/server/src/sync/sync.controller.spec.ts @@ -1,4 +1,5 @@ import { SyncController } from './sync.controller'; +import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto'; describe('SyncController — schedule sync options', () => { it('forwards the attendance-machine-only option', async () => { @@ -7,11 +8,11 @@ describe('SyncController — schedule sync options', () => { }; const controller = new SyncController(syncService as never); - await (controller.syncSchedule as unknown as ( - dateFrom?: string, - days?: string, - attendanceMachineOnly?: string, - ) => Promise)('2026-07-10', '30', 'true'); + await controller.syncSchedule(Object.assign(new ScheduleSyncQueryDto(), { + dateFrom: '2026-07-10', + days: 30, + attendanceMachineOnly: true, + })); expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith( '2026-07-10', diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index 8f5cd87..139109b 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -3,6 +3,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { SyncService } from './sync.service'; import type { SyncPlatform } from '../entities/sync-log.entity'; +import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto'; @UseGuards(JwtAuthGuard) @Controller('sync') @@ -79,15 +80,11 @@ export class SyncController { /** 触发排班同步到钉钉考勤排班 */ @Post('schedule/sync') @RequirePermission('sync:trigger') - async syncSchedule( - @Query('dateFrom') dateFrom?: string, - @Query('days') days?: string, - @Query('attendanceMachineOnly') attendanceMachineOnly?: string, - ) { + async syncSchedule(@Query() query: ScheduleSyncQueryDto) { const result = await this.syncService.syncScheduleToDingTalk( - dateFrom, - days ? parseInt(days, 10) : 30, - attendanceMachineOnly === 'true', + query.dateFrom, + query.days, + query.attendanceMachineOnly, ); return { success: true, data: result }; } diff --git a/apps/server/src/wallets/wallets.service.spec.ts b/apps/server/src/wallets/wallets.service.spec.ts index 6724164..c730c62 100644 --- a/apps/server/src/wallets/wallets.service.spec.ts +++ b/apps/server/src/wallets/wallets.service.spec.ts @@ -50,3 +50,70 @@ describe('WalletsService payment rules', () => { expect(ctx.saved.some((row) => row.type === 'bill_refund' && Number(row.amount) === 40)).toBe(true); }); }); + +describe('WalletsService financial boundaries', () => { + it('caps a debit at total minus already paid even when outstandingAmount is stale', async () => { + const ctx = manager(50); + const service = new WalletsService({} as any, {} as any, {} as any, {} as any); + const bill = { + id: 10, + studentId: 10, + totalAmount: 100, + paidAmount: 90, + outstandingAmount: 100, + status: 'partially_paid', + } as Bill; + + await service.debitBill(ctx.value as any, bill); + + expect(bill).toMatchObject({ paidAmount: 100, outstandingAmount: 0, status: 'paid' }); + expect(ctx.wallet.balance).toBe(40); + expect(ctx.saved.some((row) => row.type === 'bill_payment' && row.amount === -10)).toBe(true); + }); + + it('caps a refund at the bill total when paidAmount is corrupt', async () => { + const ctx = manager(10); + const service = new WalletsService({} as any, {} as any, {} as any, {} as any); + const bill = { + id: 11, + studentId: 10, + totalAmount: 100, + paidAmount: 150, + outstandingAmount: 0, + status: 'paid', + } as Bill; + + await service.refundBill(ctx.value as any, bill, '冲正'); + + expect(ctx.wallet.balance).toBe(110); + expect(ctx.saved.some((row) => row.type === 'bill_refund' && row.amount === 100)).toBe(true); + }); + + it('does not issue a second refund for an already cancelled bill', async () => { + const ctx = manager(10); + const service = new WalletsService({} as any, {} as any, {} as any, {} as any); + const bill = { + id: 12, + studentId: 10, + totalAmount: 100, + paidAmount: 100, + outstandingAmount: 0, + status: 'cancelled', + } as Bill; + + await service.refundBill(ctx.value as any, bill, '重复取消'); + + expect(ctx.wallet.balance).toBe(10); + expect(ctx.saved).toHaveLength(0); + }); + + 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); + + await expect(service.changeBalance({ studentId: 1, amount: 0.004, type: 'adjustment' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 312c17b..f9cd709 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -61,12 +61,17 @@ export class WalletsService { } async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { - if (dto.type === 'recharge' && dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0'); + const amount = money(dto.amount); + if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { + throw new BadRequestException('调账金额最多保留两位小数'); + } + if (amount === 0) throw new BadRequestException('调账金额不能为 0'); + if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0'); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); return this.dataSource.transaction(async (manager) => { const wallet = await this.getOrCreateWallet(manager, dto.studentId); - const nextBalance = money(Number(wallet.balance) + dto.amount); + const nextBalance = money(Number(wallet.balance) + amount); if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0'); wallet.balance = nextBalance; await manager.save(wallet); @@ -75,29 +80,43 @@ export class WalletsService { studentId: dto.studentId, billId: null, type: dto.type, - amount: money(dto.amount), + amount, balanceAfter: nextBalance, description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'), recordedBy: recordedBy || null, }), ); - const payments = dto.amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; + const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId }); return { wallet: finalWallet, payments }; }); } async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) { - if (bill.status === 'cancelled' || money(bill.outstandingAmount) <= 0) return bill; - const wallet = await this.getOrCreateWallet(manager, bill.studentId); - const amount = money(Math.min(Number(wallet.balance), Number(bill.outstandingAmount))); - if (amount <= 0) { - bill.status = money(bill.paidAmount) > 0 ? 'partially_paid' : 'unpaid'; + if (bill.status === 'cancelled') return bill; + + const total = money(bill.totalAmount); + const paid = Math.max(0, Math.min(money(bill.paidAmount), total)); + const remaining = money(Math.max(0, total - paid)); + if (remaining <= 0) { + bill.paidAmount = total; + bill.outstandingAmount = 0; + bill.status = 'paid'; return manager.save(bill); } + + const wallet = await this.getOrCreateWallet(manager, bill.studentId); + const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining)); + if (amount <= 0) { + bill.paidAmount = paid; + bill.outstandingAmount = remaining; + bill.status = paid > 0 ? 'partially_paid' : 'unpaid'; + return manager.save(bill); + } + wallet.balance = money(Number(wallet.balance) - amount); - bill.paidAmount = money(Number(bill.paidAmount) + amount); - bill.outstandingAmount = money(Number(bill.totalAmount) - Number(bill.paidAmount)); + 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); @@ -109,14 +128,16 @@ export class WalletsService { amount: -amount, balanceAfter: wallet.balance, description: `账单 #${bill.id} 自动扣款`, - recordedBy: recordedBy || null, + recordedBy: recordedBy ?? null, }), ); return bill; } async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) { - const paid = money(bill.paidAmount); + if (bill.status === 'cancelled') return bill; + + const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount))); if (paid > 0) { const wallet = await this.getOrCreateWallet(manager, bill.studentId); wallet.balance = money(Number(wallet.balance) + paid); @@ -129,7 +150,7 @@ export class WalletsService { amount: paid, balanceAfter: wallet.balance, description: `取消账单 #${bill.id} 冲正:${reason}`, - recordedBy: recordedBy || null, + recordedBy: recordedBy ?? null, }), ); } -- 2.49.1 From fcde6caaaa242bc9753d910b572cbd60b21d91b4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 15 Jul 2026 09:39:21 +0800 Subject: [PATCH 2/4] fix: defer attendance settlement until lesson end --- .../attendance-workspace.integration.test.ts | 22 ++++++ .../pages/Attendance/attendance-workspace.ts | 24 +++++++ .../admin/src/pages/Attendance/attendance.css | 42 +++++++++++ apps/admin/src/pages/Attendance/index.tsx | 41 ++++++++++- .../attendance-settlement.service.spec.ts | 69 +++++++++++++++++++ .../attendance-settlement.service.ts | 18 ++++- 6 files changed, 213 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts index a8dd74e..440012a 100644 --- a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts +++ b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { canPullAttendance, + filterLessonAttendanceRecords, getAttendanceExperience, getPunchDisplayInfo, getSchedulePhase, @@ -68,6 +69,27 @@ describe('lesson check-in summary', () => { }); +describe('lesson attendance filters', () => { + const records = [ + { id: 1, student: { name: '张三' }, status: 'present' }, + { id: 2, student: { name: '李四' }, status: 'late' }, + { id: 3, student: { name: '王五' }, status: 'pending' }, + { id: 4, student: { name: '赵六' }, status: 'absent' }, + ]; + + it('searches students by name and ignores surrounding whitespace', () => { + expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([1]); + }); + + it('groups present and late as checked in', () => { + expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual([1, 2]); + }); + + it('groups pending and absent as not checked in and combines with search', () => { + expect(filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id)).toEqual([3]); + }); +}); + describe('lesson punch device display', () => { it('labels attendance machine punches with the machine name and id', () => { expect( diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.ts b/apps/admin/src/pages/Attendance/attendance-workspace.ts index a78250d..dcaf3b9 100644 --- a/apps/admin/src/pages/Attendance/attendance-workspace.ts +++ b/apps/admin/src/pages/Attendance/attendance-workspace.ts @@ -84,6 +84,30 @@ export function summarizeLessonCheckins( } +export type LessonAttendanceFilter = 'all' | 'checked_in' | 'not_checked_in'; + +export interface LessonAttendanceFilterRecord { + student: { name: string }; + status: string; +} + +export function filterLessonAttendanceRecords( + records: readonly T[], + keyword: string, + filter: LessonAttendanceFilter, +): T[] { + const normalizedKeyword = keyword.trim().toLocaleLowerCase('zh-CN'); + return records.filter((record) => { + const matchesKeyword = + !normalizedKeyword || + record.student.name.toLocaleLowerCase('zh-CN').includes(normalizedKeyword); + if (!matchesKeyword || filter === 'all') return matchesKeyword; + + const checkedIn = record.status === 'present' || record.status === 'late'; + return filter === 'checked_in' ? checkedIn : !checkedIn; + }); +} + export interface PunchDisplayRecord { status: string; source?: string; diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index c9745e4..f80a2de 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -286,6 +286,32 @@ .is-leave { color: #2874c6 !important; background: #edf5ff; } .is-pending { color: #667085 !important; background: #f1f3f6; } +.lesson-record-filters { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 14px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: #f8fafc; +} + +.lesson-record-search { + width: 260px; +} + +.lesson-record-filter-select { + width: 130px; +} + +.lesson-record-filter-count { + margin-left: auto; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + .attendance-status { display: inline-flex; align-items: center; @@ -414,6 +440,22 @@ color: #a1a9b5; } +@media (max-width: 640px) { + .lesson-record-filters { + align-items: stretch; + flex-direction: column; + } + + .lesson-record-search, + .lesson-record-filter-select { + width: 100%; + } + + .lesson-record-filter-count { + margin-left: 0; + } +} + @media (max-width: 900px) { .attendance-hero, .archive-toolbar { diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 4c3a063..745a15a 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -40,11 +40,13 @@ import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; import { canPullAttendance, + filterLessonAttendanceRecords, getAttendanceExperience, getPunchDisplayInfo, getSchedulePhase, summarizeLessonCheckins, type AttendanceSummary, + type LessonAttendanceFilter, type SchedulePhase, } from './attendance-workspace'; import './attendance.css'; @@ -261,6 +263,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { const [lessonRecords, setLessonRecords] = useState([]); const [recordLoading, setRecordLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); + const [studentKeyword, setStudentKeyword] = useState(''); + const [checkinFilter, setCheckinFilter] = useState('all'); const loadWorkspace = useCallback(async () => { setLoading(true); @@ -284,6 +288,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { const openAttendance = useCallback(async (schedule: TodaySchedule) => { + setStudentKeyword(''); + setCheckinFilter('all'); setSelectedSchedule(schedule); setDrawerOpen(true); setRecordLoading(true); @@ -334,6 +340,10 @@ const TeacherAttendanceWorkspace: React.FC = () => { (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', ); const isAttendanceCompleted = lessonSession?.status === 'completed'; + const filteredLessonRecords = useMemo( + () => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter), + [lessonRecords, studentKeyword, checkinFilter], + ); return (
@@ -407,12 +417,39 @@ const TeacherAttendanceWorkspace: React.FC = () => { /> )} +
+ setStudentKeyword(event.target.value)} + className="lesson-record-search" + /> + + value={checkinFilter} + onChange={setCheckinFilter} + options={[ + { value: 'all', label: '全部学生' }, + { value: 'checked_in', label: '已打卡' }, + { value: 'not_checked_in', label: '未打卡' }, + ]} + className="lesson-record-filter-select" + /> + + 显示 {filteredLessonRecords.length} / {lessonRecords.length} 人 + +
rowKey="id" loading={recordLoading} - dataSource={lessonRecords} + dataSource={filteredLessonRecords} pagination={false} - locale={{ emptyText: }} + locale={{ + emptyText: , + }} columns={[ { title: '学生', dataIndex: ['student', 'name'], diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index 6c1bbce..1c17af6 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -80,6 +80,49 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); + it('does not settle an in-progress lesson before its end time', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + scheduleRepo.find.mockResolvedValue([schedule]); + sessionRepo.find.mockResolvedValue([ + { + id: 90, + scheduleId: 2, + lessonDate: '2026-07-13', + status: 'in_progress', + schedule, + }, + ]); + + await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00')); + + expect(importService.importFromDingTalk).not.toHaveBeenCalled(); + expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); + }); + + it('settles an in-progress lesson when its end time is reached', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); + scheduleRepo.find.mockResolvedValue([schedule]); + sessionRepo.find.mockResolvedValue([ + { + id: 90, + scheduleId: 2, + lessonDate: '2026-07-13', + status: 'in_progress', + schedule, + }, + ]); + + await service.settleEndedLessons(new Date('2026-07-13T10:00:00+08:00')); + + expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledTimes(1); + expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith( + 2, + '2026-07-13', + 21, + true, + ); + }); + it('continues with the next lesson when one settlement fails', async () => { const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]); @@ -163,6 +206,32 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); + it('does not settle an in-progress overnight lesson before its next-day end time', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + const overnightSchedule = { + ...schedule, + id: 4, + weekDay: 7, + startTime: '22:00', + endTime: '01:00', + }; + scheduleRepo.find.mockResolvedValue([]); + sessionRepo.find.mockResolvedValue([ + { + id: 91, + scheduleId: 4, + lessonDate: '2026-07-12', + status: 'in_progress', + schedule: overnightSchedule, + }, + ]); + + await service.settleEndedLessons(new Date('2026-07-13T00:30:00+08:00')); + + expect(importService.importFromDingTalk).not.toHaveBeenCalled(); + expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); + }); + it('settles an overnight lesson after its next-day end time', async () => { const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); scheduleRepo.find.mockResolvedValue([ diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index fda60e1..0c15735 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -61,7 +61,11 @@ export class AttendanceSettlementService { } } for (const session of sessions) { - if (session.status === 'in_progress' && session.schedule) { + if ( + session.status === 'in_progress' && + session.schedule && + this.hasOccurrenceEnded(session.schedule, session.lessonDate, clock) + ) { candidates.set(`${session.scheduleId}|${session.lessonDate}`, { schedule: session.schedule, lessonDate: session.lessonDate, @@ -163,6 +167,18 @@ export class AttendanceSettlementService { return null; } + private hasOccurrenceEnded( + schedule: ClassSchedule, + lessonDate: string, + clock: { date: string; minutes: number }, + ): boolean { + const occurrenceEndDate = this.isOvernight(schedule) + ? this.shiftDate(lessonDate, 1) + : lessonDate; + if (clock.date !== occurrenceEndDate) return clock.date > occurrenceEndDate; + return clock.minutes >= this.toMinutes(schedule.endTime); + } + private isOvernight(schedule: ClassSchedule): boolean { return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime); } -- 2.49.1 From 8bd445df5a8a808c6e4ed2ecded16167becb33ec Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 15 Jul 2026 10:52:43 +0800 Subject: [PATCH 3/4] fix student import and profile labels --- .../StudentProfileContent/index.tsx | 62 +++++- apps/admin/src/pages/Students/index.tsx | 145 ++++++++++--- .../src/students/students.controller.ts | 203 +++++++++++------- apps/server/src/students/students.service.ts | 11 +- 4 files changed, 297 insertions(+), 124 deletions(-) diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index f66f5e6..b360fbe 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -162,6 +162,19 @@ const RECORD_TYPE_OPTIONS = [ { value: 'other', label: '其他' }, ]; +const STUDENT_STATUS_MAP: Record = { + active: { text: '在读', color: 'green' }, + graduated: { text: '已毕业', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, + archived: { text: '已归档', color: '#999' }, +}; + +const ENROLLMENT_STATUS_MAP: Record = { + active: { text: '报读中', color: 'green' }, + completed: { text: '已结课', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, +}; + const COURSE_CATEGORY_OPTIONS = [ { value: 'culture', label: '文化课' }, { value: 'professional', label: '专业课' }, @@ -176,6 +189,34 @@ const CLASS_TYPE_OPTIONS = [ { value: 'offline', label: '线下' }, ]; +const getOptionLabel = ( + options: Array<{ value: string; label: string }>, + value?: string | null, +): string => { + if (!value) return '-'; + return options.find((option) => option.value === value)?.label || value; +}; + +const getCourseCategoryLabel = (value?: string | null): string => + getOptionLabel(COURSE_CATEGORY_OPTIONS, value); + +const getClassTypeLabel = (value?: string | null): string => + getOptionLabel(CLASS_TYPE_OPTIONS, value); + +const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => { + if (!value) return { text: '-', color: 'default' }; + return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; +}; + +const getStudentStatus = (value?: string | null): { text: string; color: string } => { + if (!value) return { text: '-', color: 'default' }; + return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' }; +}; + +const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => + enrollment.className || + (enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id)); + const ATTACHMENT_CATEGORY_OPTIONS = [ { value: 'id_card', label: '身份证' }, { value: 'transcript', label: '成绩单' }, @@ -350,8 +391,8 @@ const EnrollmentsTab: React.FC = ({ }; const columns: ColumnsType = [ - { title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' }, - { title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' }, + { title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel }, + { title: '班型', dataIndex: 'classType', render: getClassTypeLabel }, { title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' }, { title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' }, { title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' }, @@ -361,12 +402,8 @@ const EnrollmentsTab: React.FC = ({ title: '状态', dataIndex: 'status', render: (v: string) => { - const colorMap: Record = { - active: 'green', - completed: 'blue', - withdrawn: 'red', - }; - return {v || '-'}; + const status = getEnrollmentStatus(v); + return {status.text}; }, }, ]; @@ -477,7 +514,7 @@ const ExamScoresTab: React.FC { if (v === undefined) return '-'; const enr = enrollments.find((e) => e.id === v); - return enr ? `${enr.className || enr.courseCategory || v}` : String(v); + return enr ? formatEnrollmentDisplayName(enr) : String(v); }, }, ]; @@ -540,7 +577,7 @@ const ExamScoresTab: React.FC ({ value: e.id, - label: `${e.className || e.courseCategory || e.id} (${e.classType})`, + label: `${formatEnrollmentDisplayName(e)}(${getClassTypeLabel(e.classType)})`, }))} /> @@ -976,7 +1013,10 @@ const StudentProfileContent: React.FC = ({ ) : '-'} - {student.status || '-'} + {(() => { + const status = getStudentStatus(student.status); + return {status.text}; + })()} {profile?.targetCollege && ( {profile.targetCollege} diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 62d3dc8..ca75b6e 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { + Alert, App, Button, Card, @@ -62,6 +63,19 @@ interface EnrollmentInfo { }; } + +interface StudentCreateImportResult { + message?: string; + imported?: number; + skipped?: number; +} + +interface StudentUpdateImportResult { + message?: string; + matched?: number; + skipped?: number; +} + const StudentsPage: React.FC = () => { const { modal } = App.useApp(); const [data, setData] = useState([]); @@ -221,20 +235,94 @@ const StudentsPage: React.FC = () => { .catch(() => message.error('下载失败')); }; - const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { + const showCreateImportResult = (result: StudentCreateImportResult) => { + const imported = result.imported ?? 0; + const skipped = result.skipped ?? 0; + + modal.success({ + title: '导入完成', + okText: '知道了', + content: ( +
+ + {imported} 人 + {skipped} 人 + +
跳过原因:
+
    +
  • 姓名为空
  • +
  • 已存在同名学生
  • +
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。 +
+
+ ), + }); + }; + + const showUpdateImportResult = (result: StudentUpdateImportResult) => { + const matched = result.matched ?? 0; + const skipped = result.skipped ?? 0; + + modal.success({ + title: '更新完成', + okText: '知道了', + content: ( +
+ + {matched} 人 + {skipped} 人 + +
匹配规则:
+
手机号优先,身份证号其次
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。 +
+
+ ), + }); + }; + + const handleCreateStudentsImport: UploadProps['customRequest'] = async ({ + file, + onSuccess, + onError, + }) => { + const formData = new FormData(); + formData.append('file', file as File); + try { + const res = (await api.post('/students/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + })) as StudentCreateImportResult; + showCreateImportResult(res); + onSuccess?.(res); + fetchData(); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '导入失败'); + onError?.(e instanceof Error ? e : new Error(err?.message || '导入失败')); + } + }; + + const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({ + file, + onSuccess, + onError, + }) => { const formData = new FormData(); formData.append('file', file as File); try { const res = (await api.post('/students/import-match', formData, { headers: { 'Content-Type': 'multipart/form-data' }, - })) as { message: string }; - message.success(res.message); + })) as StudentUpdateImportResult; + showUpdateImportResult(res); onSuccess?.(res); fetchData(); } catch (e: unknown) { const err = e as { message?: string }; - message.error(err?.message || '匹配导入失败'); - onError?.(e instanceof Error ? e : new Error(err?.message || '匹配导入失败')); + message.error(err?.message || '更新已有学生资料失败'); + onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败')); } }; @@ -277,12 +365,12 @@ const StudentsPage: React.FC = () => { render: (v: string, record: any) => { if (!v) return '-'; return ( - + {maskPhone(v)} + { - const formData = new FormData(); - formData.append('file', file); - try { - const res: any = await api.post('/students/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - message.success(res.message); - onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败')); - } - }} + customRequest={handleUpdateExistingStudentsImport} > - - - - + {
+ + 更新已有学生资料:先按手机号、再按身份证号匹配;Excel + 中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。 + + } + /> ({ + ...column, + header: column.key === 'organization' ? '所属机构' : column.header, + })), + { header: '状态', key: 'status', width: 10 }, +]; + +const STUDENT_IMPORT_HEADER_MAP: Record = { + 姓名: 'name', + 学号: 'studentNo', + 电话: 'phone', + 手机号: 'phone', + '学号/身份证': 'idNumber', + 身份证: 'idNumber', + 身份证号: 'idNumber', + 性别: 'gender', + 民族: 'ethnicity', + 紧急联系人: 'emergencyContact', + 紧急联系人电话: 'emergencyPhone', + 所属机构: 'organization', + 所属机构名称: 'organization', + 负责人: 'supervisor', + '负责人/班主任': 'supervisor', +}; + +function getExcelCellText(cell: ExcelJS.Cell): string { + const value = cell.value; + if (value === null || value === undefined) return ''; + if (typeof value === 'object') { + if ('text' in value) return String(value.text || ''); + if ('richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join(''); + } + if ('result' in value) return String(value.result || ''); + } + return String(value); +} + +function parseStudentImportRows(ws: ExcelJS.Worksheet): StudentImportRow[] { + const headerIndex = new Map(); + ws.getRow(1).eachCell((cell, colNumber) => { + const header = getExcelCellText(cell).trim(); + const field = STUDENT_IMPORT_HEADER_MAP[header]; + if (field) headerIndex.set(colNumber, field); + }); + + const rows: StudentImportRow[] = []; + ws.eachRow((row, idx) => { + if (idx === 1) return; + + const parsed: Partial = {}; + if (headerIndex.size > 0) { + headerIndex.forEach((field, colNumber) => { + const value = getExcelCellText(row.getCell(colNumber)).trim(); + if (value) { + Object.assign(parsed, { [field]: value }); + } + }); + } else { + parsed.name = getExcelCellText(row.getCell(1)).trim(); + parsed.studentNo = getExcelCellText(row.getCell(2)).trim() || undefined; + parsed.gender = getExcelCellText(row.getCell(3)).trim() || undefined; + parsed.phone = getExcelCellText(row.getCell(4)).trim() || undefined; + parsed.idNumber = getExcelCellText(row.getCell(5)).trim() || undefined; + parsed.ethnicity = getExcelCellText(row.getCell(6)).trim() || undefined; + parsed.emergencyContact = getExcelCellText(row.getCell(7)).trim() || undefined; + parsed.emergencyPhone = getExcelCellText(row.getCell(8)).trim() || undefined; + parsed.organization = getExcelCellText(row.getCell(9)).trim() || undefined; + parsed.supervisor = getExcelCellText(row.getCell(10)).trim() || undefined; + } + + rows.push({ + name: parsed.name || '', + studentNo: parsed.studentNo, + phone: parsed.phone, + idNumber: parsed.idNumber, + gender: parsed.gender, + ethnicity: parsed.ethnicity, + emergencyContact: parsed.emergencyContact, + emergencyPhone: parsed.emergencyPhone, + organization: parsed.organization, + supervisor: parsed.supervisor, + }); + }); + return rows; +} + @UseGuards(JwtAuthGuard) @Controller('students') export class StudentsController { @@ -92,18 +208,7 @@ export class StudentsController { ); const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('学生名单'); - ws.columns = [ - { header: '姓名', key: 'name', width: 12 }, - { header: '性别', key: 'gender', width: 8 }, - { header: '电话', key: 'phone', width: 18 }, - { header: '学号/身份证', key: 'idNumber', width: 22 }, - { header: '民族', key: 'ethnicity', width: 10 }, - { header: '紧急联系人', key: 'emergencyContact', width: 15 }, - { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, - { header: '所属机构', key: 'organization', width: 18 }, - { header: '负责人/班主任', key: 'supervisor', width: 15 }, - { header: '状态', key: 'status', width: 10 }, - ]; + ws.columns = STUDENT_EXPORT_COLUMNS; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; const statusMap: Record = { @@ -115,6 +220,7 @@ export class StudentsController { for (const s of students) { ws.addRow({ name: s.name, + studentNo: s.studentNo || '', gender: s.gender || '', phone: s.phone || '', idNumber: s.idNumber || '', @@ -150,24 +256,15 @@ export class StudentsController { async downloadTemplate(@Res() res: Response) { const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('学生导入模板'); - ws.columns = [ - { header: '姓名', key: 'name', width: 15 }, - { header: '电话', key: 'phone', width: 18 }, - { header: '学号/身份证', key: 'idNumber', width: 22 }, - { header: '性别', key: 'gender', width: 8 }, - { header: '民族', key: 'ethnicity', width: 10 }, - { header: '紧急联系人', key: 'emergencyContact', width: 15 }, - { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, - { header: '所属机构名称', key: 'organization', width: 18 }, - { header: '负责人/班主任', key: 'supervisor', width: 15 }, - ]; + ws.columns = STUDENT_IMPORT_COLUMNS; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.addRow({ name: '张三', - phone: '13800138000', - idNumber: '2024001', + studentNo: '2024001', gender: '男', + phone: '13800138000', + idNumber: '11010120060101001X', ethnicity: '汉族', emergencyContact: '张父', emergencyPhone: '13900000000', @@ -288,32 +385,7 @@ export class StudentsController { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as any); const ws = workbook.worksheets[0]; - const rows: { - name: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[] = []; - ws.eachRow((row, idx) => { - if (idx === 1) return; - rows.push({ - name: String(row.getCell(1).value || ''), - phone: String(row.getCell(2).value || ''), - idNumber: String(row.getCell(3).value || ''), - gender: String(row.getCell(4).value || '').trim() || undefined, - ethnicity: String(row.getCell(5).value || '').trim() || undefined, - emergencyContact: String(row.getCell(6).value || '').trim() || undefined, - emergencyPhone: String(row.getCell(7).value || '').trim() || undefined, - organization: String(row.getCell(8).value || '').trim() || undefined, - supervisor: String(row.getCell(9).value || '').trim() || undefined, - }); - }); + const rows = parseStudentImportRows(ws); // Resolve organization names to IDs for (const row of rows) { if (row.organization) { @@ -346,32 +418,7 @@ export class StudentsController { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: { - name: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[] = []; - ws.eachRow((row, idx) => { - if (idx === 1) return; - rows.push({ - name: String(row.getCell(1).value || ''), - phone: String(row.getCell(2).value || ''), - idNumber: String(row.getCell(3).value || ''), - gender: String(row.getCell(4).value || '').trim() || undefined, - ethnicity: String(row.getCell(5).value || '').trim() || undefined, - emergencyContact: String(row.getCell(6).value || '').trim() || undefined, - emergencyPhone: String(row.getCell(7).value || '').trim() || undefined, - organization: String(row.getCell(8).value || '').trim() || undefined, - supervisor: String(row.getCell(9).value || '').trim() || undefined, - }); - }); + const rows = parseStudentImportRows(ws); // Resolve organization names to IDs for (const row of rows) { if (row.organization) { @@ -386,7 +433,7 @@ export class StudentsController { userId: req.user?.id, username: req.user?.username, module: '学生管理', - action: '匹配导入学生', + action: '更新已有学生资料', detail: result.message, ipAddress, userAgent, diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 4a3aac6..adf7a86 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -132,6 +132,7 @@ export class StudentsService { async batchImport( rows: { name: string; + studentNo?: string; phone?: string; idNumber?: string; gender?: string; @@ -158,6 +159,7 @@ export class StudentsService { 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, @@ -180,6 +182,7 @@ export class StudentsService { async matchImport( rows: { name: string; + studentNo?: string; phone?: string; idNumber?: string; gender?: string; @@ -194,10 +197,6 @@ export class StudentsService { let matched = 0; let skipped = 0; for (const row of rows) { - if (!row.name || !row.name.trim()) { - skipped++; - continue; - } // Match by phone first, then idNumber let student = row.phone?.trim() ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) @@ -214,6 +213,7 @@ export class StudentsService { Pick< Student, | 'name' + | 'studentNo' | 'phone' | 'idNumber' | 'gender' @@ -225,6 +225,7 @@ export class StudentsService { > > = {}; 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; @@ -237,7 +238,7 @@ export class StudentsService { matched++; } return { - message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`, + message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`, matched, skipped, }; -- 2.49.1 From bc49d1016ab775867aa4b7f557dadbc71cb08339 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 15 Jul 2026 13:52:39 +0800 Subject: [PATCH 4/4] feat: filter rooms by rental category --- apps/admin/src/pages/Rooms/index.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index e27ce95..3c94994 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -88,6 +88,7 @@ const RoomsPage: React.FC = () => { const [searchText, setSearchText] = useState(''); const [filterBuilding, setFilterBuilding] = useState(undefined); const [filterStatus, setFilterStatus] = useState(undefined); + const [filterRentalCategory, setFilterRentalCategory] = useState(undefined); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); @@ -154,8 +155,11 @@ const RoomsPage: React.FC = () => { } if (filterBuilding) result = result.filter((r: Record) => r.building === filterBuilding); if (filterStatus) result = result.filter((r: Record) => r.status === filterStatus); + if (filterRentalCategory) { + result = result.filter((r: Record) => r.rentalCategory === filterRentalCategory); + } return result; - }, [data, searchText, filterBuilding, filterStatus]); + }, [data, searchText, filterBuilding, filterStatus, filterRentalCategory]); const remainingBedSlots = useMemo(() => { const capacity = Number(drawerRoom?.capacity) || 0; return Math.max(capacity - beds.length, 0); @@ -434,6 +438,17 @@ const RoomsPage: React.FC = () => { />