Commit d7285a1cd82b17cb717430bd885dcdf50360168a
1 parent
b5b16599
docs(spec:REQ-USR-003): 派生规格
Showing
1 changed file
with
156 additions
and
0 deletions
docs/superpowers/specs/2026-06-01-REQ-USR-003.md
0 → 100644
| 1 | +# REQ-USR-003 查询用户 — 实现规格(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 | |
| 4 | +> SSoT 引用:需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-003.md`;DB 设计 `docs/03-数据库设计文档.md`;API 契约 `docs/05-API接口契约.md`;技术规范 `docs/04-技术规范.md`。 | |
| 5 | +> 本规格只消费已锁定事实,忽略 UI 描述(控件类型/下拉数据源/列表布局/序号渲染),但查询条件、匹配规则、分页规则全部下沉到后端 DTO + Service。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Goal(目标) | |
| 10 | + | |
| 11 | +后台用户可按单一条件(用户名 / 员工名 / 用户号 / 部门 / 用户类型 / 作废 / 登录日期 / 制单人)配合匹配方式(包含 / 不包含 / 等于)筛选并分页浏览用户列表。查询为**只读**,不产生任何写副作用;密码与敏感字段绝不返回。对外仅提供一个端点:`GET /api/usr/users`。 | |
| 12 | + | |
| 13 | +输出列含跨表字段「员工名」「部门」,来源 `usr_employee`,按 `usr_user.iEmployeeId` 左关联取得(用户未关联职员时该两列为 null)。 | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 2. 输入 / 输出 | |
| 18 | + | |
| 19 | +### 2.1 输入(请求) | |
| 20 | + | |
| 21 | +- **Method / Path**:`GET /api/usr/users` | |
| 22 | +- **Auth**:需要 Bearer JWT(任意已认证用户可调用,无管理员限制——查询为只读,docs/05 标注 Auth「需要(Bearer JWT)」未附加角色约束,见 § 8 D5)。无 / 失效 token → 401。 | |
| 23 | +- **Query 参数 → `UserQueryDTO`**(全部可选,空条件返回全量分页): | |
| 24 | + | |
| 25 | +| DTO 字段 | 类型 | 必填 | 默认 | 校验 | 说明 | | |
| 26 | +|---|---|---|---|---|---| | |
| 27 | +| `queryField` | String | 否 | `用户名` | 取值 ∈ {`用户名`,`员工名`,`用户号`,`部门`,`用户类型`,`作废`,`登录日期`,`制单人`},越界 `40001` | 查询字段;为空时按默认 `用户名` 解释(仅当 `queryValue` 非空才生效) | | |
| 28 | +| `matchType` | String | 否 | `包含` | 取值 ∈ {`包含`,`不包含`,`等于`},越界 `40001` | 匹配方式;对枚举/外键/日期型字段仅 `等于` 有效(见 § 3 规则 5) | | |
| 29 | +| `queryValue` | String | 否 | — | `@Size(max=100)` | 查询值;为空 / null 表示不施加该条件(选择全部,见 § 3 规则 2) | | |
| 30 | +| `pageNum` | Integer | 否 | `1` | ≥ 1,否则 `42201` | 页码,从 1 起 | | |
| 31 | +| `pageSize` | Integer | 否 | `10` | 1 ≤ pageSize ≤ 100,否则 `42201` | 每页条数;上限 100(docs/04 § 3.2、docs/05 REQ-USR-003) | | |
| 32 | + | |
| 33 | +> 不接受 / 忽略的入参:任何写类字段、密码、租户列覆盖等本接口均不接收(GET 只读)。多条件组合不在本 REQ 范围(卡片输入表为单 `queryField` + 单 `matchType` + 单 `queryValue`,见 § 8 D2)。 | |
| 34 | + | |
| 35 | +### 2.2 输出(响应) | |
| 36 | + | |
| 37 | +- 成功:`Result<PageResult<UserVO>>`,`code=0`。 | |
| 38 | +- `PageResult<T>`(docs/04 § 1.4 / § 3.2):`{ records: UserVO[], total: number, pageNum: number, pageSize: number }`。 | |
| 39 | +- `UserVO`(docs/05 REQ-USR-003 契约 + 卡片输出表 1,**不含 `sPassword` 及任何敏感字段**): | |
| 40 | + | |
| 41 | +| VO 字段 | 类型 | 来源 | 卡片输出列 | 说明 | | |
| 42 | +|---|---|---|---|---| | |
| 43 | +| `id` | Integer | `usr_user.iIncrement` | 序号映射主键 | 主键 ID;「序号」列由前端按行号渲染,后端只回 `id`(见 § 8 D4) | | |
| 44 | +| `sUserName` | String | `usr_user.sUserName` | 用户名 | 登录账号 | | |
| 45 | +| `employeeName` | String | `usr_employee.sEmployeeName` | 员工名 | 左关联,未关联职员为 null | | |
| 46 | +| `sUserNo` | String | `usr_user.sUserNo` | 用户号 | | | |
| 47 | +| `department` | String | `usr_employee.sDepartment` | 部门 | 左关联,未关联职员为 null | | |
| 48 | +| `sUserType` | String | `usr_user.sUserType` | 用户类型 | | | |
| 49 | +| `sLanguage` | String | `usr_user.sLanguage` | 语言 | | | |
| 50 | +| `iIsVoid` | Integer | `usr_user.iIsVoid` | 作废 | 0 正常 / 1 已作废(布尔语义,回传 0/1) | | |
| 51 | +| `tLastLoginDate` | DateTime | `usr_user.tLastLoginDate` | 登录日期 | 可为 null(从未登录) | | |
| 52 | +| `sCreator` | String | `usr_user.sCreator` | 制单人 | | | |
| 53 | +| `tCreateDate` | DateTime | `usr_user.tCreateDate` | 制单日期 | | | |
| 54 | + | |
| 55 | +- 失败:统一 `Result`(见 § 6 错误码),`message` 给可读中文提示,不抛栈。 | |
| 56 | +- 响应**绝不返回** `sPassword` 或任何敏感字段(VO 不含密码列;不得 `SELECT *` 透传实体)。 | |
| 57 | + | |
| 58 | +--- | |
| 59 | + | |
| 60 | +## 3. 业务规则 | |
| 61 | + | |
| 62 | +1. **只读无副作用**:本接口仅 `SELECT`,不写任何表、不更新 `tLastLoginDate`、不落审计;方法可标 `@Transactional(readOnly = true)`(可选)。 | |
| 63 | +2. **空条件返回全量分页**:`queryValue` 为空 / null(trim 后为空串亦视为空)时不施加业务过滤条件,仅按分页返回全部用户;此时 `queryField` / `matchType` 不生效。 | |
| 64 | +3. **单条件查询**:仅按 `queryField` 指定的单字段 + `matchType` 施加一个过滤条件(卡片输入表为单字段单匹配,非多条件 AND/OR 组合,见 § 8 D2)。 | |
| 65 | +4. **字段 → 列映射**(`queryField` 中文 → 实际查询列): | |
| 66 | + - `用户名` → `usr_user.sUserName`(文本) | |
| 67 | + - `员工名` → `usr_employee.sEmployeeName`(文本,跨表) | |
| 68 | + - `用户号` → `usr_user.sUserNo`(文本) | |
| 69 | + - `部门` → `usr_employee.sDepartment`(文本,跨表) | |
| 70 | + - `用户类型` → `usr_user.sUserType`(枚举:{普通用户,超级管理员}) | |
| 71 | + - `作废` → `usr_user.iIsVoid`(布尔 0/1) | |
| 72 | + - `登录日期` → `usr_user.tLastLoginDate`(日期时间) | |
| 73 | + - `制单人` → `usr_user.sCreator`(文本) | |
| 74 | +5. **匹配方式语义**(按 `matchType` 与字段类型组合,docs/04 § 3.2「文本条件模糊匹配,枚举/外键条件精确匹配」): | |
| 75 | + - **文本字段**(用户名 / 员工名 / 用户号 / 部门 / 制单人):`包含` → `LIKE '%v%'`;`不包含` → `NOT LIKE '%v%'`;`等于` → `= v`(精确)。`queryValue` 中的 LIKE 通配符 `%` `_` 须转义(见 § 8 D3)。 | |
| 76 | + - **枚举字段**(用户类型):仅 `等于`(`= v`)有业务意义;`包含` 按 `LIKE '%v%'`、`不包含` 按 `NOT LIKE '%v%'` 也允许执行(容错,不报错),但推荐 `等于`。 | |
| 77 | + - **布尔字段**(作废):`queryValue` 须可解析为 0/1(接受 `0`/`1`,亦可接受 `是`/`否`、`true`/`false` 归一化为 1/0,见 § 8 D6);按 `等于` 处理(`= 0/1`);`包含`/`不包含` 退化为 `=`/`<>`。无法解析为 0/1 → `40001`。 | |
| 78 | + - **日期字段**(登录日期):`queryValue` 须为合法日期(`yyyy-MM-dd`)或日期时间(`yyyy-MM-dd HH:mm:ss`);`等于` → 命中该「整天」区间 `[day 00:00:00, day+1 00:00:00)`(仅给到日期时按当日匹配,见 § 8 D6);`包含`/`不包含` 在日期字段无模糊语义,按 `等于` 当日区间 / 取反处理。无法解析 → `40001`。 | |
| 79 | +6. **跨表关联**:员工名 / 部门来自 `usr_employee`,按 `usr_user.iEmployeeId = usr_employee.iIncrement` **LEFT JOIN**(未关联职员的用户仍出现在结果,员工名 / 部门为 null)。当 `queryField` ∈ {员工名, 部门} 时,过滤施加在 join 后的 `usr_employee` 列上(未关联职员的行因该列为 null,`包含`/`等于` 自然不命中、`不包含` 的 null 处理见 § 8 D7)。 | |
| 80 | +7. **分页规则**: | |
| 81 | + - `pageNum` < 1 或 `pageSize` < 1 或 `pageSize` > 100 → `42201`(参数非法,先于查询判定)。 | |
| 82 | + - `pageNum` 超过实际总页数(数据层「越界」,如请求第 99 页但仅 3 页)→ **返回最后一页**数据(卡片验收「分页参数越界时返回最后一页」),`PageResult.pageNum` 回传被钳制后的实际页号;`total` 仍为真实总数(见 § 8 D1)。total 为 0 时返回空 `records`、`pageNum` 回传 1。 | |
| 83 | + - 默认 `pageNum=1`、`pageSize=10`(docs/05 契约默认)。 | |
| 84 | +8. **空结果**:无匹配记录时返回 `code=0` + `records=[]` + `total=0`,**不报错**(卡片验收「无匹配时返回空列表而非报错」)。 | |
| 85 | +9. **密码与敏感字段不返回**:查询 SQL 不 SELECT `sPassword`;VO 不含密码;多租户列 `sBrandsId`/`sSubsidiaryId`/`sId` 不在输出列(输出列严格按 § 2.2 UserVO)。 | |
| 86 | + | |
| 87 | +--- | |
| 88 | + | |
| 89 | +## 4. 约束(技术 / 安全) | |
| 90 | + | |
| 91 | +- **分层**(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,不另起类。 | |
| 92 | +- **包路径**:`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/**`,不跨模块。 | |
| 93 | +- **命名**(docs/04 § 1.3):方法 `queryUsers`;REST 路径 `GET /api/usr/users`;DTO `UserQueryDTO`、VO `UserVO`。 | |
| 94 | +- **统一响应**(docs/04 § 1.4):返回 `Result<PageResult<UserVO>>`,`code=0` 成功;错误码集中在 `ResultCode` 枚举(含 `42201`、`40001`)。 | |
| 95 | +- **异常处理**(docs/04 § 1.5):业务/参数错误抛 `BusinessException(ResultCode,msg)`,由 `GlobalExceptionHandler` 转 `Result`;`@Valid` 基础校验失败由全局处理器转 `40001`;分页越界(`pageNum<1`/`pageSize` 越界)转 `42201`(在 Controller/Service 入口判定或 `@Min/@Max` 校验 + 处理器映射,见 § 8 D8)。 | |
| 96 | +- **分页实现**(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。 | |
| 97 | +- **数据访问**(docs/04 § 3.4):只走 Mapper;文本条件模糊匹配、枚举/布尔/日期条件精确匹配(docs/04 § 3.2)。SQL 参数化(`#{}` 预编译占位)防注入;LIKE 通配符转义(§ 8 D3)。 | |
| 98 | +- **安全**:受保护接口经 `JwtAuthenticationFilter`;查询结果不含密码 / 不进日志敏感字段。 | |
| 99 | +- **配置**:JWT 密钥、DB 凭据只从 `config-vars.yaml` / `application.yml` 读取,不硬编码。 | |
| 100 | +- **schema**:本 REQ 仅**读** `usr_user` / `usr_employee`,二表已由 `sql/migrations/V1__initial_schema.sql` 建好,结构与 docs/03 一致,**无需新增 migration**(见 § 8 D9)。 | |
| 101 | + | |
| 102 | +--- | |
| 103 | + | |
| 104 | +## 5. Schema 引用(docs/03 SSoT) | |
| 105 | + | |
| 106 | +- **读**:`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) 可被查询命中。 | |
| 107 | +- **读**:`usr_employee`(按 `usr_user.iEmployeeId = usr_employee.iIncrement` LEFT JOIN;取 `sEmployeeName`→员工名、`sDepartment`→部门;过滤员工名 / 部门时命中其列)。索引 `idx_usr_employee_name`(sEmployeeName) 可被命中。 | |
| 108 | +- 关系:`usr_user N:1 usr_employee`(外键 `fk_usr_user_employee`,ON DELETE SET NULL)。 | |
| 109 | +- 实体:`UsrUser` ↔ `usr_user`,`UsrEmployee` ↔ `usr_employee`(匈牙利前缀列名,实体字段与列名映射保持一致);查询结果装配为 `UserVO`(非实体直出)。 | |
| 110 | + | |
| 111 | +--- | |
| 112 | + | |
| 113 | +## 6. API 引用 / 错误码(docs/05 SSoT) | |
| 114 | + | |
| 115 | +- 端点契约见 `docs/05-API接口契约.md` § REQ-USR-003(`GET /api/usr/users`)。 | |
| 116 | +- 错误码: | |
| 117 | + - `0` — 成功,返回 `PageResult<UserVO>`。 | |
| 118 | + - `42201` — 分页参数非法(`pageNum < 1` 或 `pageSize < 1` 或 `pageSize > 100`)。 | |
| 119 | + - `40001` — 查询参数校验失败(`queryField` / `matchType` 枚举越界 / `queryValue` 超长 / 布尔字段值无法解析为 0/1 / 日期字段值非法)。 | |
| 120 | + - 401 — 未认证(无 / 失效 token,由安全过滤器返回,非业务错误码)。 | |
| 121 | + | |
| 122 | +--- | |
| 123 | + | |
| 124 | +## 7. 验收标准(Acceptance Criteria) | |
| 125 | + | |
| 126 | +1. **空条件全量分页**:不传任何 `queryValue`(或 `queryValue` 空)调用 `GET /api/usr/users?pageNum=1&pageSize=10` → `code=0`,`records` 为前 10 条用户,`total` = 用户总数,`pageNum=1`、`pageSize=10`;每条含 § 2.2 全部 VO 列,**不含 `sPassword`**。 | |
| 127 | +2. **文本「包含」筛选**:`queryField=用户名&matchType=包含&queryValue=adm` → 仅返回 `sUserName` 含 `adm` 的用户(LIKE `%adm%`),结果集正确。 | |
| 128 | +3. **文本「等于」精确**:`queryField=用户名&matchType=等于&queryValue=admin` → 仅返回 `sUserName` 严格等于 `admin` 的用户。 | |
| 129 | +4. **文本「不包含」**:`queryField=制单人&matchType=不包含&queryValue=admin` → 返回 `sCreator` 不含 `admin` 的用户。 | |
| 130 | +5. **枚举「等于」**:`queryField=用户类型&matchType=等于&queryValue=超级管理员` → 仅返回 `sUserType=超级管理员` 的用户。 | |
| 131 | +6. **布尔(作废)筛选**:`queryField=作废&queryValue=1` → 仅返回 `iIsVoid=1` 的用户;`queryValue=0` → 仅 `iIsVoid=0`;不可解析为 0/1(如 `abc`)→ `code=40001`。 | |
| 132 | +7. **日期(登录日期)筛选**:`queryField=登录日期&matchType=等于&queryValue=2026-06-01` → 返回 `tLastLoginDate` 落在 2026-06-01 当日的用户;非法日期值 → `code=40001`。 | |
| 133 | +8. **跨表员工名 / 部门**:返回每条 VO 的 `employeeName` / `department` 来自该用户关联职员;未关联职员(`iEmployeeId` 为 null)的用户仍在结果中,两列为 null。`queryField=部门&matchType=包含&queryValue=财务` 仅返回所属部门含「财务」的用户。 | |
| 134 | +9. **无匹配返回空**:筛选条件无命中 → `code=0`,`records=[]`,`total=0`(不报错)。 | |
| 135 | +10. **分页越界返回最后一页**:总数据仅 3 页(每页 10),请求 `pageNum=99` → `code=0`,`records` 为最后一页数据,`PageResult.pageNum` 回传实际最后页号,`total` 为真实总数。 | |
| 136 | +11. **分页参数非法**:`pageNum=0` 或 `pageSize=0` 或 `pageSize=500` → `code=42201`。 | |
| 137 | +12. **未认证**:无 / 失效 token 调用 → 401,不返回任何用户数据。 | |
| 138 | +13. **密码绝不泄露**:任意查询的响应体均不含 `sPassword` 字段、不含明文密码。 | |
| 139 | + | |
| 140 | +--- | |
| 141 | + | |
| 142 | +## 8. 自主决策记录(decisions) | |
| 143 | + | |
| 144 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 145 | +|---|---|---|---|---| | |
| 146 | +| 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 | | |
| 147 | +| D2 | 查询是单条件还是多条件组合 | **单条件**:单 `queryField` + 单 `matchType` + 单 `queryValue` | 卡片输入表 1 为三行单值(查询字段/匹配方式/查询值),无多字段并列;docs/05 query 参数亦为单组,无条件数组 | high | | |
| 148 | +| D3 | LIKE 模糊查询中 `queryValue` 含 `%`/`_`/`\` 等通配符的处理 | 对 `%` `_` `\` 做转义(`ESCAPE '\\'`),按字面匹配,防止用户输入被当通配符 / 注入 | 安全与正确性默认实践;docs/04 § 3.2 文本模糊匹配未禁止转义,转义更稳妥 | high | | |
| 149 | +| D4 | 卡片输出列「序号」如何承载 | 后端 VO 回 `id`(主键),「序号」由前端按行号渲染;后端不生成连续行号 | 卡片标「序号—系统生成」属列表展示行号(前端职责);docs/05 `UserVO` 含 `id` 不含「序号」;后端阶段不做 UI 行号 | high | | |
| 150 | +| D5 | 是否限制仅管理员可查询 | **不限制**,任意已认证用户可调用 | docs/05 REQ-USR-003 Auth 仅标「需要(Bearer JWT)」,未附加「仅管理员」(对比 REQ-USR-001/002 明确「仅管理员/超级管理员」);只读查询无敏感写操作 | high | | |
| 151 | +| D6 | 布尔(作废)/ 日期(登录日期)字段的 `queryValue` 解析口径 | 作废:接受 `0`/`1`(兼容 `是`/`否`、`true`/`false` 归一化),否则 `40001`;登录日期:接受 `yyyy-MM-dd`(按当日区间)或 `yyyy-MM-dd HH:mm:ss`,否则 `40001` | 卡片「查询值=手工输入文本」需后端按目标列类型解析;docs/04 § 3.2「枚举/外键精确匹配」延伸到布尔/日期取精确区间;非法值显式 `40001` 比静默放行更可靠 | medium | | |
| 152 | +| D7 | 跨表字段(员工名/部门)「不包含」对 NULL(未关联职员)行的处理 | `不包含` 仅对该列**非 null** 的行做 `NOT LIKE`;未关联职员(列为 null)的行不纳入「不包含」结果(SQL `NOT LIKE` 对 NULL 返回 unknown 自然不命中) | 遵循标准 SQL 三值逻辑;卡片未对 null 行的「不包含」语义特别要求,取 SQL 默认行为,避免引入易错的 `OR col IS NULL` 隐式语义 | medium | | |
| 153 | +| D8 | `42201` 的实现层判定方式 | 在 Controller/Service 入口显式判定 `pageNum`/`pageSize` 范围并抛 `BusinessException(42201)`(或 `@Min/@Max` + 全局处理器映射到 `42201`),不依赖分页插件静默兜底 | docs/04 § 1.5 业务/参数错误统一走 `BusinessException` + 全局处理器;显式判定才能精确回 `42201` 而非 `40001` | medium | | |
| 154 | +| D9 | 是否新增 migration | 不新增,复用 `V1__initial_schema.sql` | 本 REQ 纯读 `usr_user` / `usr_employee`,二表已在 V1 建好,结构与 docs/03 一致,无 schema 变更 | high | | |
| 155 | + | |
| 156 | +> 注:docs/03 中 `sLanguage` / `usr_company.sVersion` / `usr_permission` 粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本只读查询 REQ 作用域内消解;本规格沿用 USR 模块既有取值锁定(语言 ∈ {中文,英文,繁体}、用户类型 ∈ {普通用户,超级管理员}),不阻塞。 | ... | ... |