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

REQ-USR-003 查询用户 — 实现规格(后端)

阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 SSoT 引用:需求卡片 docs/01-需求清单/USR-用户管理/REQ-USR-003.md;DB 设计 docs/03-数据库设计文档.md;API 契约 docs/05-API接口契约.md;技术规范 docs/04-技术规范.md。 本规格只消费已锁定事实,忽略 UI 描述(控件类型/下拉数据源/列表布局/序号渲染),但查询条件、匹配规则、分页规则全部下沉到后端 DTO + Service。


1. Goal(目标)

后台用户可按单一条件(用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 作废 / 登录日期 / 制单人)配合匹配方式(包含 / 不包含 / 等于)筛选并分页浏览用户列表。查询为只读,不产生任何写副作用;密码与敏感字段绝不返回。对外仅提供一个端点:GET /api/usr/users

输出列含跨表字段「员工名」「部门」,来源 usr_employee,按 usr_user.iEmployeeId 左关联取得(用户未关联职员时该两列为 null)。


2. 输入 / 输出

2.1 输入(请求)

  • Method / PathGET /api/usr/users
  • Auth:需要 Bearer JWT(任意已认证用户可调用,无管理员限制——查询为只读,docs/05 标注 Auth「需要(Bearer JWT)」未附加角色约束,见 § 8 D5)。无 / 失效 token → 401。
  • Query 参数 → UserQueryDTO(全部可选,空条件返回全量分页):
DTO 字段 类型 必填 默认 校验 说明
queryField String 用户名 取值 ∈ {用户名,员工名,用户号,部门,用户类型,作废,登录日期,制单人},越界 40001 查询字段;为空时按默认 用户名 解释(仅当 queryValue 非空才生效)
matchType String 包含 取值 ∈ {包含,不包含,等于},越界 40001 匹配方式;对枚举/外键/日期型字段仅 等于 有效(见 § 3 规则 5)
queryValue String @Size(max=100) 查询值;为空 / null 表示不施加该条件(选择全部,见 § 3 规则 2)
pageNum Integer 1 ≥ 1,否则 42201 页码,从 1 起
pageSize Integer 10 1 ≤ pageSize ≤ 100,否则 42201 每页条数;上限 100(docs/04 § 3.2、docs/05 REQ-USR-003)

不接受 / 忽略的入参:任何写类字段、密码、租户列覆盖等本接口均不接收(GET 只读)。多条件组合不在本 REQ 范围(卡片输入表为单 queryField + 单 matchType + 单 queryValue,见 § 8 D2)。

2.2 输出(响应)

  • 成功:Result<PageResult<UserVO>>code=0
  • PageResult<T>(docs/04 § 1.4 / § 3.2):{ records: UserVO[], total: number, pageNum: number, pageSize: number }
  • UserVO(docs/05 REQ-USR-003 契约 + 卡片输出表 1,不含 sPassword 及任何敏感字段):
VO 字段 类型 来源 卡片输出列 说明
id Integer usr_user.iIncrement 序号映射主键 主键 ID;「序号」列由前端按行号渲染,后端只回 id(见 § 8 D4)
sUserName String usr_user.sUserName 用户名 登录账号
employeeName String usr_employee.sEmployeeName 员工名 左关联,未关联职员为 null
sUserNo String usr_user.sUserNo 用户号
department String usr_employee.sDepartment 部门 左关联,未关联职员为 null
sUserType String usr_user.sUserType 用户类型
sLanguage String usr_user.sLanguage 语言
iIsVoid Integer usr_user.iIsVoid 作废 0 正常 / 1 已作废(布尔语义,回传 0/1)
tLastLoginDate DateTime usr_user.tLastLoginDate 登录日期 可为 null(从未登录)
sCreator String usr_user.sCreator 制单人
tCreateDate DateTime usr_user.tCreateDate 制单日期
  • 失败:统一 Result(见 § 6 错误码),message 给可读中文提示,不抛栈。
  • 响应绝不返回 sPassword 或任何敏感字段(VO 不含密码列;不得 SELECT * 透传实体)。

