2026-06-01-REQ-USR-003.md 33.5 KB

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:接收 UserQueryDTOqueryField / 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。

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 + <if> 动态条件 + 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 XMLUsrUserMapper.selectUserPage(IPage<UserVO> page, ...)),由 MP PaginationInnerInterceptorMybatisPlusConfig 已注册)自动补 LIMITCOUNT(*)。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 用 <resultMap> 显式把列(含 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=40001PAGE_PARAM_INVALID=42201SUCCESS=0 已存在):
    • SUCCESS=0 — 成功,data = PageResult<UserVO>
    • PARAM_INVALID=40001 — 查询参数校验失败(queryField / matchType 枚举越界 / queryValue 超长 / 布尔字段值不可解析为 0/1 / 日期字段值非法)。
    • PAGE_PARAM_INVALID=42201 — 分页参数非法(pageNum<1pageSize<1pageSize>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 '\\'等于= vv% _ \ 须转义。
    • 枚举字段(用户类型):等于= 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-ddyyyy-MM-dd HH:mm:ss(spec § 8 D6);等于/包含 → 命中当日整天区间 [day 00:00:00, day+1 00:00:00)不包含 → 当日区间取反;非法 → 40001
  • 分页语义(spec § 3.7 / § 8 D1):
    • 默认 pageNum=1pageSize=10;上限 pageSize=100
    • 参数非法pageNum<1 / pageSize<1 / pageSize>100)→ 42201,先于查询判定。
    • 数据越界pageNum≥1 合法但超实际总页数)→ 钳制到最后一页返回 code=0PageResult.pageNum 回传钳制后实际页号,total 为真实总数;total=0 时返回空 recordspageNum 回传 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 sPasswordUserVO 不含密码、不含租户列 sId/sBrandsId/sSubsidiaryId;输出列严格按下「VO 形状」。

关键签名(首次出现处给出,跨 task 保持一致)

  • UsrUserService#queryUsers(UserQueryDTO dto) 返回 PageResult<UserVO>
  • UsrUserController#queryUsers(@Valid UserQueryDTO dto) 返回 Result<PageResult<UserVO>>(query 参数自动绑定到 DTO,无 @RequestBody)。
  • 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 内做中文枚举判断与类型解析。
    • 备选实现(若执行者认为更简单):用 @Param 直接传若干已解析标量(如 columnmatchOptextValueintValuedateStartdateEndisText/isBool/isDate 标志)替代 UserQueryCondition 对象,二者择一、保持 XML <if> 分支与 Service 解析结果一致即可。column 必须由 Service 从固定白名单映射产出(绝不拼接用户输入列名),防注入。
  • 复用既有:Result.success(T)BusinessException(ResultCode) 暴露 getResultCode()UsrUserMapper extends BaseMapper<UsrUser>;实体 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="^(用户名 员工名 用户号
matchType String `@Pattern(regexp="^(包含 不包含 等于)$")`(null 跳过)
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 <resultMap> 把列映射到 VO 字段名。

PageResult<T> 形状(common/response/PageResult.java,docs/04 § 1.4 / § 3.2)

字段 类型 语义
records List<T> 当前页数据
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<T> 公共分页响应体

  • 测试backend/src/test/java/com/xly/erp/common/response/PageResultTest.java
    • ::ofAssemblesAllFields —— 用 records=[..]total=23pageNum=2pageSize=10 构造(构造器或 of),断言四字段读回一致。
    • ::emptyRecordsAllowed —— records=[]total=0pageNum=1 构造,断言 records 非 null 且为空、total==0
  • 实现common/response/PageResult.java,按上「PageResult<T> 形状」加 records/total/pageNum/pageSize 字段 + getter/setter + 全参构造器(或 of 静态工厂)+ implements Serializable
  • 验证:子会话跑 PageResultTest PASS。
  • commitfeat(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。
  • commitfeat(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<UserVO>(1,10)selectUserPage → 返回 IPage<UserVO>,关联职员的用户行 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<UserVO> selectUserPage(IPage<UserVO> page, @Param("cond") UserQueryCondition cond)(或备选标量 @Param 版,见「关键签名」)。
    • 新增 resources/mapper/usr/UsrUserMapper.xmlnamespace=com.xly.erp.modules.usr.mapper.UsrUserMapper<resultMap id="userVOMap" type="com.xly.erp.modules.usr.vo.UserVO">u.iIncrement→idu.sUserName→sUserNamee.sEmployeeName→employeeNameu.sUserNo→sUserNoe.sDepartment→departmentu.sUserType→sUserTypeu.sLanguage→sLanguageu.iIsVoid→iIsVoidu.tLastLoginDate→tLastLoginDateu.sCreator→sCreatoru.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、布尔 =/<>、日期区间 >=/<),全部 #{} 占位。
    • UserQueryCondition 若采用对象版,置于 modules/usr/dtomodules/usr/service 内部包;属内部解析载体,非对外契约。)
  • 验证:子会话跑 UsrUserMapperPageTest PASS(连库,确认 XML 被 MP 默认扫描加载、resultMap 映射正确、分页插件生效)。若 XML 未被加载(报 selectUserPage not found)→ 在 application.ymlmybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml 一行后重跑,并把该决策记入返回 decisions。
  • commitfeat(usr): UsrUserMapper.selectUserPage 跨表分页查询 XML REQ-USR-003

