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.

109 lines
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. 三类太重——`SaveParam` 与 `PageParam` 拆分增加了认知负担
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 掉以下服务端裁定字段:
`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)中引用的类名会变,需同步更新。