Commit 8f292bcfb6939019f5208b5ce6389e533cf0a204

Authored by zichun
1 parent 7de7347d

docs(module_usr): add specs and plans for REQ-USR-002 and REQ-USR-003

docs/superpowers/plans/2026-05-08-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-08
  4 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-002.md
  5 +---
  6 +
  7 +# 修改用户 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `PUT /api/usr/users/{userId}`,允许超级管理员修改用户类型、语言、权限等,并在前端用户列表增加"修改"按钮触发编辑抽屉。
  12 +
  13 +**Architecture:** 后端遵循现有三层(Controller → Service → Mapper)模式,新增 UserUpdateReqDTO/UserUpdateRespVO,UserServiceImpl.updateUser 做权限、存在性、自身角色三重校验后执行字段更新 + 权限组先删后插;前端复用 UserFormDrawer(新增 userId/initialData props 区分 create/edit 模式),UserListPage 增加"操作"列。
  14 +
  15 +**Tech Stack:** Spring Boot 3 / MyBatis-Plus LambdaUpdateWrapper / @Transactional / React 18 / Ant Design 5 / Vitest
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无(不需要新 migration)
  22 +
  23 +## 文件变更清单
  24 +
  25 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java` — 修改用户请求体
  26 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java` — 修改用户响应 VO
  27 +- Modify: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java` — 追加 USER_NOT_FOUND=40400 / SELF_ADMIN_CHANGE=40301
  28 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` — 追加 updateUser 接口方法
  29 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 updateUser
  30 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` — 追加 PUT /users/{userId}
  31 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` — 追加 updateUser 测试
  32 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` — 追加 HTTP 测试
  33 +- Modify: `frontend/src/api/usr.ts` — 追加 UserUpdateReq / UserUpdateResp / updateUser()
  34 +- Modify: `frontend/src/pages/usr/UserFormDrawer.tsx` — 支持 edit 模式(userId + initialData props)
  35 +- Modify: `frontend/src/pages/usr/UserListPage.tsx` — 追加操作列 + editingUser 状态
  36 +- Modify: `frontend/src/test/UserListPage.test.tsx` — 追加编辑流程测试
  37 +
  38 +---
  39 +
  40 +## 任务步骤
  41 +
  42 +### Task 1: 错误码 + DTO/VO 数据结构
  43 +
  44 +**Files:**
  45 +- Modify: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java`
  46 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java`
  47 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java`
  48 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  49 +
  50 +**API shape:**
  51 +```
  52 +UserUpdateReqDTO {
  53 + String userType; // "普通用户" | "超级管理员"
  54 + String language; // "中文" | "英文" | "繁体"
  55 + boolean canEditDoc;
  56 + boolean isDisabled;
  57 + String employeeId; // nullable
  58 + List<String> permGroupIds;
  59 +}
  60 +
  61 +UserUpdateRespVO {
  62 + String userId;
  63 + String username;
  64 + LocalDateTime updatedAt;
  65 +}
  66 +
  67 +UsrErrorCode.USER_NOT_FOUND = 40400
  68 +UsrErrorCode.SELF_ADMIN_CHANGE = 40301
  69 +```
  70 +
  71 +- [ ] **Step 1: 写失败测试**
  72 + - 测试名: `UserServiceTest#updateUser_dtoDefaults`
  73 + - 意图: `new UserUpdateReqDTO()` 后各字段可 set/get;`new UserUpdateRespVO()` 可 set/get userId/username/updatedAt;`UsrErrorCode.USER_NOT_FOUND == 40400`,`UsrErrorCode.SELF_ADMIN_CHANGE == 40301`
  74 + - 子会话确认 FAIL(类不存在)
  75 +
  76 +- [ ] **Step 2: 实现最小代码**
  77 + - 在 UsrErrorCode 追加两个常量
  78 + - 创建 UserUpdateReqDTO(@Getter @Setter,6 个字段)
  79 + - 创建 UserUpdateRespVO(@Getter @Setter,3 个字段:userId/username/updatedAt: LocalDateTime)
  80 +
  81 +- [ ] **Step 3: 子会话验证 PASS**
  82 +
  83 +- [ ] **Step 4: Commit**
  84 + - `git add backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  85 + - `git commit -m "feat(usr): 添加 UserUpdateReqDTO / UserUpdateRespVO / 错误码 REQ-USR-002"`
  86 +
  87 +---
  88 +
  89 +### Task 2: UserService.updateUser 实现(含权限校验与权限组 replace)
  90 +
  91 +**Files:**
  92 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java`
  93 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java`
  94 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  95 +
  96 +**API shape:**
  97 +```
  98 +UserService#updateUser(String userId, UserUpdateReqDTO req, UserPrincipal principal) : UserUpdateRespVO
  99 +```
  100 +
  101 +业务逻辑约束(按顺序执行):
  102 +1. `!"超级管理员".equals(principal.userType())` → `BizException(PERMISSION_DENIED, "权限不足")`
  103 +2. `userMapper.selectOne(sId=userId AND sBrandsId=brandId)` == null → `BizException(USER_NOT_FOUND, "用户不存在")`
  104 +3. `principal.userId().equals(userId) && !"超级管理员".equals(req.getUserType())` → `BizException(SELF_ADMIN_CHANGE, "禁止修改自己的管理员角色")`
  105 +4. `req.getEmployeeId() != null` → staffMapper 校验存在性,不存在 → `BizException(EMPLOYEE_NOT_FOUND, "员工不存在")`
  106 +5. 用 `LambdaUpdateWrapper<UsrUserEntity>` 更新 sUserType/sLanguage/bCanEditDoc/bIsDisabled/sEmployeeId(按 sId=userId AND sBrandsId=brandId)
  107 +6. `userPermissionMapper.delete(sUserId=userId AND sBrandsId=brandId)`,再批量 insert 新 UserPermissionEntity 列表(permGroupIds 为空时只删不插)
  108 +7. 返回 UserUpdateRespVO(userId, username=entity.sUsername, updatedAt=LocalDateTime.now())
  109 +
  110 +- [ ] **Step 1: 写失败测试(共 6 个)**
  111 +
  112 + **T1** `updateUser_nonAdmin_throws40300`
  113 + - 意图: principal.userType="普通用户" → BizException.code==40300
  114 + - principal: new UserPrincipal("u1","admin","普通用户","b1")
  115 +
  116 + **T2** `updateUser_userNotFound_throws40400`
  117 + - 意图: 超级管理员 principal,userMapper.selectOne 返回 null → BizException.code==40400
  118 +
  119 + **T3** `updateUser_selfAdminChange_throws40301`
  120 + - 意图: principal.userId=="u1",userId=="u1",req.userType="普通用户" → BizException.code==40301
  121 + - userMapper.selectOne 需 stub 返回一个存在的用户实体
  122 +
  123 + **T4** `updateUser_invalidEmployee_throws40001`
  124 + - 意图: req.employeeId="bad-emp",staffMapper.selectOne 返回 null → BizException.code==40001
  125 + - userMapper.selectOne stub 返回有效用户;principal.userId!="u-target" 确保不触发 40301
  126 +
  127 + **T5** `updateUser_happyPath_updatesAndReturnsResp`
  128 + - 意图: 一切校验通过 → userMapper 执行 update;permissionMapper 先 delete 再 insert;返回 vo.userId/username/updatedAt 非 null
  129 + - Stub: userMapper.selectOne 返回含 sUsername="alice" 的实体;staffMapper.selectOne 返回非 null(若 employeeId 非 null);userMapper.update 返回 1;delete/insert 正常
  130 +
  131 + **T6** `updateUser_emptyPermGroupIds_onlyDeletes`
  132 + - 意图: req.permGroupIds=[] → userPermissionMapper.delete 被调用;insert 不被调用
  133 + - verify(userPermissionMapper, never()).insert(anyList())
  134 +
  135 + - 子会话确认 6 个测试均 FAIL(方法不存在)
  136 +
  137 +- [ ] **Step 2: 在 UserService 接口追加方法声明,在 UserServiceImpl 实现 updateUser**
  138 + - 使用 @Transactional
  139 + - 注意: UsrUserEntity 已有 sId / sUserType / sLanguage / bCanEditDoc / bIsDisabled / sEmployeeId 字段(来自现有代码)
  140 +
  141 +- [ ] **Step 3: 子会话验证全部 PASS**
  142 +
  143 +- [ ] **Step 4: Commit**
  144 + - `git add backend/src/main/java/com/example/erp/module/usr/service/ backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  145 + - `git commit -m "feat(usr): UserService.updateUser 三重校验 + 权限组 replace REQ-USR-002"`
  146 +
  147 +---
  148 +
  149 +### Task 3: UserController PUT /api/usr/users/{userId}
  150 +
  151 +**Files:**
  152 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java`
  153 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  154 +
  155 +**API shape:**
  156 +```
  157 +@PutMapping("/users/{userId}")
  158 +public Result<UserUpdateRespVO> updateUser(
  159 + @PathVariable String userId,
  160 + @Valid @RequestBody UserUpdateReqDTO req,
  161 + @AuthenticationPrincipal UserPrincipal principal)
  162 +// REQ-USR-002: 修改用户
  163 +```
  164 +
  165 +- [ ] **Step 1: 写失败测试(共 2 个)**
  166 +
  167 + **T1** `updateUser_withToken_returns200`
  168 + - 意图: PUT /api/usr/users/u-target,含超级管理员 Token,body={userType,language,canEditDoc,isDisabled,permGroupIds}
  169 + - stub: userService.updateUser(any,any,any) 返回 UserUpdateRespVO{userId="u-target",username="alice",updatedAt=now}
  170 + - 断言: HTTP 200, $.code==200, $.data.userId=="u-target", $.data.username=="alice"
  171 +
  172 + **T2** `updateUser_noAuth_returns401`
  173 + - 意图: 无 Token → HTTP 401
  174 +
  175 + - 子会话确认 FAIL(endpoint 不存在)
  176 +
  177 +- [ ] **Step 2: 在 UserController 追加 PUT /users/{userId} 方法,加 // REQ-USR-002: 修改用户 注释**
  178 +
  179 +- [ ] **Step 3: 子会话验证 PASS**
  180 +
  181 +- [ ] **Step 4: Commit**
  182 + - `git add backend/src/main/java/com/example/erp/module/usr/controller/UserController.java backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java`
  183 + - `git commit -m "feat(usr): PUT /api/usr/users/{userId} REQ-USR-002"`
  184 +
  185 +---
  186 +
  187 +### Task 4: 前端 API + UserFormDrawer edit 模式
  188 +
  189 +**Files:**
  190 +- Modify: `frontend/src/api/usr.ts`
  191 +- Modify: `frontend/src/pages/usr/UserFormDrawer.tsx`
  192 +- Test: `frontend/src/test/UserListPage.test.tsx`
  193 +
  194 +**API shape (usr.ts):**
  195 +```ts
  196 +export interface UserUpdateReq {
  197 + userType: string
  198 + language: string
  199 + canEditDoc: boolean
  200 + isDisabled: boolean
  201 + employeeId: string | null
  202 + permGroupIds: string[]
  203 +}
  204 +
  205 +export interface UserUpdateResp {
  206 + userId: string
  207 + username: string
  208 + updatedAt: string
  209 +}
  210 +
  211 +export function updateUser(userId: string, req: UserUpdateReq): Promise<UserUpdateResp>
  212 +// → request.put(`/usr/users/${userId}`, req)
  213 +```
  214 +
  215 +**UserFormDrawer props 扩展:**
  216 +```ts
  217 +interface Props {
  218 + open: boolean
  219 + onClose: () => void
  220 + onSuccess: () => void
  221 + userId?: string // 非 null = edit 模式
  222 + initialData?: {
  223 + userType: string
  224 + language: string
  225 + canEditDoc: boolean
  226 + isDisabled: boolean
  227 + employeeId?: string | null
  228 + }
  229 +}
  230 +```
  231 +edit 模式行为:
  232 +- 抽屉 title="修改用户",隐藏 userCode/username Form.Item
  233 +- open 时用 initialData 初始化 form(form.setFieldsValue),selectedPermIds=[]
  234 +- 提交调 updateUser(userId, req) 而非 createUser;req.isDisabled 从 form.values.isDisabled 读取(Checkbox valuePropName="checked")
  235 +
  236 +- [ ] **Step 1: 写失败测试**
  237 +
  238 + 在 `UserListPage.test.tsx` vi.mock 里追加:
  239 + ```ts
  240 + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' })
  241 + ```
  242 +
  243 + **T1** `editMode_drawerTitle_shows修改用户`
  244 + - renderPage,设 drawerOpen=true + editingUser 存在(通过点击模拟行上的修改按钮)
  245 + - 断言: screen 中存在 "修改用户" 文本
  246 +
  247 + **T2** `editMode_submit_callsUpdateUser`
  248 + - 打开 edit 抽屉,点击确认
  249 + - waitFor: vi.mocked(updateUser) 被调用 1 次
  250 +
  251 + - 子会话确认 FAIL(updateUser 不存在于 mock 和 usr.ts)
  252 +
  253 +- [ ] **Step 2: 在 usr.ts 追加 UserUpdateReq / UserUpdateResp / updateUser;改造 UserFormDrawer 支持 edit props**
  254 +
  255 +- [ ] **Step 3: 子会话验证 PASS**
  256 +
  257 +- [ ] **Step 4: Commit**
  258 + - `git add frontend/src/api/usr.ts frontend/src/pages/usr/UserFormDrawer.tsx frontend/src/test/UserListPage.test.tsx`
  259 + - `git commit -m "feat(usr): updateUser API + UserFormDrawer edit 模式 REQ-USR-002"`
  260 +
  261 +---
  262 +
  263 +### Task 5: UserListPage 操作列 + editingUser 状态
  264 +
  265 +**Files:**
  266 +- Modify: `frontend/src/pages/usr/UserListPage.tsx`
  267 +- Test: `frontend/src/test/UserListPage.test.tsx`
  268 +
  269 +**行为:**
  270 +- columns 末尾追加 "操作" 列,每行渲染 `<PermButton permission="usr:edit" onClick={() => setEditingUser(row)}>修改</PermButton>`
  271 +- 新增状态: `const [editingUser, setEditingUser] = useState<UserListItemVO | null>(null)`
  272 +- UserFormDrawer 新增两个 props:
  273 + - `open={drawerOpen || editingUser !== null}`(或用 editDrawerOpen 独立状态)
  274 + - `userId={editingUser?.sId}`
  275 + - `initialData={editingUser ? { userType: editingUser.sUserType, language: editingUser.sLanguage, canEditDoc: false, isDisabled: editingUser.bIsDisabled === 1, employeeId: null } : undefined}`
  276 + - 注意:UserListItemVO 不含 bCanEditDoc,edit 模式下默认 false,用户可手动勾选
  277 + - `onSuccess={() => { setEditingUser(null); load(1) }}`
  278 + - `onClose={() => setEditingUser(null)}`
  279 +
  280 + 注意:区分新增抽屉(drawerOpen)和编辑抽屉(editingUser !== null),两者可独立触发 UserFormDrawer,也可复用同一个实例——推荐独立实例避免状态冲突:新增用 drawerOpen/setDrawerOpen,编辑用 editingUser/setEditingUser。
  281 +
  282 +- [ ] **Step 1: 写失败测试**
  283 +
  284 + **T1** `editButton_openEditDrawer`
  285 + - mock getUserList 返回 1 行(sId='u1',sUsername='alice',...)
  286 + - renderPage('超级管理员')
  287 + - waitFor: screen 中有 "alice" 渲染出来
  288 + - 点击 "修改" 按钮
  289 + - waitFor: screen 中有 "修改用户" 文本(抽屉标题)
  290 +
  291 + - 子会话确认 FAIL(无"修改"按钮)
  292 +
  293 +- [ ] **Step 2: 在 UserListPage 追加操作列和编辑抽屉逻辑;追加 // REQ-USR-002: 修改用户 注释**
  294 +
  295 +- [ ] **Step 3: 子会话验证 PASS(全量测试:后端 45+ / 前端 11+)**
  296 +
  297 +- [ ] **Step 4: Commit**
  298 + - `git add frontend/src/pages/usr/UserListPage.tsx frontend/src/test/UserListPage.test.tsx`
  299 + - `git commit -m "feat(usr): UserListPage 修改按钮 + 编辑抽屉 REQ-USR-002"`
  300 +
  301 +---
  302 +
  303 +## 提交计划
  304 +
  305 +- `feat(usr): 添加 UserUpdateReqDTO / UserUpdateRespVO / 错误码 REQ-USR-002`(覆盖 Task 1)
  306 +- `feat(usr): UserService.updateUser 三重校验 + 权限组 replace REQ-USR-002`(覆盖 Task 2)
  307 +- `feat(usr): PUT /api/usr/users/{userId} REQ-USR-002`(覆盖 Task 3)
  308 +- `feat(usr): updateUser API + UserFormDrawer edit 模式 REQ-USR-002`(覆盖 Task 4)
  309 +- `feat(usr): UserListPage 修改按钮 + 编辑抽屉 REQ-USR-002`(覆盖 Task 5)
... ...
docs/superpowers/plans/2026-05-08-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-08
  4 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-003.md
  5 +---
  6 +
  7 +# Plan: REQ-USR-003 查询用户
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `GET /api/usr/users` 动态查询分页接口(8 字段 × 3 匹配方式,LEFT JOIN tStaff)及前端搜索列表页。
  12 +
  13 +**Architecture:** 自定义 MyBatis XML Mapper 实现 LEFT JOIN 动态 SQL + MyBatis-Plus `IPage` 分页;Service 层负责 pageSize 上限截断与 queryValue 空值短路;Controller 收 `@RequestParam`,`@AuthenticationPrincipal` 取 brandId。前端用 `useEffect` 初始加载 + 搜索按钮触发重新查询,Ant Design Table 接收 `PageVO` 分页数据。
  14 +
  15 +**Tech Stack:** Spring Boot 3 / MyBatis-Plus 3.x / JUnit 5 + Mockito / React 18 / Ant Design 5 / Vitest + @testing-library/react
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +无(V1 已包含 usr_user、tStaff 所有字段)
  21 +
  22 +## 文件变更清单
  23 +
  24 +- Create: `backend/src/main/java/com/example/erp/common/vo/PageVO.java` — 通用分页包装(含 `static of(IPage<T>)` 工厂)
  25 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java` — 查询入参 DTO
  26 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java` — 列表项 VO(11 字段)
  27 +- Create: `backend/src/main/resources/mapper/UsrUserMapper.xml` — LEFT JOIN 动态分页 SQL
  28 +- Modify: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` — 添加 `selectUserList`
  29 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` — 添加 `getUserList` 签名
  30 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 `getUserList`
  31 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` — 添加 `GET /api/usr/users`
  32 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` — 添加 `getUserList` 测试
  33 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` — 添加 `getUsers` 测试
  34 +- Modify: `frontend/src/api/usr.ts` — 添加 `UserListQueryReq`, `UserListItemVO`, `PageVO`, `getUserList()`
  35 +- Modify: `frontend/src/pages/usr/UserListPage.tsx` — 搜索表单 + 真实 Table + 分页
  36 +- Modify: `frontend/src/test/UserListPage.test.tsx` — 添加列表初始加载测试
  37 +
  38 +## 任务步骤
  39 +
  40 +---
  41 +
  42 +### Task 1: 后端 VO/DTO 骨架(PageVO + UserListItemVO + UserListQueryDTO)
  43 +
  44 +**Files:**
  45 +- Create: `backend/src/main/java/com/example/erp/common/vo/PageVO.java`
  46 +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java`
  47 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java`
  48 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  49 +
  50 +**API shape(锁定签名):**
  51 +```java
  52 +// PageVO<T> — com.example.erp.common.vo
  53 +@Getter @Setter
  54 +public class PageVO<T> {
  55 + private long total;
  56 + private long page;
  57 + private long pageSize;
  58 + private List<T> list;
  59 +
  60 + public static <T> PageVO<T> of(IPage<T> iPage) {
  61 + PageVO<T> vo = new PageVO<>();
  62 + vo.total = iPage.getTotal();
  63 + vo.page = iPage.getCurrent();
  64 + vo.pageSize = iPage.getSize();
  65 + vo.list = iPage.getRecords();
  66 + return vo;
  67 + }
  68 +}
  69 +
  70 +// UserListQueryDTO — com.example.erp.module.usr.dto
  71 +@Getter @Setter
  72 +public class UserListQueryDTO {
  73 + private String queryField = "username";
  74 + private String matchType = "contains";
  75 + private String queryValue;
  76 + private int page = 1;
  77 + private int pageSize = 20;
  78 +}
  79 +
  80 +// UserListItemVO — com.example.erp.module.usr.vo(全部字段加 @JsonProperty)
  81 +@Getter @Setter
  82 +public class UserListItemVO {
  83 + @JsonProperty("sId") private String sId;
  84 + @JsonProperty("sUsername") private String sUsername;
  85 + @JsonProperty("sUserCode") private String sUserCode;
  86 + @JsonProperty("sUserType") private String sUserType;
  87 + @JsonProperty("sLanguage") private String sLanguage;
  88 + @JsonProperty("bIsDisabled") private Integer bIsDisabled;
  89 + @JsonProperty("tLastLoginDate") private LocalDateTime tLastLoginDate;
  90 + @JsonProperty("sCreatorUsername") private String sCreatorUsername;
  91 + @JsonProperty("tCreateDate") private LocalDateTime tCreateDate;
  92 + @JsonProperty("sStaffName") private String sStaffName;
  93 + @JsonProperty("sDepartment") private String sDepartment;
  94 +}
  95 +```
  96 +
  97 +- [ ] **Step 1: 写失败测试**
  98 + - 在 `UserServiceTest.java` 顶部 import `UserListQueryDTO`
  99 + - 添加占位测试方法(方法体可为空,仅引用该类型使编译失败):
  100 + ```java
  101 + @Test
  102 + void getUserList_capsPageSizeAt100() {
  103 + UserListQueryDTO q = new UserListQueryDTO();
  104 + q.setPageSize(200);
  105 + }
  106 + ```
  107 + - 子会话确认:`mvn test -pl backend -Dtest=UserServiceTest` 编译失败(`UserListQueryDTO` 符号找不到)
  108 +
  109 +- [ ] **Step 2: 创建 VO/DTO**
  110 + - 创建 `PageVO.java`(含 `of()` 工厂)
  111 + - 创建 `UserListQueryDTO.java`
  112 + - 创建 `UserListItemVO.java`
  113 +
  114 +- [ ] **Step 3: 子会话验证 PASS**
  115 + - `mvn test -pl backend -Dtest=UserServiceTest` 全部通过
  116 +
  117 +- [ ] **Step 4: Commit**
  118 + - `git add backend/src/main/java/com/example/erp/common/vo/PageVO.java backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  119 + - `git commit -m "feat(usr): 添加 PageVO / UserListQueryDTO / UserListItemVO REQ-USR-003"`
  120 +
  121 +---
  122 +
  123 +### Task 2: 自定义 Mapper(UsrUserMapper.xml + 方法声明)
  124 +
  125 +**Files:**
  126 +- Create: `backend/src/main/resources/mapper/UsrUserMapper.xml`
  127 +- Modify: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java`
  128 +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java`
  129 +
  130 +**API shape(锁定方法签名):**
  131 +```java
  132 +// UsrUserMapper.java — 新增方法
  133 +IPage<UserListItemVO> selectUserList(
  134 + IPage<UserListItemVO> page,
  135 + @Param("brandId") String brandId,
  136 + @Param("queryField") String queryField,
  137 + @Param("matchType") String matchType,
  138 + @Param("queryValue") String queryValue
  139 +);
  140 +```
  141 +
  142 +**XML SQL 完整内容(UsrUserMapper.xml):**
  143 +```xml
  144 +<?xml version="1.0" encoding="UTF-8"?>
  145 +<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  146 + "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  147 +<mapper namespace="com.example.erp.module.usr.mapper.UsrUserMapper">
  148 +
  149 + <select id="selectUserList" resultType="com.example.erp.module.usr.vo.UserListItemVO">
  150 + SELECT u.sId, u.sUsername, u.sUserCode, u.sUserType, u.sLanguage,
  151 + u.bIsDisabled, u.tLastLoginDate, u.sCreatorUsername, u.tCreateDate,
  152 + s.sStaffName, s.sDepartment
  153 + FROM usr_user u
  154 + LEFT JOIN tStaff s ON u.sEmployeeId = s.sId
  155 + AND s.sBrandsId = u.sBrandsId
  156 + AND s.bDeleted = 0
  157 + WHERE u.sBrandsId = #{brandId}
  158 + <if test="queryValue != null and queryValue != ''">
  159 + <choose>
  160 + <when test="queryField == 'username'">
  161 + <choose>
  162 + <when test="matchType == 'contains'">AND u.sUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
  163 + <when test="matchType == 'notContains'">AND u.sUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  164 + <otherwise>AND u.sUsername = #{queryValue}</otherwise>
  165 + </choose>
  166 + </when>
  167 + <when test="queryField == 'staffName'">
  168 + <choose>
  169 + <when test="matchType == 'contains'">AND s.sStaffName LIKE CONCAT('%', #{queryValue}, '%')</when>
  170 + <when test="matchType == 'notContains'">AND s.sStaffName NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  171 + <otherwise>AND s.sStaffName = #{queryValue}</otherwise>
  172 + </choose>
  173 + </when>
  174 + <when test="queryField == 'userCode'">
  175 + <choose>
  176 + <when test="matchType == 'contains'">AND u.sUserCode LIKE CONCAT('%', #{queryValue}, '%')</when>
  177 + <when test="matchType == 'notContains'">AND u.sUserCode NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  178 + <otherwise>AND u.sUserCode = #{queryValue}</otherwise>
  179 + </choose>
  180 + </when>
  181 + <when test="queryField == 'department'">
  182 + <choose>
  183 + <when test="matchType == 'contains'">AND s.sDepartment LIKE CONCAT('%', #{queryValue}, '%')</when>
  184 + <when test="matchType == 'notContains'">AND s.sDepartment NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  185 + <otherwise>AND s.sDepartment = #{queryValue}</otherwise>
  186 + </choose>
  187 + </when>
  188 + <when test="queryField == 'userType'">
  189 + <choose>
  190 + <when test="matchType == 'contains'">AND u.sUserType LIKE CONCAT('%', #{queryValue}, '%')</when>
  191 + <when test="matchType == 'notContains'">AND u.sUserType NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  192 + <otherwise>AND u.sUserType = #{queryValue}</otherwise>
  193 + </choose>
  194 + </when>
  195 + <when test="queryField == 'creator'">
  196 + <choose>
  197 + <when test="matchType == 'contains'">AND u.sCreatorUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
  198 + <when test="matchType == 'notContains'">AND u.sCreatorUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
  199 + <otherwise>AND u.sCreatorUsername = #{queryValue}</otherwise>
  200 + </choose>
  201 + </when>
  202 + <when test="queryField == 'disabled'">
  203 + <choose>
  204 + <when test="queryValue == '是'">AND u.bIsDisabled = 1</when>
  205 + <when test="queryValue == '否'">AND u.bIsDisabled = 0</when>
  206 + </choose>
  207 + </when>
  208 + <when test="queryField == 'lastLoginDate'">
  209 + AND DATE(u.tLastLoginDate) = #{queryValue}
  210 + </when>
  211 + </choose>
  212 + </if>
  213 + ORDER BY u.tCreateDate DESC
  214 + </select>
  215 +
  216 +</mapper>
  217 +```
  218 +
  219 +注:`mybatis-plus.mapper-locations` 默认为 `classpath*:/mapper/**/*.xml`,文件放 `src/main/resources/mapper/UsrUserMapper.xml` 会自动扫描到。
  220 +
  221 +- [ ] **Step 1: 写失败测试**
  222 + - 在 `UserServiceTest.java` 中添加 mock stub 引用 `selectUserList`:
  223 + ```java
  224 + @Test
  225 + void getUserList_callsSelectUserList() {
  226 + IPage<UserListItemVO> mockPage = new Page<>(1, 20, 0);
  227 + when(userMapper.selectUserList(any(), eq("b1"), any(), any(), any()))
  228 + .thenReturn(mockPage);
  229 + // getUserList 方法此时不存在于 UserService → 编译失败
  230 + }
  231 + ```
  232 + - 同时 `@Mock private UsrUserMapper userMapper` 已有,直接 `userMapper.selectUserList(...)` 引用即可触发编译失败
  233 + - 子会话确认 FAIL(`selectUserList` 方法不存在)
  234 +
  235 +- [ ] **Step 2: 实现 Mapper**
  236 + - `UsrUserMapper.java` 添加 `selectUserList` 方法(带 `@Param` 注解,需 import `com.baomidou.mybatisplus.extension.plugins.pagination.Page`)
  237 + - 创建 `src/main/resources/mapper/UsrUserMapper.xml`
  238 +
  239 +- [ ] **Step 3: 子会话验证 PASS**
  240 + - `mvn test -pl backend -Dtest=UserServiceTest` 通过
  241 +
  242 +- [ ] **Step 4: Commit**
  243 + - `git commit -m "feat(usr): 添加 selectUserList XML 动态分页查询 REQ-USR-003"`
  244 +
  245 +---
  246 +
  247 +### Task 3: Service 层 + Controller 端点
  248 +
  249 +**Files:**
  250 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java`
  251 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java`
  252 +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java`
  253 +- Test: `UserServiceTest.java`(service 层)、`UserControllerTest.java`(HTTP 层)
  254 +
  255 +**API shape(锁定签名):**
  256 +```java
  257 +// UserService 接口
  258 +PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId);
  259 +
  260 +// UserServiceImpl — getUserList 实现逻辑
  261 +@Transactional(readOnly = true)
  262 +public PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId) {
  263 + int cappedSize = Math.min(query.getPageSize(), 100);
  264 + IPage<UserListItemVO> iPage = new Page<>(query.getPage(), cappedSize);
  265 + userMapper.selectUserList(iPage, brandId,
  266 + query.getQueryField(), query.getMatchType(),
  267 + query.getQueryValue() == null ? "" : query.getQueryValue());
  268 + return PageVO.of(iPage);
  269 +}
  270 +
  271 +// UserController — 新增端点
  272 +@GetMapping("/users")
  273 +public Result<PageVO<UserListItemVO>> getUsers(
  274 + @RequestParam(defaultValue = "username") String queryField,
  275 + @RequestParam(defaultValue = "contains") String matchType,
  276 + @RequestParam(defaultValue = "") String queryValue,
  277 + @RequestParam(defaultValue = "1") int page,
  278 + @RequestParam(defaultValue = "20") int pageSize,
  279 + @AuthenticationPrincipal UserPrincipal principal) {
  280 + UserListQueryDTO q = new UserListQueryDTO();
  281 + q.setQueryField(queryField);
  282 + q.setMatchType(matchType);
  283 + q.setQueryValue(queryValue);
  284 + q.setPage(page);
  285 + q.setPageSize(pageSize);
  286 + return Result.ok(userService.getUserList(q, principal.brandId()));
  287 +}
  288 +```
  289 +
  290 +- [ ] **Step 1: 写失败测试(Service)**
  291 + - 测试名: `UserServiceTest#getUserList_capsPageSizeAt100_callsMapper`
  292 + - 意图:`pageSize=200` → mapper 被调用时 `iPage.getSize() == 100`;返回的 `PageVO.getTotal() == 5`
  293 + - 断言 sketch:
  294 + ```java
  295 + UserListQueryDTO q = new UserListQueryDTO();
  296 + q.setPageSize(200);
  297 + IPage<UserListItemVO> mockPage = new Page<>(1, 100, 5);
  298 + when(userMapper.selectUserList(argThat(p -> p.getSize() == 100), eq("b1"), any(), any(), any()))
  299 + .thenReturn(mockPage);
  300 + PageVO<UserListItemVO> result = userService.getUserList(q, "b1"); // 编译失败
  301 + assertEquals(5, result.getTotal());
  302 + ```
  303 + - 子会话确认 FAIL(`getUserList` 不在接口/实现中)
  304 +
  305 +- [ ] **Step 2: 实现 Service**
  306 + - `UserService.java` 添加 `getUserList` 方法签名
  307 + - `UserServiceImpl.java` 实现(pageSize cap + delegate + `PageVO.of`)
  308 +
  309 +- [ ] **Step 3: 子会话验证 Service PASS**
  310 + - `mvn test -pl backend -Dtest=UserServiceTest` 通过
  311 +
  312 +- [ ] **Step 4: 写失败测试(Controller)**
  313 + - 测试名: `UserControllerTest#getUsers_withToken_returns200`
  314 + - 意图:带 JWT Token 的 `GET /api/usr/users` → HTTP 200,响应 JSON 中 `$.data.total` 存在
  315 + - mock stub:`when(userService.getUserList(any(), eq("b1"))).thenReturn(pageVO)`(pageVO.total=0, pageVO.list=[])
  316 + - 子会话确认 FAIL(端点不存在 → Spring 返回 404 或方法签名缺失导致编译失败)
  317 +
  318 +- [ ] **Step 5: 实现 Controller 端点**
  319 + - `UserController.java` 添加 `@GetMapping("/users")` 方法
  320 +
  321 +- [ ] **Step 6: 子会话验证 Controller PASS**
  322 + - `mvn test -pl backend -Dtest=UserControllerTest` 通过
  323 +
  324 +- [ ] **Step 7: Commit**
  325 + - `git commit -m "feat(usr): getUserList service + GET /api/usr/users 端点 REQ-USR-003"`
  326 +
  327 +---
  328 +
  329 +### Task 4: 前端 API 类型 + 用户列表页
  330 +
  331 +**Files:**
  332 +- Modify: `frontend/src/api/usr.ts`
  333 +- Modify: `frontend/src/pages/usr/UserListPage.tsx`
  334 +- Modify: `frontend/src/test/UserListPage.test.tsx`
  335 +
  336 +**API shape(锁定 usr.ts 新增部分):**
  337 +```ts
  338 +export interface UserListQueryReq {
  339 + queryField?: string;
  340 + matchType?: string;
  341 + queryValue?: string;
  342 + page?: number;
  343 + pageSize?: number;
  344 +}
  345 +
  346 +export interface UserListItemVO {
  347 + sId: string;
  348 + sUsername: string;
  349 + sUserCode: string;
  350 + sUserType: string;
  351 + sLanguage: string;
  352 + bIsDisabled: number;
  353 + tLastLoginDate: string | null;
  354 + sCreatorUsername: string | null;
  355 + tCreateDate: string;
  356 + sStaffName: string | null;
  357 + sDepartment: string | null;
  358 +}
  359 +
  360 +export interface PageVO<T> {
  361 + total: number;
  362 + page: number;
  363 + pageSize: number;
  364 + list: T[];
  365 +}
  366 +
  367 +export function getUserList(params?: UserListQueryReq): Promise<PageVO<UserListItemVO>> {
  368 + return request.get('/usr/users', { params })
  369 +}
  370 +```
  371 +
  372 +**UserListPage.tsx 结构(含搜索表单):**
  373 +```tsx
  374 +export default function UserListPage() {
  375 + const [data, setData] = useState<PageVO<UserListItemVO> | null>(null)
  376 + const [queryField, setQueryField] = useState('username')
  377 + const [matchType, setMatchType] = useState('contains')
  378 + const [queryValue, setQueryValue] = useState('')
  379 + const [page, setPage] = useState(1)
  380 +
  381 + const load = (pg = 1) => {
  382 + getUserList({ queryField, matchType, queryValue, page: pg, pageSize: 20 }).then(setData)
  383 + setPage(pg)
  384 + }
  385 +
  386 + useEffect(() => { load() }, []) // 初始加载
  387 +
  388 + const columns: ColumnsType<UserListItemVO> = [
  389 + { title: '用户名', dataIndex: 'sUsername' },
  390 + { title: '员工名', dataIndex: 'sStaffName' },
  391 + { title: '用户号', dataIndex: 'sUserCode' },
  392 + { title: '部门', dataIndex: 'sDepartment' },
  393 + { title: '用户类型', dataIndex: 'sUserType' },
  394 + { title: '语言', dataIndex: 'sLanguage' },
  395 + { title: '作废', dataIndex: 'bIsDisabled', render: v => v ? '是' : '否' },
  396 + { title: '登录日期', dataIndex: 'tLastLoginDate' },
  397 + { title: '制单人', dataIndex: 'sCreatorUsername' },
  398 + { title: '制单日期', dataIndex: 'tCreateDate' },
  399 + ]
  400 +
  401 + return (
  402 + <div>
  403 + <Space style={{ marginBottom: 16 }}>
  404 + <Select value={queryField} onChange={setQueryField} style={{ width: 120 }}>
  405 + <Select.Option value="username">用户名</Select.Option>
  406 + <Select.Option value="staffName">员工名</Select.Option>
  407 + <Select.Option value="userCode">用户号</Select.Option>
  408 + <Select.Option value="department">部门</Select.Option>
  409 + <Select.Option value="userType">用户类型</Select.Option>
  410 + <Select.Option value="disabled">作废</Select.Option>
  411 + <Select.Option value="lastLoginDate">登录日期</Select.Option>
  412 + <Select.Option value="creator">制单人</Select.Option>
  413 + </Select>
  414 + <Select value={matchType} onChange={setMatchType} style={{ width: 100 }}>
  415 + <Select.Option value="contains">包含</Select.Option>
  416 + <Select.Option value="notContains">不包含</Select.Option>
  417 + <Select.Option value="equals">等于</Select.Option>
  418 + </Select>
  419 + <Input value={queryValue} onChange={e => setQueryValue(e.target.value)} placeholder="查询值" />
  420 + <Button type="primary" onClick={() => load(1)}>搜索</Button>
  421 + <PermButton permission="usr:create" type="primary" onClick={() => setDrawerOpen(true)}>新增</PermButton>
  422 + </Space>
  423 + <Table
  424 + dataSource={data?.list ?? []}
  425 + columns={columns}
  426 + rowKey="sId"
  427 + pagination={{
  428 + total: data?.total ?? 0,
  429 + pageSize: 20,
  430 + current: page,
  431 + onChange: load,
  432 + }}
  433 + />
  434 + <UserFormDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} onSuccess={() => { setDrawerOpen(false); load(1) }} />
  435 + </div>
  436 + )
  437 +}
  438 +```
  439 +
  440 +注:`drawerOpen` state 也需添加(与 Task 1 中 UserListPage 原来的 `drawerOpen` 合并)。
  441 +
  442 +- [ ] **Step 1: 写失败测试**
  443 + - 测试名: `UserListPage.test.tsx#initialLoad_rendersTableRows`
  444 + - 意图:mock `getUserList` 返回含 1 条 `{ sId:'u1', sUsername:'alice', ... }` 的 PageVO → 等待 data 渲染后 table 中有 "alice" 文本
  445 + - 断言 sketch:
  446 + ```ts
  447 + vi.mock('@/api/usr', async (importOriginal) => {
  448 + const actual = await importOriginal<typeof import('@/api/usr')>()
  449 + return { ...actual, getUserList: vi.fn().mockResolvedValue({ total: 1, page: 1, pageSize: 20, list: [{ sId:'u1', sUsername:'alice', sUserCode:'UC001', sUserType:'普通用户', sLanguage:'中文', bIsDisabled:0, tLastLoginDate:null, sCreatorUsername:'admin', tCreateDate:'2026-01-01', sStaffName:null, sDepartment:null }] }) }
  450 + })
  451 + // render + waitFor → screen.getByText('alice')
  452 + ```
  453 + - 子会话确认 FAIL(`getUserList` 不在 `usr.ts` 中 → import 报错)
  454 +
  455 +- [ ] **Step 2: 实现前端**
  456 + - `usr.ts` 添加 `UserListQueryReq`, `UserListItemVO`, `PageVO`, `getUserList()`
  457 + - `UserListPage.tsx` 重构为搜索表单 + 真实 Table(保留 `drawerOpen` 和 `UserFormDrawer`)
  458 +
  459 +- [ ] **Step 3: 子会话验证 PASS**
  460 + - `pnpm test --run` 全部通过
  461 +
  462 +- [ ] **Step 4: Commit**
  463 + - `git commit -m "feat(usr): 用户列表搜索表单 + 分页表格 REQ-USR-003"`
  464 +
  465 +---
  466 +
  467 +## 提交计划
  468 +
  469 +- `feat(usr): 添加 PageVO / UserListQueryDTO / UserListItemVO REQ-USR-003`(覆盖 Task 1)
  470 +- `feat(usr): 添加 selectUserList XML 动态分页查询 REQ-USR-003`(覆盖 Task 2)
  471 +- `feat(usr): getUserList service + GET /api/usr/users 端点 REQ-USR-003`(覆盖 Task 3)
  472 +- `feat(usr): 用户列表搜索表单 + 分页表格 REQ-USR-003`(覆盖 Task 4)
... ...
docs/superpowers/specs/2026-05-08-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-08
  4 +module: usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-002 — 修改用户
  8 +
  9 +## 目标
  10 +超级管理员可通过 userId 定位已有用户,更新其用户类型、语言、单据修改权限、启/停用状态、关联员工和权限组,修改立即生效;用户名不可修改,密码由单独接口重置。
  11 +
  12 +## 输入 / 触发
  13 +
  14 +HTTP 请求:`PUT /api/usr/users/{userId}`(需 Bearer Token 鉴权)
  15 +
  16 +Path 参数:`userId`(`usr_user.sId`,目标用户的业务 ID)
  17 +
  18 +Request Body(JSON):
  19 +
  20 +| 字段 | 类型 | 必填 | 说明 |
  21 +|---|---|---|---|
  22 +| userType | String | 是 | `普通用户` 或 `超级管理员` |
  23 +| language | String | 是 | `中文` / `英文` / `繁体` |
  24 +| canEditDoc | boolean | 是 | 是否有单据修改权限 |
  25 +| isDisabled | boolean | 是 | true = 禁用账号 |
  26 +| employeeId | String\|null | 否 | 关联职员 sId,null = 取消关联 |
  27 +| permGroupIds | String[] | 是 | 权限组 sId 列表(空数组 = 清空所有权限) |
  28 +
  29 +## 输出 / 结果
  30 +
  31 +HTTP 200,`Result<UserUpdateRespVO>`
  32 +
  33 +```json
  34 +{
  35 + "code": 200,
  36 + "data": {
  37 + "userId": "string",
  38 + "username": "string",
  39 + "updatedAt": "2026-05-08T10:00:00"
  40 + }
  41 +}
  42 +```
  43 +
  44 +`updatedAt` 为服务端处理时的 `LocalDateTime.now()`,序列化为 ISO-8601。
  45 +
  46 +## 业务规则
  47 +
  48 +1. **权限校验**:`principal.userType() != "超级管理员"` → 抛 BizException(40300),与 createUser 逻辑一致(来源:`UserServiceImpl.createUser`)
  49 +2. **用户存在性**:按 `sId = userId AND sBrandsId = brandId` 查 `usr_user`;找不到 → 抛 BizException(40400)
  50 +3. **自身角色保护**:`principal.userId().equals(userId)` 时,禁止将 `userType` 从 `超级管理员` 改为其他值 → 抛 BizException(40301)(防止唯一管理员自我降权导致系统锁死;即使目标 userType 已是普通用户也拦截,保持规则简单)
  51 +4. **employeeId 校验**:若 `employeeId != null`,需确认 `tStaff.sId = employeeId AND sBrandsId = brandId` 存在;不存在 → 抛 BizException(40001)
  52 +5. **更新字段**:仅更新 `sUserType / sLanguage / bCanEditDoc / bIsDisabled / sEmployeeId`;`sUsername / sUserCode / sPasswordHash / sCreatorUsername / tCreateDate` 不修改
  53 +6. **权限组 replace 策略**:先 `DELETE FROM usr_user_permission WHERE sUserId = userId AND sBrandsId = brandId`,再批量 INSERT 新的关联记录(与 createUser 的 permGroupIds 处理方式一致);permGroupIds 为空数组时仅删除,不插入
  54 +7. **多租户隔离**:所有查询和写入均带 `sBrandsId = principal.brandId()`
  55 +
  56 +## 边界与约束
  57 +
  58 +- 接口为写操作,需加 `@Transactional`
  59 +- 鉴权失败返回 401(SecurityConfig 已配置)
  60 +- `userType` / `language` 仅接受指定枚举值;违反 → 由 `@Valid` + `@NotNull` 在 DTO 层拦截返回 40001
  61 +- 密码不在此接口处理,`sPasswordHash` 保持原值不变
  62 +- 响应 JSON 不返回 sPasswordHash / iLoginFailCount / tLockUntil
  63 +
  64 +## 依赖的 schema 表 / 字段
  65 +
  66 +- `usr_user (别名 u)`:sId, sUserType, sLanguage, bCanEditDoc, bIsDisabled, sEmployeeId, sBrandsId(查找 + 更新)
  67 +- `tStaff`:sId, sBrandsId(校验 employeeId 存在性)
  68 +- `usr_permission_group`:仅前端下拉,不涉及后端写操作
  69 +- `usr_user_permission`:sUserId, sPermGroupId, sBrandsId, sId, tCreateDate(先删后插)
  70 +
  71 +## 依赖的接口
  72 +
  73 +- `POST /api/auth/login`(REQ-USR-004,获取 Bearer Token)
  74 +- `GET /api/usr/users`(REQ-USR-003,获取列表后点击行触发修改抽屉)
  75 +- `GET /api/usr/users/staffs`(REQ-USR-001,前端抽屉加载员工下拉)
  76 +- `GET /api/usr/users/permission-groups`(REQ-USR-001,前端抽屉加载权限组列表)
  77 +
  78 +## 验收标准
  79 +
  80 +1. `PUT /api/usr/users/{validId}` Body 合法 → HTTP 200,返回 userId/username/updatedAt,数据库对应字段已更新
  81 +2. `userId` 不存在(或属于其他 brand)→ 返回 40400
  82 +3. 非超级管理员 Token 调用 → 返回 40300
  83 +4. 超级管理员修改自己的 userType → 返回 40301
  84 +5. `isDisabled=true` 修改后,目标用户再登录 → 返回 40101(账号已禁用)
  85 +6. permGroupIds 为空数组 → 该用户权限组被清空,下次查询 permission-groups 对应关联为空
  86 +7. permGroupIds 非空 → 权限组先删后插,关联记录与提交值一致
  87 +8. 无 Token → 返回 401
  88 +9. 前端:UserListPage 表格行有"修改"按钮,点击弹出预填修改抽屉;提交后关闭抽屉并刷新列表
  89 +
  90 +## 前端交互设计
  91 +
  92 +**UserFormDrawer 改造(复用已有组件)**:
  93 +- 新增 props:`userId?: string`,`initialData?: { userType, language, canEditDoc, isDisabled, employeeId, permGroupIds: string[] }`
  94 +- 当 `userId` 存在时(edit 模式):
  95 + - 抽屉标题改为 "修改用户"
  96 + - 隐藏用户号和用户名 Form.Item(或显示为只读文本)
  97 + - `open` 时用 `initialData` 初始化 form 和 selectedPermIds
  98 + - 提交调 `updateUser(userId, req)` 而非 `createUser`
  99 +- 当 `userId` 不存在时(create 模式):行为与现有完全一致
  100 +
  101 +**UserListPage 改造**:
  102 +- columns 末尾加"操作"列,包含"修改"按钮(PermButton,权限 `usr:edit`)
  103 +- 点击"修改"按钮:将该行 `UserListItemVO` 转换为 `initialData`(permGroupIds 需额外从后端获取或在列表中推导——因列表不返回 permGroupIds,修改抽屉打开时无法预填权限组复选框,初始化为空数组即可,用户可重新勾选)
  104 +- 新增状态:`editingUser: UserListItemVO | null`,控制修改抽屉开关
  105 +
  106 +**新增 API 函数**(`frontend/src/api/usr.ts`):
  107 +```ts
  108 +export interface UserUpdateReq {
  109 + userType: string
  110 + language: string
  111 + canEditDoc: boolean
  112 + isDisabled: boolean
  113 + employeeId: string | null
  114 + permGroupIds: string[]
  115 +}
  116 +
  117 +export interface UserUpdateResp {
  118 + userId: string
  119 + username: string
  120 + updatedAt: string
  121 +}
  122 +
  123 +export function updateUser(userId: string, req: UserUpdateReq): Promise<UserUpdateResp> {
  124 + return request.put(`/usr/users/${userId}`, req)
  125 +}
  126 +```
... ...
docs/superpowers/specs/2026-05-08-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-08
  4 +module: usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-003 — 查询用户
  8 +
  9 +## 目标
  10 +超级管理员可按 8 种查询字段 + 3 种匹配方式组合筛选,分页浏览本品牌下的用户列表;列表同时展示关联职员的员工名与部门。
  11 +
  12 +## 输入 / 触发
  13 +HTTP 请求:`GET /api/usr/users`(需 Bearer Token 鉴权)
  14 +
  15 +Query 参数:
  16 +
  17 +| 参数 | 类型 | 必填 | 默认值 | 说明 |
  18 +|---|---|---|---|---|
  19 +| queryField | String | 否 | `username` | 查询字段枚举,见下表 |
  20 +| matchType | String | 否 | `contains` | 匹配方式枚举:`contains` / `notContains` / `equals` |
  21 +| queryValue | String | 否 | — | 查询值;空字符串或不传 = 全部 |
  22 +| page | int | 否 | 1 | 页码,从 1 开始 |
  23 +| pageSize | int | 否 | 20 | 每页条数,最大 100 |
  24 +
  25 +queryField 枚举(前端显示名 → 参数值 → 后端列):
  26 +
  27 +| 前端显示 | 参数值 | 后端列 |
  28 +|---|---|---|
  29 +| 用户名 | `username` | `u.sUsername` |
  30 +| 员工名 | `staffName` | `s.sStaffName` |
  31 +| 用户号 | `userCode` | `u.sUserCode` |
  32 +| 部门 | `department` | `s.sDepartment` |
  33 +| 用户类型 | `userType` | `u.sUserType` |
  34 +| 作废 | `disabled` | `u.bIsDisabled` |
  35 +| 登录日期 | `lastLoginDate` | `u.tLastLoginDate` |
  36 +| 制单人 | `creator` | `u.sCreatorUsername` |
  37 +
  38 +## 输出 / 结果
  39 +HTTP 200,`Result<PageVO<UserListItemVO>>`
  40 +
  41 +`PageVO<T>` 结构(通用分页包装):
  42 +```json
  43 +{
  44 + "total": 25,
  45 + "page": 1,
  46 + "pageSize": 20,
  47 + "list": [ ...UserListItemVO... ]
  48 +}
  49 +```
  50 +
  51 +`UserListItemVO` 字段(禁止返回 sPasswordHash / iLoginFailCount / tLockUntil):
  52 +
  53 +| JSON 字段 | 来源列 | 可 null |
  54 +|---|---|---|
  55 +| sId | usr_user.sId | 否 |
  56 +| sUsername | usr_user.sUsername | 否 |
  57 +| sUserCode | usr_user.sUserCode | 否 |
  58 +| sUserType | usr_user.sUserType | 否 |
  59 +| sLanguage | usr_user.sLanguage | 否 |
  60 +| bIsDisabled | usr_user.bIsDisabled | 否 |
  61 +| tLastLoginDate | usr_user.tLastLoginDate | 是 |
  62 +| sCreatorUsername | usr_user.sCreatorUsername | 是 |
  63 +| tCreateDate | usr_user.tCreateDate | 否 |
  64 +| sStaffName | tStaff.sStaffName | 是(LEFT JOIN) |
  65 +| sDepartment | tStaff.sDepartment | 是(LEFT JOIN) |
  66 +
  67 +## 业务规则
  68 +
  69 +1. **多租户隔离**:所有查询必须附加 `u.sBrandsId = principal.brandId()`
  70 +2. **queryValue 为空**:忽略查询条件,返回该品牌全部用户
  71 +3. **匹配方式映射**(文本字段:username / staffName / userCode / department / userType / creator):
  72 + - `contains` → `LIKE '%value%'`
  73 + - `notContains` → `NOT LIKE '%value%'`
  74 + - `equals` → `= 'value'`
  75 +4. **布尔字段(disabled)**:仅有意义的匹配是 equals;queryValue="是" → `bIsDisabled = 1`;"否" → `bIsDisabled = 0`;其他值(包括 contains/notContains 的非 "是"/"否")忽略条件
  76 +5. **日期字段(lastLoginDate)**:queryValue 格式 `YYYY-MM-DD`;无论 matchType 为何,均使用 `DATE(tLastLoginDate) = 'YYYY-MM-DD'`
  77 +6. **pageSize 截断**:pageSize > 100 时强制截断为 100
  78 +7. **分页越界**:page 超出总页数时返回最后一页(MyBatis-Plus `Page` 默认行为)
  79 +8. **职员 JOIN**:`LEFT JOIN tStaff s ON u.sEmployeeId = s.sId AND s.sBrandsId = u.sBrandsId AND s.bDeleted = 0`;未关联职员时 sStaffName / sDepartment 为 null
  80 +
  81 +## 边界与约束
  82 +- 接口为只读,无写副作用
  83 +- 鉴权失败返回 401(SecurityConfig.authenticationEntryPoint 已配置)
  84 +- 单次最多返回 100 条
  85 +
  86 +## 依赖的 schema 表 / 字段
  87 +- `usr_user (别名 u)`:sId, sUsername, sUserCode, sUserType, sLanguage, bIsDisabled, sEmployeeId, sCreatorUsername, tLastLoginDate, tCreateDate, sBrandsId
  88 +- `tStaff (别名 s)`:sId, sStaffName, sDepartment, sBrandsId, bDeleted
  89 +
  90 +## 依赖的接口
  91 +- `POST /api/auth/login`(REQ-USR-004,获取 Bearer Token 供本接口鉴权使用)
  92 +
  93 +## 验收标准
  94 +1. `GET /api/usr/users`(无参)HTTP 200,返回本品牌所有用户,不含密码字段
  95 +2. `queryField=username&matchType=contains&queryValue=admin` 仅返回 sUsername 含 "admin" 的用户
  96 +3. `queryField=disabled&matchType=equals&queryValue=是` 仅返回 bIsDisabled=1 的记录
  97 +4. 无匹配条件时返回 `{ total: 0, list: [] }` 而非 4xx/5xx
  98 +5. page=999(超出总页)返回最后一页而不报错
  99 +6. Token 品牌 A 无法查到品牌 B 的用户(多租户隔离)
  100 +7. 响应 JSON 中不含 sPasswordHash / iLoginFailCount / tLockUntil
  101 +8. 无 Token 请求返回 401
... ...