req_id: REQ-USR-003 date: 2026-05-06
spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-003.md
REQ-USR-003 用户查询 Implementation Plan
Execution: Parent skill
feature-tddexecutes this plan task-by-task.
Goal: 实现 GET /api/users 列表查询:跨表 JOIN tStaff,按 queryField + matchType + queryValue 三件套过滤 + 分页,返回 PageResult。
Architecture: 引入 MP PaginationInnerInterceptor + 通用 PageResult<T>。Mapper.xml 自定义 searchUsers SQL(LEFT JOIN tStaff + dynamic WHERE)。Service 层做 queryField 白名单校验防 SQL 注入,把白名单 column 字符串放入 query 对象传给 mapper。
Tech Stack: 沿用前序 REQ;首次启用 MP 分页插件 + XML mapper。
Schema 改动
无。
文件变更清单
- 创建:
backend/src/main/java/com/xly/erp/common/response/PageResult.java— 通用分页 VO - 创建:
backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java—PaginationInnerInterceptorbean - 创建:
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java - 创建:
backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java - 修改:
backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java— 追加IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query)方法签名 - 创建:
backend/src/main/resources/mapper/usr/UserMapper.xml— 自定义 SQL - 修改:
backend/src/main/java/com/xly/erp/module/usr/service/UserService.java— 追加search(UserQueryDTO query): PageResult<UserListItemVO> - 修改:
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java— 实现 search + 白名单 - 修改:
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java— 追加@GetMapping - 创建:
backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java - 修改:
backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java— 追加 search 单测 - 修改:
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java— 追加 GET IT
任务步骤
Task 1: PageResult + MybatisPlusConfig(横切骨架)
Files:
- Create:
backend/src/main/java/com/xly/erp/common/response/PageResult.java - Create:
backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java
API shape:
-
PageResult<T>:字段long total+List<T> list+long pageNum+long pageSize;@Data + 静态工厂of(IPage<T> mpPage)(从 MP IPage 构造) MybatisPlusConfig:@Bean MybatisPlusInterceptor mybatisPlusInterceptor()注册PaginationInnerInterceptor(DbType.MYSQL)Step 1.1 实现两个文件(无独立单测,由 Task 4 的 Mapper IT 验证分页)
Step 1.2 子会话 mvn 全量测试(验证 SpringBoot context 启动 + 122 现有测试不回归)
-
Step 1.3 提交
git commit -m "feat(common): PageResult + MP pagination config REQ-USR-003"
Task 2: UserQueryDTO + UserListItemVO + Validation
Files:
- Create:
backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java - Create:
backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java - Test:
backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java
API shape:
UserQueryDTO:
-
@Min(1) Integer pageNum = 1(默认) @Min(1) @Max(100) Integer pageSize = 20-
@Pattern(regexp="^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$") String queryField(可空) -
@Pattern(regexp="^(contains|notContains|equals)?$") String matchType(可空) -
@Size(max=100) String queryValue(可空)
UserListItemVO:11 字段(spec § 输出)。Lombok @Data,无静态工厂(mapper 直接通过 ResultMap / autoMap 映射)。
-
Step 2.1 写失败测试(5 个)
-
UserQueryDTOValidationTest#allValid_yieldsNoViolations(含 default 值) -
UserQueryDTOValidationTest#pageSizeTooLarge_yieldsViolation(>100) -
UserQueryDTOValidationTest#pageSizeTooSmall_yieldsViolation(<1) UserQueryDTOValidationTest#queryFieldInvalidEnum_yieldsViolation-
UserQueryDTOValidationTest#queryValueOverSized_yieldsViolation(101 字符) - 子会话: FAIL
-
Step 2.2 实现 DTO + VO
-
Step 2.3 提交
git commit -m "feat(usr): user query DTO + list item VO REQ-USR-003"
Task 3: UserMapper.xml searchUsers + Mapper smoke IT
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java(追加IPage<UserListItemVO> searchUsers(...)方法签名) - Create:
backend/src/main/resources/mapper/usr/UserMapper.xml— 自定义 SQL(spec § 实现路径选择 已锁定 SQL 模板) - Test:
backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java(新文件,独立 IT)
XML SQL 锁定(spec 已写):
<select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO">
SELECT
u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo,
s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted,
u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
FROM tUser u
LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0
<where>
<if test="query.queryField != 'deleted'">
u.bDeleted = 0
</if>
<if test="query.column != null and query.column != '' and query.queryValue != null and query.queryValue != ''">
AND
<choose>
<when test="query.matchType == 'equals'">${query.column} = #{query.queryValue}</when>
<when test="query.matchType == 'notContains'">${query.column} NOT LIKE CONCAT('%', #{query.queryValue}, '%')</when>
<otherwise>${query.column} LIKE CONCAT('%', #{query.queryValue}, '%')</otherwise>
</choose>
</if>
</where>
ORDER BY u.tCreateDate DESC, u.iIncrement DESC
</select>
query.column字段是 service 层白名单映射后的 SQL 列字符串(如"u.sUserName"),由${...}渲染——绝不接受 DTO 原 queryField 直接拼。
为支持 ${query.column},需要在 UserQueryDTO 加一个 transient 字段 String column(service 写入;前端不接受)。
-
Step 3.1 写失败 IT
-
UserMapperSearchIT#searchUsers_emptyFilter_returnsAllUndeletedAsPage:插入 2 个 user(含 1 个 staff 关联),@Autowired UserMapper,调用userMapper.searchUsers(new Page<>(1,10), query);断言 page.getTotal() ≥ 2、page.getRecords() 含 sUserName + sStaffName 字段 -
UserMapperSearchIT#searchUsers_filterByUserName_filtersCorrectly:插入 alice / bob;query.queryField=username, column="u.sUserName", matchType=contains, queryValue="ali";断言只返回 alice -
@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback+@Autowired UserMapper / StaffMapper - 子会话: FAIL(searchUsers 方法未定义)
-
-
Step 3.2 实现 mapper 方法签名 + XML + UserQueryDTO 加 column 字段
- 子会话: PASS
-
Step 3.3 提交
git commit -m "feat(usr): UserMapper.xml searchUsers REQ-USR-003"
Task 4: UserService.search + Mockito 单测
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/service/UserService.java - Modify:
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java - Modify:
backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java
API shape:
UserService.search(UserQueryDTO query): PageResult<UserListItemVO>- 实现步骤(plan 锁定):
- 白名单校验 + 列映射:
Map<String,String> COLUMN_MAP = Map.of( "username", "u.sUserName", "staffname", "s.sStaffName", "userno", "u.sUserNo", "department", "s.sDepartment", "usertype", "u.sUserType", "language", "u.sLanguage", "deleted", "u.bDeleted", "lastLoginDate", "u.tLastLoginDate", "createdBy", "u.sCreatedBy");若query.queryField非空但不在 map →BizException(PARAM_INVALID, "queryField 非法"); 若query.matchType非空但不在 {contains, notContains, equals} → 同样错误。 - 把映射后的列字符串写到
query.setColumn(mappedCol);如果 queryField 为空,column 也为空。 - 默认值兜底:pageNum 默认 1,pageSize 默认 20,matchType 默认 contains。
- 构造
Page<UserListItemVO> page = new Page<>(query.getPageNum(), query.getPageSize()) - 调
userMapper.searchUsers(page, query) - 返回
PageResult.of(result)
- 白名单校验 + 列映射:
标
@Transactional(readOnly = true)-
Step 4.1 写失败测试(5 个)
-
search_emptyDb_returnsEmptyPage:mock searchUsers 返回 empty Page;service 返回 PageResult total=0 -
search_invalidQueryField_throws40010:query.queryField="invalid" -
search_invalidMatchType_throws40010:query.matchType="like" -
search_passesMappedColumnToMapper:query.queryField="username";ArgumentCaptor 捕 query 实参,断言 query.column == "u.sUserName" -
search_appliesDefaultPagination_whenNullPageNumOrSize:query.pageNum=null, pageSize=null;断言 service 创建的 Page.size==20 && current==1 - 子会话: FAIL
-
-
Step 4.2 实现 service.search
- 子会话: PASS
-
Step 4.3 提交
git commit -m "feat(usr): user query service REQ-USR-003"
Task 5: UserController GET + 端到端 IT
Files:
- Modify:
backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java - Modify:
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java
API shape:
@GetMapping ApiResponse<PageResult<UserListItemVO>> search(@Valid UserQueryDTO query)Javadoc:
REQ-USR-003 用户查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")-
Step 5.1 写失败测试(9 个)
get_emptyKeyword_returnsAllUndeletedget_filterByUsernameContains_returnsMatchedSubsetget_filterByStaffnameContains_returnsJoinedResultsget_filterByDeletedTrue_returnsOnlyDeletedget_pagination_returnsCorrectSlice-
get_responseExcludesInternalFields:断言 jsonPath$.data.list[0].sPasswordHashdoesNotExist + sId / iStaffId / sBrandsId 都不出现 get_pageSizeTooLarge_returns40010get_invalidQueryField_returns40010get_userWithoutStaff_listItemHasNullStaffFields-
@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback+ insert helpers - 子会话: FAIL
-
Step 5.2 实现 GET 端点
- 子会话: PASS
-
Step 5.3 子会话跑全量 mvn test
- 期望:122 + 5(query DTO valid) + 5(service search unit) + 2(mapper IT) + 9(controller IT) = 143 测试,全绿
-
Step 5.4 提交
git commit -m "feat(usr): GET /api/users controller REQ-USR-003"
提交计划
-
feat(common): PageResult + MP pagination config REQ-USR-003(Task 1) -
feat(usr): user query DTO + list item VO REQ-USR-003(Task 2) -
feat(usr): UserMapper.xml searchUsers REQ-USR-003(Task 3) -
feat(usr): user query service REQ-USR-003(Task 4) -
feat(usr): GET /api/users controller REQ-USR-003(Task 5)