# 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)中引用的类名会变,需同步更新。