diff --git a/docs/superpowers/plans/2026-05-08-REQ-USR-002.md b/docs/superpowers/plans/2026-05-08-REQ-USR-002.md new file mode 100644 index 0000000..9593785 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-REQ-USR-002.md @@ -0,0 +1,309 @@ +--- +req_id: REQ-USR-002 +date: 2026-05-08 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-002.md +--- + +# 修改用户 Implementation Plan + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `PUT /api/usr/users/{userId}`,允许超级管理员修改用户类型、语言、权限等,并在前端用户列表增加"修改"按钮触发编辑抽屉。 + +**Architecture:** 后端遵循现有三层(Controller → Service → Mapper)模式,新增 UserUpdateReqDTO/UserUpdateRespVO,UserServiceImpl.updateUser 做权限、存在性、自身角色三重校验后执行字段更新 + 权限组先删后插;前端复用 UserFormDrawer(新增 userId/initialData props 区分 create/edit 模式),UserListPage 增加"操作"列。 + +**Tech Stack:** Spring Boot 3 / MyBatis-Plus LambdaUpdateWrapper / @Transactional / React 18 / Ant Design 5 / Vitest + +--- + +## Schema 改动 + +无(不需要新 migration) + +## 文件变更清单 + +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java` — 修改用户请求体 +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java` — 修改用户响应 VO +- Modify: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java` — 追加 USER_NOT_FOUND=40400 / SELF_ADMIN_CHANGE=40301 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` — 追加 updateUser 接口方法 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 updateUser +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` — 追加 PUT /users/{userId} +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` — 追加 updateUser 测试 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` — 追加 HTTP 测试 +- Modify: `frontend/src/api/usr.ts` — 追加 UserUpdateReq / UserUpdateResp / updateUser() +- Modify: `frontend/src/pages/usr/UserFormDrawer.tsx` — 支持 edit 模式(userId + initialData props) +- Modify: `frontend/src/pages/usr/UserListPage.tsx` — 追加操作列 + editingUser 状态 +- Modify: `frontend/src/test/UserListPage.test.tsx` — 追加编辑流程测试 + +--- + +## 任务步骤 + +### Task 1: 错误码 + DTO/VO 数据结构 + +**Files:** +- Modify: `backend/src/main/java/com/example/erp/common/constants/UsrErrorCode.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserUpdateReqDTO.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserUpdateRespVO.java` +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` + +**API shape:** +``` +UserUpdateReqDTO { + String userType; // "普通用户" | "超级管理员" + String language; // "中文" | "英文" | "繁体" + boolean canEditDoc; + boolean isDisabled; + String employeeId; // nullable + List permGroupIds; +} + +UserUpdateRespVO { + String userId; + String username; + LocalDateTime updatedAt; +} + +UsrErrorCode.USER_NOT_FOUND = 40400 +UsrErrorCode.SELF_ADMIN_CHANGE = 40301 +``` + +- [ ] **Step 1: 写失败测试** + - 测试名: `UserServiceTest#updateUser_dtoDefaults` + - 意图: `new UserUpdateReqDTO()` 后各字段可 set/get;`new UserUpdateRespVO()` 可 set/get userId/username/updatedAt;`UsrErrorCode.USER_NOT_FOUND == 40400`,`UsrErrorCode.SELF_ADMIN_CHANGE == 40301` + - 子会话确认 FAIL(类不存在) + +- [ ] **Step 2: 实现最小代码** + - 在 UsrErrorCode 追加两个常量 + - 创建 UserUpdateReqDTO(@Getter @Setter,6 个字段) + - 创建 UserUpdateRespVO(@Getter @Setter,3 个字段:userId/username/updatedAt: LocalDateTime) + +- [ ] **Step 3: 子会话验证 PASS** + +- [ ] **Step 4: Commit** + - `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` + - `git commit -m "feat(usr): 添加 UserUpdateReqDTO / UserUpdateRespVO / 错误码 REQ-USR-002"` + +--- + +### Task 2: UserService.updateUser 实现(含权限校验与权限组 replace) + +**Files:** +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` + +**API shape:** +``` +UserService#updateUser(String userId, UserUpdateReqDTO req, UserPrincipal principal) : UserUpdateRespVO +``` + +业务逻辑约束(按顺序执行): +1. `!"超级管理员".equals(principal.userType())` → `BizException(PERMISSION_DENIED, "权限不足")` +2. `userMapper.selectOne(sId=userId AND sBrandsId=brandId)` == null → `BizException(USER_NOT_FOUND, "用户不存在")` +3. `principal.userId().equals(userId) && !"超级管理员".equals(req.getUserType())` → `BizException(SELF_ADMIN_CHANGE, "禁止修改自己的管理员角色")` +4. `req.getEmployeeId() != null` → staffMapper 校验存在性,不存在 → `BizException(EMPLOYEE_NOT_FOUND, "员工不存在")` +5. 用 `LambdaUpdateWrapper` 更新 sUserType/sLanguage/bCanEditDoc/bIsDisabled/sEmployeeId(按 sId=userId AND sBrandsId=brandId) +6. `userPermissionMapper.delete(sUserId=userId AND sBrandsId=brandId)`,再批量 insert 新 UserPermissionEntity 列表(permGroupIds 为空时只删不插) +7. 返回 UserUpdateRespVO(userId, username=entity.sUsername, updatedAt=LocalDateTime.now()) + +- [ ] **Step 1: 写失败测试(共 6 个)** + + **T1** `updateUser_nonAdmin_throws40300` + - 意图: principal.userType="普通用户" → BizException.code==40300 + - principal: new UserPrincipal("u1","admin","普通用户","b1") + + **T2** `updateUser_userNotFound_throws40400` + - 意图: 超级管理员 principal,userMapper.selectOne 返回 null → BizException.code==40400 + + **T3** `updateUser_selfAdminChange_throws40301` + - 意图: principal.userId=="u1",userId=="u1",req.userType="普通用户" → BizException.code==40301 + - userMapper.selectOne 需 stub 返回一个存在的用户实体 + + **T4** `updateUser_invalidEmployee_throws40001` + - 意图: req.employeeId="bad-emp",staffMapper.selectOne 返回 null → BizException.code==40001 + - userMapper.selectOne stub 返回有效用户;principal.userId!="u-target" 确保不触发 40301 + + **T5** `updateUser_happyPath_updatesAndReturnsResp` + - 意图: 一切校验通过 → userMapper 执行 update;permissionMapper 先 delete 再 insert;返回 vo.userId/username/updatedAt 非 null + - Stub: userMapper.selectOne 返回含 sUsername="alice" 的实体;staffMapper.selectOne 返回非 null(若 employeeId 非 null);userMapper.update 返回 1;delete/insert 正常 + + **T6** `updateUser_emptyPermGroupIds_onlyDeletes` + - 意图: req.permGroupIds=[] → userPermissionMapper.delete 被调用;insert 不被调用 + - verify(userPermissionMapper, never()).insert(anyList()) + + - 子会话确认 6 个测试均 FAIL(方法不存在) + +- [ ] **Step 2: 在 UserService 接口追加方法声明,在 UserServiceImpl 实现 updateUser** + - 使用 @Transactional + - 注意: UsrUserEntity 已有 sId / sUserType / sLanguage / bCanEditDoc / bIsDisabled / sEmployeeId 字段(来自现有代码) + +- [ ] **Step 3: 子会话验证全部 PASS** + +- [ ] **Step 4: Commit** + - `git add backend/src/main/java/com/example/erp/module/usr/service/ backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` + - `git commit -m "feat(usr): UserService.updateUser 三重校验 + 权限组 replace REQ-USR-002"` + +--- + +### Task 3: UserController PUT /api/usr/users/{userId} + +**Files:** +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` +- Test: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` + +**API shape:** +``` +@PutMapping("/users/{userId}") +public Result updateUser( + @PathVariable String userId, + @Valid @RequestBody UserUpdateReqDTO req, + @AuthenticationPrincipal UserPrincipal principal) +// REQ-USR-002: 修改用户 +``` + +- [ ] **Step 1: 写失败测试(共 2 个)** + + **T1** `updateUser_withToken_returns200` + - 意图: PUT /api/usr/users/u-target,含超级管理员 Token,body={userType,language,canEditDoc,isDisabled,permGroupIds} + - stub: userService.updateUser(any,any,any) 返回 UserUpdateRespVO{userId="u-target",username="alice",updatedAt=now} + - 断言: HTTP 200, $.code==200, $.data.userId=="u-target", $.data.username=="alice" + + **T2** `updateUser_noAuth_returns401` + - 意图: 无 Token → HTTP 401 + + - 子会话确认 FAIL(endpoint 不存在) + +- [ ] **Step 2: 在 UserController 追加 PUT /users/{userId} 方法,加 // REQ-USR-002: 修改用户 注释** + +- [ ] **Step 3: 子会话验证 PASS** + +- [ ] **Step 4: Commit** + - `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` + - `git commit -m "feat(usr): PUT /api/usr/users/{userId} REQ-USR-002"` + +--- + +### Task 4: 前端 API + UserFormDrawer edit 模式 + +**Files:** +- Modify: `frontend/src/api/usr.ts` +- Modify: `frontend/src/pages/usr/UserFormDrawer.tsx` +- Test: `frontend/src/test/UserListPage.test.tsx` + +**API shape (usr.ts):** +```ts +export interface UserUpdateReq { + userType: string + language: string + canEditDoc: boolean + isDisabled: boolean + employeeId: string | null + permGroupIds: string[] +} + +export interface UserUpdateResp { + userId: string + username: string + updatedAt: string +} + +export function updateUser(userId: string, req: UserUpdateReq): Promise +// → request.put(`/usr/users/${userId}`, req) +``` + +**UserFormDrawer props 扩展:** +```ts +interface Props { + open: boolean + onClose: () => void + onSuccess: () => void + userId?: string // 非 null = edit 模式 + initialData?: { + userType: string + language: string + canEditDoc: boolean + isDisabled: boolean + employeeId?: string | null + } +} +``` +edit 模式行为: +- 抽屉 title="修改用户",隐藏 userCode/username Form.Item +- open 时用 initialData 初始化 form(form.setFieldsValue),selectedPermIds=[] +- 提交调 updateUser(userId, req) 而非 createUser;req.isDisabled 从 form.values.isDisabled 读取(Checkbox valuePropName="checked") + +- [ ] **Step 1: 写失败测试** + + 在 `UserListPage.test.tsx` vi.mock 里追加: + ```ts + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }) + ``` + + **T1** `editMode_drawerTitle_shows修改用户` + - renderPage,设 drawerOpen=true + editingUser 存在(通过点击模拟行上的修改按钮) + - 断言: screen 中存在 "修改用户" 文本 + + **T2** `editMode_submit_callsUpdateUser` + - 打开 edit 抽屉,点击确认 + - waitFor: vi.mocked(updateUser) 被调用 1 次 + + - 子会话确认 FAIL(updateUser 不存在于 mock 和 usr.ts) + +- [ ] **Step 2: 在 usr.ts 追加 UserUpdateReq / UserUpdateResp / updateUser;改造 UserFormDrawer 支持 edit props** + +- [ ] **Step 3: 子会话验证 PASS** + +- [ ] **Step 4: Commit** + - `git add frontend/src/api/usr.ts frontend/src/pages/usr/UserFormDrawer.tsx frontend/src/test/UserListPage.test.tsx` + - `git commit -m "feat(usr): updateUser API + UserFormDrawer edit 模式 REQ-USR-002"` + +--- + +### Task 5: UserListPage 操作列 + editingUser 状态 + +**Files:** +- Modify: `frontend/src/pages/usr/UserListPage.tsx` +- Test: `frontend/src/test/UserListPage.test.tsx` + +**行为:** +- columns 末尾追加 "操作" 列,每行渲染 ` setEditingUser(row)}>修改` +- 新增状态: `const [editingUser, setEditingUser] = useState(null)` +- UserFormDrawer 新增两个 props: + - `open={drawerOpen || editingUser !== null}`(或用 editDrawerOpen 独立状态) + - `userId={editingUser?.sId}` + - `initialData={editingUser ? { userType: editingUser.sUserType, language: editingUser.sLanguage, canEditDoc: false, isDisabled: editingUser.bIsDisabled === 1, employeeId: null } : undefined}` + - 注意:UserListItemVO 不含 bCanEditDoc,edit 模式下默认 false,用户可手动勾选 + - `onSuccess={() => { setEditingUser(null); load(1) }}` + - `onClose={() => setEditingUser(null)}` + + 注意:区分新增抽屉(drawerOpen)和编辑抽屉(editingUser !== null),两者可独立触发 UserFormDrawer,也可复用同一个实例——推荐独立实例避免状态冲突:新增用 drawerOpen/setDrawerOpen,编辑用 editingUser/setEditingUser。 + +- [ ] **Step 1: 写失败测试** + + **T1** `editButton_openEditDrawer` + - mock getUserList 返回 1 行(sId='u1',sUsername='alice',...) + - renderPage('超级管理员') + - waitFor: screen 中有 "alice" 渲染出来 + - 点击 "修改" 按钮 + - waitFor: screen 中有 "修改用户" 文本(抽屉标题) + + - 子会话确认 FAIL(无"修改"按钮) + +- [ ] **Step 2: 在 UserListPage 追加操作列和编辑抽屉逻辑;追加 // REQ-USR-002: 修改用户 注释** + +- [ ] **Step 3: 子会话验证 PASS(全量测试:后端 45+ / 前端 11+)** + +- [ ] **Step 4: Commit** + - `git add frontend/src/pages/usr/UserListPage.tsx frontend/src/test/UserListPage.test.tsx` + - `git commit -m "feat(usr): UserListPage 修改按钮 + 编辑抽屉 REQ-USR-002"` + +--- + +## 提交计划 + +- `feat(usr): 添加 UserUpdateReqDTO / UserUpdateRespVO / 错误码 REQ-USR-002`(覆盖 Task 1) +- `feat(usr): UserService.updateUser 三重校验 + 权限组 replace REQ-USR-002`(覆盖 Task 2) +- `feat(usr): PUT /api/usr/users/{userId} REQ-USR-002`(覆盖 Task 3) +- `feat(usr): updateUser API + UserFormDrawer edit 模式 REQ-USR-002`(覆盖 Task 4) +- `feat(usr): UserListPage 修改按钮 + 编辑抽屉 REQ-USR-002`(覆盖 Task 5) diff --git a/docs/superpowers/plans/2026-05-08-REQ-USR-003.md b/docs/superpowers/plans/2026-05-08-REQ-USR-003.md new file mode 100644 index 0000000..c5ef5df --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-REQ-USR-003.md @@ -0,0 +1,472 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-08 +spec_ref: docs/superpowers/specs/2026-05-08-REQ-USR-003.md +--- + +# Plan: REQ-USR-003 查询用户 + +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `GET /api/usr/users` 动态查询分页接口(8 字段 × 3 匹配方式,LEFT JOIN tStaff)及前端搜索列表页。 + +**Architecture:** 自定义 MyBatis XML Mapper 实现 LEFT JOIN 动态 SQL + MyBatis-Plus `IPage` 分页;Service 层负责 pageSize 上限截断与 queryValue 空值短路;Controller 收 `@RequestParam`,`@AuthenticationPrincipal` 取 brandId。前端用 `useEffect` 初始加载 + 搜索按钮触发重新查询,Ant Design Table 接收 `PageVO` 分页数据。 + +**Tech Stack:** Spring Boot 3 / MyBatis-Plus 3.x / JUnit 5 + Mockito / React 18 / Ant Design 5 / Vitest + @testing-library/react + +--- + +## Schema 改动 +无(V1 已包含 usr_user、tStaff 所有字段) + +## 文件变更清单 + +- Create: `backend/src/main/java/com/example/erp/common/vo/PageVO.java` — 通用分页包装(含 `static of(IPage)` 工厂) +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java` — 查询入参 DTO +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java` — 列表项 VO(11 字段) +- Create: `backend/src/main/resources/mapper/UsrUserMapper.xml` — LEFT JOIN 动态分页 SQL +- Modify: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` — 添加 `selectUserList` +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` — 添加 `getUserList` 签名 +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 `getUserList` +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` — 添加 `GET /api/usr/users` +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` — 添加 `getUserList` 测试 +- Modify: `backend/src/test/java/com/example/erp/module/usr/UserControllerTest.java` — 添加 `getUsers` 测试 +- Modify: `frontend/src/api/usr.ts` — 添加 `UserListQueryReq`, `UserListItemVO`, `PageVO`, `getUserList()` +- Modify: `frontend/src/pages/usr/UserListPage.tsx` — 搜索表单 + 真实 Table + 分页 +- Modify: `frontend/src/test/UserListPage.test.tsx` — 添加列表初始加载测试 + +## 任务步骤 + +--- + +### Task 1: 后端 VO/DTO 骨架(PageVO + UserListItemVO + UserListQueryDTO) + +**Files:** +- Create: `backend/src/main/java/com/example/erp/common/vo/PageVO.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/dto/UserListQueryDTO.java` +- Create: `backend/src/main/java/com/example/erp/module/usr/vo/UserListItemVO.java` +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` + +**API shape(锁定签名):** +```java +// PageVO — com.example.erp.common.vo +@Getter @Setter +public class PageVO { + private long total; + private long page; + private long pageSize; + private List list; + + public static PageVO of(IPage iPage) { + PageVO vo = new PageVO<>(); + vo.total = iPage.getTotal(); + vo.page = iPage.getCurrent(); + vo.pageSize = iPage.getSize(); + vo.list = iPage.getRecords(); + return vo; + } +} + +// UserListQueryDTO — com.example.erp.module.usr.dto +@Getter @Setter +public class UserListQueryDTO { + private String queryField = "username"; + private String matchType = "contains"; + private String queryValue; + private int page = 1; + private int pageSize = 20; +} + +// UserListItemVO — com.example.erp.module.usr.vo(全部字段加 @JsonProperty) +@Getter @Setter +public class UserListItemVO { + @JsonProperty("sId") private String sId; + @JsonProperty("sUsername") private String sUsername; + @JsonProperty("sUserCode") private String sUserCode; + @JsonProperty("sUserType") private String sUserType; + @JsonProperty("sLanguage") private String sLanguage; + @JsonProperty("bIsDisabled") private Integer bIsDisabled; + @JsonProperty("tLastLoginDate") private LocalDateTime tLastLoginDate; + @JsonProperty("sCreatorUsername") private String sCreatorUsername; + @JsonProperty("tCreateDate") private LocalDateTime tCreateDate; + @JsonProperty("sStaffName") private String sStaffName; + @JsonProperty("sDepartment") private String sDepartment; +} +``` + +- [ ] **Step 1: 写失败测试** + - 在 `UserServiceTest.java` 顶部 import `UserListQueryDTO` + - 添加占位测试方法(方法体可为空,仅引用该类型使编译失败): + ```java + @Test + void getUserList_capsPageSizeAt100() { + UserListQueryDTO q = new UserListQueryDTO(); + q.setPageSize(200); + } + ``` + - 子会话确认:`mvn test -pl backend -Dtest=UserServiceTest` 编译失败(`UserListQueryDTO` 符号找不到) + +- [ ] **Step 2: 创建 VO/DTO** + - 创建 `PageVO.java`(含 `of()` 工厂) + - 创建 `UserListQueryDTO.java` + - 创建 `UserListItemVO.java` + +- [ ] **Step 3: 子会话验证 PASS** + - `mvn test -pl backend -Dtest=UserServiceTest` 全部通过 + +- [ ] **Step 4: Commit** + - `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` + - `git commit -m "feat(usr): 添加 PageVO / UserListQueryDTO / UserListItemVO REQ-USR-003"` + +--- + +### Task 2: 自定义 Mapper(UsrUserMapper.xml + 方法声明) + +**Files:** +- Create: `backend/src/main/resources/mapper/UsrUserMapper.xml` +- Modify: `backend/src/main/java/com/example/erp/module/usr/mapper/UsrUserMapper.java` +- Test: `backend/src/test/java/com/example/erp/module/usr/UserServiceTest.java` + +**API shape(锁定方法签名):** +```java +// UsrUserMapper.java — 新增方法 +IPage selectUserList( + IPage page, + @Param("brandId") String brandId, + @Param("queryField") String queryField, + @Param("matchType") String matchType, + @Param("queryValue") String queryValue +); +``` + +**XML SQL 完整内容(UsrUserMapper.xml):** +```xml + + + + + + + +``` + +注:`mybatis-plus.mapper-locations` 默认为 `classpath*:/mapper/**/*.xml`,文件放 `src/main/resources/mapper/UsrUserMapper.xml` 会自动扫描到。 + +- [ ] **Step 1: 写失败测试** + - 在 `UserServiceTest.java` 中添加 mock stub 引用 `selectUserList`: + ```java + @Test + void getUserList_callsSelectUserList() { + IPage mockPage = new Page<>(1, 20, 0); + when(userMapper.selectUserList(any(), eq("b1"), any(), any(), any())) + .thenReturn(mockPage); + // getUserList 方法此时不存在于 UserService → 编译失败 + } + ``` + - 同时 `@Mock private UsrUserMapper userMapper` 已有,直接 `userMapper.selectUserList(...)` 引用即可触发编译失败 + - 子会话确认 FAIL(`selectUserList` 方法不存在) + +- [ ] **Step 2: 实现 Mapper** + - `UsrUserMapper.java` 添加 `selectUserList` 方法(带 `@Param` 注解,需 import `com.baomidou.mybatisplus.extension.plugins.pagination.Page`) + - 创建 `src/main/resources/mapper/UsrUserMapper.xml` + +- [ ] **Step 3: 子会话验证 PASS** + - `mvn test -pl backend -Dtest=UserServiceTest` 通过 + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): 添加 selectUserList XML 动态分页查询 REQ-USR-003"` + +--- + +### Task 3: Service 层 + Controller 端点 + +**Files:** +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/UserService.java` +- Modify: `backend/src/main/java/com/example/erp/module/usr/service/impl/UserServiceImpl.java` +- Modify: `backend/src/main/java/com/example/erp/module/usr/controller/UserController.java` +- Test: `UserServiceTest.java`(service 层)、`UserControllerTest.java`(HTTP 层) + +**API shape(锁定签名):** +```java +// UserService 接口 +PageVO getUserList(UserListQueryDTO query, String brandId); + +// UserServiceImpl — getUserList 实现逻辑 +@Transactional(readOnly = true) +public PageVO getUserList(UserListQueryDTO query, String brandId) { + int cappedSize = Math.min(query.getPageSize(), 100); + IPage iPage = new Page<>(query.getPage(), cappedSize); + userMapper.selectUserList(iPage, brandId, + query.getQueryField(), query.getMatchType(), + query.getQueryValue() == null ? "" : query.getQueryValue()); + return PageVO.of(iPage); +} + +// UserController — 新增端点 +@GetMapping("/users") +public Result> getUsers( + @RequestParam(defaultValue = "username") String queryField, + @RequestParam(defaultValue = "contains") String matchType, + @RequestParam(defaultValue = "") String queryValue, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize, + @AuthenticationPrincipal UserPrincipal principal) { + UserListQueryDTO q = new UserListQueryDTO(); + q.setQueryField(queryField); + q.setMatchType(matchType); + q.setQueryValue(queryValue); + q.setPage(page); + q.setPageSize(pageSize); + return Result.ok(userService.getUserList(q, principal.brandId())); +} +``` + +- [ ] **Step 1: 写失败测试(Service)** + - 测试名: `UserServiceTest#getUserList_capsPageSizeAt100_callsMapper` + - 意图:`pageSize=200` → mapper 被调用时 `iPage.getSize() == 100`;返回的 `PageVO.getTotal() == 5` + - 断言 sketch: + ```java + UserListQueryDTO q = new UserListQueryDTO(); + q.setPageSize(200); + IPage mockPage = new Page<>(1, 100, 5); + when(userMapper.selectUserList(argThat(p -> p.getSize() == 100), eq("b1"), any(), any(), any())) + .thenReturn(mockPage); + PageVO result = userService.getUserList(q, "b1"); // 编译失败 + assertEquals(5, result.getTotal()); + ``` + - 子会话确认 FAIL(`getUserList` 不在接口/实现中) + +- [ ] **Step 2: 实现 Service** + - `UserService.java` 添加 `getUserList` 方法签名 + - `UserServiceImpl.java` 实现(pageSize cap + delegate + `PageVO.of`) + +- [ ] **Step 3: 子会话验证 Service PASS** + - `mvn test -pl backend -Dtest=UserServiceTest` 通过 + +- [ ] **Step 4: 写失败测试(Controller)** + - 测试名: `UserControllerTest#getUsers_withToken_returns200` + - 意图:带 JWT Token 的 `GET /api/usr/users` → HTTP 200,响应 JSON 中 `$.data.total` 存在 + - mock stub:`when(userService.getUserList(any(), eq("b1"))).thenReturn(pageVO)`(pageVO.total=0, pageVO.list=[]) + - 子会话确认 FAIL(端点不存在 → Spring 返回 404 或方法签名缺失导致编译失败) + +- [ ] **Step 5: 实现 Controller 端点** + - `UserController.java` 添加 `@GetMapping("/users")` 方法 + +- [ ] **Step 6: 子会话验证 Controller PASS** + - `mvn test -pl backend -Dtest=UserControllerTest` 通过 + +- [ ] **Step 7: Commit** + - `git commit -m "feat(usr): getUserList service + GET /api/usr/users 端点 REQ-USR-003"` + +--- + +### Task 4: 前端 API 类型 + 用户列表页 + +**Files:** +- Modify: `frontend/src/api/usr.ts` +- Modify: `frontend/src/pages/usr/UserListPage.tsx` +- Modify: `frontend/src/test/UserListPage.test.tsx` + +**API shape(锁定 usr.ts 新增部分):** +```ts +export interface UserListQueryReq { + queryField?: string; + matchType?: string; + queryValue?: string; + page?: number; + pageSize?: number; +} + +export interface UserListItemVO { + sId: string; + sUsername: string; + sUserCode: string; + sUserType: string; + sLanguage: string; + bIsDisabled: number; + tLastLoginDate: string | null; + sCreatorUsername: string | null; + tCreateDate: string; + sStaffName: string | null; + sDepartment: string | null; +} + +export interface PageVO { + total: number; + page: number; + pageSize: number; + list: T[]; +} + +export function getUserList(params?: UserListQueryReq): Promise> { + return request.get('/usr/users', { params }) +} +``` + +**UserListPage.tsx 结构(含搜索表单):** +```tsx +export default function UserListPage() { + const [data, setData] = useState | null>(null) + const [queryField, setQueryField] = useState('username') + const [matchType, setMatchType] = useState('contains') + const [queryValue, setQueryValue] = useState('') + const [page, setPage] = useState(1) + + const load = (pg = 1) => { + getUserList({ queryField, matchType, queryValue, page: pg, pageSize: 20 }).then(setData) + setPage(pg) + } + + useEffect(() => { load() }, []) // 初始加载 + + const columns: ColumnsType = [ + { title: '用户名', dataIndex: 'sUsername' }, + { title: '员工名', dataIndex: 'sStaffName' }, + { title: '用户号', dataIndex: 'sUserCode' }, + { title: '部门', dataIndex: 'sDepartment' }, + { title: '用户类型', dataIndex: 'sUserType' }, + { title: '语言', dataIndex: 'sLanguage' }, + { title: '作废', dataIndex: 'bIsDisabled', render: v => v ? '是' : '否' }, + { title: '登录日期', dataIndex: 'tLastLoginDate' }, + { title: '制单人', dataIndex: 'sCreatorUsername' }, + { title: '制单日期', dataIndex: 'tCreateDate' }, + ] + + return ( +
+ + + + setQueryValue(e.target.value)} placeholder="查询值" /> + + setDrawerOpen(true)}>新增 + + + setDrawerOpen(false)} onSuccess={() => { setDrawerOpen(false); load(1) }} /> + + ) +} +``` + +注:`drawerOpen` state 也需添加(与 Task 1 中 UserListPage 原来的 `drawerOpen` 合并)。 + +- [ ] **Step 1: 写失败测试** + - 测试名: `UserListPage.test.tsx#initialLoad_rendersTableRows` + - 意图:mock `getUserList` 返回含 1 条 `{ sId:'u1', sUsername:'alice', ... }` 的 PageVO → 等待 data 渲染后 table 中有 "alice" 文本 + - 断言 sketch: + ```ts + vi.mock('@/api/usr', async (importOriginal) => { + const actual = await importOriginal() + 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 }] }) } + }) + // render + waitFor → screen.getByText('alice') + ``` + - 子会话确认 FAIL(`getUserList` 不在 `usr.ts` 中 → import 报错) + +- [ ] **Step 2: 实现前端** + - `usr.ts` 添加 `UserListQueryReq`, `UserListItemVO`, `PageVO`, `getUserList()` + - `UserListPage.tsx` 重构为搜索表单 + 真实 Table(保留 `drawerOpen` 和 `UserFormDrawer`) + +- [ ] **Step 3: 子会话验证 PASS** + - `pnpm test --run` 全部通过 + +- [ ] **Step 4: Commit** + - `git commit -m "feat(usr): 用户列表搜索表单 + 分页表格 REQ-USR-003"` + +--- + +## 提交计划 + +- `feat(usr): 添加 PageVO / UserListQueryDTO / UserListItemVO REQ-USR-003`(覆盖 Task 1) +- `feat(usr): 添加 selectUserList XML 动态分页查询 REQ-USR-003`(覆盖 Task 2) +- `feat(usr): getUserList service + GET /api/usr/users 端点 REQ-USR-003`(覆盖 Task 3) +- `feat(usr): 用户列表搜索表单 + 分页表格 REQ-USR-003`(覆盖 Task 4) diff --git a/docs/superpowers/specs/2026-05-08-REQ-USR-002.md b/docs/superpowers/specs/2026-05-08-REQ-USR-002.md new file mode 100644 index 0000000..5a4b1ba --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-REQ-USR-002.md @@ -0,0 +1,126 @@ +--- +req_id: REQ-USR-002 +date: 2026-05-08 +module: usr +--- + +# Spec: REQ-USR-002 — 修改用户 + +## 目标 +超级管理员可通过 userId 定位已有用户,更新其用户类型、语言、单据修改权限、启/停用状态、关联员工和权限组,修改立即生效;用户名不可修改,密码由单独接口重置。 + +## 输入 / 触发 + +HTTP 请求:`PUT /api/usr/users/{userId}`(需 Bearer Token 鉴权) + +Path 参数:`userId`(`usr_user.sId`,目标用户的业务 ID) + +Request Body(JSON): + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| userType | String | 是 | `普通用户` 或 `超级管理员` | +| language | String | 是 | `中文` / `英文` / `繁体` | +| canEditDoc | boolean | 是 | 是否有单据修改权限 | +| isDisabled | boolean | 是 | true = 禁用账号 | +| employeeId | String\|null | 否 | 关联职员 sId,null = 取消关联 | +| permGroupIds | String[] | 是 | 权限组 sId 列表(空数组 = 清空所有权限) | + +## 输出 / 结果 + +HTTP 200,`Result` + +```json +{ + "code": 200, + "data": { + "userId": "string", + "username": "string", + "updatedAt": "2026-05-08T10:00:00" + } +} +``` + +`updatedAt` 为服务端处理时的 `LocalDateTime.now()`,序列化为 ISO-8601。 + +## 业务规则 + +1. **权限校验**:`principal.userType() != "超级管理员"` → 抛 BizException(40300),与 createUser 逻辑一致(来源:`UserServiceImpl.createUser`) +2. **用户存在性**:按 `sId = userId AND sBrandsId = brandId` 查 `usr_user`;找不到 → 抛 BizException(40400) +3. **自身角色保护**:`principal.userId().equals(userId)` 时,禁止将 `userType` 从 `超级管理员` 改为其他值 → 抛 BizException(40301)(防止唯一管理员自我降权导致系统锁死;即使目标 userType 已是普通用户也拦截,保持规则简单) +4. **employeeId 校验**:若 `employeeId != null`,需确认 `tStaff.sId = employeeId AND sBrandsId = brandId` 存在;不存在 → 抛 BizException(40001) +5. **更新字段**:仅更新 `sUserType / sLanguage / bCanEditDoc / bIsDisabled / sEmployeeId`;`sUsername / sUserCode / sPasswordHash / sCreatorUsername / tCreateDate` 不修改 +6. **权限组 replace 策略**:先 `DELETE FROM usr_user_permission WHERE sUserId = userId AND sBrandsId = brandId`,再批量 INSERT 新的关联记录(与 createUser 的 permGroupIds 处理方式一致);permGroupIds 为空数组时仅删除,不插入 +7. **多租户隔离**:所有查询和写入均带 `sBrandsId = principal.brandId()` + +## 边界与约束 + +- 接口为写操作,需加 `@Transactional` +- 鉴权失败返回 401(SecurityConfig 已配置) +- `userType` / `language` 仅接受指定枚举值;违反 → 由 `@Valid` + `@NotNull` 在 DTO 层拦截返回 40001 +- 密码不在此接口处理,`sPasswordHash` 保持原值不变 +- 响应 JSON 不返回 sPasswordHash / iLoginFailCount / tLockUntil + +## 依赖的 schema 表 / 字段 + +- `usr_user (别名 u)`:sId, sUserType, sLanguage, bCanEditDoc, bIsDisabled, sEmployeeId, sBrandsId(查找 + 更新) +- `tStaff`:sId, sBrandsId(校验 employeeId 存在性) +- `usr_permission_group`:仅前端下拉,不涉及后端写操作 +- `usr_user_permission`:sUserId, sPermGroupId, sBrandsId, sId, tCreateDate(先删后插) + +## 依赖的接口 + +- `POST /api/auth/login`(REQ-USR-004,获取 Bearer Token) +- `GET /api/usr/users`(REQ-USR-003,获取列表后点击行触发修改抽屉) +- `GET /api/usr/users/staffs`(REQ-USR-001,前端抽屉加载员工下拉) +- `GET /api/usr/users/permission-groups`(REQ-USR-001,前端抽屉加载权限组列表) + +## 验收标准 + +1. `PUT /api/usr/users/{validId}` Body 合法 → HTTP 200,返回 userId/username/updatedAt,数据库对应字段已更新 +2. `userId` 不存在(或属于其他 brand)→ 返回 40400 +3. 非超级管理员 Token 调用 → 返回 40300 +4. 超级管理员修改自己的 userType → 返回 40301 +5. `isDisabled=true` 修改后,目标用户再登录 → 返回 40101(账号已禁用) +6. permGroupIds 为空数组 → 该用户权限组被清空,下次查询 permission-groups 对应关联为空 +7. permGroupIds 非空 → 权限组先删后插,关联记录与提交值一致 +8. 无 Token → 返回 401 +9. 前端:UserListPage 表格行有"修改"按钮,点击弹出预填修改抽屉;提交后关闭抽屉并刷新列表 + +## 前端交互设计 + +**UserFormDrawer 改造(复用已有组件)**: +- 新增 props:`userId?: string`,`initialData?: { userType, language, canEditDoc, isDisabled, employeeId, permGroupIds: string[] }` +- 当 `userId` 存在时(edit 模式): + - 抽屉标题改为 "修改用户" + - 隐藏用户号和用户名 Form.Item(或显示为只读文本) + - `open` 时用 `initialData` 初始化 form 和 selectedPermIds + - 提交调 `updateUser(userId, req)` 而非 `createUser` +- 当 `userId` 不存在时(create 模式):行为与现有完全一致 + +**UserListPage 改造**: +- columns 末尾加"操作"列,包含"修改"按钮(PermButton,权限 `usr:edit`) +- 点击"修改"按钮:将该行 `UserListItemVO` 转换为 `initialData`(permGroupIds 需额外从后端获取或在列表中推导——因列表不返回 permGroupIds,修改抽屉打开时无法预填权限组复选框,初始化为空数组即可,用户可重新勾选) +- 新增状态:`editingUser: UserListItemVO | null`,控制修改抽屉开关 + +**新增 API 函数**(`frontend/src/api/usr.ts`): +```ts +export interface UserUpdateReq { + userType: string + language: string + canEditDoc: boolean + isDisabled: boolean + employeeId: string | null + permGroupIds: string[] +} + +export interface UserUpdateResp { + userId: string + username: string + updatedAt: string +} + +export function updateUser(userId: string, req: UserUpdateReq): Promise { + return request.put(`/usr/users/${userId}`, req) +} +``` diff --git a/docs/superpowers/specs/2026-05-08-REQ-USR-003.md b/docs/superpowers/specs/2026-05-08-REQ-USR-003.md new file mode 100644 index 0000000..ea1d80c --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-REQ-USR-003.md @@ -0,0 +1,101 @@ +--- +req_id: REQ-USR-003 +date: 2026-05-08 +module: usr +--- + +# Spec: REQ-USR-003 — 查询用户 + +## 目标 +超级管理员可按 8 种查询字段 + 3 种匹配方式组合筛选,分页浏览本品牌下的用户列表;列表同时展示关联职员的员工名与部门。 + +## 输入 / 触发 +HTTP 请求:`GET /api/usr/users`(需 Bearer Token 鉴权) + +Query 参数: + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| queryField | String | 否 | `username` | 查询字段枚举,见下表 | +| matchType | String | 否 | `contains` | 匹配方式枚举:`contains` / `notContains` / `equals` | +| queryValue | String | 否 | — | 查询值;空字符串或不传 = 全部 | +| page | int | 否 | 1 | 页码,从 1 开始 | +| pageSize | int | 否 | 20 | 每页条数,最大 100 | + +queryField 枚举(前端显示名 → 参数值 → 后端列): + +| 前端显示 | 参数值 | 后端列 | +|---|---|---| +| 用户名 | `username` | `u.sUsername` | +| 员工名 | `staffName` | `s.sStaffName` | +| 用户号 | `userCode` | `u.sUserCode` | +| 部门 | `department` | `s.sDepartment` | +| 用户类型 | `userType` | `u.sUserType` | +| 作废 | `disabled` | `u.bIsDisabled` | +| 登录日期 | `lastLoginDate` | `u.tLastLoginDate` | +| 制单人 | `creator` | `u.sCreatorUsername` | + +## 输出 / 结果 +HTTP 200,`Result>` + +`PageVO` 结构(通用分页包装): +```json +{ + "total": 25, + "page": 1, + "pageSize": 20, + "list": [ ...UserListItemVO... ] +} +``` + +`UserListItemVO` 字段(禁止返回 sPasswordHash / iLoginFailCount / tLockUntil): + +| JSON 字段 | 来源列 | 可 null | +|---|---|---| +| sId | usr_user.sId | 否 | +| sUsername | usr_user.sUsername | 否 | +| sUserCode | usr_user.sUserCode | 否 | +| sUserType | usr_user.sUserType | 否 | +| sLanguage | usr_user.sLanguage | 否 | +| bIsDisabled | usr_user.bIsDisabled | 否 | +| tLastLoginDate | usr_user.tLastLoginDate | 是 | +| sCreatorUsername | usr_user.sCreatorUsername | 是 | +| tCreateDate | usr_user.tCreateDate | 否 | +| sStaffName | tStaff.sStaffName | 是(LEFT JOIN) | +| sDepartment | tStaff.sDepartment | 是(LEFT JOIN) | + +## 业务规则 + +1. **多租户隔离**:所有查询必须附加 `u.sBrandsId = principal.brandId()` +2. **queryValue 为空**:忽略查询条件,返回该品牌全部用户 +3. **匹配方式映射**(文本字段:username / staffName / userCode / department / userType / creator): + - `contains` → `LIKE '%value%'` + - `notContains` → `NOT LIKE '%value%'` + - `equals` → `= 'value'` +4. **布尔字段(disabled)**:仅有意义的匹配是 equals;queryValue="是" → `bIsDisabled = 1`;"否" → `bIsDisabled = 0`;其他值(包括 contains/notContains 的非 "是"/"否")忽略条件 +5. **日期字段(lastLoginDate)**:queryValue 格式 `YYYY-MM-DD`;无论 matchType 为何,均使用 `DATE(tLastLoginDate) = 'YYYY-MM-DD'` +6. **pageSize 截断**:pageSize > 100 时强制截断为 100 +7. **分页越界**:page 超出总页数时返回最后一页(MyBatis-Plus `Page` 默认行为) +8. **职员 JOIN**:`LEFT JOIN tStaff s ON u.sEmployeeId = s.sId AND s.sBrandsId = u.sBrandsId AND s.bDeleted = 0`;未关联职员时 sStaffName / sDepartment 为 null + +## 边界与约束 +- 接口为只读,无写副作用 +- 鉴权失败返回 401(SecurityConfig.authenticationEntryPoint 已配置) +- 单次最多返回 100 条 + +## 依赖的 schema 表 / 字段 +- `usr_user (别名 u)`:sId, sUsername, sUserCode, sUserType, sLanguage, bIsDisabled, sEmployeeId, sCreatorUsername, tLastLoginDate, tCreateDate, sBrandsId +- `tStaff (别名 s)`:sId, sStaffName, sDepartment, sBrandsId, bDeleted + +## 依赖的接口 +- `POST /api/auth/login`(REQ-USR-004,获取 Bearer Token 供本接口鉴权使用) + +## 验收标准 +1. `GET /api/usr/users`(无参)HTTP 200,返回本品牌所有用户,不含密码字段 +2. `queryField=username&matchType=contains&queryValue=admin` 仅返回 sUsername 含 "admin" 的用户 +3. `queryField=disabled&matchType=equals&queryValue=是` 仅返回 bIsDisabled=1 的记录 +4. 无匹配条件时返回 `{ total: 0, list: [] }` 而非 4xx/5xx +5. page=999(超出总页)返回最后一页而不报错 +6. Token 品牌 A 无法查到品牌 B 的用户(多租户隔离) +7. 响应 JSON 中不含 sPasswordHash / iLoginFailCount / tLockUntil +8. 无 Token 请求返回 401