--- req_id: REQ-USR-003 date: 2026-04-30 module: module_usr --- # Spec: REQ-USR-003 — 用户查询 ## 目标 按 `field` × `match` × `value` 单条件检索用户分页列表,输出含 tUser + LEFT JOIN tStaff 字段的 11 列扁平 VO。 ## 输入 / 触发 ### HTTP 接口(docs/05 § REQ-USR-003) - Method / Path: `GET /api/usr/users` - Auth: 必需(沿用 USR-001 stub:路径已 `/api/usr/**` permitAll) ### Query 参数 | 参数 | 类型 | 必填 | 取值 | 默认 | |---|---|---|---|---| | `field` | `String` | 否 | 枚举:`用户名` / `员工名` / `用户号` / `部门` / `用户类型` / `作废` / `登录日期` / `制单人` | `用户名` | | `match` | `String` | 否 | 枚举:`包含` / `不包含` / `等于` | `包含` | | `value` | `String` | 否 | 任意(含布尔/日期字面) | 空 → 不加过滤条件 | | `pageNum` | `Integer` | 否 | ≥ 1 | `1` | | `pageSize` | `Integer` | 否 | 1..100 | `20` | ### field × match 兼容矩阵 | field 物理列 | 类型 | 允许的 match | |---|---|---| | 用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 制单人 | 字符串 | 包含 / 不包含 / 等于 | | 作废 | 布尔(tinyint) | 仅 `等于`(解析 value: `true`/`false`/`1`/`0` → 1/0) | | 登录日期 | 日期 | 仅 `等于`(按 `YYYY-MM-DD` 精确日期匹配 `DATE(tLastLoginDate) = ?`) | 非法组合(如 `field=作废 & match=包含`)→ `BizException(40001, "field/match 取值或组合非法")`。 ## 输出 / 结果 ### 成功响应 ```json { "code": 0, "msg": "ok", "data": { "records": [ { "iIncrement": 1, "sUserName": "u1", "staffName": "员工A", "sUserNo": "001", "department": "IT", "sUserType": "普通用户", "sLanguage": "zh", "bDeleted": false, "tLastLoginDate": "2026-04-30T09:00:00", "sCreatedBy": "STUB_ADMIN", "tCreateDate": "2026-04-30T08:00:00" } ], "total": 42, "pageNum": 1, "pageSize": 20 } } ``` ### VO `UserListVO`(11 字段) | 字段 | 类型 | 来源 | |---|---|---| | `iIncrement` | `Integer` | `tUser.iIncrement` | | `sUserName` | `String` | `tUser.sUserName` | | `staffName` | `String` | `tStaff.sStaffName`(LEFT JOIN,未关联时为 null) | | `sUserNo` | `String` | `tUser.sUserNo` | | `department` | `String` | `tStaff.sDepartment`(LEFT JOIN) | | `sUserType` | `String` | `tUser.sUserType` | | `sLanguage` | `String` | `tUser.sLanguage` | | `bDeleted` | `Boolean` | `tUser.bDeleted` | | `tLastLoginDate` | `LocalDateTime` | `tUser.tLastLoginDate` | | `sCreatedBy` | `String` | `tUser.sCreatedBy` | | `tCreateDate` | `LocalDateTime` | `tUser.tCreateDate` | > **不返回** `sPasswordHash` / `iStaffId` / `sBrandsId` / `sSubsidiaryId` / `sId` / 软删除审计字段(spec § 边界与约束 显式排除敏感字段)。 > > **不过滤** `bDeleted=0`:因为「作废」是用户可主动查询的字段;docs/03 § tUser 业务注记的"默认过滤 bDeleted=0"针对常规列表场景,本 REQ 的"作废"字段查询是显式例外。 ## 业务规则 1. **参数归一化**:`field` / `match` 缺失或空 → 默认 `用户名` / `包含`;`value` trim,空串当无过滤;`pageNum` < 1 → 1;`pageSize` 缺失 → 20。 2. **pageSize 上限**:`pageSize > 100` → `BizException(40002, "pageSize 超过 100")`。 3. **field/match 校验**: - `field` 不在枚举内 → `BizException(40001, "field 取值非法")` - `match` 不在枚举内 → `BizException(40001, "match 取值非法")` - field=作废/登录日期 时 match 必须是 `等于`;否则 → `BizException(40001, "field/match 组合非法")` 4. **value 解析**(match=等于 + 非字符串列): - field=作废:value ∈ `[true, false, 1, 0]` → 1/0;其他 → 视为不命中(结果空);空 value → 不加过滤 - field=登录日期:value 必须可被 `LocalDate.parse(value)` 解析为 `YYYY-MM-DD`;不能解析 → `BizException(40001, "登录日期值格式非法(应为 YYYY-MM-DD)")`;空 value → 不加过滤 5. **SQL 拼装**: ```sql SELECT u.iIncrement, u.sUserName, s.sStaffName AS staffName, u.sUserNo, s.sDepartment AS department, u.sUserType, u.sLanguage, u.bDeleted, u.tLastLoginDate, u.sCreatedBy, u.tCreateDate FROM tUser u LEFT JOIN tStaff s ON s.iIncrement = u.iStaffId AND s.bDeleted = 0 WHERE <动态条件> ORDER BY u.iIncrement DESC LIMIT #{offset}, #{pageSize} ``` - 字符串列:`WHERE col LIKE CONCAT('%', #{value}, '%')`(包含)/ `NOT LIKE`(不包含)/ `= #{value}`(等于) - 布尔列:`WHERE u.bDeleted = #{boolValue}` - 日期列:`WHERE DATE(u.tLastLoginDate) = #{dateValue}` 6. **总数查询**:另一条 `SELECT COUNT(1) FROM tUser u LEFT JOIN tStaff s ON ... WHERE <同条件>`。 7. **只读**:service 上 `@Transactional(readOnly = true)`。 ## 边界与约束 - **field/match 非法或组合非法** → `40001` - **pageSize > 100** → `40002` - **登录日期 value 格式错** → `40001` - **value 空** → 不加过滤,返回全部(按 field/match 仍校验合法性) - **空结果** → `records=[]` + `total=0`;HTTP 200 / `code=0` - **JWT 伪造** → `20001` - **JWT 缺失** → permitAll stub - **`sPasswordHash` 不返回**(VO 不含字段) ## 实现范围与边界抉择 1. **复用 USR-001/002 工程**:不新增 mapper;UserMapper 追加自定义 SQL 方法 + 新 VO 即可。 2. **field 枚举映射用 Java enum 或 Map 常量**:选 `Map` 简化(无需新 enum 类);`FieldDef` 内含物理列名 + 类型 + 允许的 match 集合。 3. **不支持多字段同时查询**:spec 仅描述单 field × match × value;docs/05 同款。多字段后续 REQ 再补。 4. **不支持自定义排序**:固定 `ORDER BY u.iIncrement DESC`;docs/05 未要求 sort 参数。 5. **使用 MyBatis-Plus `IPage`**:mapper 方法 `IPage pageWithFilter(IPage page, ...)`;service 把 IPage 转为响应 Map。 ## 依赖的 schema 表 / 字段 读取: - `tUser`:iIncrement / sUserName / sUserNo / sUserType / sLanguage / bDeleted / tLastLoginDate / sCreatedBy / tCreateDate / iStaffId(用于 JOIN) - `tStaff`:iIncrement / sStaffName / sDepartment / bDeleted(用于 JOIN ON) 依赖索引: - `tUser.uk_user_no` / `uk_user_name`(不直接命中 LIKE 但 ORDER BY iIncrement 走主键) - `tStaff.iIncrement` PK 兜底 JOIN ## 依赖的接口 无。 ## 验收标准 ### 单元测试(追加到 `UserServiceImplTest`) - [x] `listWithDefaults_invokesMapperWithUserNameContainsEmpty` — 全空参数;mapper 入参 field=用户名 / match=包含 / value="" / page=1 / size=20 - [x] `listWithEmptyValue_skipsFilterCondition` - [x] `listWithInvalidField_throws40001` - [x] `listWithInvalidMatch_throws40001` - [x] `listWithIncompatibleFieldMatch_throws40001` — field=作废 + match=包含 - [x] `listWithPageSizeExceeds100_throws40002` - [x] `listWithInvalidLoginDateFormat_throws40001` — field=登录日期 / match=等于 / value="abc" - [x] `listWithBooleanFieldEqualsTrue_passesIntegerOne` — field=作废 / match=等于 / value="true" - [x] `listWithKeywordTrim` — value=" abc " → 传给 mapper 时 trim 为 "abc" - [x] `listReturnsEmptyRecords_whenMapperReturnsEmptyPage` ### Mapper IT(追加到 `UserMapperIT`) - [x] `userMapper#pageWithFilter_filtersAndJoins` — JdbcTemplate 直插 staff + 2 user(一个有 iStaffId,一个无);调 `pageWithFilter` field=用户名 match=包含 value="";返回 records=[user1, user2],user1.staffName == "员工X",user2.staffName == null ### 集成测试(`UserControllerIT`,追加 8 用例) - [x] `getDefaults_with_jwt_returns200_andList` - [x] `getKeywordContains_filtersByUsername` - [x] `getKeywordEquals_filtersExact` - [x] `getInvalidField_returns40001` - [x] `getPageSizeExceeds100_returns40002` - [x] `getNoMatch_returnsEmptyArray` - [x] `getWithoutJwt_permitAllStub_returns200` - [x] `getTamperedJwt_returns20001` ### 工程验收 - [x] `mvn -B test` 全绿(110 + 新增 ≥ 19 = ≥ 129 用例) - [x] 响应 records 不含 sPasswordHash 字段 - [x] LEFT JOIN 在用户无 iStaffId 时 staffName/department 为 null