T4 — Service:参数判定 + 条件解析 + 分页装配(核心读)

  • 测试backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java(续既有类,Mockito mock UsrUserMapper 等;selectUserPagethenReturnPage<UserVO>):
    • ::pageParamTooSmallThrows42201 —— pageNum=0 → 抛 BusinessException(PAGE_PARAM_INVALID),不调 selectUserPagepageSize=0 同理。
    • ::pageSizeOverMaxThrows42201 —— pageSize=500BusinessException(PAGE_PARAM_INVALID),不调 Mapper。
    • ::blankQueryValueAppliesNoFilter —— queryValue=null(或空串)→ 调 selectUserPage 时传入的 cond 表示「无过滤条件」(用 ArgumentCaptor 断言 cond 的过滤标志为空 / 文本值为 null)。
    • ::boolFieldUnparsableThrows40001 —— queryField=作废&queryValue=abcBusinessException(PARAM_INVALID),不调 Mapper;queryValue=1 不抛、cond 布尔值为 1;queryValue=是 归一化为 1。
    • ::dateFieldIllegalThrows40001 —— queryField=登录日期&queryValue=2026-13-99BusinessException(PARAM_INVALID)queryValue=2026-06-01 解析出区间 [2026-06-01T00:00, 2026-06-02T00:00)(断言 cond 的 dateStart/dateEnd)。
    • ::textLikeEscapesWildcards —— queryField=用户名&matchType=包含&queryValue=a%_b → cond 的文本值对 %/_ 转义(断言转义后含 \%/\_,且匹配方式为 LIKE 包含)。
    • ::dataPageOutOfRangeClampsToLastPage —— mock selectUserPage 返回 total=23size=10、请求 pageNum=99:Service 装配的 PageResult.pageNum 钳制为 3(ceil(23/10)),total=23records 为桩返回的最后一页 records。(实现可借 MP Pagecurrent/pages 或自行 Math.ceil 钳制并以钳后页号回查/回传,见实现说明。)
    • ::emptyResultReturnsZeroTotal —— mock 返回空 → PageResult.records=[]total=0pageNum=1(不抛错)。
    • ::defaultsApplied —— DTO 全 null → cond 用默认 queryField=用户名matchType=包含,分页默认 pageNum=1/pageSize=10(断言传给 Mapper 的 IPage current=1/size=10)。
  • 实现UsrUserService.java 新增 PageResult<UserVO> queryUsers(UserQueryDTO dto)UsrUserServiceImpl.java 新增实现并标 @Transactional(readOnly = true)
    1. 分页参数判定(先于一切):pageNum 兜底默认 1、pageSize 兜底默认 10;若 pageNum<1pageSize<1pageSize>100 → 抛 BusinessException(PAGE_PARAM_INVALID)
    2. 条件解析queryField 兜底 用户名matchType 兜底 包含queryValue trim 后为空 → cond「无过滤」。非空时按字段类型解析:文本→转义 %/_/\ + 选 LIKE/NOT LIKE/=;枚举→=/LIKE;布尔→归一化 0/1(不可解析抛 40001)+ =/<>;日期→解析当日区间起止(不可解析抛 40001)。把白名单列 token + 解析结果填入 UserQueryCondition(或标量 @Param)。
    3. 分页查询:构造 MP Page<UserVO>(pageNum, pageSize)usrUserMapper.selectUserPage(page, cond)
    4. 越界钳制:取真实 total,算总页数 pages = total==0?1:ceil(total/pageSize);若 pageNum>pages → 用钳后页号回查一次(或对已查 IPage 重算回传页号——执行者择稳妥实现,保证 records 为最后一页真实数据);装配 PageResultrecords/total/钳后 pageNum/pageSize)返回。total=0pageNum 回传 1。
  • 验证:子会话跑上述用例 PASS。
  • commitfeat(usr): 查询用户 Service 参数判定与条件解析分页装配 REQ-USR-003

