From 3be59f719869637e170caf54b0410b9ac1df3679 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 7 May 2026 09:05:38 +0800 Subject: [PATCH] docs(usr): review approval REQ-USR-003 round 2 --- docs/08-模块任务管理.md | 2 +- docs/superpowers/plans/2026-05-06-REQ-USR-003.md | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/reviews/2026-05-06-REQ-USR-003.md | 36 ++++++++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-05-06-REQ-USR-003.md | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-06-REQ-USR-003.md create mode 100644 docs/superpowers/reviews/2026-05-06-REQ-USR-003.md create mode 100644 docs/superpowers/specs/2026-05-06-REQ-USR-003.md diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index eba0530..e2e34c2 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -72,5 +72,5 @@ - 功能: - [x] REQ-USR-001 用户新增 - [x] REQ-USR-002 用户修改 - - [ ] REQ-USR-003 用户查询 + - [x] REQ-USR-003 用户查询 - [ ] REQ-USR-004 用户登录 diff --git a/docs/superpowers/plans/2026-05-06-REQ-USR-003.md b/docs/superpowers/plans/2026-05-06-REQ-USR-003.md new file mode 100644 index 0000000..32597e2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-USR-003.md @@ -0,0 +1,230 @@ +--- +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) diff --git a/docs/superpowers/reviews/2026-05-06-REQ-USR-003.md b/docs/superpowers/reviews/2026-05-06-REQ-USR-003.md new file mode 100644 index 0000000..51569ee --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-USR-003.md @@ -0,0 +1,36 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-06 +round: 2 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-003 — round 2 + +## 结论 +approve + +## Must-fix +(无) + +Round 1 三条 must_fix 处理结果(commit f53689c): +1. **HIGH SQL 注入** — RESOLVED。UserQueryDTO 删除 `column` 字段;UserMapper.searchUsers 三参签名含 `@Param("column") String column`;UserMapper.xml 用 `${column}`;UserServiceImpl.search 用局部变量映射后通过 mapper 单独传入。 +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` 单测。 +3. **MEDIUM XML deleted 边界** — RESOLVED。queryField=deleted 但 queryValue 空时仍保留默认过滤 `u.bDeleted = 0`,避免返回全量含已删除。 + +## Nice-to-have + +- UserServiceImpl.search:212/214 — 直接 `query.setQueryValue("1")` 改写入参 DTO,单测靠副作用断言。语义上 service 不应突变 controller 入参;可改用局部 `normalizedDeletedValue` + `@Param("deletedValue")` 传给 mapper,XML 改用 `#{deletedValue}`,更纯。 +- round 1 遗留的 6 条 IT 覆盖缺口(department equals / deleted=false / notContains / 排序 / matchType 非枚举 IT / 空结果 IT)+ UserMapperSearchIT 断言强化 + PageResult javadoc + QUERY_COLUMN_MAP 位置 + Base_Column_List 抽取——本轮也不在 must_fix 范畴,留待后续 sweep。 + +## 反例 / 测试覆盖缺口 + +Round 1 must_fix 1 + 2 + 3 三项均已落地: + +1. 注入:UserMapper.xml 唯一 `${...}` 插值仅来自 service 白名单映射,外部输入完全经过 `#{...}` 参数化绑定。 +2. deleted 标准化:单测 `search_deletedQueryValueTrue_normalizedToOne` 钉死 `query.getQueryValue() == "1"`;IT `get_filterByDeletedTrue_returnsOnlyDeleted` 端到端验证返回已删除用户。 +3. XML 边界:deleted 空值分支保留默认 `u.bDeleted = 0`,与 spec § 业务规则 1 一致。 + +`mvn -B test` 经 .env.local 注入后 144/144 全绿,无新高危。 + +**核心结论**:round 1 high 注入风险 + spec § 6 契约缺失全部修复;其余 nice-to-have 不阻塞,留下一 sweep。verdict: approve。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-USR-003.md b/docs/superpowers/specs/2026-05-06-REQ-USR-003.md new file mode 100644 index 0000000..aba4e90 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-USR-003.md @@ -0,0 +1,236 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-06 +module: module_usr +--- + +# Spec: REQ-USR-003 — 用户查询 + +## 目标 + +实现后端 `GET /api/users` 接口:按 `queryField` + `matchType` + `queryValue` 三件套对 `tUser` 进行过滤(含跨表 JOIN `tStaff` 取员工名 / 部门),分页返回精简 VO 列表。 + +## 输入 / 触发 + +**接口**:`GET /api/users`,无请求体。 + +**Query parameters**(`UserQueryDTO`): + +| 字段 | 类型 | 必填 | 校验 / 取值 | +|---|---|---|---| +| `pageNum` | Integer | 否 | ≥1,默认 1 | +| `pageSize` | Integer | 否 | 1-100,默认 20 | +| `queryField` | String | 否 | 枚举:`username` / `staffname` / `userno` / `department` / `usertype` / `language` / `deleted` / `lastLoginDate` / `createdBy`;缺省视为不过滤 | +| `matchType` | String | 否 | 枚举:`contains` / `notContains` / `equals`;默认 `contains` | +| `queryValue` | String | 否 | 长度 ≤ 100;缺省视为不过滤;`deleted` 字段下视为 `'true' / 'false'` 字符串 | + +**鉴权**:契约要求 `Authorization: Bearer ` + `USR:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")`。 + +## 输出 / 结果 + +**HTTP 200,响应体**: + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "total": 42, + "list": [ + { + "iIncrement": 12, + "sUserName": "alice", + "sStaffName": "张三", + "sUserNo": "u001", + "sDepartment": "研发部", + "sUserType": "普通用户", + "sLanguage": "zh", + "bDeleted": false, + "tLastLoginDate": "2026-05-06T09:00:00", + "sCreatedBy": "admin", + "tCreateDate": "2026-05-06T08:00:00" + } + ], + "pageNum": 1, + "pageSize": 20 + }, + "timestamp": 1746528600000 +} +``` + +新建 `UserListItemVO`(11 字段)+ 复用项目通用 `PageResult`(4 字段:total / list / pageNum / pageSize;本期首次引入,作为 module_usr 横切组件)。 + +> **不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `iStaffId` / `bCanModifyDocs` / `tDeletedDate` / `sDeletedBy`。 + +## 业务规则 + +1. **范围过滤**:默认仅返回 `bDeleted=0` 用户**除非** `queryField=deleted` 且 `queryValue` 显式过滤——见规则 6。 +2. **跨表 JOIN**:`tUser LEFT JOIN tStaff ON tUser.iStaffId = tStaff.iIncrement AND tStaff.bDeleted = 0`;空关联字段(无 staff)展示为空字符串 `""`(VO 字段为 null)。 +3. **queryField 列映射**: + + | queryField 值 | SQL 实际列 | + |---|---| + | `username` | `tUser.sUserName` | + | `staffname` | `tStaff.sStaffName` | + | `userno` | `tUser.sUserNo` | + | `department` | `tStaff.sDepartment` | + | `usertype` | `tUser.sUserType` | + | `language` | `tUser.sLanguage` | + | `deleted` | `tUser.bDeleted` | + | `lastLoginDate` | `tUser.tLastLoginDate` | + | `createdBy` | `tUser.sCreatedBy` | + +4. **matchType 映射**: + + | matchType 值 | SQL 片段 | + |---|---| + | `contains`(默认) | ` LIKE '%' || queryValue || '%'` | + | `notContains` | ` NOT LIKE '%' || queryValue || '%'` | + | `equals` | ` = queryValue` | + +5. **空过滤值语义**:`queryField` 或 `queryValue` 缺省 / 空串 → 不附加该 WHERE 条件。 +6. **deleted 字段特殊处理**:当 `queryField=deleted` 时,`bDeleted=0` 的默认过滤不应用(让 `queryValue` 直接控制——`'false'` / `'0'` → bDeleted=0;`'true'` / `'1'` → bDeleted=1)。 +7. **排序**:按 `tUser.tCreateDate DESC, tUser.iIncrement DESC` 稳定排序。 +8. **分页**:MyBatis-Plus `Page` + `selectPage` 标准分页;total 由 MP 自动计数。 +9. **只读**:`@Transactional(readOnly = true)`;不写库。 +10. **空结果**:`data.list = []` + `data.total = 0` + `code=200`,不返回 404。 + +## 边界与约束 + +### 鉴权策略 + +沿用 SecurityConfig permitAll。 + +### 错误码映射 + +| 场景 | 错误码 | ErrorCode 枚举 | +|---|---|---| +| `pageSize` 超 1-100 / `queryField` / `matchType` 非枚举 / `queryValue` 长度超限 | 40010 | `PARAM_INVALID`(已存在) | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | + +### 实现路径选择 + +引入**自定义 XML SQL**(`UserMapper.xml`):本期首次需要跨表 JOIN,纯 LambdaQueryWrapper 难以表达 LEFT JOIN + 动态 WHERE。在 `mapper/usr/UserMapper.xml` 添加 `searchUsers(IPage page, @Param("query") UserQueryDTO query)` 方法,对应 ResultMap 映射到 `UserListItemVO`。 + +XML SQL 草稿(写入 plan 锁定): + +```xml + +``` + +> `${col}` 由 service 层通过 enum 映射成实际列名后传入参数(**不是直接拼用户输入**——避免 SQL 注入);queryField 不在白名单 → 抛 PARAM_INVALID。 + +实际实现可能要把 `${col}` 替换逻辑放到 service:service 把 queryField 翻译为列字符串放进 query 对象的临时字段(如 `query.setColumn(...)`),XML 用 `${query.column}` 渲染——XMl `${...}` 直接拼接字符串,服务层先白名单校验。 + +### 性能 + +- LEFT JOIN tStaff 走 `tUser.iStaffId` 上的索引(`fk_user_staff`)。 +- `LIKE '%X%'` 左模糊不走索引;本期数据量低可接受。 +- MP `Page` 走 PaginationInnerInterceptor,需要在 `MybatisPlusConfig` 注册(如已存在跳过;如未引入,本 REQ 引入)。 + +### Bean Validation + +`UserQueryDTO` 用 `@Min(1) Integer pageNum`、`@Min(1) @Max(100) Integer pageSize`、`@Pattern` 校验枚举字段。Controller 用 `@Valid`(query 参数 bean 绑定)。 + +## 依赖的 schema 表 / 字段 + +**读表**:`tUser`(主体)+ `tStaff`(LEFT JOIN) + +| `tUser` 字段 | 用途 | +|---|---| +| 全部 11 个输出字段 + `bDeleted` 过滤 | SELECT + WHERE | +| `iStaffId` | LEFT JOIN 键 | + +| `tStaff` 字段 | 用途 | +|---|---| +| `iIncrement` | LEFT JOIN 键 | +| `sStaffName` / `sDepartment` | SELECT + 可选 WHERE | +| `bDeleted` | LEFT JOIN 时附加条件 (s.bDeleted=0) | + +**索引利用**: +- `pk_user`:分页 ORDER BY iIncrement +- `fk_user_staff`(隐式 BTREE on `iStaffId`):JOIN +- `uk_user_no` / `uk_user_name`:equals 匹配能走索引 + +## 依赖的接口 + +无(独立查询接口)。 + +## 验收标准 + +### 功能正确性 + +1. **空查询返回所有未删除用户**:DB 有 5 个 user(含 1 个 bDeleted=1),GET 不带 queryField 返回 4 个未删除(含 LEFT JOIN 出来的 staff 名 / 部门)。 +2. **按用户名 contains 过滤**:DB 有 alice / alex / bob,`queryField=username&matchType=contains&queryValue=al` 返回 alice + alex。 +3. **按员工名 contains 过滤**:通过 LEFT JOIN,匹配 tStaff.sStaffName。 +4. **按部门 equals 过滤**:matchType=equals,命中精确部门。 +5. **deleted=false 过滤**:返回未删除集合。 +6. **deleted=true 过滤**:仅返回已软删除(验证规则 6)。 +7. **notContains 排除**:matchType=notContains,排除指定关键字。 +8. **分页**:pageSize=2,pageNum=1 / 2,断言 list.size + total。 +9. **空结果**:queryValue 完全不匹配,返回 list=[] + total=0。 +10. **响应字段精简**:jsonPath 验证 sPasswordHash / sId / sBrandsId / iStaffId 不出现。 +11. **未关联 staff 用户**:DB 中有 user.iStaffId=null,列表 sStaffName / sDepartment 应为 null。 +12. **pageSize 超限**:pageSize=101,返回 40010。 +13. **queryField 非枚举**:queryField=invalid,返回 40010。 +14. **matchType 非枚举**:返回 40010。 +15. **排序**:tCreateDate DESC,新建在前。 + +### 接口契约一致性 + +- 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{total, list, pageNum, pageSize}`。 +- 错误码 200 / 40010 / 50000。 +- 不暴露 sPasswordHash 等内部字段。 + +### 测试覆盖 + +- **单元测试** `UserServiceImplTest` 追加(mock UserMapper.searchUsers): + - search_emptyDb_returnsEmptyPage + - search_invalidQueryField_throws40010(service 内白名单校验) + - search_invalidMatchType_throws40010 + - search_passesColumnAndValue_toMapper(ArgumentCaptor 验 query.column 由白名单映射) + +- **集成测试** `UserControllerIT` 追加: + - get_emptyKeyword_returnsAllUndeleted + - get_filterByUsernameContains_returnsMatchedSubset + - get_filterByStaffnameContains_returnsJoinedResults + - get_filterByDeletedTrue_returnsOnlyDeleted + - get_pagination_returnsCorrectSlice + - get_responseExcludesInternalFields + - get_pageSizeTooLarge_returns40010 + - get_invalidQueryField_returns40010 + - get_userWithoutStaff_listItemHasNullStaffFields + +### 代码与文档 + +- `// REQ-USR-003` 注释贴在 Controller / Service 方法 / Mapper.xml 顶部。 +- 提交按 `feat(usr): REQ-USR-003`。 +- **新增** `MybatisPlusConfig`(如不存在):`PaginationInnerInterceptor` bean 启用 MP 分页。 +- **新增** 通用 `PageResult` VO(horizontal scope;本 REQ 首次引入但属于 common 命名空间)。 -- libgit2 0.22.2