diff --git a/.kilocode/rules/memory-bank/tech.md b/.kilocode/rules/memory-bank/tech.md index 20d00cc..4340e04 100644 --- a/.kilocode/rules/memory-bank/tech.md +++ b/.kilocode/rules/memory-bank/tech.md @@ -254,6 +254,121 @@ NODE_ENV=development - **SQL 注入防护**: Sequelize ORM 自动防护 - **XSS 防护**: 输入清理和输出编码 +#### DTO 验证装饰器编写规范 + +**基本原则**: +- 所有 DTO 类必须同时包含 `@ApiProperty` 和 `class-validator` 装饰器 +- `@ApiProperty` 用于 Swagger 文档生成,`class-validator` 用于数据验证 +- 缺少验证装饰器会导致参数校验失败,使 API 端点无法正常工作 + +**必需要导入的验证装饰器**: +```typescript +import { IsOptional, IsDateString, IsNumber, IsString, IsNotEmpty, IsEnum, MaxLength, Min, Max } from 'class-validator'; +``` + +**常用字段验证规则**: +- **分页参数**:在 GET 请求中使用 `@IsOptional()` + `@IsString()`,在控制器中转换为数字;在 POST/PUT 请求中使用 `@IsOptional()` + `@IsNumber()` +- **日期参数**:`startDate` 和 `endDate` 使用 `@IsOptional()` + `@IsDateString()` +- **字符串参数**:使用 `@IsOptional()` + `@IsString()`,必要时添加 `@MaxLength()` +- **枚举参数**:使用 `@IsOptional()` + `@IsEnum(EnumType)` +- **必填字段**:使用 `@IsNotEmpty()` 而不是只使用 `@IsString()` + +#### GET 请求参数特殊处理 + +**重要说明**:GET 请求的查询参数(Query Parameters)在 HTTP 协议中都是字符串类型,即使看起来是数字(如 `?page=1&limit=20`)。因此需要特殊处理: + +**GET 请求 DTO 定义**: +```typescript +export class GetRecordsQueryDto { + @ApiProperty({ description: '页码', example: 1, required: false }) + @IsOptional() + @IsString() // 注意:GET 请求中使用 @IsString() 而不是 @IsNumber() + page?: string; + + @ApiProperty({ description: '每页数量', example: 20, required: false }) + @IsOptional() + @IsString() // 注意:GET 请求中使用 @IsString() 而不是 @IsNumber() + limit?: string; +} +``` + +**控制器中的类型转换**: +```typescript +async getRecords( + @Query() query: GetRecordsQueryDto, + @CurrentUser() user: AccessTokenPayload, +): Promise { + // 转换查询参数中的字符串为数字 + const convertedQuery = { + page: query.page ? parseInt(query.page, 10) : undefined, + limit: query.limit ? parseInt(query.limit, 10) : undefined, + // 其他字符串参数保持不变 + startDate: query.startDate, + endDate: query.endDate, + status: query.status, + }; + + const result = await this.service.getRecords(user.sub, convertedQuery); + return result; +} +``` + +**POST/PUT 请求 DTO 定义**(对比): +```typescript +export class CreateRecordDto { + @ApiProperty({ description: '数量', example: 10 }) + @IsNumber() // POST/PUT 请求中可以直接使用 @IsNumber() + quantity: number; +} +``` + +**正确示例(GET 请求)**: +```typescript +export class GetRecordsQueryDto { + @ApiProperty({ description: '页码', example: 1, required: false }) + @IsOptional() + @IsString() // GET 请求中使用 @IsString() + page?: string; + + @ApiProperty({ description: '每页数量', example: 20, required: false }) + @IsOptional() + @IsString() // GET 请求中使用 @IsString() + limit?: string; + + @ApiProperty({ description: '开始日期', example: '2023-01-01', required: false }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiProperty({ description: '状态', example: 'active', required: false }) + @IsOptional() + @IsString() + status?: string; +} +``` + +**正确示例(POST/PUT 请求)**: +```typescript +export class CreateRecordDto { + @ApiProperty({ description: '数量', example: 10 }) + @IsNumber() // POST/PUT 请求中可以直接使用 @IsNumber() + quantity: number; + + @ApiProperty({ description: '名称', example: '测试' }) + @IsString() + @IsNotEmpty() + name: string; +} +``` + +**常见错误**: +- ❌ 只有 `@ApiProperty` 而缺少 `class-validator` 装饰器 +- ❌ GET 请求中的数字参数使用 `@IsNumber()` 而不是 `@IsString()` +- ❌ 使用 `@IsString()` 但没有 `@IsOptional()` 处理可选参数 +- ❌ 日期字段没有使用 `@IsDateString()` 验证 +- ❌ GET 请求中忘记在控制器进行类型转换,导致服务层接收到字符串而不是数字 +- ❌ 类型转换时没有进行空值检查,可能导致 `parseInt(undefined)` 返回 `NaN` + ### 访问控制 - **JWT 认证**: 无状态 Token 认证 - **权限守卫**: 基于角色的访问控制 diff --git a/src/diet-records/diet-records.controller.ts b/src/diet-records/diet-records.controller.ts index 37caf18..3e1e373 100644 --- a/src/diet-records/diet-records.controller.ts +++ b/src/diet-records/diet-records.controller.ts @@ -227,7 +227,16 @@ export class DietRecordsController { this.logger.log(`获取营养成分分析记录 - 用户ID: ${user.sub}`); try { - const result = await this.nutritionAnalysisService.getAnalysisRecords(user.sub, query); + // 转换查询参数中的字符串为数字 + const convertedQuery = { + page: query.page ? parseInt(query.page, 10) : undefined, + limit: query.limit ? parseInt(query.limit, 10) : undefined, + startDate: query.startDate, + endDate: query.endDate, + status: query.status, + }; + + const result = await this.nutritionAnalysisService.getAnalysisRecords(user.sub, convertedQuery); // 转换为DTO格式 const recordDtos: NutritionAnalysisRecordDto[] = result.records.map(record => ({ diff --git a/src/diet-records/dto/nutrition-analysis-record.dto.ts b/src/diet-records/dto/nutrition-analysis-record.dto.ts index 6944c6d..73e58bc 100644 --- a/src/diet-records/dto/nutrition-analysis-record.dto.ts +++ b/src/diet-records/dto/nutrition-analysis-record.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsDateString, IsNumber, IsString } from 'class-validator'; import { ApiResponseDto } from '../../base.dto'; /** @@ -98,17 +99,27 @@ export class NutritionAnalysisRecordsResponseDto extends ApiResponseDto<{ */ export class GetNutritionAnalysisRecordsQueryDto { @ApiProperty({ description: '页码', example: 1, required: false }) - page?: number; + @IsOptional() + @IsString() + page?: string; @ApiProperty({ description: '每页数量', example: 20, required: false }) - limit?: number; + @IsOptional() + @IsString() + limit?: string; @ApiProperty({ description: '开始日期', example: '2023-01-01', required: false }) + @IsOptional() + @IsDateString() startDate?: string; @ApiProperty({ description: '结束日期', example: '2023-12-31', required: false }) + @IsOptional() + @IsDateString() endDate?: string; @ApiProperty({ description: '分析状态', example: 'success', required: false }) + @IsOptional() + @IsString() status?: string; } \ No newline at end of file