2026-05-08-REQ-USR-003.md 20.6 KB

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<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 顶部 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(锁定方法签名):

// 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 引用 selectUserListjava @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 注解,需 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(锁定签名):

// 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(保留 drawerOpenUserFormDrawer
  • 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)