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-tddexecutes 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<T>)工厂) - 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(锁定签名):
// PageVO<T> — com.example.erp.common.vo
@Getter @Setter
public class PageVO<T> {
private long total;
private long page;
private long pageSize;
private List<T> list;
public static <T> PageVO<T> of(IPage<T> iPage) {
PageVO<T> 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顶部 importUserListQueryDTO - 添加占位测试方法(方法体可为空,仅引用该类型使编译失败):
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.javagit 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(锁定方法签名):
// UsrUserMapper.java — 新增方法
IPage<UserListItemVO> selectUserList(
IPage<UserListItemVO> page,
@Param("brandId") String brandId,
@Param("queryField") String queryField,
@Param("matchType") String matchType,
@Param("queryValue") String queryValue
);
XML SQL 完整内容(UsrUserMapper.xml):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.erp.module.usr.mapper.UsrUserMapper">
<select id="selectUserList" resultType="com.example.erp.module.usr.vo.UserListItemVO">
SELECT u.sId, u.sUsername, u.sUserCode, u.sUserType, u.sLanguage,
u.bIsDisabled, u.tLastLoginDate, u.sCreatorUsername, u.tCreateDate,
s.sStaffName, s.sDepartment
FROM usr_user u
LEFT JOIN tStaff s ON u.sEmployeeId = s.sId
AND s.sBrandsId = u.sBrandsId
AND s.bDeleted = 0
WHERE u.sBrandsId = #{brandId}
<if test="queryValue != null and queryValue != ''">
<choose>
<when test="queryField == 'username'">
<choose>
<when test="matchType == 'contains'">AND u.sUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
<when test="matchType == 'notContains'">AND u.sUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
<otherwise>AND u.sUsername = #{queryValue}</otherwise>
</choose>
</when>
<when test="queryField == 'staffName'">
<choose>
<when test="matchType == 'contains'">AND s.sStaffName LIKE CONCAT('%', #{queryValue}, '%')</when>
<when test="matchType == 'notContains'">AND s.sStaffName NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
<otherwise>AND s.sStaffName = #{queryValue}</otherwise>
</choose>
</when>
<when test="queryField == 'userCode'">
<choose>
<when test="matchType == 'contains'">AND u.sUserCode LIKE CONCAT('%', #{queryValue}, '%')</when>
<when test="matchType == 'notContains'">AND u.sUserCode NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
<otherwise>AND u.sUserCode = #{queryValue}</otherwise>
</choose>
</when>
<when test="queryField == 'department'">
<choose>
<when test="matchType == 'contains'">AND s.sDepartment LIKE CONCAT('%', #{queryValue}, '%')</when>
<when test="matchType == 'notContains'">AND s.sDepartment NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
<otherwise>AND s.sDepartment = #{queryValue}</otherwise>
</choose>
</when>
<when test="queryField == 'userType'">
<choose>
<when test="matchType == 'contains'">AND u.sUserType LIKE CONCAT('%', #{queryValue}, '%')</when>
<when test="matchType == 'notContains'">AND u.sUserType NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
<otherwise>AND u.sUserType = #{queryValue}</otherwise>
</choose>
</when>
<when test="queryField == 'creator'">
<choose>
<when test="matchType == 'contains'">AND u.sCreatorUsername LIKE CONCAT('%', #{queryValue}, '%')</when>
<when test="matchType == 'notContains'">AND u.sCreatorUsername NOT LIKE CONCAT('%', #{queryValue}, '%')</when>
<otherwise>AND u.sCreatorUsername = #{queryValue}</otherwise>
</choose>
</when>
<when test="queryField == 'disabled'">
<choose>
<when test="queryValue == '是'">AND u.bIsDisabled = 1</when>
<when test="queryValue == '否'">AND u.bIsDisabled = 0</when>
</choose>
</when>
<when test="queryField == 'lastLoginDate'">
AND DATE(u.tLastLoginDate) = #{queryValue}
</when>
</choose>
</if>
ORDER BY u.tCreateDate DESC
</select>
</mapper>
注: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<UserListItemVO> 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注解,需 importcom.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(锁定签名):
// UserService 接口
PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId);
// UserServiceImpl — getUserList 实现逻辑
@Transactional(readOnly = true)
public PageVO<UserListItemVO> getUserList(UserListQueryDTO query, String brandId) {
int cappedSize = Math.min(query.getPageSize(), 100);
IPage<UserListItemVO> 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<PageVO<UserListItemVO>> 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<UserListItemVO> mockPage = new Page<>(1, 100, 5); when(userMapper.selectUserList(argThat(p -> p.getSize() == 100), eq("b1"), any(), any(), any())) .thenReturn(mockPage); PageVO<UserListItemVO> 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 新增部分):
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<T> {
total: number;
page: number;
pageSize: number;
list: T[];
}
export function getUserList(params?: UserListQueryReq): Promise<PageVO<UserListItemVO>> {
return request.get('/usr/users', { params })
}
UserListPage.tsx 结构(含搜索表单):
export default function UserListPage() {
const [data, setData] = useState<PageVO<UserListItemVO> | 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<UserListItemVO> = [
{ 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 (
<div>
<Space style={{ marginBottom: 16 }}>
<Select value={queryField} onChange={setQueryField} style={{ width: 120 }}>
<Select.Option value="username">用户名</Select.Option>
<Select.Option value="staffName">员工名</Select.Option>
<Select.Option value="userCode">用户号</Select.Option>
<Select.Option value="department">部门</Select.Option>
<Select.Option value="userType">用户类型</Select.Option>
<Select.Option value="disabled">作废</Select.Option>
<Select.Option value="lastLoginDate">登录日期</Select.Option>
<Select.Option value="creator">制单人</Select.Option>
</Select>
<Select value={matchType} onChange={setMatchType} style={{ width: 100 }}>
<Select.Option value="contains">包含</Select.Option>
<Select.Option value="notContains">不包含</Select.Option>
<Select.Option value="equals">等于</Select.Option>
</Select>
<Input value={queryValue} onChange={e => setQueryValue(e.target.value)} placeholder="查询值" />
<Button type="primary" onClick={() => load(1)}>搜索</Button>
<PermButton permission="usr:create" type="primary" onClick={() => setDrawerOpen(true)}>新增</PermButton>
</Space>
<Table
dataSource={data?.list ?? []}
columns={columns}
rowKey="sId"
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: load,
}}
/>
<UserFormDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} onSuccess={() => { setDrawerOpen(false); load(1) }} />
</div>
)
}
注: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<typeof import('@/api/usr')>() 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)