Commit 56d83a0e9b168863a1818b9d96b34910a3c1ece2

Authored by zichun
1 parent d7285a1c

docs(plan:REQ-USR-003): 任务级 TDD 计划

docs/superpowers/plans/2026-06-01-REQ-USR-003.md 0 → 100644
  1 +# REQ-USR-003 查询用户 — 任务级 TDD 计划(后端)
  2 +
  3 +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / mapper XML / DTO / VO / 公共响应 / REST 契约实现)。**禁止**写 `frontend/**`。
  4 +> 上游 SSoT:spec `docs/superpowers/specs/2026-06-01-REQ-USR-003.md`;需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-003.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`;配置 `config-vars.yaml`。
  5 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。
  6 +> 本 REQ 复用 REQ-USR-001/002 已建的 `modules/usr/**`(`UsrUserController` / `UsrUserService` / `UsrUserServiceImpl` / `UsrUserMapper` / `UsrEmployee`/`UsrUser` 实体)与 `common/**`(`Result` / `ResultCode` / `BusinessException` / `GlobalExceptionHandler` / `SecurityUtil` / JWT / `MybatisPlusConfig` 分页插件);**纯只读查询**,**不新增 migration**(spec § 4 / § 8 D9,仅读 `usr_user` / `usr_employee`,二表已在 `V1__initial_schema.sql` 建好)。
  7 +
  8 +---
  9 +
  10 +## Goal(目标)
  11 +
  12 +实现后台用户查询的唯一只读端点 `GET /api/usr/users`:接收 `UserQueryDTO`(`queryField` / `matchType` / `queryValue` / `pageNum` / `pageSize`,全部可选),按"单字段 + 单匹配方式"施加一个过滤条件(空值返回全量分页),`usr_user LEFT JOIN usr_employee` 取员工名 / 部门,分页装配 `PageResult<UserVO>` 返回 `Result<PageResult<UserVO>>`(`code=0`)。查询**无任何写副作用**,响应**绝不返回 `sPassword` 或任何敏感字段**。分页参数非法(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`;查询参数越界(枚举不合法 / 超长 / 布尔值不可解析为 0/1 / 日期值非法)→ `40001`;数据层「请求页超总页数」钳制到最后一页返回 `code=0`。任意已认证用户可调用(无管理员限制,spec § 8 D5),无 / 失效 token → 401。
  13 +
  14 +## Architecture(架构 / 分层)
  15 +
  16 +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**` 与新增公共分页响应体 `common/response/PageResult.java`(spec § 2.2 / docs/04 § 1.4 / § 3.2 引用但前序 REQ 未建),不跨业务模块:
  17 +
  18 +```
  19 +backend/src/main/java/com/xly/erp/
  20 +├── common/response/PageResult.java # 【本 REQ 新增】通用分页响应体(records/total/pageNum/pageSize),后续模块复用
  21 +├── modules/usr/
  22 +│ ├── controller/UsrUserController.java # 既有类,新增 @GetMapping("/users") queryUsers;仅 @Valid + 委派(本端点无管理员前置,spec § 8 D5)
  23 +│ ├── service/UsrUserService.java # 既有接口,新增 queryUsers(UserQueryDTO dto)
  24 +│ ├── service/impl/UsrUserServiceImpl.java # 既有实现类,新增 queryUsers 实现(参数判定 + 条件解析 + 分页装配)
  25 +│ ├── mapper/UsrUserMapper.java # 既有,新增自定义方法 selectUserPage(LEFT JOIN + 动态条件,走 XML)
  26 +│ ├── entity/{UsrUser,UsrEmployee}.java # 既有,只读,无需改
  27 +│ ├── dto/UserQueryDTO.java # 【本 REQ 新增】查询入参
  28 +│ └── vo/UserVO.java # 【本 REQ 新增】查询输出(不含 sPassword/租户列)
  29 +└── resources/mapper/usr/UsrUserMapper.xml # 【本 REQ 新增】queryUsers 复杂 SQL(LEFT JOIN + <if> 动态条件 + resultMap → UserVO)
  30 +```
  31 +
  32 +- **跨模块**:无。本 REQ 落在 `modules/usr/**` + 新增 `common/response/PageResult.java`(公共契约,非跨业务模块)。新增 `PageResult` 属于 docs/04 § 1.4 / § 3.2 已定义但尚未落盘的公共响应体,建在 `common/response`(与 `Result` / `ResultCode` 同包),后续分页 REQ 复用——需在《模块完成报告》留痕「新增公共分页响应体」(CLAUDE.md 跨模块改动留痕,虽非跨业务模块,新增公共契约一并记录)。
  33 +- **数据访问**:只走 Mapper(MyBatis-Plus)。LEFT JOIN(`usr_user ⋈ usr_employee`)+ 动态单条件 + 分页属"复杂 SQL",按 docs/04 § 3.4 写 **Mapper XML**(`UsrUserMapper.selectUserPage(IPage<UserVO> page, ...)`),由 MP `PaginationInnerInterceptor`(`MybatisPlusConfig` 已注册)自动补 `LIMIT` 与 `COUNT(*)`。Controller 禁止直接调 Mapper。
  34 +- **XML 扫描 / 列映射**:`application.yml` 未显式配 `mybatis-plus.mapper-locations`,MP 默认扫描 `classpath*:/mapper/**/*.xml`,故 XML 落在 `resources/mapper/usr/` 即被自动加载,**无需改 `application.yml`**;若 T4 子会话验证发现 XML 未加载,则最小补 `mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml`(仅此一行,记入 decisions)。因 `map-underscore-to-camel-case: false`,XML 用 `<resultMap>` 显式把列(含 `usr_employee` 别名列)映射到 `UserVO` 字段,不依赖驼峰自动映射。
  35 +- **只读 / 不写**:`queryUsers` 标 `@Transactional(readOnly = true)`(spec § 3.1,可选但推荐);SQL 仅 `SELECT`,**不 SELECT `sPassword`**,不写任何表、不更新 `tLastLoginDate`、不落审计。
  36 +- **安全过滤**:防注入用 `#{}` 预编译占位;LIKE 模糊查询对 `queryValue` 的 `%` `_` `\` 转义并 `ESCAPE '\\'`(spec § 8 D3),转义在 Service 端对 `queryValue` 预处理后传入 XML。
  37 +
  38 +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars)
  39 +
  40 +- Spring Boot 3.x / Java 17 / Maven 3.9.x;MyBatis-Plus(分页插件 `PaginationInnerInterceptor` 已在 `MybatisPlusConfig` 注册);MySQL 8.x;Flyway 10.x(启动 / 测试启动自动 apply `sql/migrations/`,**本 REQ 不新增 migration**,复用 `V1__initial_schema.sql`,spec § 4 / § 8 D9)。
  41 +- Spring Security + JWT(既有 `JwtAuthenticationFilter` / `JwtUtil` / `SecurityUtil`);本接口受保护(非登录端点,安全链已要求认证),但**无管理员前置**(任意已认证用户可调用,spec § 8 D5)。
  42 +- 根包 `com.xly.erp`;端口 / DB 凭据 / JWT 密钥只读 `config-vars.yaml` / `application.yml`,不硬编码。
  43 +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。
  44 +
  45 +## 合同级常量(跨 task 必须一致)
  46 +
  47 +- REST:`GET /api/usr/users`(query 参数绑定到 `UserQueryDTO`,非 `@RequestBody`)。
  48 +- 错误码(复用既有 `ResultCode` 枚举,spec § 6 / docs/05,**不新增不修改枚举**;`PARAM_INVALID=40001`、`PAGE_PARAM_INVALID=42201`、`SUCCESS=0` 已存在):
  49 + - `SUCCESS=0` — 成功,`data` = `PageResult<UserVO>`。
  50 + - `PARAM_INVALID=40001` — 查询参数校验失败(`queryField` / `matchType` 枚举越界 / `queryValue` 超长 / 布尔字段值不可解析为 0/1 / 日期字段值非法)。
  51 + - `PAGE_PARAM_INVALID=42201` — 分页参数非法(`pageNum<1` 或 `pageSize<1` 或 `pageSize>100`)。
  52 + - 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。
  53 +- 枚举取值(spec § 2.1 / § 3.4):
  54 + - `queryField ∈ {用户名, 员工名, 用户号, 部门, 用户类型, 作废, 登录日期, 制单人}`,默认 `用户名`。
  55 + - `matchType ∈ {包含, 不包含, 等于}`,默认 `包含`。
  56 +- 字段 → 列映射(中文 `queryField` → 查询列,spec § 3.4,写死于 Service 映射常量):
  57 + | queryField | 列 | 类型语义 |
  58 + |---|---|---|
  59 + | `用户名` | `usr_user.sUserName` | 文本 |
  60 + | `员工名` | `usr_employee.sEmployeeName` | 文本(跨表) |
  61 + | `用户号` | `usr_user.sUserNo` | 文本 |
  62 + | `部门` | `usr_employee.sDepartment` | 文本(跨表) |
  63 + | `用户类型` | `usr_user.sUserType` | 枚举 |
  64 + | `作废` | `usr_user.iIsVoid` | 布尔 0/1 |
  65 + | `登录日期` | `usr_user.tLastLoginDate` | 日期时间 |
  66 + | `制单人` | `usr_user.sCreator` | 文本 |
  67 +- 匹配语义(spec § 3.5):
  68 + - 文本字段:`包含` → `LIKE '%v%' ESCAPE '\\'`;`不包含` → `NOT LIKE '%v%' ESCAPE '\\'`;`等于` → `= v`。`v` 中 `%` `_` `\` 须转义。
  69 + - 枚举字段(用户类型):`等于` → `= v`;`包含` → `LIKE '%v%'`、`不包含` → `NOT LIKE '%v%'`(容错允许,不报错)。
  70 + - 布尔字段(作废):`queryValue` 归一化为 0/1(接受 `0`/`1`、`是`/`否`、`true`/`false`,spec § 8 D6),按 `等于`→`= 0/1`、`包含`→`= 0/1`、`不包含`→`<> 0/1`;不可解析 → `40001`。
  71 + - 日期字段(登录日期):`queryValue` 解析为 `yyyy-MM-dd` 或 `yyyy-MM-dd HH:mm:ss`(spec § 8 D6);`等于`/`包含` → 命中当日整天区间 `[day 00:00:00, day+1 00:00:00)`;`不包含` → 当日区间取反;非法 → `40001`。
  72 +- 分页语义(spec § 3.7 / § 8 D1):
  73 + - 默认 `pageNum=1`、`pageSize=10`;上限 `pageSize=100`。
  74 + - **参数非法**(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`,先于查询判定。
  75 + - **数据越界**(`pageNum≥1` 合法但超实际总页数)→ 钳制到最后一页返回 `code=0`,`PageResult.pageNum` 回传钳制后实际页号,`total` 为真实总数;`total=0` 时返回空 `records`、`pageNum` 回传 1。
  76 +- 空条件语义(spec § 3.2):`queryValue` 为 null / trim 后空串 → 不施加业务过滤,仅按分页返回全量;此时 `queryField` / `matchType` 不生效。
  77 +- 跨表关联(spec § 3.6 / § 8 D7):`usr_user.iEmployeeId = usr_employee.iIncrement` **LEFT JOIN**;未关联职员的用户仍出现在结果,`employeeName` / `department` 为 null;`不包含` 对该列 null 行依标准 SQL 三值逻辑自然不命中(不写 `OR col IS NULL`)。
  78 +- 敏感字段(spec § 3.9 / § 9):SQL 不 SELECT `sPassword`;`UserVO` 不含密码、不含租户列 `sId`/`sBrandsId`/`sSubsidiaryId`;输出列严格按下「VO 形状」。
  79 +
  80 +## 关键签名(首次出现处给出,跨 task 保持一致)
  81 +
  82 +- `UsrUserService#queryUsers(UserQueryDTO dto)` 返回 `PageResult<UserVO>`。
  83 +- `UsrUserController#queryUsers(@Valid UserQueryDTO dto)` 返回 `Result<PageResult<UserVO>>`(query 参数自动绑定到 DTO,无 `@RequestBody`)。
  84 +- `UsrUserMapper#selectUserPage(com.baomidou.mybatisplus.core.metadata.IPage<UserVO> page, @Param("cond") UserQueryCondition cond)` 返回 `IPage<UserVO>`(XML 实现;MP 分页插件补 LIMIT/COUNT)。其中 `UserQueryCondition` 为 Service 内部传给 Mapper 的「已解析」条件载体(携带目标列名 token、SQL 片段类型、转义后文本值 / 布尔值 / 日期区间起止),由 Service 把 `UserQueryDTO` 的中文 `queryField`/`matchType`/原始 `queryValue` 解析归一为可直接拼 XML `<if>` 分支的结构——避免在 XML 内做中文枚举判断与类型解析。
  85 + - 备选实现(若执行者认为更简单):用 `@Param` 直接传若干已解析标量(如 `column`、`matchOp`、`textValue`、`intValue`、`dateStart`、`dateEnd`、`isText`/`isBool`/`isDate` 标志)替代 `UserQueryCondition` 对象,二者择一、保持 XML `<if>` 分支与 Service 解析结果一致即可。`column` 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。
  86 +- 复用既有:`Result.success(T)`;`BusinessException(ResultCode)` 暴露 `getResultCode()`;`UsrUserMapper extends BaseMapper<UsrUser>`;实体 getter 匈牙利前缀(`getSUserName`/`getIIsVoid`/`getTLastLoginDate` 等)。
  87 +- 实体 / VO getter-setter 沿用匈牙利前缀风格;`UserVO` 跨表字段用驼峰 `employeeName`/`department`(spec § 2.2 契约键名)。
  88 +
  89 +### DTO 形状(`UserQueryDTO`,置于 `modules/usr/dto`)
  90 +
  91 +> query 参数绑定(非 JSON body)。带匈牙利前缀字段的 getter(如 `getSUserName` 类似情况此处不涉及)与查询参数键名需一致;`queryField`/`matchType`/`queryValue`/`pageNum`/`pageSize` 为普通小驼峰,query 参数名直接同名绑定,无需 `@JsonProperty`。
  92 +
  93 +| 字段 | 类型 | 校验注解 | 默认 | 语义 |
  94 +|---|---|---|---|---|
  95 +| `queryField` | String | `@Pattern(regexp="^(用户名|员工名|用户号|部门|用户类型|作废|登录日期|制单人)$")`(null 跳过校验,默认值在 Service 兜底)| `用户名` | 查询字段;越界 `40001` |
  96 +| `matchType` | String | `@Pattern(regexp="^(包含|不包含|等于)$")`(null 跳过)| `包含` | 匹配方式;越界 `40001` |
  97 +| `queryValue` | String | `@Size(max=100)` | — | 查询值;null / trim 后空 = 不施加条件 |
  98 +| `pageNum` | Integer | —(范围在 Service 入口显式判定 `42201`,spec § 8 D8)| `1` | 页码,从 1 起 |
  99 +| `pageSize` | Integer | —(范围在 Service 入口显式判定 `42201`)| `10` | 每页条数,1..100 |
  100 +
  101 +> 注:`pageNum`/`pageSize` **不**用 `@Min/@Max`(避免 `@Valid` 失败被全局处理器统一转 `40001`,与 spec 要求的 `42201` 冲突,spec § 8 D8);改在 Service 入口显式判定范围并抛 `BusinessException(PAGE_PARAM_INVALID)`。`@Valid` 失败(`queryField`/`matchType`/`queryValue` 注解)由既有 `GlobalExceptionHandler` 统一转 `40001`。DTO 提供 getter/setter,默认值可在字段初始化或 Service 兜底(择一,保持 Service 对 null 的兜底逻辑一致)。
  102 +
  103 +### VO 形状(`UserVO`,置于 `modules/usr/vo`,严格按 spec § 2.2,**不含 `sPassword` / 租户列**)
  104 +
  105 +| VO 字段 | 类型 | 来源列 |
  106 +|---|---|---|
  107 +| `id` | Integer | `usr_user.iIncrement` |
  108 +| `sUserName` | String | `usr_user.sUserName` |
  109 +| `employeeName` | String | `usr_employee.sEmployeeName`(LEFT JOIN,可 null) |
  110 +| `sUserNo` | String | `usr_user.sUserNo` |
  111 +| `department` | String | `usr_employee.sDepartment`(LEFT JOIN,可 null) |
  112 +| `sUserType` | String | `usr_user.sUserType` |
  113 +| `sLanguage` | String | `usr_user.sLanguage` |
  114 +| `iIsVoid` | Integer | `usr_user.iIsVoid` |
  115 +| `tLastLoginDate` | LocalDateTime | `usr_user.tLastLoginDate`(可 null) |
  116 +| `sCreator` | String | `usr_user.sCreator` |
  117 +| `tCreateDate` | LocalDateTime | `usr_user.tCreateDate` |
  118 +
  119 +> `UserVO` 提供全部字段 getter/setter;带匈牙利前缀字段(`sUserName`/`sUserNo`/`sUserType`/`sLanguage`/`iIsVoid`/`sCreator`)的 getter 形如 `getSUserName` 会被 Jackson 推断为 `SUserName`,与契约键名不符——对这些字段加 `@JsonProperty`(与 `CreateUserDTO`/`UpdateUserDTO` 同做法)锁定 JSON 键为 `sUserName` 等小驼峰;`employeeName`/`department`/`id`/`tLastLoginDate`/`tCreateDate` 普通驼峰无需 `@JsonProperty`。XML `<resultMap>` 把列映射到 VO 字段名。
  120 +
  121 +### `PageResult<T>` 形状(`common/response/PageResult.java`,docs/04 § 1.4 / § 3.2)
  122 +
  123 +| 字段 | 类型 | 语义 |
  124 +|---|---|---|
  125 +| `records` | `List<T>` | 当前页数据 |
  126 +| `total` | `long` | 真实总记录数 |
  127 +| `pageNum` | `long` | 当前页号(数据越界钳制后的实际页号)|
  128 +| `pageSize` | `long` | 每页条数 |
  129 +
  130 +> 提供全字段 getter/setter + 全参构造器(或静态工厂 `of(records, total, pageNum, pageSize)`),便于 Service 从 MP `IPage` 装配。`implements Serializable`(与 `Result` 一致)。
  131 +
  132 +---
  133 +
  134 +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟)
  135 +
  136 +> 业务类 commit subject 必须带 `REQ-USR-003` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。
  137 +
  138 +### T1 — `PageResult<T>` 公共分页响应体
  139 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/PageResultTest.java`:
  140 + - `::ofAssemblesAllFields` —— 用 `records=[..]`、`total=23`、`pageNum=2`、`pageSize=10` 构造(构造器或 `of`),断言四字段读回一致。
  141 + - `::emptyRecordsAllowed` —— `records=[]`、`total=0`、`pageNum=1` 构造,断言 `records` 非 null 且为空、`total==0`。
  142 +- [ ] **实现**:`common/response/PageResult.java`,按上「`PageResult<T>` 形状」加 `records`/`total`/`pageNum`/`pageSize` 字段 + getter/setter + 全参构造器(或 `of` 静态工厂)+ `implements Serializable`。
  143 +- [ ] **验证**:子会话跑 `PageResultTest` PASS。
  144 +- [ ] **commit**:`feat(usr): 通用分页响应体 PageResult REQ-USR-003`
  145 +
  146 +### T2 — `UserQueryDTO` + `UserVO`(入参校验 + 输出 JSON 键)
  147 +- [ ] **测试**:
  148 + - `backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java`(用 `jakarta.validation.Validator` validate):
  149 + - `::acceptsAllNullAsValid` —— 全 null(含 `queryField`/`matchType`/`queryValue`)无违反(全部可选)。
  150 + - `::acceptsLegalEnums` —— `queryField=登录日期`、`matchType=不包含` 无违反。
  151 + - `::rejectsIllegalQueryField` —— `queryField=身份证` 违反 `@Pattern`。
  152 + - `::rejectsIllegalMatchType` —— `matchType=大于` 违反 `@Pattern`。
  153 + - `::rejectsTooLongQueryValue` —— `queryValue` 长度 101 违反 `@Size(max=100)`。
  154 + - `backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java`(用 `ObjectMapper` 序列化):
  155 + - `::serializesContractKeysNoPassword` —— 构造一个填满字段的 `UserVO`,序列化后 JSON 含键 `id`/`sUserName`/`employeeName`/`sUserNo`/`department`/`sUserType`/`sLanguage`/`iIsVoid`/`tLastLoginDate`/`sCreator`/`tCreateDate`,且**不含** `sPassword`/`password`/`SUserName`(验证 `@JsonProperty` 锁键生效、无密码字段)。
  156 +- [ ] **实现**:`modules/usr/dto/UserQueryDTO.java`(按「DTO 形状」字段 + `@Pattern`/`@Size` + getter/setter,`pageNum`/`pageSize` 不加 `@Min/@Max`);`modules/usr/vo/UserVO.java`(按「VO 形状」11 字段 + `@JsonProperty` 锁匈牙利前缀字段键名 + getter/setter,不含 `sPassword`/租户列)。
  157 +- [ ] **验证**:子会话跑两测试 PASS。
  158 +- [ ] **commit**:`feat(usr): 查询用户入参 UserQueryDTO 与输出 UserVO REQ-USR-003`
  159 +
  160 +### T3 — Mapper:`selectUserPage` 自定义查询(LEFT JOIN + 动态条件 XML)
  161 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java`(`@SpringBootTest` + `@ActiveProfiles("test")` + `@Transactional`(测试回滚,避免污染库)连测试库;`@Autowired UsrUserMapper`;测试内插少量 fixture:1 个关联职员的用户、1 个未关联职员的用户、1 个 `usr_employee`):
  162 + - `::pageReturnsLeftJoinedEmployeeColumns` —— 无过滤条件、`Page<UserVO>(1,10)` 调 `selectUserPage` → 返回 `IPage<UserVO>`,关联职员的用户行 `employeeName`/`department` 非 null 且等于 fixture 职员值;未关联职员的用户行两列为 null;`total≥2`。
  163 + - `::pageAppliesTextLikeOnUserName` —— 条件为「`sUserName` 文本 `包含` fixture 用户名片段」→ 仅命中该用户、`total` 正确。
  164 + - `::pageNeverSelectsPassword` —— 任意结果 `UserVO` 无密码字段(VO 无该属性即天然满足;额外断言 `selectUserPage` 不抛错且 records 元素为 `UserVO` 类型)。
  165 + - (命名前缀如 `t3_user_` / `T3_EMP_` 便于 `@Transactional` 回滚兜底外再清理;若用 `@Transactional` 回滚则无需 `@AfterEach`。)
  166 +- [ ] **实现**:
  167 + - `modules/usr/mapper/UsrUserMapper.java` 新增方法签名 `IPage<UserVO> selectUserPage(IPage<UserVO> page, @Param("cond") UserQueryCondition cond)`(或备选标量 `@Param` 版,见「关键签名」)。
  168 + - 新增 `resources/mapper/usr/UsrUserMapper.xml`:`namespace=com.xly.erp.modules.usr.mapper.UsrUserMapper`;`<resultMap id="userVOMap" type="com.xly.erp.modules.usr.vo.UserVO">` 把 `u.iIncrement→id`、`u.sUserName→sUserName`、`e.sEmployeeName→employeeName`、`u.sUserNo→sUserNo`、`e.sDepartment→department`、`u.sUserType→sUserType`、`u.sLanguage→sLanguage`、`u.iIsVoid→iIsVoid`、`u.tLastLoginDate→tLastLoginDate`、`u.sCreator→sCreator`、`u.tCreateDate→tCreateDate` 映射;`<select id="selectUserPage" resultMap="userVOMap">` 显式列出上述列别名(**不写 `SELECT *`、不 SELECT `u.sPassword`**),`FROM usr_user u LEFT JOIN usr_employee e ON u.iEmployeeId = e.iIncrement`,`<where>` 内按解析后的条件用 `<if>` 拼单分支(文本 `LIKE/NOT LIKE/=` 带 `ESCAPE '\\'`、枚举 `=`/`LIKE`、布尔 `=`/`<>`、日期区间 `>=`/`<`),全部 `#{}` 占位。
  169 + - (`UserQueryCondition` 若采用对象版,置于 `modules/usr/dto` 或 `modules/usr/service` 内部包;属内部解析载体,非对外契约。)
  170 +- [ ] **验证**:子会话跑 `UsrUserMapperPageTest` PASS(连库,确认 XML 被 MP 默认扫描加载、resultMap 映射正确、分页插件生效)。**若 XML 未被加载**(报 `selectUserPage` not found)→ 在 `application.yml` 补 `mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml` 一行后重跑,并把该决策记入返回 decisions。
  171 +- [ ] **commit**:`feat(usr): UsrUserMapper.selectUserPage 跨表分页查询 XML REQ-USR-003`
  172 +
  173 +### T4 — Service:参数判定 + 条件解析 + 分页装配(核心读)
  174 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java`(续既有类,Mockito mock `UsrUserMapper` 等;`selectUserPage` 用 `thenReturn` 桩 `Page<UserVO>`):
  175 + - `::pageParamTooSmallThrows42201` —— `pageNum=0` → 抛 `BusinessException(PAGE_PARAM_INVALID)`,不调 `selectUserPage`;`pageSize=0` 同理。
  176 + - `::pageSizeOverMaxThrows42201` —— `pageSize=500` → `BusinessException(PAGE_PARAM_INVALID)`,不调 Mapper。
  177 + - `::blankQueryValueAppliesNoFilter` —— `queryValue=null`(或空串)→ 调 `selectUserPage` 时传入的 cond 表示「无过滤条件」(用 `ArgumentCaptor` 断言 cond 的过滤标志为空 / 文本值为 null)。
  178 + - `::boolFieldUnparsableThrows40001` —— `queryField=作废&queryValue=abc` → `BusinessException(PARAM_INVALID)`,不调 Mapper;`queryValue=1` 不抛、cond 布尔值为 1;`queryValue=是` 归一化为 1。
  179 + - `::dateFieldIllegalThrows40001` —— `queryField=登录日期&queryValue=2026-13-99` → `BusinessException(PARAM_INVALID)`;`queryValue=2026-06-01` 解析出区间 `[2026-06-01T00:00, 2026-06-02T00:00)`(断言 cond 的 dateStart/dateEnd)。
  180 + - `::textLikeEscapesWildcards` —— `queryField=用户名&matchType=包含&queryValue=a%_b` → cond 的文本值对 `%`/`_` 转义(断言转义后含 `\%`/`\_`,且匹配方式为 LIKE 包含)。
  181 + - `::dataPageOutOfRangeClampsToLastPage` —— mock `selectUserPage` 返回 `total=23`、`size=10`、请求 `pageNum=99`:Service 装配的 `PageResult.pageNum` 钳制为 3(`ceil(23/10)`),`total=23`、`records` 为桩返回的最后一页 records。(实现可借 MP `Page` 的 `current`/`pages` 或自行 `Math.ceil` 钳制并以钳后页号回查/回传,见实现说明。)
  182 + - `::emptyResultReturnsZeroTotal` —— mock 返回空 → `PageResult.records=[]`、`total=0`、`pageNum=1`(不抛错)。
  183 + - `::defaultsApplied` —— DTO 全 null → cond 用默认 `queryField=用户名`、`matchType=包含`,分页默认 `pageNum=1`/`pageSize=10`(断言传给 Mapper 的 `IPage` current=1/size=10)。
  184 +- [ ] **实现**:`UsrUserService.java` 新增 `PageResult<UserVO> queryUsers(UserQueryDTO dto)`;`UsrUserServiceImpl.java` 新增实现并标 `@Transactional(readOnly = true)`:
  185 + 1. **分页参数判定**(先于一切):`pageNum` 兜底默认 1、`pageSize` 兜底默认 10;若 `pageNum<1` 或 `pageSize<1` 或 `pageSize>100` → 抛 `BusinessException(PAGE_PARAM_INVALID)`。
  186 + 2. **条件解析**:`queryField` 兜底 `用户名`、`matchType` 兜底 `包含`;`queryValue` trim 后为空 → cond「无过滤」。非空时按字段类型解析:文本→转义 `%`/`_`/`\` + 选 `LIKE/NOT LIKE/=`;枚举→`=`/`LIKE`;布尔→归一化 `0/1`(不可解析抛 `40001`)+ `=`/`<>`;日期→解析当日区间起止(不可解析抛 `40001`)。把白名单列 token + 解析结果填入 `UserQueryCondition`(或标量 `@Param`)。
  187 + 3. **分页查询**:构造 MP `Page<UserVO>(pageNum, pageSize)` 调 `usrUserMapper.selectUserPage(page, cond)`。
  188 + 4. **越界钳制**:取真实 `total`,算总页数 `pages = total==0?1:ceil(total/pageSize)`;若 `pageNum>pages` → 用钳后页号回查一次(或对已查 `IPage` 重算回传页号——执行者择稳妥实现,保证 `records` 为最后一页真实数据);装配 `PageResult`(`records`/`total`/钳后 `pageNum`/`pageSize`)返回。`total=0` 时 `pageNum` 回传 1。
  189 +- [ ] **验证**:子会话跑上述用例 PASS。
  190 +- [ ] **commit**:`feat(usr): 查询用户 Service 参数判定与条件解析分页装配 REQ-USR-003`
  191 +
  192 +### T5 — Controller:`GET /api/usr/users` 端点(无管理员前置)
  193 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java`(续既有类,MockMvc standaloneSetup + 真实 `GlobalExceptionHandler` + mock `UsrUserService`):
  194 + - `::queryReturnsCodeZeroWithPageResult` —— `GET /api/usr/users?pageNum=1&pageSize=10`,`usrUserService.queryUsers` 桩返回含 1 条 `UserVO` 的 `PageResult` → HTTP 200,`code==0`,`data.total` / `data.pageNum` / `data.pageSize` 正确,`data.records[0].sUserName` 存在;响应体不含 `sPassword`/`password`。
  195 + - `::queryAllowsNonAdmin` —— 普通用户调用(无管理员前置,spec § 8 D5)→ `code==0`,`usrUserService.queryUsers` 被调用(区别于 createUser/updateUser 的 `40301`)。
  196 + - `::queryIllegalEnumReturns40001` —— `queryField=身份证`(`@Pattern` 失败)→ `code==40001`,Service 不被调用。
  197 + - `::queryPageParamInvalidReturns42201` —— Service 桩抛 `BusinessException(PAGE_PARAM_INVALID)`(模拟 `pageNum=0`)→ `code==42201`(验证 Controller 不吞业务异常、全局处理器转码)。
  198 +- [ ] **实现**:`UsrUserController.java` 新增 `@GetMapping("/users")` 方法 `queryUsers(@Valid UserQueryDTO dto)`:**不做管理员前置**(spec § 8 D5,与 `createUser`/`updateUser` 区分);直接委派 `usrUserService.queryUsers(dto)`;返回 `Result.success(pageResult)`。Controller 不直接调 Mapper、不写业务逻辑。
  199 +- [ ] **验证**:子会话跑 `UsrUserControllerTest`(既有 + 新增用例)PASS。
  200 +- [ ] **commit**:`feat(usr): 查询用户 Controller GET /api/usr/users REQ-USR-003`
  201 +
  202 +### T6 — 端到端验收回归(按 spec § 7 验收标准收口)
  203 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/UsrUserQueryIT.java`(`@SpringBootTest` + `@AutoConfigureMockMvc` + `@ActiveProfiles("test")`,连测试库 Flyway 已 apply V1;真实 `JwtUtil` 签发 token 走安全链;`@AfterEach` 按前缀清理本测试 fixture,命名前缀如 `it3_user_` / `IT3_EMP_`;管理员 / 普通用户 token 仿 `UsrUserCreateIT` 生成)覆盖 spec § 7:
  204 + - `::ac1EmptyConditionFullPage` —— 预置数条用户 fixture,`GET /api/usr/users?pageNum=1&pageSize=10` → `code=0`,`records.size≤10`,`total≥fixture 数`,`pageNum=1`、`pageSize=10`;每条 VO 含 § 2.2 全列、**响应体不含 `sPassword`/`password`**。
  205 + - `::ac2TextContains` —— `queryField=用户名&matchType=包含&queryValue=<fixture 片段>` → 仅返回用户名含该片段的用户。
  206 + - `::ac3TextEquals` —— `matchType=等于&queryValue=<完整 fixture 用户名>` → 仅返回严格等于该名的用户(含 1 条;近似名不命中)。
  207 + - `::ac4TextNotContains` —— `queryField=制单人&matchType=不包含&queryValue=<某 creator>` → 返回 `sCreator` 不含该值的用户。
  208 + - `::ac5EnumEquals` —— `queryField=用户类型&matchType=等于&queryValue=超级管理员` → 仅返回 `sUserType=超级管理员` 的用户。
  209 + - `::ac6BoolFilter` —— `queryField=作废&queryValue=1` 仅返回 `iIsVoid=1`;`queryValue=0` 仅 `iIsVoid=0`;`queryValue=abc` → `code=40001`。
  210 + - `::ac7DateFilter` —— 预置一条 `tLastLoginDate=2026-06-01 12:00:00` 的用户,`queryField=登录日期&matchType=等于&queryValue=2026-06-01` → 命中该用户;`queryValue=2026-13-99` → `code=40001`。
  211 + - `::ac8CrossTableEmployeeDept` —— 预置 1 关联职员(部门含「财务」)的用户 + 1 未关联职员的用户:返回 VO 中关联用户 `employeeName`/`department` 来自其职员、未关联用户两列为 null(均在结果中);`queryField=部门&matchType=包含&queryValue=财务` 仅返回所属部门含「财务」的用户。
  212 + - `::ac9NoMatchEmptyList` —— 条件无命中(如 `queryValue=<极不可能的串>`)→ `code=0`、`records=[]`、`total=0`(不报错)。
  213 + - `::ac10DataOutOfRangeLastPage` —— 控制 fixture 使总数落在已知页数(如插 fixture 后用 `queryField` 限定到恰 3 页内),`pageNum=99&pageSize=10` → `code=0`、`records` 为最后一页数据、`PageResult.pageNum` 回传实际最后页号、`total` 为真实总数。(fixture 数难精确时,可先 `total` 查询计算期望末页号再断言。)
  214 + - `::ac11PageParamInvalid` —— `pageNum=0` / `pageSize=0` / `pageSize=500` 各 → `code=42201`。
  215 + - `::ac12NoToken` —— 无 token → HTTP 401,不返回任何用户数据;失效 token 同样 401。
  216 + - `::ac13PasswordNeverLeaks` —— 任意上述成功响应体均不含 `sPassword` 字段、不含明文密码 / `password` 字段。
  217 +- [ ] **实现**:仅在前序 task 暴露缺口时做最小修补(如 XML resultMap 列名 / 日期区间边界 / 越界钳制页号回传与 IT 期望不符时调整),不引入新公共契约、不新增 migration。
  218 +- [ ] **验证**:子会话跑 `UsrUserQueryIT` PASS(连库);随后全量 `mvn -q -B test` 全绿、`mvn -q -B checkstyle:check` 通过。
  219 +- [ ] **commit**:`test(usr): 查询用户端到端验收回归 REQ-USR-003`
  220 +
  221 +---
  222 +
  223 +## 自审
  224 +
  225 +### 占位符扫描
  226 +- 全文无 `【人工填写】` / `TBD` / `TODO` / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记不在本只读查询 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员})继续,不阻塞。
  227 +
  228 +### Spec coverage(spec 每节 → task 映射)
  229 +- § 1 Goal(唯一只读端点 `GET /api/usr/users`、跨表员工名/部门、不返回密码)→ T5(端点)+ T3(LEFT JOIN)+ T4(装配)+ 全部 task。
  230 +- § 2.1 输入 / `UserQueryDTO` 字段与校验 / Auth(无管理员限制)→ T2(DTO 校验)+ T4(默认值兜底 + 范围判定)+ T5(端点 + 无管理员前置)。
  231 +- § 2.2 输出 `Result<PageResult<UserVO>>` / `UserVO` 列 / 不含 `sPassword` → T1(`PageResult`)+ T2(`UserVO` + JSON 键 + 无密码)+ T3(resultMap 不取密码)+ T5(Controller 组装)+ T6 AC1/AC13。
  232 +- § 3.1 只读无副作用 → T4(`@Transactional(readOnly=true)`)+ T3(仅 SELECT)+ T6(无写入断言隐含)。
  233 +- § 3.2 空条件返回全量分页 → T4(空值不施加条件)+ T6 AC1。
  234 +- § 3.3 单条件查询 → T4(解析单字段单匹配)+ T3(XML 单分支)。
  235 +- § 3.4 字段→列映射 → 合同级常量映射表 + T3(XML 列)+ T4(白名单列 token)。
  236 +- § 3.5 匹配方式语义(文本 LIKE/精确、枚举、布尔、日期)→ T4(解析)+ T3(XML 分支 + ESCAPE)+ T6 AC2/3/4/5/6/7。
  237 +- § 3.6 跨表 LEFT JOIN(员工名/部门,未关联为 null)→ T3(XML JOIN + resultMap)+ T6 AC8。
  238 +- § 3.7 分页规则(`42201` 参数非法 / 数据越界返回末页 / 默认值)→ T4(范围判定 + 钳制)+ T6 AC10/AC11。
  239 +- § 3.8 空结果返回空列表不报错 → T4(空装配)+ T6 AC9。
  240 +- § 3.9 密码与敏感字段不返回 → T2(VO 无密码/租户列)+ T3(SQL 不取密码)+ T6 AC13。
  241 +- § 4 约束(分层 / 包路径 / 命名 `queryUsers` / 统一响应 / 异常 / 分页实现 XML / 数据访问 / 安全 / 配置 / schema 不改)→ T1-T5 分层落位,分页 XML 在 T3,参数化/转义在 T3/T4,schema 复用 V1(Tech Stack)。
  242 +- § 5 Schema 引用(读 `usr_user` / `usr_employee`,LEFT JOIN,不 SELECT 密码)→ T3(XML)。
  243 +- § 6 错误码(`0`/`40001`/`42201`/401)→ 复用既有 `ResultCode`,T4(`42201`/`40001`)/T5(全局处理器转码)/T6(401 安全链)。
  244 +- § 7 验收标准 1-13 → T6 AC1-AC13 逐条覆盖。
  245 +- § 8 decisions(D1-D9)→ D1(参数非法 vs 数据越界)T4/合同级常量、D2(单条件)T4/T3、D3(LIKE 转义 ESCAPE)T4/T3、D4(序号→`id`,前端渲染)T2(VO 含 `id` 不含序号)、D5(不限管理员)T5、D6(布尔/日期解析口径)T4、D7(`不包含` 对 null 行 SQL 三值逻辑)T3、D8(`42201` Service 入口显式判定)T4、D9(不新增 migration)Tech Stack 已体现。
  246 +
  247 +### 类型一致性
  248 +- `UsrUserService#queryUsers(UserQueryDTO):PageResult<UserVO>` 在 T4 定义,T5(Controller 调用)/T6(IT)一致引用。
  249 +- `UsrUserController#queryUsers(@Valid UserQueryDTO)` 返回 `Result<PageResult<UserVO>>`,与 docs/05 契约一致。
  250 +- `UsrUserMapper#selectUserPage(IPage<UserVO>, @Param("cond") UserQueryCondition)`(或备选标量 `@Param` 版)在 T3 定义,T4 调用一致;`column` 仅来自固定白名单映射,防注入。
  251 +- `PageResult<T>`(`records`/`total`/`pageNum`/`pageSize`)在 T1 锁定,T4/T5/T6 一致使用;与 docs/04 § 1.4 / § 3.2 字段名一致。
  252 +- `UserQueryDTO` 字段(`queryField`/`matchType`/`queryValue`/`pageNum`/`pageSize`)+ 校验注解在 T2 锁定,T4/T5/T6 一致使用。
  253 +- `UserVO` 11 字段 + `@JsonProperty` 锁键在 T2 锁定,T3(resultMap 映射)/T5/T6 一致;严格不含 `sPassword`/租户列。
  254 +- 错误码字面量 `0`/`40001`/`42201` 复用既有 `ResultCode`(`SUCCESS`/`PARAM_INVALID`/`PAGE_PARAM_INVALID`),与 docs/05、spec § 6 一致,不新增枚举常量。
  255 +- REST 路径 `GET /api/usr/users`、枚举取值 `{包含,不包含,等于}` / `{用户名,...,制单人}` 与 docs/05、spec、合同级常量一致。
  256 +- Mapper 既有继承 `BaseMapper<UsrUser>` + 新增一个自定义 XML 方法;实体 / VO getter 沿用匈牙利前缀风格 + `@JsonProperty` 锁键(与 `CreateUserDTO`/`UpdateUserDTO` 同风格)。