Skip to content

ValidationPipe 配置优化说明

问题

更新菜单时,前端可能会传入一些只读字段(如 idcreatedAtupdatedAtchildren),之前的配置会导致验证错误:

json
{
  "errors": [
    "property id should not exist",
    "property createdAt should not exist",
    "property updatedAt should not exist",
    "property children should not exist"
  ]
}

原因

main.ts 中的 ValidationPipe 配置使用了 forbidNonWhitelisted: true

typescript
// ❌ 之前的配置
new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: true,  // 会对额外字段报错
  transform: true,
})

这导致任何未在 DTO 中定义的字段都会触发验证错误。

解决方案

forbidNonWhitelisted 改为 false

typescript
// ✅ 新的配置
new ValidationPipe({
  whitelist: true,                // 自动剥离未在 DTO 中定义的属性
  forbidNonWhitelisted: false,    // 不报错,只是忽略额外的字段
  transform: true,
})

配置说明

whitelist: true

  • 作用: 自动删除(剥离)请求中未在 DTO 类中用装饰器标注的属性
  • 好处: 防止客户端发送不需要的数据
  • 示例:
    typescript
    // DTO 定义
    class UpdateMenuDto {
      @IsString()
      menuName?: string;
    }
    
    // 请求体
    {
      "menuName": "新名称",
      "id": "123",           // 会被自动删除
      "createdAt": "...",    // 会被自动删除
    }
    
    // 实际接收到的数据
    {
      "menuName": "新名称"
    }

forbidNonWhitelisted: false

  • 作用: 当设为 false 时,额外的属性会被静默删除,不会抛出错误
  • 作用: 当设为 true 时,额外的属性会导致验证错误
  • 选择:
    • false: 更宽容,适合前端可能传入额外字段的场景(推荐)
    • true: 更严格,适合严格控制输入的场景

transform: true

  • 作用: 自动将请求载荷转换为 DTO 类实例
  • 好处:
    • 启用类型转换(如字符串 "123" 转为数字 123)
    • 应用 @Transform() 装饰器的转换逻辑

transformOptions.enableImplicitConversion: true

  • 作用: 自动进行隐式类型转换
  • 示例:
    typescript
    class QueryDto {
      @IsNumber()
      page: number;
    }
    
    // 请求: ?page=1  (字符串)
    // 自动转换为: { page: 1 } (数字)

行为对比

之前 (forbidNonWhitelisted: true)

bash
# PATCH /api/menus/:id
curl -X PATCH http://localhost:9423/api/menus/:id \
  -H "Content-Type: application/json" \
  -d '{
    "menuName": "新名称",
    "id": "123",
    "createdAt": "2024-01-01",
    "children": []
  }'

# ❌ 响应: 400 Bad Request
{
  "success": false,
  "statusCode": 400,
  "message": [
    "property id should not exist",
    "property createdAt should not exist",
    "property children should not exist"
  ]
}

现在 (forbidNonWhitelisted: false)

bash
# PATCH /api/menus/:id
curl -X PATCH http://localhost:9423/api/menus/:id \
  -H "Content-Type: application/json" \
  -d '{
    "menuName": "新名称",
    "id": "123",
    "createdAt": "2024-01-01",
    "children": []
  }'

# ✅ 响应: 200 OK
{
  "success": true,
  "data": {
    "id": "real-uuid",
    "menuName": "新名称",
    "createdAt": "2024-01-01T00:00:00.000Z",
    ...
  }
}

# 注意: id, createdAt, children 被自动忽略了

受影响的场景

这个配置是全局的,会影响所有 API 端点。

✅ 好处

  1. 前端更宽容:

    • 前端可以直接传递完整的对象而不需要手动删除只读字段
    • 减少前端代码复杂度
  2. 更新操作更友好:

    typescript
    // 前端常见模式
    const menu = await getMenu(id);
    menu.menuName = "新名称";
    await updateMenu(id, menu);  // ✅ 不需要删除 id, createdAt 等字段
  3. 向后兼容:

    • 如果前端已经传入了额外字段,这个改动不会破坏它们
    • 只是让这些字段被忽略而不是报错

⚠️ 注意事项

  1. 类型安全性稍弱:

    • 客户端可以发送任何额外字段而不会收到警告
    • 但由于 whitelist: true,这些字段不会影响数据库
  2. 调试可能稍难:

    • 如果前端意外传入了错误的字段名(如 manuName 而不是 menuName),不会报错
    • 建议在开发环境使用 forbidNonWhitelisted: true 以发现这类问题

开发建议

生产环境配置 (推荐)

typescript
new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: false,  // 宽容模式
  transform: true,
})

开发环境严格配置 (可选)

typescript
new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: process.env.NODE_ENV === 'development',  // 开发时严格
  transform: true,
})

前端最佳实践

虽然后端会忽略额外字段,但前端最好还是遵循最佳实践:

✅ 推荐:使用 pick/omit

typescript
import { omit } from 'lodash';

const updateMenu = async (id, menu) => {
  // 方式1: 使用 lodash omit
  const updateData = omit(menu, ['id', 'createdAt', 'updatedAt', 'children']);
  return await api.patch(`/api/menus/${id}`, updateData);

  // 方式2: 手动解构
  const { id, createdAt, updatedAt, children, ...updateData } = menu;
  return await api.patch(`/api/menus/${id}`, updateData);

  // 方式3: 只传需要的字段
  const updateData = {
    menuName: menu.menuName,
    routePath: menu.routePath,
    // ... 其他需要更新的字段
  };
  return await api.patch(`/api/menus/${id}`, updateData);
};

⚠️ 可接受:直接传完整对象

typescript
const updateMenu = async (id, menu) => {
  // 虽然不是最佳实践,但现在也能工作
  return await api.patch(`/api/menus/${id}`, menu);
};

受保护的字段

以下字段无论客户端是否传入,都不会被修改:

所有模型通用

  • id - 主键,创建时自动生成
  • createdAt - 创建时间,自动生成
  • updatedAt - 更新时间,自动更新

菜单特有

  • parentId - 父菜单 ID,创建后不可修改(在 service 层处理)
  • children - 子菜单列表,查询时动态生成(不是数据库字段)

关联关系

  • roleMenus - 角色菜单关联(通过专门的 API 管理)
  • permissions - 权限列表(通过专门的 API 管理)

测试验证

bash
# 测试:传入只读字段
curl -X PATCH http://localhost:9423/api/menus/:id \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "fake-id",
    "menuName": "新菜单名",
    "createdAt": "1970-01-01",
    "updatedAt": "1970-01-01",
    "children": [{"id": "child"}],
    "someRandomField": "random"
  }'

# ✅ 期望: 成功更新,只有 menuName 被修改
# ✅ 期望: id, createdAt, updatedAt, children, someRandomField 被忽略
# ✅ 期望: 没有报错信息

修改日期: 2025-10-27 版本: v1.4.3 影响范围: 全局验证管道配置 文件: src/main.ts