From 56d83a0e9b168863a1818b9d96b34910a3c1ece2 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 14:19:06 +0800 Subject: [PATCH] docs(plan:REQ-USR-003): 任务级 TDD 计划 --- docs/superpowers/plans/2026-06-01-REQ-USR-003.md | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+), 0 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-01-REQ-USR-003.md diff --git a/docs/superpowers/plans/2026-06-01-REQ-USR-003.md b/docs/superpowers/plans/2026-06-01-REQ-USR-003.md new file mode 100644 index 0000000..304485d --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-REQ-USR-003.md @@ -0,0 +1,256 @@ +# REQ-USR-003 查询用户 — 任务级 TDD 计划(后端) + +> 阶段:后端(backend)。作用域:`backend/**`(controller / service / service.impl / mapper / mapper XML / DTO / VO / 公共响应 / REST 契约实现)。**禁止**写 `frontend/**`。 +> 上游 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`。 +> 本计划告诉 TDD 执行者**做什么 / 文件边界 / 测试意图 / API 形状 / 完成判据**;具体代码由红-绿-提交循环产出,不在此 dump 整文件。 +> 本 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` 建好)。 + +--- + +## Goal(目标) + +实现后台用户查询的唯一只读端点 `GET /api/usr/users`:接收 `UserQueryDTO`(`queryField` / `matchType` / `queryValue` / `pageNum` / `pageSize`,全部可选),按"单字段 + 单匹配方式"施加一个过滤条件(空值返回全量分页),`usr_user LEFT JOIN usr_employee` 取员工名 / 部门,分页装配 `PageResult` 返回 `Result>`(`code=0`)。查询**无任何写副作用**,响应**绝不返回 `sPassword` 或任何敏感字段**。分页参数非法(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`;查询参数越界(枚举不合法 / 超长 / 布尔值不可解析为 0/1 / 日期值非法)→ `40001`;数据层「请求页超总页数」钳制到最后一页返回 `code=0`。任意已认证用户可调用(无管理员限制,spec § 8 D5),无 / 失效 token → 401。 + +## Architecture(架构 / 分层) + +遵循 `docs/04 § 1.2`,根包 `com.xly.erp`;本 REQ 仅触及 `modules/usr/**` 与新增公共分页响应体 `common/response/PageResult.java`(spec § 2.2 / docs/04 § 1.4 / § 3.2 引用但前序 REQ 未建),不跨业务模块: + +``` +backend/src/main/java/com/xly/erp/ +├── common/response/PageResult.java # 【本 REQ 新增】通用分页响应体(records/total/pageNum/pageSize),后续模块复用 +├── modules/usr/ +│ ├── controller/UsrUserController.java # 既有类,新增 @GetMapping("/users") queryUsers;仅 @Valid + 委派(本端点无管理员前置,spec § 8 D5) +│ ├── service/UsrUserService.java # 既有接口,新增 queryUsers(UserQueryDTO dto) +│ ├── service/impl/UsrUserServiceImpl.java # 既有实现类,新增 queryUsers 实现(参数判定 + 条件解析 + 分页装配) +│ ├── mapper/UsrUserMapper.java # 既有,新增自定义方法 selectUserPage(LEFT JOIN + 动态条件,走 XML) +│ ├── entity/{UsrUser,UsrEmployee}.java # 既有,只读,无需改 +│ ├── dto/UserQueryDTO.java # 【本 REQ 新增】查询入参 +│ └── vo/UserVO.java # 【本 REQ 新增】查询输出(不含 sPassword/租户列) +└── resources/mapper/usr/UsrUserMapper.xml # 【本 REQ 新增】queryUsers 复杂 SQL(LEFT JOIN + 动态条件 + resultMap → UserVO) +``` + +- **跨模块**:无。本 REQ 落在 `modules/usr/**` + 新增 `common/response/PageResult.java`(公共契约,非跨业务模块)。新增 `PageResult` 属于 docs/04 § 1.4 / § 3.2 已定义但尚未落盘的公共响应体,建在 `common/response`(与 `Result` / `ResultCode` 同包),后续分页 REQ 复用——需在《模块完成报告》留痕「新增公共分页响应体」(CLAUDE.md 跨模块改动留痕,虽非跨业务模块,新增公共契约一并记录)。 +- **数据访问**:只走 Mapper(MyBatis-Plus)。LEFT JOIN(`usr_user ⋈ usr_employee`)+ 动态单条件 + 分页属"复杂 SQL",按 docs/04 § 3.4 写 **Mapper XML**(`UsrUserMapper.selectUserPage(IPage page, ...)`),由 MP `PaginationInnerInterceptor`(`MybatisPlusConfig` 已注册)自动补 `LIMIT` 与 `COUNT(*)`。Controller 禁止直接调 Mapper。 +- **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 用 `` 显式把列(含 `usr_employee` 别名列)映射到 `UserVO` 字段,不依赖驼峰自动映射。 +- **只读 / 不写**:`queryUsers` 标 `@Transactional(readOnly = true)`(spec § 3.1,可选但推荐);SQL 仅 `SELECT`,**不 SELECT `sPassword`**,不写任何表、不更新 `tLastLoginDate`、不落审计。 +- **安全过滤**:防注入用 `#{}` 预编译占位;LIKE 模糊查询对 `queryValue` 的 `%` `_` `\` 转义并 `ESCAPE '\\'`(spec § 8 D3),转义在 Service 端对 `queryValue` 预处理后传入 XML。 + +## Tech Stack(技术栈,源自 docs/04 § 零 + config-vars) + +- 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)。 +- Spring Security + JWT(既有 `JwtAuthenticationFilter` / `JwtUtil` / `SecurityUtil`);本接口受保护(非登录端点,安全链已要求认证),但**无管理员前置**(任意已认证用户可调用,spec § 8 D5)。 +- 根包 `com.xly.erp`;端口 / DB 凭据 / JWT 密钥只读 `config-vars.yaml` / `application.yml`,不硬编码。 +- 命令(docs/04 § 零):build `mvn -q -B -DskipTests package`;lint `mvn -q -B checkstyle:check`;unit `mvn -q -B test`;e2e 无。 + +## 合同级常量(跨 task 必须一致) + +- REST:`GET /api/usr/users`(query 参数绑定到 `UserQueryDTO`,非 `@RequestBody`)。 +- 错误码(复用既有 `ResultCode` 枚举,spec § 6 / docs/05,**不新增不修改枚举**;`PARAM_INVALID=40001`、`PAGE_PARAM_INVALID=42201`、`SUCCESS=0` 已存在): + - `SUCCESS=0` — 成功,`data` = `PageResult`。 + - `PARAM_INVALID=40001` — 查询参数校验失败(`queryField` / `matchType` 枚举越界 / `queryValue` 超长 / 布尔字段值不可解析为 0/1 / 日期字段值非法)。 + - `PAGE_PARAM_INVALID=42201` — 分页参数非法(`pageNum<1` 或 `pageSize<1` 或 `pageSize>100`)。 + - 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。 +- 枚举取值(spec § 2.1 / § 3.4): + - `queryField ∈ {用户名, 员工名, 用户号, 部门, 用户类型, 作废, 登录日期, 制单人}`,默认 `用户名`。 + - `matchType ∈ {包含, 不包含, 等于}`,默认 `包含`。 +- 字段 → 列映射(中文 `queryField` → 查询列,spec § 3.4,写死于 Service 映射常量): + | queryField | 列 | 类型语义 | + |---|---|---| + | `用户名` | `usr_user.sUserName` | 文本 | + | `员工名` | `usr_employee.sEmployeeName` | 文本(跨表) | + | `用户号` | `usr_user.sUserNo` | 文本 | + | `部门` | `usr_employee.sDepartment` | 文本(跨表) | + | `用户类型` | `usr_user.sUserType` | 枚举 | + | `作废` | `usr_user.iIsVoid` | 布尔 0/1 | + | `登录日期` | `usr_user.tLastLoginDate` | 日期时间 | + | `制单人` | `usr_user.sCreator` | 文本 | +- 匹配语义(spec § 3.5): + - 文本字段:`包含` → `LIKE '%v%' ESCAPE '\\'`;`不包含` → `NOT LIKE '%v%' ESCAPE '\\'`;`等于` → `= v`。`v` 中 `%` `_` `\` 须转义。 + - 枚举字段(用户类型):`等于` → `= v`;`包含` → `LIKE '%v%'`、`不包含` → `NOT LIKE '%v%'`(容错允许,不报错)。 + - 布尔字段(作废):`queryValue` 归一化为 0/1(接受 `0`/`1`、`是`/`否`、`true`/`false`,spec § 8 D6),按 `等于`→`= 0/1`、`包含`→`= 0/1`、`不包含`→`<> 0/1`;不可解析 → `40001`。 + - 日期字段(登录日期):`queryValue` 解析为 `yyyy-MM-dd` 或 `yyyy-MM-dd HH:mm:ss`(spec § 8 D6);`等于`/`包含` → 命中当日整天区间 `[day 00:00:00, day+1 00:00:00)`;`不包含` → 当日区间取反;非法 → `40001`。 +- 分页语义(spec § 3.7 / § 8 D1): + - 默认 `pageNum=1`、`pageSize=10`;上限 `pageSize=100`。 + - **参数非法**(`pageNum<1` / `pageSize<1` / `pageSize>100`)→ `42201`,先于查询判定。 + - **数据越界**(`pageNum≥1` 合法但超实际总页数)→ 钳制到最后一页返回 `code=0`,`PageResult.pageNum` 回传钳制后实际页号,`total` 为真实总数;`total=0` 时返回空 `records`、`pageNum` 回传 1。 +- 空条件语义(spec § 3.2):`queryValue` 为 null / trim 后空串 → 不施加业务过滤,仅按分页返回全量;此时 `queryField` / `matchType` 不生效。 +- 跨表关联(spec § 3.6 / § 8 D7):`usr_user.iEmployeeId = usr_employee.iIncrement` **LEFT JOIN**;未关联职员的用户仍出现在结果,`employeeName` / `department` 为 null;`不包含` 对该列 null 行依标准 SQL 三值逻辑自然不命中(不写 `OR col IS NULL`)。 +- 敏感字段(spec § 3.9 / § 9):SQL 不 SELECT `sPassword`;`UserVO` 不含密码、不含租户列 `sId`/`sBrandsId`/`sSubsidiaryId`;输出列严格按下「VO 形状」。 + +## 关键签名(首次出现处给出,跨 task 保持一致) + +- `UsrUserService#queryUsers(UserQueryDTO dto)` 返回 `PageResult`。 +- `UsrUserController#queryUsers(@Valid UserQueryDTO dto)` 返回 `Result>`(query 参数自动绑定到 DTO,无 `@RequestBody`)。 +- `UsrUserMapper#selectUserPage(com.baomidou.mybatisplus.core.metadata.IPage page, @Param("cond") UserQueryCondition cond)` 返回 `IPage`(XML 实现;MP 分页插件补 LIMIT/COUNT)。其中 `UserQueryCondition` 为 Service 内部传给 Mapper 的「已解析」条件载体(携带目标列名 token、SQL 片段类型、转义后文本值 / 布尔值 / 日期区间起止),由 Service 把 `UserQueryDTO` 的中文 `queryField`/`matchType`/原始 `queryValue` 解析归一为可直接拼 XML `` 分支的结构——避免在 XML 内做中文枚举判断与类型解析。 + - 备选实现(若执行者认为更简单):用 `@Param` 直接传若干已解析标量(如 `column`、`matchOp`、`textValue`、`intValue`、`dateStart`、`dateEnd`、`isText`/`isBool`/`isDate` 标志)替代 `UserQueryCondition` 对象,二者择一、保持 XML `` 分支与 Service 解析结果一致即可。`column` 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。 +- 复用既有:`Result.success(T)`;`BusinessException(ResultCode)` 暴露 `getResultCode()`;`UsrUserMapper extends BaseMapper`;实体 getter 匈牙利前缀(`getSUserName`/`getIIsVoid`/`getTLastLoginDate` 等)。 +- 实体 / VO getter-setter 沿用匈牙利前缀风格;`UserVO` 跨表字段用驼峰 `employeeName`/`department`(spec § 2.2 契约键名)。 + +### DTO 形状(`UserQueryDTO`,置于 `modules/usr/dto`) + +> query 参数绑定(非 JSON body)。带匈牙利前缀字段的 getter(如 `getSUserName` 类似情况此处不涉及)与查询参数键名需一致;`queryField`/`matchType`/`queryValue`/`pageNum`/`pageSize` 为普通小驼峰,query 参数名直接同名绑定,无需 `@JsonProperty`。 + +| 字段 | 类型 | 校验注解 | 默认 | 语义 | +|---|---|---|---|---| +| `queryField` | String | `@Pattern(regexp="^(用户名|员工名|用户号|部门|用户类型|作废|登录日期|制单人)$")`(null 跳过校验,默认值在 Service 兜底)| `用户名` | 查询字段;越界 `40001` | +| `matchType` | String | `@Pattern(regexp="^(包含|不包含|等于)$")`(null 跳过)| `包含` | 匹配方式;越界 `40001` | +| `queryValue` | String | `@Size(max=100)` | — | 查询值;null / trim 后空 = 不施加条件 | +| `pageNum` | Integer | —(范围在 Service 入口显式判定 `42201`,spec § 8 D8)| `1` | 页码,从 1 起 | +| `pageSize` | Integer | —(范围在 Service 入口显式判定 `42201`)| `10` | 每页条数,1..100 | + +> 注:`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 的兜底逻辑一致)。 + +### VO 形状(`UserVO`,置于 `modules/usr/vo`,严格按 spec § 2.2,**不含 `sPassword` / 租户列**) + +| VO 字段 | 类型 | 来源列 | +|---|---|---| +| `id` | Integer | `usr_user.iIncrement` | +| `sUserName` | String | `usr_user.sUserName` | +| `employeeName` | String | `usr_employee.sEmployeeName`(LEFT JOIN,可 null) | +| `sUserNo` | String | `usr_user.sUserNo` | +| `department` | String | `usr_employee.sDepartment`(LEFT JOIN,可 null) | +| `sUserType` | String | `usr_user.sUserType` | +| `sLanguage` | String | `usr_user.sLanguage` | +| `iIsVoid` | Integer | `usr_user.iIsVoid` | +| `tLastLoginDate` | LocalDateTime | `usr_user.tLastLoginDate`(可 null) | +| `sCreator` | String | `usr_user.sCreator` | +| `tCreateDate` | LocalDateTime | `usr_user.tCreateDate` | + +> `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 `` 把列映射到 VO 字段名。 + +### `PageResult` 形状(`common/response/PageResult.java`,docs/04 § 1.4 / § 3.2) + +| 字段 | 类型 | 语义 | +|---|---|---| +| `records` | `List` | 当前页数据 | +| `total` | `long` | 真实总记录数 | +| `pageNum` | `long` | 当前页号(数据越界钳制后的实际页号)| +| `pageSize` | `long` | 每页条数 | + +> 提供全字段 getter/setter + 全参构造器(或静态工厂 `of(records, total, pageNum, pageSize)`),便于 Service 从 MP `IPage` 装配。`implements Serializable`(与 `Result` 一致)。 + +--- + +## 任务清单(每个 task = red → green → 子会话验证 PASS → commit;粒度 2-5 分钟) + +> 业务类 commit subject 必须带 `REQ-USR-003` 后缀(CLAUDE.md § Git 提交规范)。每个 task 完成后单独 commit。 + +### T1 — `PageResult` 公共分页响应体 +- [ ] **测试**:`backend/src/test/java/com/xly/erp/common/response/PageResultTest.java`: + - `::ofAssemblesAllFields` —— 用 `records=[..]`、`total=23`、`pageNum=2`、`pageSize=10` 构造(构造器或 `of`),断言四字段读回一致。 + - `::emptyRecordsAllowed` —— `records=[]`、`total=0`、`pageNum=1` 构造,断言 `records` 非 null 且为空、`total==0`。 +- [ ] **实现**:`common/response/PageResult.java`,按上「`PageResult` 形状」加 `records`/`total`/`pageNum`/`pageSize` 字段 + getter/setter + 全参构造器(或 `of` 静态工厂)+ `implements Serializable`。 +- [ ] **验证**:子会话跑 `PageResultTest` PASS。 +- [ ] **commit**:`feat(usr): 通用分页响应体 PageResult REQ-USR-003` + +### T2 — `UserQueryDTO` + `UserVO`(入参校验 + 输出 JSON 键) +- [ ] **测试**: + - `backend/src/test/java/com/xly/erp/modules/usr/dto/UserQueryDTOValidationTest.java`(用 `jakarta.validation.Validator` validate): + - `::acceptsAllNullAsValid` —— 全 null(含 `queryField`/`matchType`/`queryValue`)无违反(全部可选)。 + - `::acceptsLegalEnums` —— `queryField=登录日期`、`matchType=不包含` 无违反。 + - `::rejectsIllegalQueryField` —— `queryField=身份证` 违反 `@Pattern`。 + - `::rejectsIllegalMatchType` —— `matchType=大于` 违反 `@Pattern`。 + - `::rejectsTooLongQueryValue` —— `queryValue` 长度 101 违反 `@Size(max=100)`。 + - `backend/src/test/java/com/xly/erp/modules/usr/vo/UserVOJsonTest.java`(用 `ObjectMapper` 序列化): + - `::serializesContractKeysNoPassword` —— 构造一个填满字段的 `UserVO`,序列化后 JSON 含键 `id`/`sUserName`/`employeeName`/`sUserNo`/`department`/`sUserType`/`sLanguage`/`iIsVoid`/`tLastLoginDate`/`sCreator`/`tCreateDate`,且**不含** `sPassword`/`password`/`SUserName`(验证 `@JsonProperty` 锁键生效、无密码字段)。 +- [ ] **实现**:`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`/租户列)。 +- [ ] **验证**:子会话跑两测试 PASS。 +- [ ] **commit**:`feat(usr): 查询用户入参 UserQueryDTO 与输出 UserVO REQ-USR-003` + +### T3 — Mapper:`selectUserPage` 自定义查询(LEFT JOIN + 动态条件 XML) +- [ ] **测试**:`backend/src/test/java/com/xly/erp/modules/usr/mapper/UsrUserMapperPageTest.java`(`@SpringBootTest` + `@ActiveProfiles("test")` + `@Transactional`(测试回滚,避免污染库)连测试库;`@Autowired UsrUserMapper`;测试内插少量 fixture:1 个关联职员的用户、1 个未关联职员的用户、1 个 `usr_employee`): + - `::pageReturnsLeftJoinedEmployeeColumns` —— 无过滤条件、`Page(1,10)` 调 `selectUserPage` → 返回 `IPage`,关联职员的用户行 `employeeName`/`department` 非 null 且等于 fixture 职员值;未关联职员的用户行两列为 null;`total≥2`。 + - `::pageAppliesTextLikeOnUserName` —— 条件为「`sUserName` 文本 `包含` fixture 用户名片段」→ 仅命中该用户、`total` 正确。 + - `::pageNeverSelectsPassword` —— 任意结果 `UserVO` 无密码字段(VO 无该属性即天然满足;额外断言 `selectUserPage` 不抛错且 records 元素为 `UserVO` 类型)。 + - (命名前缀如 `t3_user_` / `T3_EMP_` 便于 `@Transactional` 回滚兜底外再清理;若用 `@Transactional` 回滚则无需 `@AfterEach`。) +- [ ] **实现**: + - `modules/usr/mapper/UsrUserMapper.java` 新增方法签名 `IPage selectUserPage(IPage page, @Param("cond") UserQueryCondition cond)`(或备选标量 `@Param` 版,见「关键签名」)。 + - 新增 `resources/mapper/usr/UsrUserMapper.xml`:`namespace=com.xly.erp.modules.usr.mapper.UsrUserMapper`;`` 把 `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` 映射;`