3. 业务规则

  1. 只读无副作用:本接口仅 SELECT,不写任何表、不更新 tLastLoginDate、不落审计;方法可标 @Transactional(readOnly = true)(可选)。
  2. 空条件返回全量分页queryValue 为空 / null(trim 后为空串亦视为空)时不施加业务过滤条件,仅按分页返回全部用户;此时 queryField / matchType 不生效。
  3. 单条件查询:仅按 queryField 指定的单字段 + matchType 施加一个过滤条件(卡片输入表为单字段单匹配,非多条件 AND/OR 组合,见 § 8 D2)。
  4. 字段 → 列映射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(文本)
  5. 匹配方式语义(按 matchType 与字段类型组合,docs/04 § 3.2「文本条件模糊匹配,枚举/外键条件精确匹配」):
    • 文本字段(用户名 / 员工名 / 用户号 / 部门 / 制单人):包含LIKE '%v%'不包含NOT LIKE '%v%'等于= v(精确)。queryValue 中的 LIKE 通配符 % _ 须转义(见 § 8 D3)。
    • 枚举字段(用户类型):仅 等于= v)有业务意义;包含LIKE '%v%'不包含NOT LIKE '%v%' 也允许执行(容错,不报错),但推荐 等于
    • 布尔字段(作废):queryValue 须可解析为 0/1(接受 0/1,亦可接受 /true/false 归一化为 1/0,见 § 8 D6);按 等于 处理(= 0/1);包含/不包含 退化为 =/<>。无法解析为 0/1 → 40001
    • 日期字段(登录日期):queryValue 须为合法日期(yyyy-MM-dd)或日期时间(yyyy-MM-dd HH:mm:ss);等于 → 命中该「整天」区间 [day 00:00:00, day+1 00:00:00)(仅给到日期时按当日匹配,见 § 8 D6);包含/不包含 在日期字段无模糊语义,按 等于 当日区间 / 取反处理。无法解析 → 40001
  6. 跨表关联:员工名 / 部门来自 usr_employee,按 usr_user.iEmployeeId = usr_employee.iIncrement LEFT JOIN(未关联职员的用户仍出现在结果,员工名 / 部门为 null)。当 queryField ∈ {员工名, 部门} 时,过滤施加在 join 后的 usr_employee 列上(未关联职员的行因该列为 null,包含/等于 自然不命中、不包含 的 null 处理见 § 8 D7)。
  7. 分页规则
    • pageNum < 1 或 pageSize < 1 或 pageSize > 100 → 42201(参数非法,先于查询判定)。
    • pageNum 超过实际总页数(数据层「越界」,如请求第 99 页但仅 3 页)→ 返回最后一页数据(卡片验收「分页参数越界时返回最后一页」),PageResult.pageNum 回传被钳制后的实际页号;total 仍为真实总数(见 § 8 D1)。total 为 0 时返回空 recordspageNum 回传 1。
    • 默认 pageNum=1pageSize=10(docs/05 契约默认)。
  8. 空结果:无匹配记录时返回 code=0 + records=[] + total=0不报错(卡片验收「无匹配时返回空列表而非报错」)。
  9. 密码与敏感字段不返回:查询 SQL 不 SELECT sPassword;VO 不含密码;多租户列 sBrandsId/sSubsidiaryId/sId 不在输出列(输出列严格按 § 2.2 UserVO)。

