Commit d3ff317da4fd3b369402cf7a1542ee9e64545780

Authored by zichun
1 parent 111d5015

docs(usr): spec + plan REQ-USR-003

docs/superpowers/plans/2026-04-30-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-04-30
  4 +spec_ref: docs/superpowers/specs/2026-04-30-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/usr/users` 单条件分页查询:tUser LEFT JOIN tStaff,按 field × match × value 动态过滤。
  12 +
  13 +**Architecture:** 新增 `UserListVO` + `UserListQuery` 内部封装类(field/match/value/page)+ `UserMapper#pageWithFilter` 自定义动态 SQL + `UserService#list` + controller `@GetMapping`。
  14 +
  15 +**Tech Stack:** 沿用;MyBatis 动态 SQL `<script>` + `<foreach>` / `<if>`。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无。
  22 +
  23 +## 文件变更清单
  24 +
  25 +### 新增
  26 +
  27 +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserListVO.java`
  28 +- `backend/src/main/java/com/xly/erp/module/usr/dto/UserListQuery.java`(内部封装规范化后的查询参数)
  29 +
  30 +### 修改
  31 +
  32 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` — 追加 `pageWithFilter` + `countWithFilter`
  33 +- `backend/src/main/resources/mapper/usr/UserMapper.xml` — 新建 XML(动态 SQL 复杂,注解 @Select 不易读)
  34 +- `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `list(...)`
  35 +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 list(字段映射 + 校验 + 调 mapper)
  36 +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 GET 端点
  37 +- 测试:UserMapperIT(+1) / UserServiceImplTest(+10) / UserControllerIT(+8)
  38 +
  39 +## 任务步骤
  40 +
  41 +### Task 1: UserListVO + UserMapper#pageWithFilter / countWithFilter + IT
  42 +
  43 +**Files:**
  44 +- Create: vo/UserListVO.java
  45 +- Modify: mapper/UserMapper.java + resources/mapper/usr/UserMapper.xml
  46 +- Modify: test/.../mapper/UserMapperIT.java
  47 +
  48 +**API shape:**
  49 +- `UserListVO` 11 字段(参 spec),@JsonProperty 锁定 JSON 名
  50 +- `UserMapper.pageWithFilter`(XML)参数:`@Param("field") String physicalCol, @Param("match") String matchOp, @Param("value") Object value, @Param("offset") int offset, @Param("size") int size`;`physicalCol` 由 service 映射成 `u.sUserName` / `s.sStaffName` 等
  51 +- `UserMapper.countWithFilter` 同参数(除 offset/size)
  52 +
  53 +**XML 关键结构**:
  54 +```xml
  55 +<select id="pageWithFilter" resultType="com.xly.erp.module.usr.vo.UserListVO">
  56 + SELECT u.iIncrement, u.sUserName, s.sStaffName AS staffName, u.sUserNo,
  57 + s.sDepartment AS department, u.sUserType, u.sLanguage,
  58 + u.bDeleted, u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
  59 + FROM tUser u
  60 + LEFT JOIN tStaff s ON s.iIncrement = u.iStaffId AND s.bDeleted = 0
  61 + <where>
  62 + <if test="value != null and value != ''">
  63 + <choose>
  64 + <when test="matchOp == 'contains'">${field} LIKE CONCAT('%', #{value}, '%')</when>
  65 + <when test="matchOp == 'notContains'">${field} NOT LIKE CONCAT('%', #{value}, '%')</when>
  66 + <when test="matchOp == 'equals'">
  67 + <choose>
  68 + <when test="field == 'DATE(u.tLastLoginDate)'">${field} = #{value}</when>
  69 + <otherwise>${field} = #{value}</otherwise>
  70 + </choose>
  71 + </when>
  72 + </choose>
  73 + </if>
  74 + </where>
  75 + ORDER BY u.iIncrement DESC
  76 + LIMIT #{offset}, #{size}
  77 +</select>
  78 +```
  79 +(XML 复杂度由 service 提供归一化的 `field`(物理列名 with prefix) / `matchOp`(英文常量) / `value` 后大幅简化)
  80 +
  81 +- [ ] **Step 1: 写失败 IT `pageWithFilter_filtersAndJoins`**
  82 +- [ ] **Step 2: 实现 entity/VO + mapper + XML**
  83 +- [ ] **Step 3: 子会话验证 PASS**
  84 +- [ ] **Step 4: Commit**:`feat(usr): user list mapper + vo with join REQ-USR-003`
  85 +
  86 +### Task 2: UserListQuery + UserService.list 主流程(合法 + 字段映射)
  87 +
  88 +**Files:**
  89 +- Create: dto/UserListQuery.java(service 内部)
  90 +- Modify: service/UserService.java + impl/UserServiceImpl.java
  91 +- Modify: test/.../service/UserServiceImplTest.java
  92 +
  93 +**API shape:**
  94 +- `UserService#list(String field, String match, String value, Integer pageNum, Integer pageSize) : Map<String, Object>`(返回 records/total/pageNum/pageSize)
  95 +- 内部:归一化(默认值/trim)→ field/match 校验 → value 解析(日期/布尔)→ 调 mapper.countWithFilter + pageWithFilter → 装结果 Map
  96 +
  97 +**字段映射表**(service 静态常量):
  98 +```
  99 +"用户名" -> ("u.sUserName", STRING)
  100 +"员工名" -> ("s.sStaffName", STRING)
  101 +"用户号" -> ("u.sUserNo", STRING)
  102 +"部门" -> ("s.sDepartment", STRING)
  103 +"用户类型" -> ("u.sUserType", STRING)
  104 +"作废" -> ("u.bDeleted", BOOLEAN)
  105 +"登录日期" -> ("DATE(u.tLastLoginDate)", DATE)
  106 +"制单人" -> ("u.sCreatedBy", STRING)
  107 +```
  108 +match 映射:包含→`contains`,不包含→`notContains`,等于→`equals`
  109 +
  110 +**校验顺序**:pageSize 上限 → field 枚举 → match 枚举 → field/match 兼容(布尔/日期仅 equals)→ value 解析(日期)
  111 +
  112 +- [ ] **Step 1: 写失败测试 4 条主流程用例**
  113 + - listWithDefaults_invokesMapperWithUserNameContainsEmpty
  114 + - listWithEmptyValue_skipsFilterCondition
  115 + - listWithKeywordTrim
  116 + - listReturnsEmptyRecords_whenMapperReturnsEmptyPage
  117 +- [ ] **Step 2: 实现 service**
  118 +- [ ] **Step 3: 子会话验证 PASS**
  119 +- [ ] **Step 4: Commit**:`feat(usr): user list service + field/match mapping REQ-USR-003`
  120 +
  121 +### Task 3: Service 异常分支(field/match/兼容/pageSize/日期格式/布尔)
  122 +
  123 +- [ ] **Step 1: 追加 6 用例**
  124 + - listWithInvalidField_throws40001
  125 + - listWithInvalidMatch_throws40001
  126 + - listWithIncompatibleFieldMatch_throws40001
  127 + - listWithPageSizeExceeds100_throws40002
  128 + - listWithInvalidLoginDateFormat_throws40001
  129 + - listWithBooleanFieldEqualsTrue_passesIntegerOne
  130 +- [ ] **Step 2: 实现校验分支**
  131 +- [ ] **Step 3: 子会话验证 PASS**(期望 18 + 4 + 6 = 28 用例)
  132 +- [ ] **Step 4: Commit**:`feat(usr): user list error branches REQ-USR-003`
  133 +
  134 +### Task 4: Controller GET + 8 IT + 全量回归
  135 +
  136 +- [ ] **Step 1: 追加 8 IT**(参 spec 验收清单)
  137 +- [ ] **Step 2: 实现 controller GET**
  138 +- [ ] **Step 3: 子会话跑全量回归**(期望 ≥ 129 用例)
  139 +- [ ] **Step 4: Commit**:`test(usr): user list integration coverage REQ-USR-003`
  140 +
  141 +## 提交计划
  142 +
  143 +| commit | 覆盖 |
  144 +|---|---|
  145 +| `feat(usr): user list mapper + vo with join REQ-USR-003` | Task 1 |
  146 +| `feat(usr): user list service + field/match mapping REQ-USR-003` | Task 2 |
  147 +| `feat(usr): user list error branches REQ-USR-003` | Task 3 |
  148 +| `test(usr): user list integration coverage REQ-USR-003` | Task 4 |
docs/superpowers/specs/2026-04-30-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-04-30
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-003 — 用户查询
  8 +
  9 +## 目标
  10 +
  11 +按 `field` × `match` × `value` 单条件检索用户分页列表,输出含 tUser + LEFT JOIN tStaff 字段的 11 列扁平 VO。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +### HTTP 接口(docs/05 § REQ-USR-003)
  16 +
  17 +- Method / Path: `GET /api/usr/users`
  18 +- Auth: 必需(沿用 USR-001 stub:路径已 `/api/usr/**` permitAll)
  19 +
  20 +### Query 参数
  21 +
  22 +| 参数 | 类型 | 必填 | 取值 | 默认 |
  23 +|---|---|---|---|---|
  24 +| `field` | `String` | 否 | 枚举:`用户名` / `员工名` / `用户号` / `部门` / `用户类型` / `作废` / `登录日期` / `制单人` | `用户名` |
  25 +| `match` | `String` | 否 | 枚举:`包含` / `不包含` / `等于` | `包含` |
  26 +| `value` | `String` | 否 | 任意(含布尔/日期字面) | 空 → 不加过滤条件 |
  27 +| `pageNum` | `Integer` | 否 | ≥ 1 | `1` |
  28 +| `pageSize` | `Integer` | 否 | 1..100 | `20` |
  29 +
  30 +### field × match 兼容矩阵
  31 +
  32 +| field 物理列 | 类型 | 允许的 match |
  33 +|---|---|---|
  34 +| 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 制单人 | 字符串 | 包含 / 不包含 / 等于 |
  35 +| 作废 | 布尔(tinyint) | 仅 `等于`(解析 value: `true`/`false`/`1`/`0` → 1/0) |
  36 +| 登录日期 | 日期 | 仅 `等于`(按 `YYYY-MM-DD` 精确日期匹配 `DATE(tLastLoginDate) = ?`) |
  37 +
  38 +非法组合(如 `field=作废 & match=包含`)→ `BizException(40001, "field/match 取值或组合非法")`。
  39 +
  40 +## 输出 / 结果
  41 +
  42 +### 成功响应
  43 +
  44 +```json
  45 +{
  46 + "code": 0,
  47 + "msg": "ok",
  48 + "data": {
  49 + "records": [
  50 + {
  51 + "iIncrement": 1, "sUserName": "u1", "staffName": "员工A", "sUserNo": "001",
  52 + "department": "IT", "sUserType": "普通用户", "sLanguage": "zh",
  53 + "bDeleted": false, "tLastLoginDate": "2026-04-30T09:00:00",
  54 + "sCreatedBy": "STUB_ADMIN", "tCreateDate": "2026-04-30T08:00:00"
  55 + }
  56 + ],
  57 + "total": 42, "pageNum": 1, "pageSize": 20
  58 + }
  59 +}
  60 +```
  61 +
  62 +### VO `UserListVO`(11 字段)
  63 +
  64 +| 字段 | 类型 | 来源 |
  65 +|---|---|---|
  66 +| `iIncrement` | `Integer` | `tUser.iIncrement` |
  67 +| `sUserName` | `String` | `tUser.sUserName` |
  68 +| `staffName` | `String` | `tStaff.sStaffName`(LEFT JOIN,未关联时为 null) |
  69 +| `sUserNo` | `String` | `tUser.sUserNo` |
  70 +| `department` | `String` | `tStaff.sDepartment`(LEFT JOIN) |
  71 +| `sUserType` | `String` | `tUser.sUserType` |
  72 +| `sLanguage` | `String` | `tUser.sLanguage` |
  73 +| `bDeleted` | `Boolean` | `tUser.bDeleted` |
  74 +| `tLastLoginDate` | `LocalDateTime` | `tUser.tLastLoginDate` |
  75 +| `sCreatedBy` | `String` | `tUser.sCreatedBy` |
  76 +| `tCreateDate` | `LocalDateTime` | `tUser.tCreateDate` |
  77 +
  78 +> **不返回** `sPasswordHash` / `iStaffId` / `sBrandsId` / `sSubsidiaryId` / `sId` / 软删除审计字段(spec § 边界与约束 显式排除敏感字段)。
  79 +>
  80 +> **不过滤** `bDeleted=0`:因为「作废」是用户可主动查询的字段;docs/03 § tUser 业务注记的"默认过滤 bDeleted=0"针对常规列表场景,本 REQ 的"作废"字段查询是显式例外。
  81 +
  82 +## 业务规则
  83 +
  84 +1. **参数归一化**:`field` / `match` 缺失或空 → 默认 `用户名` / `包含`;`value` trim,空串当无过滤;`pageNum` < 1 → 1;`pageSize` 缺失 → 20。
  85 +2. **pageSize 上限**:`pageSize > 100` → `BizException(40002, "pageSize 超过 100")`。
  86 +3. **field/match 校验**:
  87 + - `field` 不在枚举内 → `BizException(40001, "field 取值非法")`
  88 + - `match` 不在枚举内 → `BizException(40001, "match 取值非法")`
  89 + - field=作废/登录日期 时 match 必须是 `等于`;否则 → `BizException(40001, "field/match 组合非法")`
  90 +4. **value 解析**(match=等于 + 非字符串列):
  91 + - field=作废:value ∈ `[true, false, 1, 0]` → 1/0;其他 → 视为不命中(结果空);空 value → 不加过滤
  92 + - field=登录日期:value 必须可被 `LocalDate.parse(value)` 解析为 `YYYY-MM-DD`;不能解析 → `BizException(40001, "登录日期值格式非法(应为 YYYY-MM-DD)")`;空 value → 不加过滤
  93 +5. **SQL 拼装**:
  94 + ```sql
  95 + SELECT u.iIncrement, u.sUserName, s.sStaffName AS staffName, u.sUserNo,
  96 + s.sDepartment AS department, u.sUserType, u.sLanguage,
  97 + u.bDeleted, u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
  98 + FROM tUser u
  99 + LEFT JOIN tStaff s ON s.iIncrement = u.iStaffId AND s.bDeleted = 0
  100 + WHERE <动态条件>
  101 + ORDER BY u.iIncrement DESC
  102 + LIMIT #{offset}, #{pageSize}
  103 + ```
  104 + - 字符串列:`WHERE col LIKE CONCAT('%', #{value}, '%')`(包含)/ `NOT LIKE`(不包含)/ `= #{value}`(等于)
  105 + - 布尔列:`WHERE u.bDeleted = #{boolValue}`
  106 + - 日期列:`WHERE DATE(u.tLastLoginDate) = #{dateValue}`
  107 +6. **总数查询**:另一条 `SELECT COUNT(1) FROM tUser u LEFT JOIN tStaff s ON ... WHERE <同条件>`。
  108 +7. **只读**:service 上 `@Transactional(readOnly = true)`。
  109 +
  110 +## 边界与约束
  111 +
  112 +- **field/match 非法或组合非法** → `40001`
  113 +- **pageSize > 100** → `40002`
  114 +- **登录日期 value 格式错** → `40001`
  115 +- **value 空** → 不加过滤,返回全部(按 field/match 仍校验合法性)
  116 +- **空结果** → `records=[]` + `total=0`;HTTP 200 / `code=0`
  117 +- **JWT 伪造** → `20001`
  118 +- **JWT 缺失** → permitAll stub
  119 +- **`sPasswordHash` 不返回**(VO 不含字段)
  120 +
  121 +## 实现范围与边界抉择
  122 +
  123 +1. **复用 USR-001/002 工程**:不新增 mapper;UserMapper 追加自定义 SQL 方法 + 新 VO 即可。
  124 +2. **field 枚举映射用 Java enum 或 Map 常量**:选 `Map<String, FieldDef>` 简化(无需新 enum 类);`FieldDef` 内含物理列名 + 类型 + 允许的 match 集合。
  125 +3. **不支持多字段同时查询**:spec 仅描述单 field × match × value;docs/05 同款。多字段后续 REQ 再补。
  126 +4. **不支持自定义排序**:固定 `ORDER BY u.iIncrement DESC`;docs/05 未要求 sort 参数。
  127 +5. **使用 MyBatis-Plus `IPage<T>`**:mapper 方法 `IPage<UserListVO> pageWithFilter(IPage<UserListVO> page, ...)`;service 把 IPage 转为响应 Map。
  128 +
  129 +## 依赖的 schema 表 / 字段
  130 +
  131 +读取:
  132 +- `tUser`:iIncrement / sUserName / sUserNo / sUserType / sLanguage / bDeleted / tLastLoginDate / sCreatedBy / tCreateDate / iStaffId(用于 JOIN)
  133 +- `tStaff`:iIncrement / sStaffName / sDepartment / bDeleted(用于 JOIN ON)
  134 +
  135 +依赖索引:
  136 +- `tUser.uk_user_no` / `uk_user_name`(不直接命中 LIKE 但 ORDER BY iIncrement 走主键)
  137 +- `tStaff.iIncrement` PK 兜底 JOIN
  138 +
  139 +## 依赖的接口
  140 +
  141 +无。
  142 +
  143 +## 验收标准
  144 +
  145 +### 单元测试(追加到 `UserServiceImplTest`)
  146 +
  147 +- [x] `listWithDefaults_invokesMapperWithUserNameContainsEmpty` — 全空参数;mapper 入参 field=用户名 / match=包含 / value="" / page=1 / size=20
  148 +- [x] `listWithEmptyValue_skipsFilterCondition`
  149 +- [x] `listWithInvalidField_throws40001`
  150 +- [x] `listWithInvalidMatch_throws40001`
  151 +- [x] `listWithIncompatibleFieldMatch_throws40001` — field=作废 + match=包含
  152 +- [x] `listWithPageSizeExceeds100_throws40002`
  153 +- [x] `listWithInvalidLoginDateFormat_throws40001` — field=登录日期 / match=等于 / value="abc"
  154 +- [x] `listWithBooleanFieldEqualsTrue_passesIntegerOne` — field=作废 / match=等于 / value="true"
  155 +- [x] `listWithKeywordTrim` — value=" abc " → 传给 mapper 时 trim 为 "abc"
  156 +- [x] `listReturnsEmptyRecords_whenMapperReturnsEmptyPage`
  157 +
  158 +### Mapper IT(追加到 `UserMapperIT`)
  159 +
  160 +- [x] `userMapper#pageWithFilter_filtersAndJoins` — JdbcTemplate 直插 staff + 2 user(一个有 iStaffId,一个无);调 `pageWithFilter` field=用户名 match=包含 value="";返回 records=[user1, user2],user1.staffName == "员工X",user2.staffName == null
  161 +
  162 +### 集成测试(`UserControllerIT`,追加 8 用例)
  163 +
  164 +- [x] `getDefaults_with_jwt_returns200_andList`
  165 +- [x] `getKeywordContains_filtersByUsername`
  166 +- [x] `getKeywordEquals_filtersExact`
  167 +- [x] `getInvalidField_returns40001`
  168 +- [x] `getPageSizeExceeds100_returns40002`
  169 +- [x] `getNoMatch_returnsEmptyArray`
  170 +- [x] `getWithoutJwt_permitAllStub_returns200`
  171 +- [x] `getTamperedJwt_returns20001`
  172 +
  173 +### 工程验收
  174 +
  175 +- [x] `mvn -B test` 全绿(110 + 新增 ≥ 19 = ≥ 129 用例)
  176 +- [x] 响应 records 不含 sPasswordHash 字段
  177 +- [x] LEFT JOIN 在用户无 iStaffId 时 staffName/department 为 null