Commit 8656b6f67b3878571cad27777197713ba845e27e
1 parent
e8951b34
docs(spec:REQ-USR-002): 派生规格
Showing
1 changed file
with
120 additions
and
0 deletions
docs/superpowers/specs/2026-06-01-REQ-USR-002.md
0 → 100644
| 1 | +# REQ-USR-002 修改用户 — 实现规格(后端) | |
| 2 | + | |
| 3 | +> 阶段:后端(backend)。作用域限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约。 | |
| 4 | +> SSoT 引用:需求卡片 `docs/01-需求清单/USR-用户管理/REQ-USR-002.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 | +后台管理员修改已有用户的基本信息:用户号、关联职员、用户类型、语言、单据修改权限、作废(禁用)标志、权限组授权;保存后变更立即生效(角色/状态实时反映在用户列表,禁用账号无法登录)。对外仅提供一个端点:`PUT /api/usr/users/{id}`。 | |
| 12 | + | |
| 13 | +`sUserName`(登录账号,全局唯一标识)不可修改;密码不在本接口修改(见 § 8 D1)。 | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## 2. 输入 / 输出 | |
| 18 | + | |
| 19 | +### 2.1 输入(请求) | |
| 20 | + | |
| 21 | +- **Method / Path**:`PUT /api/usr/users/{id}` | |
| 22 | +- **路径参数**:`id`(`Integer`,目标用户主键 `usr_user.iIncrement`);必须为正整数,对应记录须存在,否则 `40401`。 | |
| 23 | +- **Auth**:需要 Bearer JWT,且调用方必须为管理员 / 超级管理员(`sUserType = 超级管理员`)。普通用户调用返回 `40301`(先于业务校验判定)。 | |
| 24 | +- **请求体(JSON)→ `UpdateUserDTO`**: | |
| 25 | + | |
| 26 | +| DTO 字段 | 类型 | 必填 | 校验 | 落库列(`usr_user`) | 说明 | | |
| 27 | +|---|---|---|---|---|---| | |
| 28 | +| `sUserNo` | String | 否 | `@Size(max=50)` | `sUserNo` | 用户号;传入即覆盖,为 null 不更新该列(见 § 8 D3 部分更新语义) | | |
| 29 | +| `iEmployeeId` | Integer | 否 | — | `iEmployeeId` | 关联职员 ID;非 null 时须为 `usr_employee` 中存在的记录,否则 `40001` | | |
| 30 | +| `sUserType` | String | 是 | `@NotBlank` + 取值 ∈ {`普通用户`,`超级管理员`} | `sUserType` | 用户类型;越界 `40001` | | |
| 31 | +| `sLanguage` | String | 是 | `@NotBlank` + 取值 ∈ {`中文`,`英文`,`繁体`} | `sLanguage` | 界面语言;越界 `40001` | | |
| 32 | +| `iCanModifyBill` | Integer | 否 | 取值 ∈ {0,1} | `iCanModifyBill` | 单据修改权限;为 null 不更新该列 | | |
| 33 | +| `iIsVoid` | Integer | 否 | 取值 ∈ {0,1} | `iIsVoid` | 作废 / 禁用标志:0 正常 / 1 禁用;为 null 不更新该列(见 § 8 D2) | | |
| 34 | +| `permissionIds` | List\<Integer\> | 否 | 元素须为 `usr_permission` 中存在的 id(去重) | 重写 `usr_user_permission` | 权限组勾选的全量集合(见 § 8 D4 全量覆盖语义);为 null 表示不改动现有授权 | | |
| 35 | + | |
| 36 | +> 不接受前端传入 / 后端忽略的字段:`sUserName`(唯一标识不可改)、`sPassword`(密码不在本接口修改)、`tCreateDate` / `sCreator`(创建审计只读,保持原值)、`tLastLoginDate`(由登录流程维护)、`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId`(主键与租户列不在本接口变更)。若请求体携带这些字段,一律忽略不落库。 | |
| 37 | + | |
| 38 | +### 2.2 输出(响应) | |
| 39 | + | |
| 40 | +- 成功:`Result<{ id: number }>`,`code=0`,`data.id` = 被修改用户主键 `usr_user.iIncrement`(= 路径 `id`)。 | |
| 41 | +- 失败:统一 `Result`(见 § 6 错误码),`message` 给可读中文提示,不抛栈。 | |
| 42 | +- 响应不返回密码 / 任何敏感字段(仅含 `data.id`)。 | |
| 43 | + | |
| 44 | +--- | |
| 45 | + | |
| 46 | +## 3. 业务规则 | |
| 47 | + | |
| 48 | +1. **目标用户必须存在**:按路径 `id` 查 `usr_user`;查不到(或已物理不存在)抛 `BusinessException(40401)`,不做任何写入。 | |
| 49 | +2. **`sUserName` 不可修改**:本接口不接收 / 不更新 `sUserName`;即便请求体携带也忽略。登录账号唯一性不在本接口被破坏。 | |
| 50 | +3. **密码不在本接口修改**:`sPassword` 列保持原值不动;本接口不读取、不重置、不返回密码(密码重置/初始化由其他流程负责,REQ 卡片表内"密码自动初始化"属前端/独立流程描述,见 § 8 D1)。 | |
| 51 | +4. **用户类型约束**:`sUserType` 必填,取值仅限 {`普通用户`,`超级管理员`},越界 `40001`。角色变更需调用方具备管理员权限(已由 § 3 规则 9 的前置权限校验保证)。 | |
| 52 | +5. **语言取值约束**:`sLanguage` 必填,取值仅限 {`中文`,`英文`,`繁体`},越界 `40001`。 | |
| 53 | +6. **关联职员可选且需存在**:传非 null `iEmployeeId` 时校验 `usr_employee` 存在;不存在 `40001`。显式传 null 表示解除关联(置空 `iEmployeeId`)的处理见 § 8 D3。 | |
| 54 | +7. **作废 / 禁用实时生效**:`iIsVoid=1` 落库后该账号即不可登录(登录由 REQ-USR-004 校验 `iIsVoid`,命中返回 `40302`),无需额外流程;`iIsVoid=0` 恢复正常。取值仅限 {0,1},越界 `40001`。 | |
| 55 | +8. **权限组授权(多对多,全量覆盖)**:`permissionIds` 非 null 时,先校验每个 id 在 `usr_permission` 存在(不存在 `40001`),去重后**以该集合全量覆盖**该用户在 `usr_user_permission` 的授权——删除不在集合内的旧授权、插入新增授权(实现可"先删后插"或差量更新,结果须等于目标集合);空数组 `[]` 表示清空全部授权;`permissionIds` 为 null 表示本次不改动授权。唯一索引 `uk_usr_user_permission` 防重复。 | |
| 56 | +9. **权限校验前置**:仅 `超级管理员` / 管理员可调用;非管理员 `40301`(在 Spring Security 或 Service 入口判定,先于业务校验)。 | |
| 57 | +10. **审计字段只读**:`sCreator` / `tCreateDate` 保持原值,本接口不更新;不引入"最后修改人/时间"列(docs/03 `usr_user` 无此列,不新增 migration)。 | |
| 58 | + | |
| 59 | +--- | |
| 60 | + | |
| 61 | +## 4. 约束(技术 / 安全) | |
| 62 | + | |
| 63 | +- **分层**(docs/04 § 1.2):`UsrUserController`(仅 `@Valid` 校验 + 委派)→ `UsrUserService` / `UsrUserServiceImpl`(业务)→ `UsrUserMapper` / `UsrUserPermissionMapper`(MyBatis-Plus)。Controller 禁止直接操作 Mapper。本 REQ 在已存在的 `UsrUserService` 上新增 `updateUser(Integer id, UpdateUserDTO dto)` 方法,复用 REQ-USR-001 已建的 controller / service / mapper / entity,不另起类。 | |
| 64 | +- **包路径**:`com.xly.erp.modules.usr.{controller,service,service.impl,mapper,entity,dto,vo}`;新增 DTO `UpdateUserDTO` 置于 `modules/usr/dto`。本 REQ 仅触及 `modules/usr/**`,不跨模块。 | |
| 65 | +- **命名**(docs/04 § 1.3):方法 `updateUser`;REST 路径 `PUT /api/usr/users/{id}`。 | |
| 66 | +- **统一响应**(docs/04 § 1.4):返回 `Result<T>`,`code=0` 成功;错误码集中在 `ResultCode` 枚举。 | |
| 67 | +- **异常处理**(docs/04 § 1.5):业务错误抛 `BusinessException(ResultCode,msg)`,由 `GlobalExceptionHandler` 转 `Result`;`@Valid` 校验失败由全局处理器转 `40001`。 | |
| 68 | +- **事务**(docs/04 § 1.6):`updateUser` 涉及 `usr_user` + `usr_user_permission` 多表写,方法上加 `@Transactional(rollbackFor = Exception.class)`,任一步失败整体回滚(权限覆盖与主记录更新同事务)。 | |
| 69 | +- **认证 / 密码**(docs/04 § 1.7):受保护接口经 `JwtAuthenticationFilter`;本接口不触碰密码,更不得将密码写入日志 / 响应。 | |
| 70 | +- **数据访问**(docs/04 § 3.4):只走 Mapper;查询 / 校验 / 删授权用 `LambdaQueryWrapper` 或 MP 内置;禁止 Controller 直接操作 Mapper。 | |
| 71 | +- **配置**:JWT 密钥、DB 凭据只从 `config-vars.yaml` / `application.yml` 读取,不硬编码。 | |
| 72 | +- **schema**:本 REQ 所需表(`usr_user` 写 / `usr_employee` 读 / `usr_permission` 读 / `usr_user_permission` 写)已由 `sql/migrations/V1__initial_schema.sql` 建好,结构与 docs/03 一致,**无需新增 migration**(见 § 8 D5)。 | |
| 73 | + | |
| 74 | +--- | |
| 75 | + | |
| 76 | +## 5. Schema 引用(docs/03 SSoT) | |
| 77 | + | |
| 78 | +- **写**:`usr_user`(主键 `iIncrement`;更新 `sUserNo` / `iEmployeeId` / `sUserType` / `sLanguage` / `iCanModifyBill` / `iIsVoid`;不更新 `sUserName` / `sPassword` / 审计列 / 租户列)。 | |
| 79 | +- **写**:`usr_user_permission`(关联表;唯一索引 `uk_usr_user_permission` on `(iUserId,iPermissionId)`;外键 `fk_usr_up_user` / `fk_usr_up_permission` 均 CASCADE)——按 `iUserId=id` 全量覆盖授权。 | |
| 80 | +- **读**:`usr_employee`(校验 `iEmployeeId` 存在,外键 `fk_usr_user_employee` ON DELETE SET NULL);`usr_permission`(校验 `permissionIds` 存在)。 | |
| 81 | +- 实体:`UsrUser` ↔ `usr_user`,`UsrUserPermission` ↔ `usr_user_permission`,`UsrEmployee` ↔ `usr_employee`,`UsrPermission` ↔ `usr_permission`(匈牙利前缀列名,实体字段与列名映射保持一致)。 | |
| 82 | + | |
| 83 | +--- | |
| 84 | + | |
| 85 | +## 6. API 引用 / 错误码(docs/05 SSoT) | |
| 86 | + | |
| 87 | +- 端点契约见 `docs/05-API接口契约.md` § REQ-USR-002(`PUT /api/usr/users/{id}`)。 | |
| 88 | +- 错误码: | |
| 89 | + - `40001` — 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id(`iEmployeeId` / `permissionIds` 元素)不存在 / `id` 非正整数)。 | |
| 90 | + - `40401` — 用户不存在(路径 `id` 无对应 `usr_user` 记录)。 | |
| 91 | + - `40301` — 无权限(非管理员调用)。 | |
| 92 | + - `0` — 成功,返回 `data.id`。 | |
| 93 | + | |
| 94 | +--- | |
| 95 | + | |
| 96 | +## 7. 验收标准(Acceptance Criteria) | |
| 97 | + | |
| 98 | +1. **正常修改基本信息**:管理员对已存在用户 `id` 携带合法 body(`sUserType` / `sLanguage` 合法)调用 `PUT /api/usr/users/{id}` → `code=0`,`data.id` = 该 `id`;`usr_user` 对应行的 `sUserType` / `sLanguage` / `iCanModifyBill` / `sUserNo` / `iEmployeeId` 按传入值更新,`sUserName` / `sPassword` / `sCreator` / `tCreateDate` 保持原值不变。 | |
| 99 | +2. **用户不存在**:以不存在的 `id` 调用 → `code=40401`,无任何写入。 | |
| 100 | +3. **参数非法**:`sUserType` 或 `sLanguage` 越界 / `iEmployeeId` 引用不存在职员 / `permissionIds` 含不存在权限 id / `iIsVoid` 或 `iCanModifyBill` 非 0/1 → `code=40001`,无副作用(事务回滚)。 | |
| 101 | +4. **禁用实时生效**:传 `iIsVoid=1` 修改成功后,该账号经 REQ-USR-004 登录返回 `40302`(账号已禁用);改回 `iIsVoid=0` 后可正常登录。 | |
| 102 | +5. **角色变更实时生效**:修改 `sUserType` 成功后,REQ-USR-003 查询该用户返回的 `sUserType` 即为新值。 | |
| 103 | +6. **权限组全量覆盖**:传 `permissionIds=[a,b]`(均存在,原授权为 `[a,c]`)→ 覆盖后 `usr_user_permission` 中该用户授权恰为 `{a,b}`(删除 c、保留/插入 a、新增 b);传 `permissionIds=[]` → 清空该用户全部授权;不传 `permissionIds`(null)→ 现有授权保持不变;重复 id 去重后仅写一次。 | |
| 104 | +7. **越权访问**:普通用户或无 / 失效 token 调用 → `code=40301`(无权限)/ 401(未认证),不发生任何修改。 | |
| 105 | +8. **密码不变 & 响应不含密码**:调用前后 `sPassword` 列字节级不变(用原密码仍可登录);成功响应体仅含 `data.id`,绝不含 `sPassword` 或明文密码。 | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +## 8. 自主决策记录(decisions) | |
| 110 | + | |
| 111 | +| # | 问题 | 选择 | 依据 | 置信度 | | |
| 112 | +|---|---|---|---|---| | |
| 113 | +| D1 | REQ 卡片输入表把「密码」标为"保存后自动设为初始化",与 docs/05 契约"密码不在本接口修改"冲突 | 后端**不在本接口修改/重置密码**,`sPassword` 保持原值;body 不含密码字段 | docs/05 契约(SSoT,明确"密码不在本接口修改")+ REQ 卡片「跨字段规则: 密码不在该接口修改」明文一致,权重高于输入表内"系统生成"备注(该备注属新增场景遗留/前端描述) | high | | |
| 114 | +| D2 | 「状态/禁用」字段:REQ 目标与验收提到"状态""被禁用账号无法登录",但 REQ 输入表 1 未显式列出状态字段 | 用 `usr_user.iIsVoid`(0 正常/1 禁用)承载"状态",作为可选入参 `iIsVoid` | docs/03 `usr_user.iIsVoid` 即"作废/禁用标志,禁用后不可登录";docs/05 REQ-USR-002 请求体已显式包含 `iIsVoid`;与验收"禁用账号无法登录"语义吻合 | high | | |
| 115 | +| D3 | 部分更新语义:可选字段(`sUserNo`/`iEmployeeId`/`iCanModifyBill`/`iIsVoid`)传 null 时是"置空列"还是"不更新" | 采用**不更新(保持原值)**语义:null 表示本次不改动该列;不提供显式"清空"区分(JSON null 与缺省不区分),与权限 `permissionIds=null` 不改动一致 | REST PUT 在本契约场景按"提交需修改的字段"实践更安全,避免误把未填字段清空原有数据;契约未要求区分"置空 vs 不传",取保守不更新可避免数据丢失。前端编辑表单预加载原值后整体回填,行为等价 | medium | | |
| 116 | +| D4 | `permissionIds` 更新语义(增量追加 vs 全量覆盖) | **全量覆盖**:以传入集合替换该用户全部授权;`[]` 清空;null 不改动 | REQ 卡片表 2「权限组」复选框预加载原值、提交即当前勾选全集,语义为"这一组就是最终授权";全量覆盖与编辑界面"勾选即最终态"一致,避免无法取消授权 | high | | |
| 117 | +| D5 | 是否新增 migration | 复用 `V1__initial_schema.sql` 已建表,不新增 | 写 `usr_user` / `usr_user_permission`、读 `usr_employee` / `usr_permission` 均已在 V1 建好,结构与 docs/03 一致,本 REQ 无 schema 变更(也不新增"最后修改人/时间"列,docs/03 未定义) | high | | |
| 118 | +| D6 | 管理员判定口径 | 与 REQ-USR-001 一致:以 `sUserType=超级管理员` 视为有调用权限;普通用户禁止 | docs/05 标注"仅管理员/超级管理员可调用";本库用户类型枚举仅 {普通用户,超级管理员},无独立角色表;保持模块内一致 | medium | | |
| 119 | + | |
| 120 | +> 注:docs/03 中 `sLanguage` / `usr_company.sVersion` / `usr_permission` 粒度的「需用户审阅」占位为 DB 文档遗留标记,不在本后端 REQ 作用域内消解;本规格沿用 REQ-USR-001 的取值锁定(语言 ∈ {中文,英文,繁体})继续,不阻塞。 | ... | ... |