fix(server): 路由 id 使用 ParseIntPipe,限制分页与批量数组上限

This commit is contained in:
2026-08-07 16:38:03 +08:00
parent 899d2dde5b
commit 5b5ffb5b9e
14 changed files with 96 additions and 58 deletions

View File

@@ -131,8 +131,8 @@ export class AttendanceQueryService {
},
accessibleClassIds?: number[],
) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
const qb = this.attendanceRepo.createQueryBuilder('ar');
@@ -233,8 +233,8 @@ export class AttendanceQueryService {
// ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));
const qb = this.dingRawRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');

View File

@@ -6,6 +6,7 @@ import {
Delete,
Body,
Param,
ParseIntPipe,
Query,
UseGuards,
UsePipes,
@@ -85,7 +86,7 @@ export class ClassesController {
@Get(':id')
@RequirePermission('class:view')
async findOne(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
async findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.findOne(+id);
}
@@ -93,7 +94,7 @@ export class ClassesController {
@Get(':id/schedule')
@RequirePermission('class:view')
async getSchedule(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Query() query: QueryClassScheduleDto,
@Request() req: AuthenticatedRequest,
) {
@@ -104,7 +105,7 @@ export class ClassesController {
@Get(':id/attendance-summary')
@RequirePermission('class:view')
async getAttendanceSummary(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Query() query: QueryClassAttendanceSummaryDto,
@Request() req: AuthenticatedRequest,
) {
@@ -125,27 +126,27 @@ export class ClassesController {
/** 批量导入学生到班级通过钉钉用户ID */
@Post(':id/students/import')
@RequirePermission('class:edit')
async batchImportStudents(@Param('id') id: string, @Body() dto: BatchImportStudentsDto) {
async batchImportStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: BatchImportStudentsDto) {
return this.service.batchImportStudents(+id, dto.users);
}
/** 归档班级 */
@Put(':id/archive')
@RequirePermission('class:edit')
async archive(@Param('id') id: string) {
async archive(@Param('id', ParseIntPipe) id: number) {
return this.service.archive(+id);
}
/** 取消归档 */
@Put(':id/restore')
@RequirePermission('class:edit')
async restore(@Param('id') id: string) {
async restore(@Param('id', ParseIntPipe) id: number) {
return this.service.restore(+id);
}
@Put(':id')
@RequirePermission('class:edit')
async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: any) {
const result = await this.service.update(+id, dto);
await logAudit(this.logService, req, {
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
@@ -155,7 +156,7 @@ export class ClassesController {
@Delete(':id')
@RequirePermission('class:delete')
async remove(@Param('id') id: string, @Request() req: any) {
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.remove(+id);
await logAudit(this.logService, req, {
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
@@ -165,7 +166,7 @@ export class ClassesController {
@Delete(':id/permanent')
@RequirePermission('class:purge')
async purge(@Param('id') id: string, @Request() req: any) {
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
@@ -176,7 +177,7 @@ export class ClassesController {
@Get(':id/roster/export')
@RequirePermission('class:view')
async exportRoster(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Res() res: Response,
@Request() req: AuthenticatedRequest,
) {
@@ -218,14 +219,14 @@ export class ClassesController {
@Get(':id/students')
@RequirePermission('class:view')
async getStudents(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
async getStudents(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.getStudents(+id);
}
@Post(':id/students')
@RequirePermission('class:edit')
async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: any) {
const result = await this.service.addStudents(+id, dto.studentIds);
await logAudit(this.logService, req, {
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
@@ -249,8 +250,8 @@ export class ClassesController {
@Delete(':id/students/:studentId')
@RequirePermission('class:edit')
async removeStudent(
@Param('id') id: string,
@Param('studentId') studentId: string,
@Param('id', ParseIntPipe) id: number,
@Param('studentId', ParseIntPipe) studentId: number,
@Request() req: any,
) {
const result = await this.service.removeStudent(+id, +studentId);
@@ -262,14 +263,14 @@ export class ClassesController {
@Get(':id/teachers')
@RequirePermission('class:view')
async getTeachers(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
async getTeachers(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.getTeachers(+id);
}
@Post(':id/teachers')
@RequirePermission('class:edit')
async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: any) {
const result = await this.service.addTeacher(+id, dto);
await logAudit(this.logService, req, {
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
@@ -290,8 +291,8 @@ export class ClassesController {
@Delete(':id/teacher-assignments/:assignmentId')
@RequirePermission('class:edit')
async removeTeacherAssignment(
@Param('id') id: string,
@Param('assignmentId') assignmentId: string,
@Param('id', ParseIntPipe) id: number,
@Param('assignmentId', ParseIntPipe) assignmentId: number,
@Request() req: any,
) {
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
@@ -304,8 +305,8 @@ export class ClassesController {
@Delete(':id/teachers/:userId')
@RequirePermission('class:edit')
async removeTeacher(
@Param('id') id: string,
@Param('userId') userId: string,
@Param('id', ParseIntPipe) id: number,
@Param('userId', ParseIntPipe) userId: number,
@Request() req: any,
) {
const result = await this.service.removeTeacher(+id, +userId);

View File

@@ -6,6 +6,7 @@ import {
Delete,
Body,
Param,
ParseIntPipe,
Query,
UseGuards,
Request,
@@ -95,7 +96,7 @@ export class ClassroomRentalsController {
@Get(':id')
@RequirePermission('rental:view')
findOne(@Param('id') id: string) {
findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(+id);
}
@@ -111,7 +112,7 @@ export class ClassroomRentalsController {
@Put(':id')
@RequirePermission('rental:edit')
async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) {
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: any) {
const result = await this.service.update(+id, dto);
await logAudit(this.logService, req, {
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto),
@@ -121,7 +122,7 @@ export class ClassroomRentalsController {
@Put(':id/cancel')
@RequirePermission('rental:edit')
async cancel(@Param('id') id: string, @Request() req: any) {
async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.cancel(+id);
await logAudit(this.logService, req, {
module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental',
@@ -131,7 +132,7 @@ export class ClassroomRentalsController {
@Put(':id/end')
@RequirePermission('rental:edit')
async end(@Param('id') id: string, @Request() req: any) {
async end(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.end(+id);
await logAudit(this.logService, req, {
module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental',
@@ -141,7 +142,7 @@ export class ClassroomRentalsController {
@Delete(':id')
@RequirePermission('rental:delete')
async remove(@Param('id') id: string, @Request() req: any) {
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.remove(+id);
await logAudit(this.logService, req, {
module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental',
@@ -151,7 +152,7 @@ export class ClassroomRentalsController {
@Delete(':id/permanent')
@RequirePermission('rental:purge')
async purge(@Param('id') id: string, @Request() req: any) {
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复',
@@ -174,7 +175,7 @@ export class ClassroomRentalsController {
}),
)
async uploadContract(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
@Request() req: any,
) {
@@ -188,7 +189,7 @@ export class ClassroomRentalsController {
@Get(':id/contract')
@RequirePermission('rental:view')
async downloadContract(@Param('id') id: string, @Res() res: Response) {
async downloadContract(@Param('id', ParseIntPipe) id: number, @Res() res: Response) {
const { fullPath, originalName } = await this.service.getContractPath(+id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
@@ -201,7 +202,7 @@ export class ClassroomRentalsController {
@Delete(':id/contract')
@RequirePermission('rental:edit')
async deleteContract(@Param('id') id: string, @Request() req: any) {
async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.removeContract(+id);
await logAudit(this.logService, req, {
module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental',

View File

@@ -17,4 +17,11 @@ describe('BatchIdsDto', () => {
const dto = Object.assign(new BatchIdsDto(), { ids: [1, 1, 2] });
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('rejects batches with more than 500 ids', async () => {
const dto = Object.assign(new BatchIdsDto(), {
ids: Array.from({ length: 501 }, (_, i) => i + 1),
});
await expect(validate(dto)).resolves.not.toHaveLength(0);
});
});

View File

@@ -1,8 +1,9 @@
import { ArrayNotEmpty, IsArray, IsInt, Min } from 'class-validator';
import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsInt, Min } from 'class-validator';
export class BatchIdsDto {
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(500)
@IsInt({ each: true })
@Min(1, { each: true })
ids: number[];

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, ParseIntPipe, UseGuards, Request } from '@nestjs/common';
import { ExpenseTypesService } from './expense-types.service';
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -47,7 +47,7 @@ export class ExpenseTypesController {
@Put(':id')
@RequirePermission('expense:edit')
async update(@Param('id') id: string, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) {
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
@@ -66,7 +66,7 @@ export class ExpenseTypesController {
@Delete(':id')
@RequirePermission('expense:delete')
async remove(@Param('id') id: string, @Request() req: any) {
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.service.remove(+id);
await this.logService.log({

View File

@@ -28,4 +28,17 @@ describe('BatchRoomExpenseDto boundaries', () => {
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
it('rejects a batch with more than 500 items', async () => {
const dto = plainToInstance(BatchRoomExpenseDto, {
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: Array.from({ length: 501 }, () => ({
roomId: 1,
expenseType: 'water',
amount: 10,
})),
});
await expect(validate(dto)).resolves.not.toHaveLength(0);
});
});

View File

@@ -1,4 +1,4 @@
import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator';
import { ArrayMaxSize, 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';
@@ -117,6 +117,7 @@ export class BatchRoomExpenseDto {
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => BatchRoomExpenseItemDto)
expenses: BatchRoomExpenseItemDto[];

View File

@@ -32,4 +32,13 @@ describe('notification DTO boundaries', () => {
expect(await validate(dto)).not.toEqual([]);
}
});
it('rejects more than 500 recipients', async () => {
const dto = plainToInstance(CreateNotificationDto, {
recipientIds: Array.from({ length: 501 }, (_, i) => i + 1),
type: 'test',
title: '标题',
});
await expect(validate(dto)).resolves.not.toEqual([]);
});
});

View File

@@ -1,5 +1,6 @@
import {
ArrayNotEmpty,
ArrayMaxSize,
IsString,
IsNotEmpty,
IsOptional,
@@ -13,6 +14,7 @@ import { Type } from 'class-transformer';
export class CreateNotificationDto {
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(500)
@IsInt({ each: true })
recipientIds: number[];

View File

@@ -3,6 +3,7 @@ import {
Get,
Put,
Param,
ParseIntPipe,
Query,
Req,
Sse,
@@ -74,7 +75,7 @@ export class NotificationsController {
@Put(':id/read')
async markRead(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Req() req: AuthenticatedRequest,
) {
await this.service.markRead(+id, req.user.id);

View File

@@ -53,8 +53,8 @@ export class OperationLogsService {
if (query?.endDate)
qb.andWhere('log.createdAt <= :endDate', { endDate: query.endDate + ' 23:59:59' });
const page = query?.page || 1;
const pageSize = query?.pageSize || 50;
const page = Math.max(1, Math.floor(Number(query?.page) || 1));
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query?.pageSize) || 50)));
const [data, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)

View File

@@ -6,6 +6,7 @@ import {
Delete,
Body,
Param,
ParseIntPipe,
Query,
UseGuards,
Request,
@@ -42,7 +43,7 @@ export class OrganizationsController {
@Get(':id')
@RequirePermission('organization:view')
findOne(@Param('id') id: string) {
findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(+id);
}
@@ -67,7 +68,7 @@ export class OrganizationsController {
@Put(':id')
@RequirePermission('organization:edit')
async update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
@@ -86,7 +87,7 @@ export class OrganizationsController {
@Delete(':id')
@RequirePermission('organization:delete')
async remove(@Param('id') id: string, @Request() req: any) {
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
@@ -104,7 +105,7 @@ export class OrganizationsController {
@Delete(':id/permanent')
@RequirePermission('organization:purge')
async purge(@Param('id') id: string, @Request() req: any) {
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.purge(+id);
await this.logService.log({

View File

@@ -10,6 +10,7 @@ import {
UseGuards,
Request,
BadRequestException,
ParseIntPipe,
} from '@nestjs/common';
import { RbacService } from './rbac.service';
import {
@@ -41,7 +42,7 @@ export class RbacController {
@Get('roles/:id')
@RequirePermission('role:view')
findRoleById(@Param('id') id: string) {
findRoleById(@Param('id', ParseIntPipe) id: number) {
return this.rbacService.findRoleById(+id);
}
@@ -57,7 +58,7 @@ export class RbacController {
@Put('roles/:id')
@RequirePermission('role:edit')
async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) {
async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: any) {
try {
const result = await this.rbacService.updateRole(+id, dto);
await logAudit(this.logService, req, {
@@ -71,7 +72,7 @@ export class RbacController {
@Delete('roles/:id')
@RequirePermission('role:delete')
async deleteRole(@Param('id') id: string, @Request() req: any) {
async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
try {
const result = await this.rbacService.deleteRole(+id);
await logAudit(this.logService, req, {
@@ -118,7 +119,7 @@ export class RbacController {
@Put('users/:id')
@RequirePermission('user:edit')
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) {
async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: any) {
try {
const result = await this.rbacService.updateUser(+id, dto);
await logAudit(this.logService, req, {
@@ -132,7 +133,7 @@ export class RbacController {
@Put('users/:id/password')
@RequirePermission('user:reset-password')
async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) {
async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: any) {
try {
const result = await this.rbacService.resetPassword(+id, dto.password);
await logAudit(this.logService, req, {
@@ -146,7 +147,7 @@ export class RbacController {
@Put('users/:id/archive')
@RequirePermission('user:edit')
async archiveUser(@Param('id') id: string) {
async archiveUser(@Param('id', ParseIntPipe) id: number) {
try {
return await this.rbacService.archiveUser(+id);
} catch (e: unknown) {
@@ -157,7 +158,7 @@ export class RbacController {
@Put('users/:id/restore')
@RequirePermission('user:edit')
async restoreUser(@Param('id') id: string) {
async restoreUser(@Param('id', ParseIntPipe) id: number) {
try {
return await this.rbacService.restoreUser(+id);
} catch (e: unknown) {
@@ -168,7 +169,7 @@ export class RbacController {
@Delete('users/:id/permanent')
@RequirePermission('user:purge')
async purgeUser(@Param('id') id: string, @Request() req: any) {
async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
try {
const result = await this.rbacService.purgeUser(+id, req.user?.id);
await logAudit(this.logService, req, {
@@ -183,7 +184,7 @@ export class RbacController {
@Put('users/:id/mark-staff')
@RequirePermission('user:edit')
async markAsStaff(@Param('id') id: string) {
async markAsStaff(@Param('id', ParseIntPipe) id: number) {
try {
return await this.rbacService.markAsStaff(+id);
} catch (e: unknown) {
@@ -194,7 +195,7 @@ export class RbacController {
@Put('users/:id/mark-student')
@RequirePermission('user:edit')
async markAsStudent(@Param('id') id: string) {
async markAsStudent(@Param('id', ParseIntPipe) id: number) {
try {
return await this.rbacService.markAsStudent(+id);
} catch (e: unknown) {
@@ -205,14 +206,14 @@ export class RbacController {
@Get('users/:id/profile')
@RequirePermission('user:view')
getUserProfile(@Param('id') id: string) {
getUserProfile(@Param('id', ParseIntPipe) id: number) {
return this.rbacService.getUserProfile(+id);
}
@Put('users/:id/profile')
@RequirePermission('user:edit')
async updateUserProfile(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateProfileDto,
@Request() req: any,
) {
@@ -250,7 +251,7 @@ export class RbacController {
@Put('teachers/:id/profile')
@RequirePermission('teacher:edit')
async updateTeacherProfile(
@Param('id') id: string,
@Param('id', ParseIntPipe) id: number,
@Body() profile: UpdateProfileDto,
@Request() req: { user?: { id: number; username: string } },
) {