req_id: REQ-USR-003 date: 2026-05-06
module: module_usr
Spec: REQ-USR-003 — 用户查询
目标
实现后端 GET /api/users 接口:按 queryField + matchType + queryValue 三件套对 tUser 进行过滤(含跨表 JOIN tStaff 取员工名 / 部门),分页返回精简 VO 列表。
输入 / 触发
接口:GET /api/users,无请求体。
Query parameters(UserQueryDTO):
| 字段 | 类型 | 必填 | 校验 / 取值 |
|---|---|---|---|
pageNum |
Integer | 否 | ≥1,默认 1 |
pageSize |
Integer | 否 | 1-100,默认 20 |
queryField |
String | 否 | 枚举:username / staffname / userno / department / usertype / language / deleted / lastLoginDate / createdBy;缺省视为不过滤 |
matchType |
String | 否 | 枚举:contains / notContains / equals;默认 contains
|
queryValue |
String | 否 | 长度 ≤ 100;缺省视为不过滤;deleted 字段下视为 'true' / 'false' 字符串 |
鉴权:契约要求 Authorization: Bearer <accessToken> + USR:READ。沿用 SecurityConfig permitAll;Controller Javadoc:REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")。
输出 / 结果
HTTP 200,响应体:
{
"code": 200,
"message": "操作成功",
"data": {
"total": 42,
"list": [
{
"iIncrement": 12,
"sUserName": "alice",
"sStaffName": "张三",
"sUserNo": "u001",
"sDepartment": "研发部",
"sUserType": "普通用户",
"sLanguage": "zh",
"bDeleted": false,
"tLastLoginDate": "2026-05-06T09:00:00",
"sCreatedBy": "admin",
"tCreateDate": "2026-05-06T08:00:00"
}
],
"pageNum": 1,
"pageSize": 20
},
"timestamp": 1746528600000
}
新建 UserListItemVO(11 字段)+ 复用项目通用 PageResult<T>(4 字段:total / list / pageNum / pageSize;本期首次引入,作为 module_usr 横切组件)。
不返回:
sPasswordHash/sId/sBrandsId/sSubsidiaryId/iStaffId/bCanModifyDocs/tDeletedDate/sDeletedBy。
业务规则
-
范围过滤:默认仅返回
bDeleted=0用户除非queryField=deleted且queryValue显式过滤——见规则 6。 -
跨表 JOIN:
tUser LEFT JOIN tStaff ON tUser.iStaffId = tStaff.iIncrement AND tStaff.bDeleted = 0;空关联字段(无 staff)展示为空字符串""(VO 字段为 null)。 - queryField 列映射:
| queryField 值 | SQL 实际列 |
|---|---|
| username | tUser.sUserName |
| staffname | tStaff.sStaffName |
| userno | tUser.sUserNo |
| department | tStaff.sDepartment |
| usertype | tUser.sUserType |
| language | tUser.sLanguage |
| deleted | tUser.bDeleted |
| lastLoginDate | tUser.tLastLoginDate |
| createdBy | tUser.sCreatedBy |
- matchType 映射:
| matchType 值 | SQL 片段 |
|---|---|
| contains(默认) | <col> LIKE '%' || queryValue || '%' |
| notContains | <col> NOT LIKE '%' || queryValue || '%' |
| equals | <col> = queryValue |
-
空过滤值语义:
queryField或queryValue缺省 / 空串 → 不附加该 WHERE 条件。 -
deleted 字段特殊处理:当
queryField=deleted时,bDeleted=0的默认过滤不应用(让queryValue直接控制——'false'/'0'→ bDeleted=0;'true'/'1'→ bDeleted=1)。 -
排序:按
tUser.tCreateDate DESC, tUser.iIncrement DESC稳定排序。 -
分页:MyBatis-Plus
Page<T>+selectPage标准分页;total 由 MP 自动计数。 -
只读:
@Transactional(readOnly = true);不写库。 -
空结果:
data.list = []+data.total = 0+code=200,不返回 404。
边界与约束
鉴权策略
沿用 SecurityConfig permitAll。
错误码映射
| 场景 | 错误码 | ErrorCode 枚举 |
|---|---|---|
pageSize 超 1-100 / queryField / matchType 非枚举 / queryValue 长度超限 |
40010 |
PARAM_INVALID(已存在) |
| 服务端兜底 | 50000 | INTERNAL_ERROR |
实现路径选择
引入自定义 XML SQL(UserMapper.xml):本期首次需要跨表 JOIN,纯 LambdaQueryWrapper 难以表达 LEFT JOIN + 动态 WHERE。在 mapper/usr/UserMapper.xml 添加 searchUsers(IPage<UserListItemVO> page, @Param("query") UserQueryDTO query) 方法,对应 ResultMap 映射到 UserListItemVO。
XML SQL 草稿(写入 plan 锁定):
<select id="searchUsers" resultType="com.xly.erp.module.usr.vo.UserListItemVO">
SELECT
u.iIncrement, u.sUserName, s.sStaffName, u.sUserNo,
s.sDepartment, u.sUserType, u.sLanguage, u.bDeleted,
u.tLastLoginDate, u.sCreatedBy, u.tCreateDate
FROM tUser u
LEFT JOIN tStaff s ON u.iStaffId = s.iIncrement AND s.bDeleted = 0
<where>
<if test="query.queryField != 'deleted'">
u.bDeleted = 0
</if>
<if test="query.queryField != null and query.queryField != '' and query.queryValue != null and query.queryValue != ''">
AND
<choose>
<when test="query.matchType == 'equals'">
${col} = #{query.queryValue}
</when>
<when test="query.matchType == 'notContains'">
${col} NOT LIKE CONCAT('%', #{query.queryValue}, '%')
</when>
<otherwise>
${col} LIKE CONCAT('%', #{query.queryValue}, '%')
</otherwise>
</choose>
</if>
</where>
ORDER BY u.tCreateDate DESC, u.iIncrement DESC
</select>
${col}由 service 层通过 enum 映射成实际列名后传入参数(不是直接拼用户输入——避免 SQL 注入);queryField 不在白名单 → 抛 PARAM_INVALID。
实际实现可能要把 ${col} 替换逻辑放到 service:service 把 queryField 翻译为列字符串放进 query 对象的临时字段(如 query.setColumn(...)),XML 用 ${query.column} 渲染——XMl ${...} 直接拼接字符串,服务层先白名单校验。
性能
- LEFT JOIN tStaff 走
tUser.iStaffId上的索引(fk_user_staff)。 -
LIKE '%X%'左模糊不走索引;本期数据量低可接受。 - MP
Page<T>走 PaginationInnerInterceptor,需要在MybatisPlusConfig注册(如已存在跳过;如未引入,本 REQ 引入)。
Bean Validation
UserQueryDTO 用 @Min(1) Integer pageNum、@Min(1) @Max(100) Integer pageSize、@Pattern 校验枚举字段。Controller 用 @Valid(query 参数 bean 绑定)。
依赖的 schema 表 / 字段
读表:tUser(主体)+ tStaff(LEFT JOIN)
tUser 字段 |
用途 |
|---|---|
全部 11 个输出字段 + bDeleted 过滤 |
SELECT + WHERE |
iStaffId |
LEFT JOIN 键 |
tStaff 字段 |
用途 |
|---|---|
iIncrement |
LEFT JOIN 键 |
sStaffName / sDepartment
|
SELECT + 可选 WHERE |
bDeleted |
LEFT JOIN 时附加条件 (s.bDeleted=0) |
索引利用:
-
pk_user:分页 ORDER BY iIncrement -
fk_user_staff(隐式 BTREE oniStaffId):JOIN -
uk_user_no/uk_user_name:equals 匹配能走索引
依赖的接口
无(独立查询接口)。
验收标准
功能正确性
- 空查询返回所有未删除用户:DB 有 5 个 user(含 1 个 bDeleted=1),GET 不带 queryField 返回 4 个未删除(含 LEFT JOIN 出来的 staff 名 / 部门)。
-
按用户名 contains 过滤:DB 有 alice / alex / bob,
queryField=username&matchType=contains&queryValue=al返回 alice + alex。 - 按员工名 contains 过滤:通过 LEFT JOIN,匹配 tStaff.sStaffName。
- 按部门 equals 过滤:matchType=equals,命中精确部门。
- deleted=false 过滤:返回未删除集合。
- deleted=true 过滤:仅返回已软删除(验证规则 6)。
- notContains 排除:matchType=notContains,排除指定关键字。
- 分页:pageSize=2,pageNum=1 / 2,断言 list.size + total。
- 空结果:queryValue 完全不匹配,返回 list=[] + total=0。
- 响应字段精简:jsonPath 验证 sPasswordHash / sId / sBrandsId / iStaffId 不出现。
- 未关联 staff 用户:DB 中有 user.iStaffId=null,列表 sStaffName / sDepartment 应为 null。
- pageSize 超限:pageSize=101,返回 40010。
- queryField 非枚举:queryField=invalid,返回 40010。
- matchType 非枚举:返回 40010。
- 排序:tCreateDate DESC,新建在前。
接口契约一致性
- 响应格式
{code, message, data, timestamp};data 嵌套{total, list, pageNum, pageSize}。 - 错误码 200 / 40010 / 50000。
- 不暴露 sPasswordHash 等内部字段。
测试覆盖
-
单元测试
UserServiceImplTest追加(mock UserMapper.searchUsers):- search_emptyDb_returnsEmptyPage
- search_invalidQueryField_throws40010(service 内白名单校验)
- search_invalidMatchType_throws40010
- search_passesColumnAndValue_toMapper(ArgumentCaptor 验 query.column 由白名单映射)
-
集成测试
UserControllerIT追加:- get_emptyKeyword_returnsAllUndeleted
- get_filterByUsernameContains_returnsMatchedSubset
- get_filterByStaffnameContains_returnsJoinedResults
- get_filterByDeletedTrue_returnsOnlyDeleted
- get_pagination_returnsCorrectSlice
- get_responseExcludesInternalFields
- get_pageSizeTooLarge_returns40010
- get_invalidQueryField_returns40010
- get_userWithoutStaff_listItemHasNullStaffFields
代码与文档
-
// REQ-USR-003注释贴在 Controller / Service 方法 / Mapper.xml 顶部。 - 提交按
feat(usr): <subject> REQ-USR-003。 -
新增
MybatisPlusConfig(如不存在):PaginationInnerInterceptorbean 启用 MP 分页。 -
新增 通用
PageResult<T>VO(horizontal scope;本 REQ 首次引入但属于 common 命名空间)。