Commit 3be59f719869637e170caf54b0410b9ac1df3679

Authored by zichun
1 parent f53689c3

docs(usr): review approval REQ-USR-003 round 2

docs/08-模块任务管理.md
@@ -72,5 +72,5 @@ @@ -72,5 +72,5 @@
72 - 功能: 72 - 功能:
73 - [x] REQ-USR-001 用户新增 73 - [x] REQ-USR-001 用户新增
74 - [x] REQ-USR-002 用户修改 74 - [x] REQ-USR-002 用户修改
75 - - [ ] REQ-USR-003 用户查询 75 + - [x] REQ-USR-003 用户查询
76 - [ ] REQ-USR-004 用户登录 76 - [ ] REQ-USR-004 用户登录
docs/superpowers/plans/2026-05-06-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-003.md
  5 +---
  6 +
  7 +# REQ-USR-003 用户查询 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 实现 `GET /api/users` 列表查询:跨表 JOIN tStaff,按 queryField + matchType + queryValue 三件套过滤 + 分页,返回 PageResult<UserListItemVO>。
  12 +
  13 +**Architecture:** 引入 MP `PaginationInnerInterceptor` + 通用 `PageResult<T>`。Mapper.xml 自定义 `searchUsers` SQL(LEFT JOIN tStaff + dynamic WHERE)。Service 层做 queryField 白名单校验防 SQL 注入,把白名单 column 字符串放入 query 对象传给 mapper。
  14 +
  15 +**Tech Stack:** 沿用前序 REQ;首次启用 MP 分页插件 + XML mapper。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 创建: `backend/src/main/java/com/xly/erp/common/response/PageResult.java` — 通用分页 VO
  26 +- 创建: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java` — `PaginationInnerInterceptor` bean
  27 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java`
  28 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java`
  29 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` — 追加 `IPage<UserListItemVO> searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query)` 方法签名
  30 +- 创建: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL
  31 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `search(UserQueryDTO query): PageResult<UserListItemVO>`
  32 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 search + 白名单
  33 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@GetMapping`
  34 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java`
  35 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 search 单测
  36 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 GET IT
  37 +
  38 +---
  39 +
  40 +## 任务步骤
  41 +
  42 +### Task 1: PageResult<T> + MybatisPlusConfig(横切骨架)
  43 +
  44 +**Files:**
  45 +- Create: `backend/src/main/java/com/xly/erp/common/response/PageResult.java`
  46 +- Create: `backend/src/main/java/com/xly/erp/config/MybatisPlusConfig.java`
  47 +
  48 +**API shape:**
  49 +- `PageResult<T>`:字段 `long total` + `List<T> list` + `long pageNum` + `long pageSize`;@Data + 静态工厂 `of(IPage<T> mpPage)`(从 MP IPage 构造)
  50 +- `MybatisPlusConfig`:`@Bean MybatisPlusInterceptor mybatisPlusInterceptor()` 注册 `PaginationInnerInterceptor(DbType.MYSQL)`
  51 +
  52 +- [ ] **Step 1.1 实现两个文件(无独立单测,由 Task 4 的 Mapper IT 验证分页)**
  53 +
  54 +- [ ] **Step 1.2 子会话 mvn 全量测试**(验证 SpringBoot context 启动 + 122 现有测试不回归)
  55 +
  56 +- [ ] **Step 1.3 提交**
  57 + - `git commit -m "feat(common): PageResult + MP pagination config REQ-USR-003"`
  58 +
  59 +---
  60 +
  61 +### Task 2: UserQueryDTO + UserListItemVO + Validation
  62 +
  63 +**Files:**
  64 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserQueryDTO.java`
  65 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserListItemVO.java`
  66 +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserQueryDTOValidationTest.java`
  67 +
  68 +**API shape:**
  69 +
  70 +`UserQueryDTO`:
  71 +- `@Min(1) Integer pageNum = 1`(默认)
  72 +- `@Min(1) @Max(100) Integer pageSize = 20`
  73 +- `@Pattern(regexp="^(username|staffname|userno|department|usertype|language|deleted|lastLoginDate|createdBy)?$") String queryField`(可空)
  74 +- `@Pattern(regexp="^(contains|notContains|equals)?$") String matchType`(可空)
  75 +- `@Size(max=100) String queryValue`(可空)
  76 +
  77 +`UserListItemVO`:11 字段(spec § 输出)。Lombok `@Data`,无静态工厂(mapper 直接通过 ResultMap / autoMap 映射)。
  78 +
  79 +- [ ] **Step 2.1 写失败测试(5 个)**
  80 + - `UserQueryDTOValidationTest#allValid_yieldsNoViolations`(含 default 值)
  81 + - `UserQueryDTOValidationTest#pageSizeTooLarge_yieldsViolation`(>100)
  82 + - `UserQueryDTOValidationTest#pageSizeTooSmall_yieldsViolation`(<1)
  83 + - `UserQueryDTOValidationTest#queryFieldInvalidEnum_yieldsViolation`
  84 + - `UserQueryDTOValidationTest#queryValueOverSized_yieldsViolation`(101 字符)
  85 + - 子会话: FAIL
  86 +
  87 +- [ ] **Step 2.2 实现 DTO + VO**
  88 +
  89 +- [ ] **Step 2.3 提交**
  90 + - `git commit -m "feat(usr): user query DTO + list item VO REQ-USR-003"`
  91 +
  92 +---
  93 +
  94 +### Task 3: UserMapper.xml searchUsers + Mapper smoke IT
  95 +
  96 +**Files:**
  97 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java`(追加 `IPage<UserListItemVO> searchUsers(...)` 方法签名)
  98 +- Create: `backend/src/main/resources/mapper/usr/UserMapper.xml` — 自定义 SQL(spec § 实现路径选择 已锁定 SQL 模板)
  99 +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperSearchIT.java`(新文件,独立 IT)
  100 +
  101 +**XML SQL 锁定**(spec 已写):
  102 +
  103 +```xml
  104 +<select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO">
  105 + SELECT
  106 + u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo,
  107 + s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted,
  108 + u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
  109 + FROM tUser u
  110 + LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0
  111 + <where>
  112 + <if test="query.queryField != 'deleted'">
  113 + u.bDeleted = 0
  114 + </if>
  115 + <if test="query.column != null and query.column != '' and query.queryValue != null and query.queryValue != ''">
  116 + AND
  117 + <choose>
  118 + <when test="query.matchType == 'equals'">${query.column} = #{query.queryValue}</when>
  119 + <when test="query.matchType == 'notContains'">${query.column} NOT LIKE CONCAT('%', #{query.queryValue}, '%')</when>
  120 + <otherwise>${query.column} LIKE CONCAT('%', #{query.queryValue}, '%')</otherwise>
  121 + </choose>
  122 + </if>
  123 + </where>
  124 + ORDER BY u.tCreateDate DESC, u.iIncrement DESC
  125 +</select>
  126 +```
  127 +
  128 +> `query.column` 字段是 service 层白名单映射后的 SQL 列字符串(如 `"u.sUserName"`),由 `${...}` 渲染——**绝不**接受 DTO 原 queryField 直接拼。
  129 +
  130 +为支持 `${query.column}`,需要在 `UserQueryDTO` 加一个 transient 字段 `String column`(service 写入;前端不接受)。
  131 +
  132 +- [ ] **Step 3.1 写失败 IT**
  133 + - `UserMapperSearchIT#searchUsers_emptyFilter_returnsAllUndeletedAsPage`:插入 2 个 user(含 1 个 staff 关联),`@Autowired UserMapper`,调用 `userMapper.searchUsers(new Page<>(1,10), query)`;断言 page.getTotal() ≥ 2、page.getRecords() 含 sUserName + sStaffName 字段
  134 + - `UserMapperSearchIT#searchUsers_filterByUserName_filtersCorrectly`:插入 alice / bob;query.queryField=username, column="u.sUserName", matchType=contains, queryValue="ali";断言只返回 alice
  135 + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper`
  136 + - 子会话: FAIL(searchUsers 方法未定义)
  137 +
  138 +- [ ] **Step 3.2 实现 mapper 方法签名 + XML + UserQueryDTO 加 column 字段**
  139 + - 子会话: PASS
  140 +
  141 +- [ ] **Step 3.3 提交**
  142 + - `git commit -m "feat(usr): UserMapper.xml searchUsers REQ-USR-003"`
  143 +
  144 +---
  145 +
  146 +### Task 4: UserService.search + Mockito 单测
  147 +
  148 +**Files:**
  149 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`
  150 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  151 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`
  152 +
  153 +**API shape:**
  154 +- `UserService.search(UserQueryDTO query): PageResult<UserListItemVO>`
  155 +- 实现步骤(plan 锁定):
  156 + 1. 白名单校验 + 列映射:
  157 + ```
  158 + Map<String,String> COLUMN_MAP = Map.of(
  159 + "username", "u.sUserName", "staffname", "s.sStaffName",
  160 + "userno", "u.sUserNo", "department", "s.sDepartment",
  161 + "usertype", "u.sUserType", "language", "u.sLanguage",
  162 + "deleted", "u.bDeleted", "lastLoginDate", "u.tLastLoginDate",
  163 + "createdBy", "u.sCreatedBy");
  164 + ```
  165 + 若 `query.queryField` 非空但不在 map → `BizException(PARAM_INVALID, "queryField 非法")`;
  166 + 若 `query.matchType` 非空但不在 {contains, notContains, equals} → 同样错误。
  167 + 2. 把映射后的列字符串写到 `query.setColumn(mappedCol)`;如果 queryField 为空,column 也为空。
  168 + 3. 默认值兜底:pageNum 默认 1,pageSize 默认 20,matchType 默认 contains。
  169 + 4. 构造 `Page<UserListItemVO> page = new Page<>(query.getPageNum(), query.getPageSize())`
  170 + 5. 调 `userMapper.searchUsers(page, query)`
  171 + 6. 返回 `PageResult.of(result)`
  172 +- 标 `@Transactional(readOnly = true)`
  173 +
  174 +- [ ] **Step 4.1 写失败测试(5 个)**
  175 + - `search_emptyDb_returnsEmptyPage`:mock searchUsers 返回 empty Page;service 返回 PageResult total=0
  176 + - `search_invalidQueryField_throws40010`:query.queryField="invalid"
  177 + - `search_invalidMatchType_throws40010`:query.matchType="like"
  178 + - `search_passesMappedColumnToMapper`:query.queryField="username";ArgumentCaptor 捕 query 实参,断言 query.column == "u.sUserName"
  179 + - `search_appliesDefaultPagination_whenNullPageNumOrSize`:query.pageNum=null, pageSize=null;断言 service 创建的 Page.size==20 && current==1
  180 + - 子会话: FAIL
  181 +
  182 +- [ ] **Step 4.2 实现 service.search**
  183 + - 子会话: PASS
  184 +
  185 +- [ ] **Step 4.3 提交**
  186 + - `git commit -m "feat(usr): user query service REQ-USR-003"`
  187 +
  188 +---
  189 +
  190 +### Task 5: UserController GET + 端到端 IT
  191 +
  192 +**Files:**
  193 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  194 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`
  195 +
  196 +**API shape:**
  197 +- `@GetMapping ApiResponse<PageResult<UserListItemVO>> search(@Valid UserQueryDTO query)`
  198 +- Javadoc:`REQ-USR-003 用户查询 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")`
  199 +
  200 +- [ ] **Step 5.1 写失败测试(9 个)**
  201 + - `get_emptyKeyword_returnsAllUndeleted`
  202 + - `get_filterByUsernameContains_returnsMatchedSubset`
  203 + - `get_filterByStaffnameContains_returnsJoinedResults`
  204 + - `get_filterByDeletedTrue_returnsOnlyDeleted`
  205 + - `get_pagination_returnsCorrectSlice`
  206 + - `get_responseExcludesInternalFields`:断言 jsonPath `$.data.list[0].sPasswordHash` doesNotExist + sId / iStaffId / sBrandsId 都不出现
  207 + - `get_pageSizeTooLarge_returns40010`
  208 + - `get_invalidQueryField_returns40010`
  209 + - `get_userWithoutStaff_listItemHasNullStaffFields`
  210 + - `@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + insert helpers
  211 + - 子会话: FAIL
  212 +
  213 +- [ ] **Step 5.2 实现 GET 端点**
  214 + - 子会话: PASS
  215 +
  216 +- [ ] **Step 5.3 子会话跑全量 mvn test**
  217 + - 期望:122 + 5(query DTO valid) + 5(service search unit) + 2(mapper IT) + 9(controller IT) = 143 测试,全绿
  218 +
  219 +- [ ] **Step 5.4 提交**
  220 + - `git commit -m "feat(usr): GET /api/users controller REQ-USR-003"`
  221 +
  222 +---
  223 +
  224 +## 提交计划
  225 +
  226 +- `feat(common): PageResult + MP pagination config REQ-USR-003`(Task 1)
  227 +- `feat(usr): user query DTO + list item VO REQ-USR-003`(Task 2)
  228 +- `feat(usr): UserMapper.xml searchUsers REQ-USR-003`(Task 3)
  229 +- `feat(usr): user query service REQ-USR-003`(Task 4)
  230 +- `feat(usr): GET /api/users controller REQ-USR-003`(Task 5)
docs/superpowers/reviews/2026-05-06-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-06
  4 +round: 2
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-003 — round 2
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +Round 1 三条 must_fix 处理结果(commit f53689c):
  17 +1. **HIGH SQL 注入** — RESOLVED。UserQueryDTO 删除 `column` 字段;UserMapper.searchUsers 三参签名含 `@Param("column") String column`;UserMapper.xml 用 `${column}`;UserServiceImpl.search 用局部变量映射后通过 mapper 单独传入。
  18 +2. **HIGH spec § 6 deleted=true** — RESOLVED。Service 实现 'true'/'false' → '1'/'0' 标准化(其它抛 PARAM_INVALID);XML deleted 分支用 `CAST(#{queryValue} AS UNSIGNED)` 兼容 bit(1);恢复 `get_filterByDeletedTrue_returnsOnlyDeleted` IT;新增 `search_deletedQueryValueTrue_normalizedToOne` 单测。
  19 +3. **MEDIUM XML deleted 边界** — RESOLVED。queryField=deleted 但 queryValue 空时仍保留默认过滤 `u.bDeleted = 0`,避免返回全量含已删除。
  20 +
  21 +## Nice-to-have
  22 +
  23 +- UserServiceImpl.search:212/214 — 直接 `query.setQueryValue("1")` 改写入参 DTO,单测靠副作用断言。语义上 service 不应突变 controller 入参;可改用局部 `normalizedDeletedValue` + `@Param("deletedValue")` 传给 mapper,XML 改用 `#{deletedValue}`,更纯。
  24 +- round 1 遗留的 6 条 IT 覆盖缺口(department equals / deleted=false / notContains / 排序 / matchType 非枚举 IT / 空结果 IT)+ UserMapperSearchIT 断言强化 + PageResult javadoc + QUERY_COLUMN_MAP 位置 + Base_Column_List 抽取——本轮也不在 must_fix 范畴,留待后续 sweep。
  25 +
  26 +## 反例 / 测试覆盖缺口
  27 +
  28 +Round 1 must_fix 1 + 2 + 3 三项均已落地:
  29 +
  30 +1. 注入:UserMapper.xml 唯一 `${...}` 插值仅来自 service 白名单映射,外部输入完全经过 `#{...}` 参数化绑定。
  31 +2. deleted 标准化:单测 `search_deletedQueryValueTrue_normalizedToOne` 钉死 `query.getQueryValue() == "1"`;IT `get_filterByDeletedTrue_returnsOnlyDeleted` 端到端验证返回已删除用户。
  32 +3. XML 边界:deleted 空值分支保留默认 `u.bDeleted = 0`,与 spec § 业务规则 1 一致。
  33 +
  34 +`mvn -B test` 经 .env.local 注入后 144/144 全绿,无新高危。
  35 +
  36 +**核心结论**:round 1 high 注入风险 + spec § 6 契约缺失全部修复;其余 nice-to-have 不阻塞,留下一 sweep。verdict: approve。
docs/superpowers/specs/2026-05-06-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-06
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-003 — 用户查询
  8 +
  9 +## 目标
  10 +
  11 +实现后端 `GET /api/users` 接口:按 `queryField` + `matchType` + `queryValue` 三件套对 `tUser` 进行过滤(含跨表 JOIN `tStaff` 取员工名 / 部门),分页返回精简 VO 列表。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**接口**:`GET /api/users`,无请求体。
  16 +
  17 +**Query parameters**(`UserQueryDTO`):
  18 +
  19 +| 字段 | 类型 | 必填 | 校验 / 取值 |
  20 +|---|---|---|---|
  21 +| `pageNum` | Integer | 否 | ≥1,默认 1 |
  22 +| `pageSize` | Integer | 否 | 1-100,默认 20 |
  23 +| `queryField` | String | 否 | 枚举:`username` / `staffname` / `userno` / `department` / `usertype` / `language` / `deleted` / `lastLoginDate` / `createdBy`;缺省视为不过滤 |
  24 +| `matchType` | String | 否 | 枚举:`contains` / `notContains` / `equals`;默认 `contains` |
  25 +| `queryValue` | String | 否 | 长度 ≤ 100;缺省视为不过滤;`deleted` 字段下视为 `'true' / 'false'` 字符串 |
  26 +
  27 +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")`。
  28 +
  29 +## 输出 / 结果
  30 +
  31 +**HTTP 200,响应体**:
  32 +
  33 +```json
  34 +{
  35 + "code": 200,
  36 + "message": "操作成功",
  37 + "data": {
  38 + "total": 42,
  39 + "list": [
  40 + {
  41 + "iIncrement": 12,
  42 + "sUserName": "alice",
  43 + "sStaffName": "张三",
  44 + "sUserNo": "u001",
  45 + "sDepartment": "研发部",
  46 + "sUserType": "普通用户",
  47 + "sLanguage": "zh",
  48 + "bDeleted": false,
  49 + "tLastLoginDate": "2026-05-06T09:00:00",
  50 + "sCreatedBy": "admin",
  51 + "tCreateDate": "2026-05-06T08:00:00"
  52 + }
  53 + ],
  54 + "pageNum": 1,
  55 + "pageSize": 20
  56 + },
  57 + "timestamp": 1746528600000
  58 +}
  59 +```
  60 +
  61 +新建 `UserListItemVO`(11 字段)+ 复用项目通用 `PageResult<T>`(4 字段:total / list / pageNum / pageSize;本期首次引入,作为 module_usr 横切组件)。
  62 +
  63 +> **不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `iStaffId` / `bCanModifyDocs` / `tDeletedDate` / `sDeletedBy`。
  64 +
  65 +## 业务规则
  66 +
  67 +1. **范围过滤**:默认仅返回 `bDeleted=0` 用户**除非** `queryField=deleted` 且 `queryValue` 显式过滤——见规则 6。
  68 +2. **跨表 JOIN**:`tUser LEFT JOIN tStaff ON tUser.iStaffId = tStaff.iIncrement AND tStaff.bDeleted = 0`;空关联字段(无 staff)展示为空字符串 `""`(VO 字段为 null)。
  69 +3. **queryField 列映射**:
  70 +
  71 + | queryField 值 | SQL 实际列 |
  72 + |---|---|
  73 + | `username` | `tUser.sUserName` |
  74 + | `staffname` | `tStaff.sStaffName` |
  75 + | `userno` | `tUser.sUserNo` |
  76 + | `department` | `tStaff.sDepartment` |
  77 + | `usertype` | `tUser.sUserType` |
  78 + | `language` | `tUser.sLanguage` |
  79 + | `deleted` | `tUser.bDeleted` |
  80 + | `lastLoginDate` | `tUser.tLastLoginDate` |
  81 + | `createdBy` | `tUser.sCreatedBy` |
  82 +
  83 +4. **matchType 映射**:
  84 +
  85 + | matchType 值 | SQL 片段 |
  86 + |---|---|
  87 + | `contains`(默认) | `<col> LIKE '%' || queryValue || '%'` |
  88 + | `notContains` | `<col> NOT LIKE '%' || queryValue || '%'` |
  89 + | `equals` | `<col> = queryValue` |
  90 +
  91 +5. **空过滤值语义**:`queryField` 或 `queryValue` 缺省 / 空串 → 不附加该 WHERE 条件。
  92 +6. **deleted 字段特殊处理**:当 `queryField=deleted` 时,`bDeleted=0` 的默认过滤不应用(让 `queryValue` 直接控制——`'false'` / `'0'` → bDeleted=0;`'true'` / `'1'` → bDeleted=1)。
  93 +7. **排序**:按 `tUser.tCreateDate DESC, tUser.iIncrement DESC` 稳定排序。
  94 +8. **分页**:MyBatis-Plus `Page<T>` + `selectPage` 标准分页;total 由 MP 自动计数。
  95 +9. **只读**:`@Transactional(readOnly = true)`;不写库。
  96 +10. **空结果**:`data.list = []` + `data.total = 0` + `code=200`,不返回 404。
  97 +
  98 +## 边界与约束
  99 +
  100 +### 鉴权策略
  101 +
  102 +沿用 SecurityConfig permitAll。
  103 +
  104 +### 错误码映射
  105 +
  106 +| 场景 | 错误码 | ErrorCode 枚举 |
  107 +|---|---|---|
  108 +| `pageSize` 超 1-100 / `queryField` / `matchType` 非枚举 / `queryValue` 长度超限 | 40010 | `PARAM_INVALID`(已存在) |
  109 +| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
  110 +
  111 +### 实现路径选择
  112 +
  113 +引入**自定义 XML SQL**(`UserMapper.xml`):本期首次需要跨表 JOIN,纯 LambdaQueryWrapper 难以表达 LEFT JOIN + 动态 WHERE。在 `mapper/usr/UserMapper.xml` 添加 `searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query)` 方法,对应 ResultMap 映射到 `UserListItemVO`。
  114 +
  115 +XML SQL 草稿(写入 plan 锁定):
  116 +
  117 +```xml
  118 +<select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO">
  119 + SELECT
  120 + u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo,
  121 + s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted,
  122 + u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
  123 + FROM tUser u
  124 + LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0
  125 + <where>
  126 + <if test="query.queryField != 'deleted'">
  127 + u.bDeleted = 0
  128 + </if>
  129 + <if test="query.queryField != null and query.queryField != '' and query.queryValue != null and query.queryValue != ''">
  130 + AND
  131 + <choose>
  132 + <when test="query.matchType == 'equals'">
  133 + ${col} = #{query.queryValue}
  134 + </when>
  135 + <when test="query.matchType == 'notContains'">
  136 + ${col} NOT LIKE CONCAT('%', #{query.queryValue}, '%')
  137 + </when>
  138 + <otherwise>
  139 + ${col} LIKE CONCAT('%', #{query.queryValue}, '%')
  140 + </otherwise>
  141 + </choose>
  142 + </if>
  143 + </where>
  144 + ORDER BY u.tCreateDate DESC, u.iIncrement DESC
  145 +</select>
  146 +```
  147 +
  148 +> `${col}` 由 service 层通过 enum 映射成实际列名后传入参数(**不是直接拼用户输入**——避免 SQL 注入);queryField 不在白名单 → 抛 PARAM_INVALID。
  149 +
  150 +实际实现可能要把 `${col}` 替换逻辑放到 service:service 把 queryField 翻译为列字符串放进 query 对象的临时字段(如 `query.setColumn(...)`),XML 用 `${query.column}` 渲染——XMl `${...}` 直接拼接字符串,服务层先白名单校验。
  151 +
  152 +### 性能
  153 +
  154 +- LEFT JOIN tStaff 走 `tUser.iStaffId` 上的索引(`fk_user_staff`)。
  155 +- `LIKE '%X%'` 左模糊不走索引;本期数据量低可接受。
  156 +- MP `Page<T>` 走 PaginationInnerInterceptor,需要在 `MybatisPlusConfig` 注册(如已存在跳过;如未引入,本 REQ 引入)。
  157 +
  158 +### Bean Validation
  159 +
  160 +`UserQueryDTO` 用 `@Min(1) Integer pageNum`、`@Min(1) @Max(100) Integer pageSize`、`@Pattern` 校验枚举字段。Controller 用 `@Valid`(query 参数 bean 绑定)。
  161 +
  162 +## 依赖的 schema 表 / 字段
  163 +
  164 +**读表**:`tUser`(主体)+ `tStaff`(LEFT JOIN)
  165 +
  166 +| `tUser` 字段 | 用途 |
  167 +|---|---|
  168 +| 全部 11 个输出字段 + `bDeleted` 过滤 | SELECT + WHERE |
  169 +| `iStaffId` | LEFT JOIN 键 |
  170 +
  171 +| `tStaff` 字段 | 用途 |
  172 +|---|---|
  173 +| `iIncrement` | LEFT JOIN 键 |
  174 +| `sStaffName` / `sDepartment` | SELECT + 可选 WHERE |
  175 +| `bDeleted` | LEFT JOIN 时附加条件 (s.bDeleted=0) |
  176 +
  177 +**索引利用**:
  178 +- `pk_user`:分页 ORDER BY iIncrement
  179 +- `fk_user_staff`(隐式 BTREE on `iStaffId`):JOIN
  180 +- `uk_user_no` / `uk_user_name`:equals 匹配能走索引
  181 +
  182 +## 依赖的接口
  183 +
  184 +无(独立查询接口)。
  185 +
  186 +## 验收标准
  187 +
  188 +### 功能正确性
  189 +
  190 +1. **空查询返回所有未删除用户**:DB 有 5 个 user(含 1 个 bDeleted=1),GET 不带 queryField 返回 4 个未删除(含 LEFT JOIN 出来的 staff 名 / 部门)。
  191 +2. **按用户名 contains 过滤**:DB 有 alice / alex / bob,`queryField=username&matchType=contains&queryValue=al` 返回 alice + alex。
  192 +3. **按员工名 contains 过滤**:通过 LEFT JOIN,匹配 tStaff.sStaffName。
  193 +4. **按部门 equals 过滤**:matchType=equals,命中精确部门。
  194 +5. **deleted=false 过滤**:返回未删除集合。
  195 +6. **deleted=true 过滤**:仅返回已软删除(验证规则 6)。
  196 +7. **notContains 排除**:matchType=notContains,排除指定关键字。
  197 +8. **分页**:pageSize=2,pageNum=1 / 2,断言 list.size + total。
  198 +9. **空结果**:queryValue 完全不匹配,返回 list=[] + total=0。
  199 +10. **响应字段精简**:jsonPath 验证 sPasswordHash / sId / sBrandsId / iStaffId 不出现。
  200 +11. **未关联 staff 用户**:DB 中有 user.iStaffId=null,列表 sStaffName / sDepartment 应为 null。
  201 +12. **pageSize 超限**:pageSize=101,返回 40010。
  202 +13. **queryField 非枚举**:queryField=invalid,返回 40010。
  203 +14. **matchType 非枚举**:返回 40010。
  204 +15. **排序**:tCreateDate DESC,新建在前。
  205 +
  206 +### 接口契约一致性
  207 +
  208 +- 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{total, list, pageNum, pageSize}`。
  209 +- 错误码 200 / 40010 / 50000。
  210 +- 不暴露 sPasswordHash 等内部字段。
  211 +
  212 +### 测试覆盖
  213 +
  214 +- **单元测试** `UserServiceImplTest` 追加(mock UserMapper.searchUsers):
  215 + - search_emptyDb_returnsEmptyPage
  216 + - search_invalidQueryField_throws40010(service 内白名单校验)
  217 + - search_invalidMatchType_throws40010
  218 + - search_passesColumnAndValue_toMapper(ArgumentCaptor 验 query.column 由白名单映射)
  219 +
  220 +- **集成测试** `UserControllerIT` 追加:
  221 + - get_emptyKeyword_returnsAllUndeleted
  222 + - get_filterByUsernameContains_returnsMatchedSubset
  223 + - get_filterByStaffnameContains_returnsJoinedResults
  224 + - get_filterByDeletedTrue_returnsOnlyDeleted
  225 + - get_pagination_returnsCorrectSlice
  226 + - get_responseExcludesInternalFields
  227 + - get_pageSizeTooLarge_returns40010
  228 + - get_invalidQueryField_returns40010
  229 + - get_userWithoutStaff_listItemHasNullStaffFields
  230 +
  231 +### 代码与文档
  232 +
  233 +- `// REQ-USR-003` 注释贴在 Controller / Service 方法 / Mapper.xml 顶部。
  234 +- 提交按 `feat(usr): <subject> REQ-USR-003`。
  235 +- **新增** `MybatisPlusConfig`(如不存在):`PaginationInnerInterceptor` bean 启用 MP 分页。
  236 +- **新增** 通用 `PageResult<T>` VO(horizontal scope;本 REQ 首次引入但属于 common 命名空间)。