You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

5.7 KiB

ADR-0017: Controller 出入参两类对象规范(Param / DTO)

Status

Accepted

Context

CRM 项目的 Controller 层长期存在出入参对象使用不一致的问题:散装 @RequestParam、实体直收、实体直出、命名混乱(同一 dto/ 包内 XxxVO / XxxDTO / 无后缀三种)。

此前曾制定过一份三类方案(SaveParam / PageParam / DTO)的 spec(.scratch/controller-io-conventions/spec.md),用户整体推翻该方案,原因:

  1. 三类太重——SaveParamPageParam 拆分增加了认知负担
  2. 写入参纯 POJO 不继承基类的结构性防御虽然安全上最强,但牺牲了 DTO 上 fromEntity() / toEntity() 的便利性
  3. 存量 XxxVO 命名不强制改的妥协使"统一"名不副实

用户要求:两类、DTO 双向、存量全改。

Considered Options

  • 三类方案(SaveParam + PageParam + DTO):写入参纯 POJO 不继承,出参 DTO 继承 BaseDTO。结构性防御 mass assignment,但三类对象认知成本高。推翻——用户认为太重。
  • 两类 + 分离 DTO(Route B)XxxSaveDTO(写入参)+ XxxDTO(出参)。实际是三类换名,分类数没少。否决。
  • 两类 + 双向 DTO(Route A,采纳):一个 XxxDTO 既是写入参又是出参。Mass assignment 防御从类型系统降级为全局 @InitBinder 拦截器。简单,但放弃了结构性防御。

Decision

1. 两类对象

类型 用途 继承
XxxParam param/ 查询/搜索/分页入参 extends BaseParam
XxxDTO dto/ 写入参 + 出参(双向) 业务类 extends BaseDTO;统计/登录等无主键语义的不继承

2. 硬约束

  • Entity 永不出现在 Controller 方法签名上(入参出参都禁)
  • deleted 字段不出现在任何 HTTP 响应中

3. DTO 双向(Route A)

同一个 XxxDTO 类既是写入参又是出参。列表与详情共用一个 DTO,详情比列表多的字段(如 resourceIds)在列表场景留 null。

4. Mass assignment 防御

全局 @ControllerAdvice + @InitBinder,调用 dataBinder.setDisallowedFields(...) strip 掉以下服务端裁定字段:

createTimeupdateTimecreatorIdupdaterIddeletedbuiltin

防御机制从"字段不在类上"(结构性)降级为"字段在类上但绑定器拒绝"(集中式拦截器)。维护成本为一处全局配置;新增服务端裁定字段时必须同步更新 setDisallowedFields 列表。

5. 封装阈值

≥3 个参数封装为 XxxDTO;≤2 个保持 @RequestParam。纯参数个数,不看点参数形状。

6. 命名统一

dto/ 包内所有 HTTP 出入参类一律 XxxDTO 后缀。存量改名:

原名 新名
RoleDetailVO RoleDTO
UserStatsVO UserStatsDTO
DictGroupVO DictGroupDTO
DictItemVO DictItemDTO
ResourceNode ResourceNodeDTO

内部传输对象(ButtonSeedPermissionModuleDescriptorUploadSession)不强制后缀。

7. BaseDTO 重构(前置条件)

当前 BaseDTO 仍是未改造版本(非 abstract,含 creatorId / updaterId / deleted,含泛型 toEntity(Supplier))。若不先重构,这些字段会在出参侧泄露给前端。需重构为:

  • abstract 类,不可直接实例化
  • 只保留 idcreateTimeupdateTime 三个字段
  • 三字段均加 @JsonInclude(NON_NULL)
  • 移除 creatorIdupdaterIddeleted 及其默认值
  • 移除泛型 toEntity(Supplier) 方法——转换方法下沉到各 DTO

8. 转换位置

fromEntity() / toEntity() 方法写在各 DTO 类上,由 Service 层调用。Controller 只做接参、调服务、包 Result。

9. 绑定方式

隐式表单绑定(不加 @RequestBody),契合项目 application/x-www-form-urlencoded 契约。

10. 分页安全

XxxParam 继承 BaseParam,经 PageConverter.toMpPage 转换——页大小上限收敛 + 排序字段白名单校验。

11. 存量全改

9 处存量违规全部按新规范重构:

违规 位置 性质
实体直收(2 处) DictGroupController.saveOrUpdate(DictGroup)DictItemController.saveOrUpdate(DictItem) 安全漏洞——可伪造 builtin / createTime
实体直出(5 处) RoleController.pageSystemController.menuTreeSystemController.deptTreeDictGroupController.enabledListDictItemController.enabledList deleted 等内部字段泄露
分页绕过(1 处) RoleController.page 自行 new Page<> 无页大小上限、排序字段可注入
@RequestBody 违反契约(1 处) SystemController.userPage 违反 application/x-www-form-urlencoded 契约

Consequences

  • 类型安全降级:Mass assignment 防御从结构性(字段不存在)变为集中式(拦截器 strip)。新增服务端裁定字段时必须同步更新 setDisallowedFields 列表,否则新字段可被伪造。
  • DTO 双向的代价:同一类上出现双契约字段(如 id 新增时不传、编辑时必传)。此前 spec 曾识别此为反模式(ResourceNode 案例),用户已知风险并接受。
  • 存量全改的风险:9 处违规涉及 4 个 Controller、2 个 Service、多个 DTO 新建/改名。需回归测试确保语义不偏移。
  • BaseDTO 重构是前置:当前 BaseDTOcreatorId / updaterId / deleted,若不先重构,这些字段会在出参侧泄露给前端。
  • 命名改名的前端影响:类名不出现在 JSON 响应中(Jackson 按字段名序列化),改名对前端无感。但 API 文档(如 OpenAPI/Swagger)中引用的类名会变,需同步更新。