T5 — Controller:GET /api/usr/users 端点(无管理员前置)

  • 测试backend/src/test/java/com/xly/erp/modules/usr/controller/UsrUserControllerTest.java(续既有类,MockMvc standaloneSetup + 真实 GlobalExceptionHandler + mock UsrUserService):
    • ::queryReturnsCodeZeroWithPageResult —— GET /api/usr/users?pageNum=1&pageSize=10usrUserService.queryUsers 桩返回含 1 条 UserVOPageResult → HTTP 200,code==0data.total / data.pageNum / data.pageSize 正确,data.records[0].sUserName 存在;响应体不含 sPassword/password
    • ::queryAllowsNonAdmin —— 普通用户调用(无管理员前置,spec § 8 D5)→ code==0usrUserService.queryUsers 被调用(区别于 createUser/updateUser 的 40301)。
    • ::queryIllegalEnumReturns40001 —— queryField=身份证@Pattern 失败)→ code==40001,Service 不被调用。
    • ::queryPageParamInvalidReturns42201 —— Service 桩抛 BusinessException(PAGE_PARAM_INVALID)(模拟 pageNum=0)→ code==42201(验证 Controller 不吞业务异常、全局处理器转码)。
  • 实现UsrUserController.java 新增 @GetMapping("/users") 方法 queryUsers(@Valid UserQueryDTO dto)不做管理员前置(spec § 8 D5,与 createUser/updateUser 区分);直接委派 usrUserService.queryUsers(dto);返回 Result.success(pageResult)。Controller 不直接调 Mapper、不写业务逻辑。
  • 验证:子会话跑 UsrUserControllerTest(既有 + 新增用例)PASS。
  • commitfeat(usr): 查询用户 Controller GET /api/usr/users REQ-USR-003

T6 — 端到端验收回归(按 spec § 7 验收标准收口)

  • 测试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:
    • ::ac1EmptyConditionFullPage —— 预置数条用户 fixture,GET /api/usr/users?pageNum=1&pageSize=10code=0records.size≤10total≥fixture 数pageNum=1pageSize=10;每条 VO 含 § 2.2 全列、响应体不含 sPassword/password
    • ::ac2TextContains —— queryField=用户名&matchType=包含&queryValue=<fixture 片段> → 仅返回用户名含该片段的用户。
    • ::ac3TextEquals —— matchType=等于&queryValue=<完整 fixture 用户名> → 仅返回严格等于该名的用户(含 1 条;近似名不命中)。
    • ::ac4TextNotContains —— queryField=制单人&matchType=不包含&queryValue=<某 creator> → 返回 sCreator 不含该值的用户。
    • ::ac5EnumEquals —— queryField=用户类型&matchType=等于&queryValue=超级管理员 → 仅返回 sUserType=超级管理员 的用户。
    • ::ac6BoolFilter —— queryField=作废&queryValue=1 仅返回 iIsVoid=1queryValue=0iIsVoid=0queryValue=abccode=40001
    • ::ac7DateFilter —— 预置一条 tLastLoginDate=2026-06-01 12:00:00 的用户,queryField=登录日期&matchType=等于&queryValue=2026-06-01 → 命中该用户;queryValue=2026-13-99code=40001
    • ::ac8CrossTableEmployeeDept —— 预置 1 关联职员(部门含「财务」)的用户 + 1 未关联职员的用户:返回 VO 中关联用户 employeeName/department 来自其职员、未关联用户两列为 null(均在结果中);queryField=部门&matchType=包含&queryValue=财务 仅返回所属部门含「财务」的用户。
    • ::ac9NoMatchEmptyList —— 条件无命中(如 queryValue=<极不可能的串>)→ code=0records=[]total=0(不报错)。
    • ::ac10DataOutOfRangeLastPage —— 控制 fixture 使总数落在已知页数(如插 fixture 后用 queryField 限定到恰 3 页内),pageNum=99&pageSize=10code=0records 为最后一页数据、PageResult.pageNum 回传实际最后页号、total 为真实总数。(fixture 数难精确时,可先 total 查询计算期望末页号再断言。)
    • ::ac11PageParamInvalid —— pageNum=0 / pageSize=0 / pageSize=500 各 → code=42201
    • ::ac12NoToken —— 无 token → HTTP 401,不返回任何用户数据;失效 token 同样 401。
    • ::ac13PasswordNeverLeaks —— 任意上述成功响应体均不含 sPassword 字段、不含明文密码 / password 字段。
  • 实现:仅在前序 task 暴露缺口时做最小修补(如 XML resultMap 列名 / 日期区间边界 / 越界钳制页号回传与 IT 期望不符时调整),不引入新公共契约、不新增 migration。
  • 验证:子会话跑 UsrUserQueryIT PASS(连库);随后全量 mvn -q -B test 全绿、mvn -q -B checkstyle:check 通过。
  • committest(usr): 查询用户端到端验收回归 REQ-USR-003

自审

