Commit 8f292bcfb6939019f5208b5ce6389e533cf0a204
1 parent
7de7347d
docs(module_usr): add specs and plans for REQ-USR-002 and REQ-USR-003
Showing
4 changed files
with
1008 additions
and
0 deletions
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 |