Commit 3be59f719869637e170caf54b0410b9ac1df3679
1 parent
f53689c3
docs(usr): review approval REQ-USR-003 round 2
Showing
4 changed files
with
503 additions
and
1 deletions
docs/08-模块任务管理.md
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 命名空间)。 |