REQ-USR-003 查询用户 — 任务级 TDD 计划(后端)
阶段:后端(backend)。作用域:
backend/**(controller / service / service.impl / mapper / mapper XML / DTO / VO / 公共响应 / REST 契约实现)。禁止写frontend/**。 上游 SSoT:specdocs/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<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 XML(UsrUserMapper.selectUserPage(IPage<UserVO> page, ...)),由 MPPaginationInnerInterceptor(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 用<resultMap>显式把列(含usr_employee别名列)映射到UserVO字段,不依赖驼峰自动映射。 -
只读 / 不写:
queryUsers标@Transactional(readOnly = true)(spec § 3.1,可选但推荐);SQL 仅SELECT,不 SELECTsPassword,不写任何表、不更新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(启动 / 测试启动自动 applysql/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;lintmvn -q -B checkstyle:check;unitmvn -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<UserVO>。 -
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.iIncrementLEFT 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<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直接传若干已解析标量(如column、matchOp、textValue、intValue、dateStart、dateEnd、isText/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 从 MPIPage装配。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=23、pageNum=2、pageSize=10构造(构造器或of),断言四字段读回一致。 -
::emptyRecordsAllowed——records=[]、total=0、pageNum=1构造,断言records非 null 且为空、total==0。
-
- 实现:
common/response/PageResult.java,按上「PageResult<T>形状」加records/total/pageNum/pageSize字段 + getter/setter + 全参构造器(或of静态工厂)+implements Serializable。 - 验证:子会话跑
PageResultTestPASS。 - 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.Validatorvalidate): -
::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<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.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 *、不 SELECTu.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/dto或modules/usr/service内部包;属内部解析载体,非对外契约。)
-
- 验证:子会话跑
UsrUserMapperPageTestPASS(连库,确认 XML 被 MP 默认扫描加载、resultMap 映射正确、分页插件生效)。若 XML 未被加载(报selectUserPagenot found)→ 在application.yml补mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml一行后重跑,并把该决策记入返回 decisions。 - commit:
feat(usr): UsrUserMapper.selectUserPage 跨表分页查询 XML REQ-USR-003
T4 — Service:参数判定 + 条件解析 + 分页装配(核心读)
- 测试:
backend/src/test/java/com/xly/erp/modules/usr/service/UsrUserServiceImplTest.java(续既有类,Mockito mockUsrUserMapper等;selectUserPage用thenReturn桩Page<UserVO>):-
::pageParamTooSmallThrows42201——pageNum=0→ 抛BusinessException(PAGE_PARAM_INVALID),不调selectUserPage;pageSize=0同理。 -
::pageSizeOverMaxThrows42201——pageSize=500→BusinessException(PAGE_PARAM_INVALID),不调 Mapper。 -
::blankQueryValueAppliesNoFilter——queryValue=null(或空串)→ 调selectUserPage时传入的 cond 表示「无过滤条件」(用ArgumentCaptor断言 cond 的过滤标志为空 / 文本值为 null)。 -
::boolFieldUnparsableThrows40001——queryField=作废&queryValue=abc→BusinessException(PARAM_INVALID),不调 Mapper;queryValue=1不抛、cond 布尔值为 1;queryValue=是归一化为 1。 -
::dateFieldIllegalThrows40001——queryField=登录日期&queryValue=2026-13-99→BusinessException(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—— mockselectUserPage返回total=23、size=10、请求pageNum=99:Service 装配的PageResult.pageNum钳制为 3(ceil(23/10)),total=23、records为桩返回的最后一页 records。(实现可借 MPPage的current/pages或自行Math.ceil钳制并以钳后页号回查/回传,见实现说明。) -
::emptyResultReturnsZeroTotal—— mock 返回空 →PageResult.records=[]、total=0、pageNum=1(不抛错)。 -
::defaultsApplied—— DTO 全 null → cond 用默认queryField=用户名、matchType=包含,分页默认pageNum=1/pageSize=10(断言传给 Mapper 的IPagecurrent=1/size=10)。
-
- 实现:
UsrUserService.java新增PageResult<UserVO> queryUsers(UserQueryDTO dto);UsrUserServiceImpl.java新增实现并标@Transactional(readOnly = true):-
分页参数判定(先于一切):
pageNum兜底默认 1、pageSize兜底默认 10;若pageNum<1或pageSize<1或pageSize>100→ 抛BusinessException(PAGE_PARAM_INVALID)。 -
条件解析:
queryField兜底用户名、matchType兜底包含;queryValuetrim 后为空 → cond「无过滤」。非空时按字段类型解析:文本→转义%/_/\+ 选LIKE/NOT LIKE/=;枚举→=/LIKE;布尔→归一化0/1(不可解析抛40001)+=/<>;日期→解析当日区间起止(不可解析抛40001)。把白名单列 token + 解析结果填入UserQueryCondition(或标量@Param)。 -
分页查询:构造 MP
Page<UserVO>(pageNum, pageSize)调usrUserMapper.selectUserPage(page, cond)。 -
越界钳制:取真实
total,算总页数pages = total==0?1:ceil(total/pageSize);若pageNum>pages→ 用钳后页号回查一次(或对已查IPage重算回传页号——执行者择稳妥实现,保证records为最后一页真实数据);装配PageResult(records/total/钳后pageNum/pageSize)返回。total=0时pageNum回传 1。
-
分页参数判定(先于一切):
- 验证:子会话跑上述用例 PASS。
- commit:
feat(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+ mockUsrUserService):-
::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。 -
::queryAllowsNonAdmin—— 普通用户调用(无管理员前置,spec § 8 D5)→code==0,usrUserService.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。 - commit:
feat(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=10→code=0,records.size≤10,total≥fixture 数,pageNum=1、pageSize=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=1;queryValue=0仅iIsVoid=0;queryValue=abc→code=40001。 -
::ac7DateFilter—— 预置一条tLastLoginDate=2026-06-01 12:00:00的用户,queryField=登录日期&matchType=等于&queryValue=2026-06-01→ 命中该用户;queryValue=2026-13-99→code=40001。 -
::ac8CrossTableEmployeeDept—— 预置 1 关联职员(部门含「财务」)的用户 + 1 未关联职员的用户:返回 VO 中关联用户employeeName/department来自其职员、未关联用户两列为 null(均在结果中);queryField=部门&matchType=包含&queryValue=财务仅返回所属部门含「财务」的用户。 -
::ac9NoMatchEmptyList—— 条件无命中(如queryValue=<极不可能的串>)→code=0、records=[]、total=0(不报错)。 -
::ac10DataOutOfRangeLastPage—— 控制 fixture 使总数落在已知页数(如插 fixture 后用queryField限定到恰 3 页内),pageNum=99&pageSize=10→code=0、records为最后一页数据、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。
- 验证:子会话跑
UsrUserQueryITPASS(连库);随后全量mvn -q -B test全绿、mvn -q -B checkstyle:check通过。 - commit:
test(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(42201Service 入口显式判定)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 一致使用。 -
UserVO11 字段 +@JsonProperty锁键在 T2 锁定,T3(resultMap 映射)/T5/T6 一致;严格不含sPassword/租户列。 - 错误码字面量
0/40001/42201复用既有ResultCode(SUCCESS/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同风格)。