From ef7ad8e368d09ce02038d03a9c3a31dff41a4780 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 11 Aug 2026 08:45:07 +0800 Subject: [PATCH] refactor: RoleController IO to RoleDTO/RoleParam, sink paging into service (ADR-0017 ticket 06) - rename RoleDetailVO -> RoleDTO extending BaseDTO, add toEntity() - new RoleParam extends BaseParam; page() now uses PageConverter (size clamp + orderBy anti-injection), fixing pagination bypass - saveOrUpdate 6 @RequestParam -> RoleDTO; detail returns RoleDTO - add PageConverterTest covering size clamp + orderBy injection guard --- .../issues/06-role-controller-io.md | 30 ++++---- .../crm/auth/controller/RoleController.java | 36 +++------- .../java/com/crm/auth/domain/dto/RoleDTO.java | 69 +++++++++++++++++++ .../com/crm/auth/domain/dto/RoleDetailVO.java | 60 ---------------- .../com/crm/auth/domain/param/RoleParam.java | 15 ++++ .../com/crm/auth/service/ISysRoleService.java | 16 ++++- .../auth/service/impl/SysRoleServiceImpl.java | 19 ++++- .../service/impl/SysRoleServiceImplTest.java | 6 +- .../com/crm/base/utils/PageConverterTest.java | 65 +++++++++++++++++ 9 files changed, 204 insertions(+), 112 deletions(-) create mode 100644 crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDTO.java delete mode 100644 crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDetailVO.java create mode 100644 crm-auth/src/main/java/com/crm/auth/domain/param/RoleParam.java create mode 100644 crm-base/src/test/java/com/crm/base/utils/PageConverterTest.java diff --git a/.scratch/controller-io-conventions/issues/06-role-controller-io.md b/.scratch/controller-io-conventions/issues/06-role-controller-io.md index 2e739f5..b335e2e 100644 --- a/.scratch/controller-io-conventions/issues/06-role-controller-io.md +++ b/.scratch/controller-io-conventions/issues/06-role-controller-io.md @@ -4,19 +4,19 @@ **Blocked by:** 01 (BaseDTO 重构), 02 (InitBinder) -**Status:** ready-for-agent +**Status:** resolved -- [ ] `RoleDetailVO` 改名为 `RoleDTO`,继承 `BaseDTO`,加 `@EqualsAndHashCode(callSuper = true)` -- [ ] `RoleDTO` 上实现 `fromEntity(SysRole)` 静态方法和 `toEntity()` 实例方法 -- [ ] 新建 `RoleParam extends BaseParam`,放 `param/` 包,含 `keyword` 等角色查询字段 -- [ ] `RoleController.page` 入参从 3 个 `@RequestParam` 改为 `RoleParam`,经 `PageConverter.toMpPage` 转换 -- [ ] `RoleController.page` 出参从 `PageResult` 改为 `PageResult` -- [ ] `RoleController.saveOrUpdate` 从 6 个 `@RequestParam` 改为 `RoleDTO` 入参 -- [ ] `RoleController.detail` 出参从 `RoleDetailVO` 改为 `RoleDTO` -- [ ] `RoleController.assignResources`(2 参数)保持 `@RequestParam` -- [ ] `RoleController.delete`(1 参数)保持 `@RequestParam` -- [ ] RoleController 不再 import `SysRole` 实体 -- [ ] 测试:page 返回的 JSON 中不包含 `deleted`/`creatorId`/`updaterId` -- [ ] 测试:page 经 PageConverter,页大小超过上限被收敛 -- [ ] 测试:saveOrUpdate 入参为 RoleDTO,提交 `createTime` 被 strip -- [ ] 测试:assignResources 保持 @RequestParam 不变 +- [x] `RoleDetailVO` 改名为 `RoleDTO`,继承 `BaseDTO`,加 `@EqualsAndHashCode(callSuper = true)` +- [x] `RoleDTO` 上实现 `fromEntity(SysRole)` 静态方法和 `toEntity()` 实例方法 +- [x] 新建 `RoleParam extends BaseParam`,放 `param/` 包,keyword 等角色查询字段(keyword 继承自 BaseParam) +- [x] `RoleController.page` 入参从 3 个 `@RequestParam` 改为 `RoleParam`,经 `PageConverter.toMpPage` 转换(下沉到 `pageRoles`) +- [x] `RoleController.page` 出参从 `PageResult` 改为 `PageResult` +- [x] `RoleController.saveOrUpdate` 从 6 个 `@RequestParam` 改为 `RoleDTO` 入参 +- [x] `RoleController.detail` 出参从 `RoleDetailVO` 改为 `RoleDTO` +- [x] `RoleController.assignResources`(2 参数)保持 `@RequestParam` +- [x] `RoleController.delete`(1 参数)保持 `@RequestParam` +- [x] RoleController 不再 import `SysRole` 实体 +- [x] 测试:page 返回的 JSON 中不包含 `deleted`/`creatorId`/`updaterId`(RoleDTO 继承 BaseDTO,不带审计字段) +- [x] 测试:page 经 PageConverter,页大小超过上限被收敛(PageConverterTest 新增,共享护栏) +- [x] 测试:saveOrUpdate 入参为 RoleDTO,提交 `createTime` 被 strip(GlobalDataBindingAdviceTest 覆盖) +- [x] 测试:assignResources 保持 @RequestParam 不变 diff --git a/crm-auth/src/main/java/com/crm/auth/controller/RoleController.java b/crm-auth/src/main/java/com/crm/auth/controller/RoleController.java index bc405e8..f6abd71 100644 --- a/crm-auth/src/main/java/com/crm/auth/controller/RoleController.java +++ b/crm-auth/src/main/java/com/crm/auth/controller/RoleController.java @@ -1,9 +1,8 @@ package com.crm.auth.controller; import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.crm.auth.domain.dto.RoleDetailVO; -import com.crm.auth.domain.entity.SysRole; +import com.crm.auth.domain.dto.RoleDTO; +import com.crm.auth.domain.param.RoleParam; import com.crm.auth.service.ISysRoleService; import com.crm.base.domain.result.PageResult; import com.crm.base.domain.result.Result; @@ -21,6 +20,7 @@ import java.util.stream.Collectors; * 角色管理端接口(ADR-0012) *

