2026-05-06-REQ-USR-003.md 9.7 KB

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 parametersUserQueryDTO):

字段 类型 必填 校验 / 取值
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

业务规则

  1. 范围过滤:默认仅返回 bDeleted=0 用户除非 queryField=deletedqueryValue 显式过滤——见规则 6。
  2. 跨表 JOINtUser LEFT JOIN tStaff ON tUser.iStaffId = tStaff.iIncrement AND tStaff.bDeleted = 0;空关联字段(无 staff)展示为空字符串 ""(VO 字段为 null)。
  3. 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 |

  1. matchType 映射

| matchType 值 | SQL 片段 | |---|---| | contains(默认) | <col> LIKE '%' || queryValue || '%' | | notContains | <col> NOT LIKE '%' || queryValue || '%' | | equals | <col> = queryValue |

  1. 空过滤值语义queryFieldqueryValue 缺省 / 空串 → 不附加该 WHERE 条件。
  2. deleted 字段特殊处理:当 queryField=deleted 时,bDeleted=0 的默认过滤不应用(让 queryValue 直接控制——'false' / '0' → bDeleted=0;'true' / '1' → bDeleted=1)。
  3. 排序:按 tUser.tCreateDate DESC, tUser.iIncrement DESC 稳定排序。
  4. 分页:MyBatis-Plus Page<T> + selectPage 标准分页;total 由 MP 自动计数。
  5. 只读@Transactional(readOnly = true);不写库。
  6. 空结果data.list = [] + data.total = 0 + code=200,不返回 404。

边界与约束

鉴权策略

沿用 SecurityConfig permitAll。

错误码映射

场景 错误码 ErrorCode 枚举
pageSize 超 1-100 / queryField / matchType 非枚举 / queryValue 长度超限 40010 PARAM_INVALID(已存在)
服务端兜底 50000 INTERNAL_ERROR

实现路径选择

引入自定义 XML SQLUserMapper.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 on iStaffId):JOIN
  • uk_user_no / uk_user_name:equals 匹配能走索引

依赖的接口

无(独立查询接口)。

验收标准

功能正确性

  1. 空查询返回所有未删除用户:DB 有 5 个 user(含 1 个 bDeleted=1),GET 不带 queryField 返回 4 个未删除(含 LEFT JOIN 出来的 staff 名 / 部门)。
  2. 按用户名 contains 过滤:DB 有 alice / alex / bob,queryField=username&matchType=contains&queryValue=al 返回 alice + alex。
  3. 按员工名 contains 过滤:通过 LEFT JOIN,匹配 tStaff.sStaffName。
  4. 按部门 equals 过滤:matchType=equals,命中精确部门。
  5. deleted=false 过滤:返回未删除集合。
  6. deleted=true 过滤:仅返回已软删除(验证规则 6)。
  7. notContains 排除:matchType=notContains,排除指定关键字。
  8. 分页:pageSize=2,pageNum=1 / 2,断言 list.size + total。
  9. 空结果:queryValue 完全不匹配,返回 list=[] + total=0。
  10. 响应字段精简:jsonPath 验证 sPasswordHash / sId / sBrandsId / iStaffId 不出现。
  11. 未关联 staff 用户:DB 中有 user.iStaffId=null,列表 sStaffName / sDepartment 应为 null。
  12. pageSize 超限:pageSize=101,返回 40010。
  13. queryField 非枚举:queryField=invalid,返回 40010。
  14. matchType 非枚举:返回 40010。
  15. 排序: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(如不存在):PaginationInnerInterceptor bean 启用 MP 分页。
  • 新增 通用 PageResult<T> VO(horizontal scope;本 REQ 首次引入但属于 common 命名空间)。