占位符扫描

  • 全文无 【人工填写】 / TBD / TODO / 待定占位。spec § 8 注记的 DB 文档「需用户审阅」遗留标记不在本只读查询 REQ 作用域,按 spec 锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员})继续,不阻塞。

Spec coverage(spec 每节 → task 映射)

  • § 1 Goal(唯一只读端点 GET /api/usr/users、跨表员工名/部门、不返回密码)→ T5(端点)+ T3(LEFT JOIN)+ T4(装配)+ 全部 task。
  • § 2.1 输入 / UserQueryDTO 字段与校验 / Auth(无管理员限制)→ T2(DTO 校验)+ T4(默认值兜底 + 范围判定)+ T5(端点 + 无管理员前置)。
  • § 2.2 输出 Result<PageResult<UserVO>> / UserVO 列 / 不含 sPassword → T1(PageResult)+ T2(UserVO + JSON 键 + 无密码)+ T3(resultMap 不取密码)+ T5(Controller 组装)+ T6 AC1/AC13。
  • § 3.1 只读无副作用 → T4(@Transactional(readOnly=true))+ T3(仅 SELECT)+ T6(无写入断言隐含)。
  • § 3.2 空条件返回全量分页 → T4(空值不施加条件)+ T6 AC1。
  • § 3.3 单条件查询 → T4(解析单字段单匹配)+ T3(XML 单分支)。
  • § 3.4 字段→列映射 → 合同级常量映射表 + T3(XML 列)+ T4(白名单列 token)。
  • § 3.5 匹配方式语义(文本 LIKE/精确、枚举、布尔、日期)→ T4(解析)+ T3(XML 分支 + ESCAPE)+ T6 AC2/3/4/5/6/7。
  • § 3.6 跨表 LEFT JOIN(员工名/部门,未关联为 null)→ T3(XML JOIN + resultMap)+ T6 AC8。
  • § 3.7 分页规则(42201 参数非法 / 数据越界返回末页 / 默认值)→ T4(范围判定 + 钳制)+ T6 AC10/AC11。
  • § 3.8 空结果返回空列表不报错 → T4(空装配)+ T6 AC9。
  • § 3.9 密码与敏感字段不返回 → T2(VO 无密码/租户列)+ T3(SQL 不取密码)+ T6 AC13。
  • § 4 约束(分层 / 包路径 / 命名 queryUsers / 统一响应 / 异常 / 分页实现 XML / 数据访问 / 安全 / 配置 / schema 不改)→ T1-T5 分层落位,分页 XML 在 T3,参数化/转义在 T3/T4,schema 复用 V1(Tech Stack)。
  • § 5 Schema 引用(读 usr_user / usr_employee,LEFT JOIN,不 SELECT 密码)→ T3(XML)。
  • § 6 错误码(0/40001/42201/401)→ 复用既有 ResultCode,T4(42201/40001)/T5(全局处理器转码)/T6(401 安全链)。
  • § 7 验收标准 1-13 → T6 AC1-AC13 逐条覆盖。
  • § 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 已体现。

类型一致性

  • UsrUserService#queryUsers(UserQueryDTO):PageResult<UserVO> 在 T4 定义,T5(Controller 调用)/T6(IT)一致引用。
  • UsrUserController#queryUsers(@Valid UserQueryDTO) 返回 Result<PageResult<UserVO>>,与 docs/05 契约一致。
  • UsrUserMapper#selectUserPage(IPage<UserVO>, @Param("cond") UserQueryCondition)(或备选标量 @Param 版)在 T3 定义,T4 调用一致;column 仅来自固定白名单映射,防注入。
  • PageResult<T>records/total/pageNum/pageSize)在 T1 锁定,T4/T5/T6 一致使用;与 docs/04 § 1.4 / § 3.2 字段名一致。
  • UserQueryDTO 字段(queryField/matchType/queryValue/pageNum/pageSize)+ 校验注解在 T2 锁定,T4/T5/T6 一致使用。
  • UserVO 11 字段 + @JsonProperty 锁键在 T2 锁定,T3(resultMap 映射)/T5/T6 一致;严格不含 sPassword/租户列。
  • 错误码字面量 0/40001/42201 复用既有 ResultCodeSUCCESS/PARAM_INVALID/PAGE_PARAM_INVALID),与 docs/05、spec § 6 一致,不新增枚举常量。
  • REST 路径 GET /api/usr/users、枚举取值 {包含,不包含,等于} / {用户名,...,制单人} 与 docs/05、spec、合同级常量一致。
  • Mapper 既有继承 BaseMapper<UsrUser> + 新增一个自定义 XML 方法;实体 / VO getter 沿用匈牙利前缀风格 + @JsonProperty 锁键(与 CreateUserDTO/UpdateUserDTO 同风格)。