Commit 56d83a0e9b168863a1818b9d96b34910a3c1ece2
1 parent
d7285a1c
docs(plan:REQ-USR-003): 任务级 TDD 计划
Showing
1 changed file
with
256 additions
and
0 deletions
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` 同风格)。 |