--- 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-tdd` executes this plan task-by-task. **Goal:** 实现 `GET /api/users` 列表查询:跨表 JOIN tStaff,按 queryField + matchType + queryValue 三件套过滤 + 分页,返回 PageResult。 **Architecture:** 引入 MP `PaginationInnerInterceptor` + 通用 `PageResult`。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` — `PaginationInnerInterceptor` bean - 创建: `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 searchUsers(IPage 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` - 修改: `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`:字段 `long total` + `List list` + `long pageNum` + `long pageSize`;@Data + 静态工厂 `of(IPage 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 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 已写): ```xml ``` > `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` - 实现步骤(plan 锁定): 1. 白名单校验 + 列映射: ``` Map 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} → 同样错误。 2. 把映射后的列字符串写到 `query.setColumn(mappedCol)`;如果 queryField 为空,column 也为空。 3. 默认值兜底:pageNum 默认 1,pageSize 默认 20,matchType 默认 contains。 4. 构造 `Page page = new Page<>(query.getPageNum(), query.getPageSize())` 5. 调 `userMapper.searchUsers(page, query)` 6. 返回 `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> search(@Valid UserQueryDTO query)` - Javadoc:`REQ-USR-003 用户查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")` - [ ] **Step 5.1 写失败测试(9 个)** - `get_emptyKeyword_returnsAllUndeleted` - `get_filterByUsernameContains_returnsMatchedSubset` - `get_filterByStaffnameContains_returnsJoinedResults` - `get_filterByDeletedTrue_returnsOnlyDeleted` - `get_pagination_returnsCorrectSlice` - `get_responseExcludesInternalFields`:断言 jsonPath `$.data.list[0].sPasswordHash` doesNotExist + sId / iStaffId / sBrandsId 都不出现 - `get_pageSizeTooLarge_returns40010` - `get_invalidQueryField_returns40010` - `get_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)