2026-05-06-REQ-USR-003.md 11 KB

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<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.javaPaginationInnerInterceptor 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<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 锁定):
    1. 白名单校验 + 列映射: 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} → 同样错误。
    2. 把映射后的列字符串写到 query.setColumn(mappedCol);如果 queryField 为空,column 也为空。
    3. 默认值兜底:pageNum 默认 1,pageSize 默认 20,matchType 默认 contains。
    4. 构造 Page<UserListItemVO> 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<PageResult<UserListItemVO>> 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)