test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -0,0 +1,102 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ArchiveService } from './archive.service';
function createService(repos: Partial<Record<string, Record<string, jest.Mock>>> = {}) {
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);
});
});

View File

@@ -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('学生不存在');

View File

@@ -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;

View File

@@ -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;
}
});
});

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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: [] }],

View File

@@ -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);
}

View File

@@ -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<Bill>[] = []) {
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);
});
});

View File

@@ -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<Bill>();
const itemRepo = mockRepo<BillItem>();
const roomExpRepo = mockRepo<RoomExpense>();
const personalExpRepo = mockRepo<PersonalExpense>();
const occRepo = mockRepo<Occupancy>();
const roomRepo = mockRepo<Room>();
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<RoomExpense>([
{ 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<Occupancy>([
{ 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<PersonalExpense>([]));
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);
});
});

View File

@@ -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('账单状态必须与实付及未付金额一致');
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,69 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ClassesService } from './classes.service';
function createService(classRepo: Record<string, jest.Mock>, 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();
});
});

View File

@@ -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(

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -0,0 +1,42 @@
import { validate } from 'class-validator';
import { CreateRentalDto } from './rental.dto';
const createRental = (overrides: Partial<CreateRentalDto> = {}) =>
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);
});
});

View File

@@ -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()

View File

@@ -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')

View File

@@ -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<unknown>;
}).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<unknown>)(...(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');
});
});

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,53 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity';
function serviceWith(deposit?: Partial<Deposit>) {
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();
});
});

View File

@@ -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;

View File

@@ -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()

View File

@@ -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([]);
});
});

View File

@@ -52,14 +52,15 @@ export class ExpenseTypesService {
}
async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> {
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<ExpenseType> {
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);
}

View File

@@ -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);
});
});

View File

@@ -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[];
}

View File

@@ -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();
});
});

View File

@@ -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<CreateRoomExpenseDto>) {
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<CreatePersonalExpenseDto>) {
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);
}

View File

@@ -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);
});
});

View File

@@ -205,13 +205,21 @@ export class IntegrationConfigService {
/** 调钉钉新版接口拿 access_token */
private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> {
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<string, unknown>;
return masked;
} catch {
return {};
}

View File

@@ -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<typeof fetch>;
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<typeof fetch>;
await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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;

View File

@@ -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([]);
}
});
});

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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<Notification[]> {
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<Notification[]> {
async findByUser(userId: number, after?: number, limit: number = 20): Promise<Notification[]> {
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<void> {
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<void> {

View File

@@ -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);
});
});

View File

@@ -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()

View File

@@ -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<Occupancy>;
const roomRepo = { update: jest.fn() } as any as Repository<Room>;
const bedRepo = { update: jest.fn() } as any as Repository<Bed>;
const lockerRepo = { update: jest.fn() } as any as Repository<Locker>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
{} as Repository<Student>,
{} as Repository<Deposit>,
bedRepo,
lockerRepo,
{} as Repository<any>,
{} 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<Occupancy>;
const service = new OccupanciesService(
occupancyRepo,
{
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, status: 'maintenance' }),
} as any,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
);
await expect(
service.checkIn({ studentId: 1, roomId: 2, checkInDate: '2026-07-10', bedId: 3 }),
).rejects.toThrow('该宿舍当前不可入住');
expect(occupancyRepo.count).not.toHaveBeenCalled();
});
});

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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 });

View File

@@ -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;
}

View File

@@ -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<string, jest.Mock>;
roleRepo?: Record<string, jest.Mock>;
userRepo?: Record<string, jest.Mock>;
}) {
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();
});
});

View File

@@ -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<Permission[]> {
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<Role[]> {
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<Role> {
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: '更新成功' };

View File

@@ -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([]);
});
});

View File

@@ -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()

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -0,0 +1,43 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { StudentsService } from './students.service';
function createService(repo: Record<string, jest.Mock>, 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);
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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;
}
});
});

View File

@@ -94,7 +94,8 @@ export class ScheduleSyncService {
attendanceMachineOnly = false,
): Promise<ScheduleSyncResult> {
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<string, Map<string, DailySchedulePeriod>>();
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);
}

View File

@@ -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<unknown>)('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',

View File

@@ -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 };
}

View File

@@ -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();
});
});

View File

@@ -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,
}),
);
}