--- req_id: REQ-USR-003 date: 2026-05-06 module: module_usr --- # Spec: REQ-USR-003 — 用户查询 ## 目标 实现后端 `GET /api/users` 接口:按 `queryField` + `matchType` + `queryValue` 三件套对 `tUser` 进行过滤(含跨表 JOIN `tStaff` 取员工名 / 部门),分页返回精简 VO 列表。 ## 输入 / 触发 **接口**:`GET /api/users`,无请求体。 **Query parameters**(`UserQueryDTO`): | 字段 | 类型 | 必填 | 校验 / 取值 | |---|---|---|---| | `pageNum` | Integer | 否 | ≥1,默认 1 | | `pageSize` | Integer | 否 | 1-100,默认 20 | | `queryField` | String | 否 | 枚举:`username` / `staffname` / `userno` / `department` / `usertype` / `language` / `deleted` / `lastLoginDate` / `createdBy`;缺省视为不过滤 | | `matchType` | String | 否 | 枚举:`contains` / `notContains` / `equals`;默认 `contains` | | `queryValue` | String | 否 | 长度 ≤ 100;缺省视为不过滤;`deleted` 字段下视为 `'true' / 'false'` 字符串 | **鉴权**:契约要求 `Authorization: Bearer ` + `USR:READ`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:READ')")`。 ## 输出 / 结果 **HTTP 200,响应体**: ```json { "code": 200, "message": "操作成功", "data": { "total": 42, "list": [ { "iIncrement": 12, "sUserName": "alice", "sStaffName": "张三", "sUserNo": "u001", "sDepartment": "研发部", "sUserType": "普通用户", "sLanguage": "zh", "bDeleted": false, "tLastLoginDate": "2026-05-06T09:00:00", "sCreatedBy": "admin", "tCreateDate": "2026-05-06T08:00:00" } ], "pageNum": 1, "pageSize": 20 }, "timestamp": 1746528600000 } ``` 新建 `UserListItemVO`(11 字段)+ 复用项目通用 `PageResult`(4 字段:total / list / pageNum / pageSize;本期首次引入,作为 module_usr 横切组件)。 > **不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `iStaffId` / `bCanModifyDocs` / `tDeletedDate` / `sDeletedBy`。 ## 业务规则 1. **范围过滤**:默认仅返回 `bDeleted=0` 用户**除非** `queryField=deleted` 且 `queryValue` 显式过滤——见规则 6。 2. **跨表 JOIN**:`tUser LEFT JOIN tStaff ON tUser.iStaffId = tStaff.iIncrement AND tStaff.bDeleted = 0`;空关联字段(无 staff)展示为空字符串 `""`(VO 字段为 null)。 3. **queryField 列映射**: | queryField 值 | SQL 实际列 | |---|---| | `username` | `tUser.sUserName` | | `staffname` | `tStaff.sStaffName` | | `userno` | `tUser.sUserNo` | | `department` | `tStaff.sDepartment` | | `usertype` | `tUser.sUserType` | | `language` | `tUser.sLanguage` | | `deleted` | `tUser.bDeleted` | | `lastLoginDate` | `tUser.tLastLoginDate` | | `createdBy` | `tUser.sCreatedBy` | 4. **matchType 映射**: | matchType 值 | SQL 片段 | |---|---| | `contains`(默认) | ` LIKE '%' || queryValue || '%'` | | `notContains` | ` NOT LIKE '%' || queryValue || '%'` | | `equals` | ` = queryValue` | 5. **空过滤值语义**:`queryField` 或 `queryValue` 缺省 / 空串 → 不附加该 WHERE 条件。 6. **deleted 字段特殊处理**:当 `queryField=deleted` 时,`bDeleted=0` 的默认过滤不应用(让 `queryValue` 直接控制——`'false'` / `'0'` → bDeleted=0;`'true'` / `'1'` → bDeleted=1)。 7. **排序**:按 `tUser.tCreateDate DESC, tUser.iIncrement DESC` 稳定排序。 8. **分页**:MyBatis-Plus `Page` + `selectPage` 标准分页;total 由 MP 自动计数。 9. **只读**:`@Transactional(readOnly = true)`;不写库。 10. **空结果**:`data.list = []` + `data.total = 0` + `code=200`,不返回 404。 ## 边界与约束 ### 鉴权策略 沿用 SecurityConfig permitAll。 ### 错误码映射 | 场景 | 错误码 | ErrorCode 枚举 | |---|---|---| | `pageSize` 超 1-100 / `queryField` / `matchType` 非枚举 / `queryValue` 长度超限 | 40010 | `PARAM_INVALID`(已存在) | | 服务端兜底 | 50000 | `INTERNAL_ERROR` | ### 实现路径选择 引入**自定义 XML SQL**(`UserMapper.xml`):本期首次需要跨表 JOIN,纯 LambdaQueryWrapper 难以表达 LEFT JOIN + 动态 WHERE。在 `mapper/usr/UserMapper.xml` 添加 `searchUsers(IPage page, @Param("query") UserQueryDTO query)` 方法,对应 ResultMap 映射到 `UserListItemVO`。 XML SQL 草稿(写入 plan 锁定): ```xml ``` > `${col}` 由 service 层通过 enum 映射成实际列名后传入参数(**不是直接拼用户输入**——避免 SQL 注入);queryField 不在白名单 → 抛 PARAM_INVALID。 实际实现可能要把 `${col}` 替换逻辑放到 service:service 把 queryField 翻译为列字符串放进 query 对象的临时字段(如 `query.setColumn(...)`),XML 用 `${query.column}` 渲染——XMl `${...}` 直接拼接字符串,服务层先白名单校验。 ### 性能 - LEFT JOIN tStaff 走 `tUser.iStaffId` 上的索引(`fk_user_staff`)。 - `LIKE '%X%'` 左模糊不走索引;本期数据量低可接受。 - MP `Page` 走 PaginationInnerInterceptor,需要在 `MybatisPlusConfig` 注册(如已存在跳过;如未引入,本 REQ 引入)。 ### Bean Validation `UserQueryDTO` 用 `@Min(1) Integer pageNum`、`@Min(1) @Max(100) Integer pageSize`、`@Pattern` 校验枚举字段。Controller 用 `@Valid`(query 参数 bean 绑定)。 ## 依赖的 schema 表 / 字段 **读表**:`tUser`(主体)+ `tStaff`(LEFT JOIN) | `tUser` 字段 | 用途 | |---|---| | 全部 11 个输出字段 + `bDeleted` 过滤 | SELECT + WHERE | | `iStaffId` | LEFT JOIN 键 | | `tStaff` 字段 | 用途 | |---|---| | `iIncrement` | LEFT JOIN 键 | | `sStaffName` / `sDepartment` | SELECT + 可选 WHERE | | `bDeleted` | LEFT JOIN 时附加条件 (s.bDeleted=0) | **索引利用**: - `pk_user`:分页 ORDER BY iIncrement - `fk_user_staff`(隐式 BTREE on `iStaffId`):JOIN - `uk_user_no` / `uk_user_name`:equals 匹配能走索引 ## 依赖的接口 无(独立查询接口)。 ## 验收标准 ### 功能正确性 1. **空查询返回所有未删除用户**:DB 有 5 个 user(含 1 个 bDeleted=1),GET 不带 queryField 返回 4 个未删除(含 LEFT JOIN 出来的 staff 名 / 部门)。 2. **按用户名 contains 过滤**:DB 有 alice / alex / bob,`queryField=username&matchType=contains&queryValue=al` 返回 alice + alex。 3. **按员工名 contains 过滤**:通过 LEFT JOIN,匹配 tStaff.sStaffName。 4. **按部门 equals 过滤**:matchType=equals,命中精确部门。 5. **deleted=false 过滤**:返回未删除集合。 6. **deleted=true 过滤**:仅返回已软删除(验证规则 6)。 7. **notContains 排除**:matchType=notContains,排除指定关键字。 8. **分页**:pageSize=2,pageNum=1 / 2,断言 list.size + total。 9. **空结果**:queryValue 完全不匹配,返回 list=[] + total=0。 10. **响应字段精简**:jsonPath 验证 sPasswordHash / sId / sBrandsId / iStaffId 不出现。 11. **未关联 staff 用户**:DB 中有 user.iStaffId=null,列表 sStaffName / sDepartment 应为 null。 12. **pageSize 超限**:pageSize=101,返回 40010。 13. **queryField 非枚举**:queryField=invalid,返回 40010。 14. **matchType 非枚举**:返回 40010。 15. **排序**:tCreateDate DESC,新建在前。 ### 接口契约一致性 - 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{total, list, pageNum, pageSize}`。 - 错误码 200 / 40010 / 50000。 - 不暴露 sPasswordHash 等内部字段。 ### 测试覆盖 - **单元测试** `UserServiceImplTest` 追加(mock UserMapper.searchUsers): - search_emptyDb_returnsEmptyPage - search_invalidQueryField_throws40010(service 内白名单校验) - search_invalidMatchType_throws40010 - search_passesColumnAndValue_toMapper(ArgumentCaptor 验 query.column 由白名单映射) - **集成测试** `UserControllerIT` 追加: - get_emptyKeyword_returnsAllUndeleted - get_filterByUsernameContains_returnsMatchedSubset - get_filterByStaffnameContains_returnsJoinedResults - get_filterByDeletedTrue_returnsOnlyDeleted - get_pagination_returnsCorrectSlice - get_responseExcludesInternalFields - get_pageSizeTooLarge_returns40010 - get_invalidQueryField_returns40010 - get_userWithoutStaff_listItemHasNullStaffFields ### 代码与文档 - `// REQ-USR-003` 注释贴在 Controller / Service 方法 / Mapper.xml 顶部。 - 提交按 `feat(usr): REQ-USR-003`。 - **新增** `MybatisPlusConfig`(如不存在):`PaginationInnerInterceptor` bean 启用 MP 分页。 - **新增** 通用 `PageResult` VO(horizontal scope;本 REQ 首次引入但属于 common 命名空间)。