feat(diet-records): 修复营养成分分析记录查询参数验证和类型转换

修复GET请求查询参数验证装饰器缺失问题,添加正确的class-validator装饰器
在控制器中实现查询参数类型转换,确保数字参数正确处理
更新技术文档,添加DTO验证装饰器编写规范和GET请求参数处理指南
This commit is contained in:
richarjiang
2025-10-16 16:26:58 +08:00
parent a2c719f10a
commit 1fe969aa97
3 changed files with 138 additions and 3 deletions

View File

@@ -254,6 +254,121 @@ NODE_ENV=development
- **SQL 注入防护**: Sequelize ORM 自动防护 - **SQL 注入防护**: Sequelize ORM 自动防护
- **XSS 防护**: 输入清理和输出编码 - **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<RecordsResponseDto> {
// 转换查询参数中的字符串为数字
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 认证 - **JWT 认证**: 无状态 Token 认证
- **权限守卫**: 基于角色的访问控制 - **权限守卫**: 基于角色的访问控制

View File

@@ -227,7 +227,16 @@ export class DietRecordsController {
this.logger.log(`获取营养成分分析记录 - 用户ID: ${user.sub}`); this.logger.log(`获取营养成分分析记录 - 用户ID: ${user.sub}`);
try { 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格式 // 转换为DTO格式
const recordDtos: NutritionAnalysisRecordDto[] = result.records.map(record => ({ const recordDtos: NutritionAnalysisRecordDto[] = result.records.map(record => ({

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsDateString, IsNumber, IsString } from 'class-validator';
import { ApiResponseDto } from '../../base.dto'; import { ApiResponseDto } from '../../base.dto';
/** /**
@@ -98,17 +99,27 @@ export class NutritionAnalysisRecordsResponseDto extends ApiResponseDto<{
*/ */
export class GetNutritionAnalysisRecordsQueryDto { export class GetNutritionAnalysisRecordsQueryDto {
@ApiProperty({ description: '页码', example: 1, required: false }) @ApiProperty({ description: '页码', example: 1, required: false })
page?: number; @IsOptional()
@IsString()
page?: string;
@ApiProperty({ description: '每页数量', example: 20, required: false }) @ApiProperty({ description: '每页数量', example: 20, required: false })
limit?: number; @IsOptional()
@IsString()
limit?: string;
@ApiProperty({ description: '开始日期', example: '2023-01-01', required: false }) @ApiProperty({ description: '开始日期', example: '2023-01-01', required: false })
@IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@ApiProperty({ description: '结束日期', example: '2023-12-31', required: false }) @ApiProperty({ description: '结束日期', example: '2023-12-31', required: false })
@IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
@ApiProperty({ description: '分析状态', example: 'success', required: false }) @ApiProperty({ description: '分析状态', example: 'success', required: false })
@IsOptional()
@IsString()
status?: string; status?: string;
} }