4. 约束(技术 / 安全)

  • 分层(docs/04 § 1.2):UsrUserController(仅 @Valid 校验 + 委派)→ UsrUserService / UsrUserServiceImpl(业务 + 分页装配)→ UsrUserMapper(MyBatis-Plus)。Controller 禁止直接操作 Mapper。本 REQ 在已存在的 UsrUserService 上新增 queryUsers(UserQueryDTO dto) 方法,复用 REQ-USR-001/002 已建的 controller / service / mapper / entity,不另起类。
  • 包路径com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto,vo};新增查询入参 DTO UserQueryDTO 置于 modules/usr/dto,输出 UserVO 置于 modules/usr/vo。本 REQ 仅触及 modules/usr/**,不跨模块。
  • 命名(docs/04 § 1.3):方法 queryUsers;REST 路径 GET /api/usr/users;DTO UserQueryDTO、VO UserVO
  • 统一响应(docs/04 § 1.4):返回 Result<PageResult<UserVO>>code=0 成功;错误码集中在 ResultCode 枚举(含 4220140001)。
  • 异常处理(docs/04 § 1.5):业务/参数错误抛 BusinessException(ResultCode,msg),由 GlobalExceptionHandlerResult@Valid 基础校验失败由全局处理器转 40001;分页越界(pageNum<1/pageSize 越界)转 42201(在 Controller/Service 入口判定或 @Min/@Max 校验 + 处理器映射,见 § 8 D8)。
  • 分页实现(docs/04 § 3.2、§ 3.4):用 MyBatis-Plus 分页插件(IPage/Page)或在 Mapper XML 内 LIMIT/OFFSET + COUNT(*) 装配 PageResult;跨表 LEFT JOIN(usr_user ⋈ usr_employee)+ 动态条件建议用 Mapper XML(docs/04 § 3.4「复杂 SQL 写 XML」)。禁止 Controller 直接操作 Mapper。
  • 数据访问(docs/04 § 3.4):只走 Mapper;文本条件模糊匹配、枚举/布尔/日期条件精确匹配(docs/04 § 3.2)。SQL 参数化(#{} 预编译占位)防注入;LIKE 通配符转义(§ 8 D3)。
  • 安全:受保护接口经 JwtAuthenticationFilter;查询结果不含密码 / 不进日志敏感字段。
  • 配置:JWT 密钥、DB 凭据只从 config-vars.yaml / application.yml 读取,不硬编码。
  • schema:本 REQ 仅 usr_user / usr_employee,二表已由 sql/migrations/V1__initial_schema.sql 建好,结构与 docs/03 一致,无需新增 migration(见 § 8 D9)。

5. Schema 引用(docs/03 SSoT)

  • usr_user(主键 iIncrement;输出列 sUserName/sUserNo/sUserType/sLanguage/iIsVoid/tLastLoginDate/sCreator/tCreateDate;过滤列另含 iEmployeeId 用于 join;不 SELECT sPassword)。相关索引:idx_usr_user_employee(iEmployeeId)、idx_usr_user_type(sUserType)、uk_usr_user_username(sUserName) 可被查询命中。
  • usr_employee(按 usr_user.iEmployeeId = usr_employee.iIncrement LEFT JOIN;取 sEmployeeName→员工名、sDepartment→部门;过滤员工名 / 部门时命中其列)。索引 idx_usr_employee_name(sEmployeeName) 可被命中。
  • 关系:usr_user N:1 usr_employee(外键 fk_usr_user_employee,ON DELETE SET NULL)。
  • 实体:UsrUserusr_userUsrEmployeeusr_employee(匈牙利前缀列名,实体字段与列名映射保持一致);查询结果装配为 UserVO(非实体直出)。

6. API 引用 / 错误码(docs/05 SSoT)

  • 端点契约见 docs/05-API接口契约.md § REQ-USR-003(GET /api/usr/users)。
  • 错误码:
    • 0 — 成功,返回 PageResult<UserVO>
    • 42201 — 分页参数非法(pageNum < 1pageSize < 1pageSize > 100)。
    • 40001 — 查询参数校验失败(queryField / matchType 枚举越界 / queryValue 超长 / 布尔字段值无法解析为 0/1 / 日期字段值非法)。
    • 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。

7. 验收标准(Acceptance Criteria)

  1. 空条件全量分页:不传任何 queryValue(或 queryValue 空)调用 GET /api/usr/users?pageNum=1&pageSize=10code=0records 为前 10 条用户,total = 用户总数,pageNum=1pageSize=10;每条含 § 2.2 全部 VO 列,不含 sPassword
  2. 文本「包含」筛选queryField=用户名&matchType=包含&queryValue=adm → 仅返回 sUserNameadm 的用户(LIKE %adm%),结果集正确。
  3. 文本「等于」精确queryField=用户名&matchType=等于&queryValue=admin → 仅返回 sUserName 严格等于 admin 的用户。
  4. 文本「不包含」queryField=制单人&matchType=不包含&queryValue=admin → 返回 sCreator 不含 admin 的用户。
  5. 枚举「等于」queryField=用户类型&matchType=等于&queryValue=超级管理员 → 仅返回 sUserType=超级管理员 的用户。
  6. 布尔(作废)筛选queryField=作废&queryValue=1 → 仅返回 iIsVoid=1 的用户;queryValue=0 → 仅 iIsVoid=0;不可解析为 0/1(如 abc)→ code=40001
  7. 日期(登录日期)筛选queryField=登录日期&matchType=等于&queryValue=2026-06-01 → 返回 tLastLoginDate 落在 2026-06-01 当日的用户;非法日期值 → code=40001
  8. 跨表员工名 / 部门:返回每条 VO 的 employeeName / department 来自该用户关联职员;未关联职员(iEmployeeId 为 null)的用户仍在结果中,两列为 null。queryField=部门&matchType=包含&queryValue=财务 仅返回所属部门含「财务」的用户。
  9. 无匹配返回空:筛选条件无命中 → code=0records=[]total=0(不报错)。
  10. 分页越界返回最后一页:总数据仅 3 页(每页 10),请求 pageNum=99code=0records 为最后一页数据,PageResult.pageNum 回传实际最后页号,total 为真实总数。
  11. 分页参数非法pageNum=0pageSize=0pageSize=500code=42201
  12. 未认证:无 / 失效 token 调用 → 401,不返回任何用户数据。
  13. 密码绝不泄露:任意查询的响应体均不含 sPassword 字段、不含明文密码。

8. 自主决策记录(decisions)

# 问题 选择 依据 置信度
D1 「分页越界」与 docs/05 42201 的边界冲突:卡片验收「越界返回最后一页」vs 契约 pageNum<142201 区分两类:参数非法pageNum<1 / pageSize<1 / pageSize>100)→ 42201数据越界pageNum 超总页数但本身合法 ≥1)→ 钳制到最后一页返回 code=0pageNum 回传钳制后实际页号 卡片验收「越界返回最后一页」明确针对「请求页超出数据范围」;docs/05 42201 文义为「pageNum<1 或 pageSize 超上限」即非法入参——二者作用域不同,可并存不矛盾 high
D2 查询是单条件还是多条件组合 单条件:单 queryField + 单 matchType + 单 queryValue 卡片输入表 1 为三行单值(查询字段/匹配方式/查询值),无多字段并列;docs/05 query 参数亦为单组,无条件数组 high
D3 LIKE 模糊查询中 queryValue%/_/\ 等通配符的处理 % _ \ 做转义(ESCAPE '\\'),按字面匹配,防止用户输入被当通配符 / 注入 安全与正确性默认实践;docs/04 § 3.2 文本模糊匹配未禁止转义,转义更稳妥 high
D4 卡片输出列「序号」如何承载 后端 VO 回 id(主键),「序号」由前端按行号渲染;后端不生成连续行号 卡片标「序号—系统生成」属列表展示行号(前端职责);docs/05 UserVOid 不含「序号」;后端阶段不做 UI 行号 high
D5 是否限制仅管理员可查询 不限制,任意已认证用户可调用 docs/05 REQ-USR-003 Auth 仅标「需要(Bearer JWT)」,未附加「仅管理员」(对比 REQ-USR-001/002 明确「仅管理员/超级管理员」);只读查询无敏感写操作 high
D6 布尔(作废)/ 日期(登录日期)字段的 queryValue 解析口径 作废:接受 0/1(兼容 /true/false 归一化),否则 40001;登录日期:接受 yyyy-MM-dd(按当日区间)或 yyyy-MM-dd HH:mm:ss,否则 40001 卡片「查询值=手工输入文本」需后端按目标列类型解析;docs/04 § 3.2「枚举/外键精确匹配」延伸到布尔/日期取精确区间;非法值显式 40001 比静默放行更可靠 medium
D7 跨表字段(员工名/部门)「不包含」对 NULL(未关联职员)行的处理 不包含 仅对该列非 null 的行做 NOT LIKE;未关联职员(列为 null)的行不纳入「不包含」结果(SQL NOT LIKE 对 NULL 返回 unknown 自然不命中) 遵循标准 SQL 三值逻辑;卡片未对 null 行的「不包含」语义特别要求,取 SQL 默认行为,避免引入易错的 OR col IS NULL 隐式语义 medium
D8 42201 的实现层判定方式 在 Controller/Service 入口显式判定 pageNum/pageSize 范围并抛 BusinessException(42201)(或 @Min/@Max + 全局处理器映射到 42201),不依赖分页插件静默兜底 docs/04 § 1.5 业务/参数错误统一走 BusinessException + 全局处理器;显式判定才能精确回 42201 而非 40001 medium
D9 是否新增 migration 不新增,复用 V1__initial_schema.sql 本 REQ 纯读 usr_user / usr_employee,二表已在 V1 建好,结构与 docs/03 一致,无 schema 变更 high

注:docs/03 中 sLanguage / usr_company.sVersion / usr_permission 粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本只读查询 REQ 作用域内消解;本规格沿用 USR 模块既有取值锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}),不阻塞。