--- 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)