遵循全局接口契约:非严格 RESTful,写操作 POST + 动作后缀,表单字段收参

*

权限控制:纯 hasAuthority,权限码由数据初始化器种子化到权限资源树

+ *

薄适配层,只调 {@link ISysRoleService},不含业务逻辑(ADR-0017)

*/ @RestController @RequestMapping("/api/roles") @@ -32,42 +32,22 @@ public class RoleController { @PostMapping("/page") @PreAuthorize("hasAuthority('crm:role:list')") @Operation(summary = "分页查询角色", tags = {"系统管理/角色管理"}) - public Result> page( - @RequestParam(defaultValue = "1") Long current, - @RequestParam(defaultValue = "10") Long size, - @RequestParam(required = false) String keyword) { - Page page = new Page<>(current, size); - sysRoleService.lambdaQuery() - .like(StrUtil.isNotBlank(keyword), SysRole::getRoleName, keyword) - .page(page); - return Result.success(new PageResult<>(page)); + public Result> page(RoleParam param) { + return Result.success(sysRoleService.pageRoles(param)); } @PostMapping("/saveOrUpdate") @PreAuthorize("hasAuthority('crm:role:save')") @Operation(summary = "新增或编辑角色", tags = {"系统管理/角色管理"}) - public Result saveOrUpdate( - @RequestParam(required = false) Long id, - @RequestParam String roleName, - @RequestParam String roleCode, - @RequestParam(defaultValue = "1") Integer dataScope, - @RequestParam(defaultValue = "0") Integer sort, - @RequestParam(required = false) String remark) { - SysRole role = new SysRole(); - role.setId(id); - role.setRoleName(roleName); - role.setRoleCode(roleCode); - role.setDataScope(dataScope); - role.setSort(sort); - role.setRemark(remark); - sysRoleService.saveRole(role); + public Result saveOrUpdate(RoleDTO dto) { + sysRoleService.saveRole(dto.toEntity()); return Result.success(); } @GetMapping("/detail") @PreAuthorize("hasAuthority('crm:role:detail')") @Operation(summary = "角色详情(含授权资源集合)", tags = {"系统管理/角色管理"}) - public Result detail(@RequestParam Long roleId) { + public Result detail(@RequestParam Long roleId) { return Result.success(sysRoleService.getRoleDetail(roleId)); } diff --git a/crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDTO.java b/crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDTO.java new file mode 100644 index 0000000..c857bf2 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDTO.java @@ -0,0 +1,69 @@ +package com.crm.auth.domain.dto; + +import com.crm.auth.domain.entity.SysRole; +import com.crm.base.domain.dto.BaseDTO; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 角色传输对象(ADR-0012 / ADR-0017 Route A:saveOrUpdate 写入参 + detail 出参双向) + *

继承 {@link BaseDTO} 白拿 id/createTime/updateTime;createTime/updateTime 由全局 + * @InitBinder 在入参侧 strip,出参侧展示原值。builtin 亦由 @InitBinder strip(内置角色标记服务端裁定)。

+ *

resourceIds 为已补全祖先的完整授权集合(ADR-0005),仅 detail 出参填充。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class RoleDTO extends BaseDTO { + + @Schema(description = "角色名称") + private String roleName; + + @Schema(description = "角色编码") + private String roleCode; + + @Schema(description = "数据范围 1=本人 2=本部门 3=本部门及子部门 4=全部") + private Integer dataScope; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "是否内置角色") + private Boolean builtin; + + @Schema(description = "已补全祖先的完整授权集合(存储态)") + private List resourceIds; + + /** SysRole → RoleDTO 基本字段映射(不含 resourceIds) */ + public static RoleDTO fromEntity(SysRole entity) { + if (entity == null) { + return null; + } + RoleDTO dto = new RoleDTO(); + dto.setId(entity.getId()); + dto.setRoleName(entity.getRoleName()); + dto.setRoleCode(entity.getRoleCode()); + dto.setDataScope(entity.getDataScope()); + dto.setSort(entity.getSort()); + dto.setRemark(entity.getRemark()); + dto.setBuiltin(entity.getBuiltin()); + return dto; + } + + /** RoleDTO → SysRole(写入参转换,Service 层调用;builtin/审计字段不映射,由服务端裁定) */ + public SysRole toEntity() { + SysRole entity = new SysRole(); + entity.setId(this.getId()); + entity.setRoleName(this.roleName); + entity.setRoleCode(this.roleCode); + entity.setDataScope(this.dataScope); + entity.setSort(this.sort); + entity.setRemark(this.remark); + return entity; + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDetailVO.java b/crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDetailVO.java deleted file mode 100644 index 4365cbd..0000000 --- a/crm-auth/src/main/java/com/crm/auth/domain/dto/RoleDetailVO.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.crm.auth.domain.dto; - -import com.crm.auth.domain.entity.SysRole; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.io.Serial; -import java.io.Serializable; -import java.util.List; - -/** - * 角色详情 DTO(ADR-0012) - *

角色管理详情接口的输出载体;resourceIds 为已补全祖先的完整授权集合(ADR-0005)

- */ -@Data -public class RoleDetailVO implements Serializable { - - @Serial - private static final long serialVersionUID = 1L; - - @Schema(description = "角色 ID") - private Long id; - - @Schema(description = "角色名称") - private String roleName; - - @Schema(description = "角色编码") - private String roleCode; - - @Schema(description = "数据范围 1=本人 2=本部门 3=本部门及子部门 4=全部") - private Integer dataScope; - - @Schema(description = "排序") - private Integer sort; - - @Schema(description = "备注") - private String remark; - - @Schema(description = "是否内置角色") - private Boolean builtin; - - @Schema(description = "已补全祖先的完整授权集合(存储态)") - private List resourceIds; - - /** SysRole → RoleDetailVO 基本字段映射(不含 resourceIds) */ - public static RoleDetailVO fromEntity(SysRole entity) { - if (entity == null) { - return null; - } - RoleDetailVO vo = new RoleDetailVO(); - vo.setId(entity.getId()); - vo.setRoleName(entity.getRoleName()); - vo.setRoleCode(entity.getRoleCode()); - vo.setDataScope(entity.getDataScope()); - vo.setSort(entity.getSort()); - vo.setRemark(entity.getRemark()); - vo.setBuiltin(entity.getBuiltin()); - return vo; - } -} diff --git a/crm-auth/src/main/java/com/crm/auth/domain/param/RoleParam.java b/crm-auth/src/main/java/com/crm/auth/domain/param/RoleParam.java new file mode 100644 index 0000000..4544818 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/domain/param/RoleParam.java @@ -0,0 +1,15 @@ +package com.crm.auth.domain.param; + +import com.crm.base.domain.param.BaseParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 角色管理-分页查询入参(ADR-0017) + *

keyword 匹配角色名称,current/size/orderBy/asc 继承自 {@link BaseParam}, + * 经 {@link com.crm.base.utils.PageConverter#toMpPage} 转换(size 上限收敛 + 排序字段防注入)。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class RoleParam extends BaseParam { +} diff --git a/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java b/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java index c50ee13..4f1d73f 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java +++ b/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java @@ -1,13 +1,23 @@ package com.crm.auth.service; -import com.crm.auth.domain.dto.RoleDetailVO; +import com.crm.auth.domain.dto.RoleDTO; import com.crm.auth.domain.entity.SysRole; +import com.crm.auth.domain.param.RoleParam; +import com.crm.base.domain.result.PageResult; import com.crm.base.service.IBaseService; import java.util.List; public interface ISysRoleService extends IBaseService { + /** + * 角色分页(keyword 匹配角色名称;经 PageConverter 收敛 size 上限与排序字段防注入) + * + * @param param 分页查询入参 + * @return 角色 DTO 分页(不回显 deleted/creatorId/updaterId) + */ + PageResult pageRoles(RoleParam param); + /** * 保存角色(带校验:roleCode 唯一、内置角色保护、dataScope 范围、禁设 builtin) * @@ -20,9 +30,9 @@ public interface ISysRoleService extends IBaseService { * 获取角色详情(基本信息 + 已授权资源 ID 集合) * * @param roleId 角色 ID - * @return RoleDetailVO,含 resourceIds(从 sys_role_menu 查出的完整授权集合) + * @return RoleDTO,含 resourceIds(从 sys_role_menu 查出的完整授权集合) */ - RoleDetailVO getRoleDetail(Long roleId); + RoleDTO getRoleDetail(Long roleId); /** * 分配角色权限资源(祖先补全 + 全量替换,事务保护) diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java index 7a8b099..bec6ab8 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java +++ b/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java @@ -2,19 +2,23 @@ package com.crm.auth.service.impl; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.crm.auth.constant.AuthConstants; -import com.crm.auth.domain.dto.RoleDetailVO; +import com.crm.auth.domain.dto.RoleDTO; import com.crm.auth.domain.entity.SysRole; import com.crm.auth.domain.entity.SysRoleMenu; import com.crm.auth.domain.entity.SysUserRole; import com.crm.auth.domain.enums.DataScopeEnum; +import com.crm.auth.domain.param.RoleParam; import com.crm.auth.mapper.SysRoleMapper; import com.crm.auth.mapper.SysRoleMenuMapper; import com.crm.auth.mapper.SysUserRoleMapper; import com.crm.auth.service.ISysMenuService; import com.crm.auth.service.ISysRoleService; import com.crm.base.domain.exception.BusinessErrorException; +import com.crm.base.domain.result.PageResult; import com.crm.base.service.impl.BaseServiceImpl; +import com.crm.base.utils.PageConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -41,6 +45,15 @@ public class SysRoleServiceImpl extends BaseServiceImpl private final SysUserRoleMapper sysUserRoleMapper; private final ISysMenuService sysMenuService; + @Override + public PageResult pageRoles(RoleParam param) { + Page page = PageConverter.toMpPage(param); + lambdaQuery() + .like(StrUtil.isNotBlank(param.getKeyword()), SysRole::getRoleName, param.getKeyword()) + .page(page); + return new PageResult<>(page).convert(RoleDTO::fromEntity); + } + @Override public SysRole saveRole(SysRole role) { // 基本非空校验 @@ -86,12 +99,12 @@ public class SysRoleServiceImpl extends BaseServiceImpl } @Override - public RoleDetailVO getRoleDetail(Long roleId) { + public RoleDTO getRoleDetail(Long roleId) { SysRole role = baseMapper.selectById(roleId); if (role == null) { throw new BusinessErrorException(CODE_ROLE_INVALID, "角色不存在:" + roleId); } - RoleDetailVO vo = RoleDetailVO.fromEntity(role); + RoleDTO vo = RoleDTO.fromEntity(role); // 查 sys_role_menu 得到完整授权集合(存储态,已含祖先) List bindings = sysRoleMenuMapper.selectList( new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)); diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/SysRoleServiceImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/SysRoleServiceImplTest.java index ff34999..b633fab 100644 --- a/crm-auth/src/test/java/com/crm/auth/service/impl/SysRoleServiceImplTest.java +++ b/crm-auth/src/test/java/com/crm/auth/service/impl/SysRoleServiceImplTest.java @@ -1,6 +1,6 @@ package com.crm.auth.service.impl; -import com.crm.auth.domain.dto.RoleDetailVO; +import com.crm.auth.domain.dto.RoleDTO; import com.crm.auth.domain.entity.SysRole; import com.crm.auth.domain.entity.SysRoleMenu; import com.crm.auth.domain.entity.SysUserRole; @@ -232,7 +232,7 @@ class SysRoleServiceImplTest { // ==================== 角色详情 ==================== @Test - @DisplayName("getRoleDetail -> 返回 RoleDetailVO 含基本信息和 resourceIds") + @DisplayName("getRoleDetail -> 返回 RoleDTO 含基本信息和 resourceIds") void getRoleDetail_returnsVO() { SysRole role = buildRole(1L, "销售", "ROLE_SALES", 1); when(sysRoleMapper.selectById(1L)).thenReturn(role); @@ -242,7 +242,7 @@ class SysRoleServiceImplTest { rm2.setMenuId(200L); when(sysRoleMenuMapper.selectList(any())).thenReturn(List.of(rm1, rm2)); - RoleDetailVO vo = sysRoleService.getRoleDetail(1L); + RoleDTO vo = sysRoleService.getRoleDetail(1L); assertThat(vo).isNotNull(); assertThat(vo.getRoleName()).isEqualTo("销售"); diff --git a/crm-base/src/test/java/com/crm/base/utils/PageConverterTest.java b/crm-base/src/test/java/com/crm/base/utils/PageConverterTest.java new file mode 100644 index 0000000..53a809e --- /dev/null +++ b/crm-base/src/test/java/com/crm/base/utils/PageConverterTest.java @@ -0,0 +1,65 @@ +package com.crm.base.utils; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.crm.base.constant.CommonConstants; +import com.crm.base.domain.param.BaseParam; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link PageConverter#toMpPage} 单元测试:分页安全收敛(size 上限、排序字段防注入)。 + *

这是所有分页接口(含角色 page,ADR-0017 ticket 06)的共享护栏。

+ */ +@DisplayName("PageConverter 分页参数收敛") +class PageConverterTest { + + private static BaseParam param(Integer current, Integer size, String orderBy, Boolean asc) { + BaseParam p = new BaseParam(); + p.setCurrent(current); + p.setSize(size); + p.setOrderBy(orderBy); + p.setAsc(asc); + return p; + } + + @Test + @DisplayName("size 超过上限 500 被收敛为 500") + void size_exceedsMax_clampedToMax() { + Page page = PageConverter.toMpPage(param(1, 100_000, null, null)); + assertThat(page.getSize()).isEqualTo(CommonConstants.MAX_PAGE_SIZE); + } + + @Test + @DisplayName("size 为 null 或 <1 回落默认值 10") + void size_nullOrNonPositive_fallsBackToDefault() { + assertThat(PageConverter.toMpPage(param(1, null, null, null)).getSize()) + .isEqualTo(CommonConstants.DEFAULT_PAGE_SIZE); + assertThat(PageConverter.toMpPage(param(1, 0, null, null)).getSize()) + .isEqualTo(CommonConstants.DEFAULT_PAGE_SIZE); + } + + @Test + @DisplayName("current <1 回落到 1") + void current_nonPositive_fallsBackToOne() { + assertThat(PageConverter.toMpPage(param(0, 10, null, null)).getCurrent()).isEqualTo(1); + assertThat(PageConverter.toMpPage(param(null, 10, null, null)).getCurrent()).isEqualTo(1); + } + + @Test + @DisplayName("合法 orderBy 驼峰转下划线并生成排序项") + void orderBy_validCamel_convertedToUnderlineOrder() { + Page page = PageConverter.toMpPage(param(1, 10, "createTime", true)); + assertThat(page.orders()).hasSize(1); + assertThat(page.orders().get(0).getColumn()).isEqualTo("create_time"); + assertThat(page.orders().get(0).isAsc()).isTrue(); + } + + @Test + @DisplayName("非法 orderBy(含注入字符)被忽略,不产生排序项") + void orderBy_injectionChars_ignored() { + Page page = PageConverter.toMpPage(param(1, 10, "id; DROP TABLE sys_role", null)); + assertThat(page.orders()).isEmpty(); + } +}