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),用户整体推翻该方案,原因:
- 三类太重——
SaveParam与PageParam拆分增加了认知负担 - 写入参纯 POJO 不继承基类的结构性防御虽然安全上最强,但牺牲了 DTO 上
fromEntity()/toEntity()的便利性 - 存量
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 掉以下服务端裁定字段:
createTime、updateTime、creatorId、updaterId、deleted、builtin
防御机制从"字段不在类上"(结构性)降级为"字段在类上但绑定器拒绝"(集中式拦截器)。维护成本为一处全局配置;新增服务端裁定字段时必须同步更新 setDisallowedFields 列表。
5. 封装阈值
≥3 个参数封装为 XxxDTO;≤2 个保持 @RequestParam。纯参数个数,不看点参数形状。
6. 命名统一
dto/ 包内所有 HTTP 出入参类一律 XxxDTO 后缀。存量改名:
| 原名 | 新名 |
|---|---|
RoleDetailVO |
RoleDTO |
UserStatsVO |
UserStatsDTO |
DictGroupVO |
DictGroupDTO |
DictItemVO |
DictItemDTO |
ResourceNode |
ResourceNodeDTO |
内部传输对象(ButtonSeed、PermissionModuleDescriptor、UploadSession)不强制后缀。
7. BaseDTO 重构(前置条件)
当前 BaseDTO 仍是未改造版本(非 abstract,含 creatorId / updaterId / deleted,含泛型 toEntity(Supplier))。若不先重构,这些字段会在出参侧泄露给前端。需重构为:
abstract类,不可直接实例化- 只保留
id、createTime、updateTime三个字段 - 三字段均加
@JsonInclude(NON_NULL) - 移除
creatorId、updaterId、deleted及其默认值 - 移除泛型
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.page、SystemController.menuTree、SystemController.deptTree、DictGroupController.enabledList、DictItemController.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 重构是前置:当前
BaseDTO含creatorId/updaterId/deleted,若不先重构,这些字段会在出参侧泄露给前端。 - 命名改名的前端影响:类名不出现在 JSON 响应中(Jackson 按字段名序列化),改名对前端无感。但 API 文档(如 OpenAPI/Swagger)中引用的类名会变,需同步更新。