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 / Path:
GET /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. 业务规则
-
只读无副作用:本接口仅
SELECT,不写任何表、不更新tLastLoginDate、不落审计;方法可标@Transactional(readOnly = true)(可选)。 -
空条件返回全量分页:
queryValue为空 / null(trim 后为空串亦视为空)时不施加业务过滤条件,仅按分页返回全部用户;此时queryField/matchType不生效。 -
单条件查询:仅按
queryField指定的单字段 +matchType施加一个过滤条件(卡片输入表为单字段单匹配,非多条件 AND/OR 组合,见 § 8 D2)。 -
字段 → 列映射(
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(文本)
-
-
匹配方式语义(按
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。
-
文本字段(用户名 / 员工名 / 用户号 / 部门 / 制单人):
-
跨表关联:员工名 / 部门来自
usr_employee,按usr_user.iEmployeeId = usr_employee.iIncrementLEFT JOIN(未关联职员的用户仍出现在结果,员工名 / 部门为 null)。当queryField∈ {员工名, 部门} 时,过滤施加在 join 后的usr_employee列上(未关联职员的行因该列为 null,包含/等于自然不命中、不包含的 null 处理见 § 8 D7)。 -
分页规则:
-
pageNum< 1 或pageSize< 1 或pageSize> 100 →42201(参数非法,先于查询判定)。 -
pageNum超过实际总页数(数据层「越界」,如请求第 99 页但仅 3 页)→ 返回最后一页数据(卡片验收「分页参数越界时返回最后一页」),PageResult.pageNum回传被钳制后的实际页号;total仍为真实总数(见 § 8 D1)。total 为 0 时返回空records、pageNum回传 1。 - 默认
pageNum=1、pageSize=10(docs/05 契约默认)。
-
-
空结果:无匹配记录时返回
code=0+records=[]+total=0,不报错(卡片验收「无匹配时返回空列表而非报错」)。 -
密码与敏感字段不返回:查询 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};新增查询入参 DTOUserQueryDTO置于modules/usr/dto,输出UserVO置于modules/usr/vo。本 REQ 仅触及modules/usr/**,不跨模块。 -
命名(docs/04 § 1.3):方法
queryUsers;REST 路径GET /api/usr/users;DTOUserQueryDTO、VOUserVO。 -
统一响应(docs/04 § 1.4):返回
Result<PageResult<UserVO>>,code=0成功;错误码集中在ResultCode枚举(含42201、40001)。 -
异常处理(docs/04 § 1.5):业务/参数错误抛
BusinessException(ResultCode,msg),由GlobalExceptionHandler转Result;@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;不 SELECTsPassword)。相关索引:idx_usr_user_employee(iEmployeeId)、idx_usr_user_type(sUserType)、uk_usr_user_username(sUserName) 可被查询命中。 -
读:
usr_employee(按usr_user.iEmployeeId = usr_employee.iIncrementLEFT JOIN;取sEmployeeName→员工名、sDepartment→部门;过滤员工名 / 部门时命中其列)。索引idx_usr_employee_name(sEmployeeName) 可被命中。 - 关系:
usr_user N:1 usr_employee(外键fk_usr_user_employee,ON DELETE SET NULL)。 - 实体:
UsrUser↔usr_user,UsrEmployee↔usr_employee(匈牙利前缀列名,实体字段与列名映射保持一致);查询结果装配为UserVO(非实体直出)。
6. API 引用 / 错误码(docs/05 SSoT)
- 端点契约见
docs/05-API接口契约.md§ REQ-USR-003(GET /api/usr/users)。 - 错误码:
-
0— 成功,返回PageResult<UserVO>。 -
42201— 分页参数非法(pageNum < 1或pageSize < 1或pageSize > 100)。 -
40001— 查询参数校验失败(queryField/matchType枚举越界 /queryValue超长 / 布尔字段值无法解析为 0/1 / 日期字段值非法)。 - 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。
-
7. 验收标准(Acceptance Criteria)
-
空条件全量分页:不传任何
queryValue(或queryValue空)调用GET /api/usr/users?pageNum=1&pageSize=10→code=0,records为前 10 条用户,total= 用户总数,pageNum=1、pageSize=10;每条含 § 2.2 全部 VO 列,不含sPassword。 -
文本「包含」筛选:
queryField=用户名&matchType=包含&queryValue=adm→ 仅返回sUserName含adm的用户(LIKE%adm%),结果集正确。 -
文本「等于」精确:
queryField=用户名&matchType=等于&queryValue=admin→ 仅返回sUserName严格等于admin的用户。 -
文本「不包含」:
queryField=制单人&matchType=不包含&queryValue=admin→ 返回sCreator不含admin的用户。 -
枚举「等于」:
queryField=用户类型&matchType=等于&queryValue=超级管理员→ 仅返回sUserType=超级管理员的用户。 -
布尔(作废)筛选:
queryField=作废&queryValue=1→ 仅返回iIsVoid=1的用户;queryValue=0→ 仅iIsVoid=0;不可解析为 0/1(如abc)→code=40001。 -
日期(登录日期)筛选:
queryField=登录日期&matchType=等于&queryValue=2026-06-01→ 返回tLastLoginDate落在 2026-06-01 当日的用户;非法日期值 →code=40001。 -
跨表员工名 / 部门:返回每条 VO 的
employeeName/department来自该用户关联职员;未关联职员(iEmployeeId为 null)的用户仍在结果中,两列为 null。queryField=部门&matchType=包含&queryValue=财务仅返回所属部门含「财务」的用户。 -
无匹配返回空:筛选条件无命中 →
code=0,records=[],total=0(不报错)。 -
分页越界返回最后一页:总数据仅 3 页(每页 10),请求
pageNum=99→code=0,records为最后一页数据,PageResult.pageNum回传实际最后页号,total为真实总数。 -
分页参数非法:
pageNum=0或pageSize=0或pageSize=500→code=42201。 - 未认证:无 / 失效 token 调用 → 401,不返回任何用户数据。
-
密码绝不泄露:任意查询的响应体均不含
sPassword字段、不含明文密码。
8. 自主决策记录(decisions)
| # | 问题 | 选择 | 依据 | 置信度 |
|---|---|---|---|---|
| D1 | 「分页越界」与 docs/05 42201 的边界冲突:卡片验收「越界返回最后一页」vs 契约 pageNum<1 报 42201
|
区分两类:参数非法(pageNum<1 / pageSize<1 / pageSize>100)→ 42201;数据越界(pageNum 超总页数但本身合法 ≥1)→ 钳制到最后一页返回 code=0。pageNum 回传钳制后实际页号 |
卡片验收「越界返回最后一页」明确针对「请求页超出数据范围」;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 UserVO 含 id 不含「序号」;后端阶段不做 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 模块既有取值锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}),不阻塞。