# 用户聚合查询设计规格 > **日期**: 2026-06-06 > **状态**: 已批准 > **作者**: AI Assistant > **相关模块**: rui-service-user --- ## 1. 背景与问题 ### 1.1 现状 当前用户数据分散在4张表中: | 表名 | 说明 | |------|------| | `uc_user` | 用户基础信息(用户名、密码、状态等) | | `uc_user_detail` | 用户详情(昵称、邮箱、手机号等) | | `uc_user_dept` | 用户部门关联(支持多部门,有主部门标记) | | `uc_user_post` | 用户岗位关联(支持多岗位) | ### 1.2 问题 - **前端请求过多**:获取完整用户信息需要3个独立请求 - **列表页性能差**:100条用户数据需要 1 + 100 + 100 = 201 个请求 - **数据一致性难保证**:多个请求可能部分失败 - **手机号位置不合理**:手机号在 `uc_user_detail` 表,但短信登录需要频繁查询,应该提升到 `uc_user` 表 - **认证接口不灵活**:当前 `loadByUsername` 只支持用户名,无法扩展支持手机号、邮箱等多种登录方式 ### 1.3 约束条件 - 一个用户通常关联 **1个主部门 + 少量岗位** - 需要支持 **多租户**(tenantId) - 必须 **保持向后兼容** - 需要 **缓存优化** - 手机号需要 **唯一约束**(按租户) --- ## 2. 设计目标 1. **减少前端请求**:从3个减少到1个 2. **优化列表页性能**:批量查询,避免 N+1 3. **引入缓存**:Redis 缓存用户聚合数据 4. **手机号迁移**:将 `phone` 从 `uc_user_detail` 迁移到 `uc_user` 表 5. **统一认证接口**:支持多种登录方式(用户名、手机号、邮箱等) 6. **保持兼容性**:现有接口不受影响 --- ## 3. 技术方案 ### 3.1 总体架构 ``` ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ │ 前端 │────▶│ UserController │────▶│ UserService │ └─────────────┘ └──────────────────┘ └──────┬───────┘ │ ┌────────────────────────────┼────────────────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Redis Cache │ │ uc_user_dept │ │ uc_user_post │ │ user:agg:{id} │ │ (主部门+关联) │ │ (岗位关联) │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` ### 3.2 核心策略 - **保留关联表**:不改表结构,保持多对多关系的灵活性 - **聚合查询**:后端做 JOIN 或批量 IN 查询,一次性返回 - **两级缓存**: - L1:单用户聚合数据缓存(Redis) - L2:批量查询结果不缓存(避免缓存过大) - **缓存失效**:数据变更时主动失效 --- ## 4. 数据模型 ### 4.1 新增 VO 对象 ```java package com.rui.service.user.vo; import com.rui.service.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import java.util.List; /** * 用户聚合信息(包含部门、岗位) */ @Data @EqualsAndHashCode(callSuper = true) @Schema(description = "用户聚合信息") public class UserAggregateVO extends User { @Schema(description = "部门列表") private List depts; @Schema(description = "岗位列表") private List posts; @Schema(description = "主部门ID") private Long mainDeptId; @Schema(description = "主部门名称") private String mainDeptName; @Schema(description = "部门编码") private String deptCode; @Schema(description = "岗位编码") private String postCode; } ``` ### 4.2 部门/岗位 VO ```java @Data @Schema(description = "用户部门信息") public class UserDeptVO { @Schema(description = "用户ID") private Long userId; @Schema(description = "部门ID") private Long deptId; @Schema(description = "部门编码") private String deptCode; @Schema(description = "部门名称") private String deptName; @Schema(description = "是否主部门") private Boolean main; } @Data @Schema(description = "用户岗位信息") public class UserPostVO { @Schema(description = "用户ID") private Long userId; @Schema(description = "岗位ID") private Long postId; @Schema(description = "岗位编码") private String postCode; @Schema(description = "岗位名称") private String postName; } ``` --- ## 5. 接口设计 ### 5.1 新增接口 #### 5.1.1 获取用户聚合信息(单用户) ``` GET /user/admin/user/{id}/aggregate ``` **响应示例:** ```json { "code": 200, "msg": "success", "data": { "id": 1, "username": "admin", "phone": "13800138000", "userNo": "U001", "userType": 2, "status": 1, "depts": [ { "deptId": 1, "deptCode": "TECH", "deptName": "技术部", "main": true }, { "deptId": 2, "deptCode": "PROD", "deptName": "产品部", "main": false } ], "posts": [ { "postId": 1, "postCode": "JAVA_DEV", "postName": "Java开发工程师" } ], "mainDeptId": 1, "mainDeptName": "技术部", "deptCode": "TECH", "postCode": "JAVA_DEV" } } ``` #### 5.1.2 用户列表(增强版) 复用现有 `/user/admin/user` 列表接口,在响应中增加 `depts` 和 `posts` 字段。 **实现方式:** - 列表查询时,先查询用户基础数据 - 批量查询所有用户的部门和岗位 - 组装到响应中 #### 5.1.3 统一用户认证查询(内部接口) ``` POST /user/inner/auth/load ``` **请求体:** ```json { "account": "13800138000", "loginType": "PHONE" } ``` **AccountType 枚举:** ```java public enum AccountType { USERNAME("用户名"), PHONE("手机号"), EMAIL("邮箱"); private final String description; AccountType(String description) { this.description = description; } } ``` **响应:** 与现有 `loadByUsername` 保持一致,返回用户认证信息 **实现逻辑:** ```java @PostMapping("/auth/load") public Result loadUser(@RequestBody LoginAccountDTO loginAccount) { User user; switch (loginAccount.getLoginType()) { case PHONE: user = userService.lambdaQuery() .eq(User::getPhone, loginAccount.getAccount()) .one(); break; case EMAIL: user = userService.lambdaQuery() .eq(User::getEmail, loginAccount.getAccount()) .one(); break; case USERNAME: default: user = userService.lambdaQuery() .eq(User::getUsername, loginAccount.getAccount()) .one(); break; } if (user == null) { return Result.ok(null); } // 组装认证信息(与现有逻辑一致) JSONObject info = buildAuthInfo(user); return Result.ok(info); } ``` ### 5.2 现有接口处理 **保留但标记为弃用:** - `GET /user/inner/auth/loadByUsername/{username}` — **@Deprecated**,建议迁移到新的 `/auth/load` **继续保留的接口:** - `GET /user/admin/user/{id}` — 用户基础信息 - `GET /user/admin/user-dept/user/{userId}` — 用户部门列表 - `GET /user/admin/user-post/user/{userId}` — 用户岗位列表 - `GET /user/admin/detail` — 用户详情(uc_user_detail,phone 字段将移除) --- ## 6. 缓存设计 ### 6.1 缓存策略 | 缓存项 | Key 格式 | TTL | 说明 | |--------|---------|-----|------| | 用户聚合信息 | `user:agg:{tenantId}:{userId}` | 10分钟 | 单用户完整数据 | | 用户部门列表 | `user:dept:{tenantId}:{userId}` | 10分钟 | 部门ID列表 | | 用户岗位列表 | `user:post:{tenantId}:{userId}` | 10分钟 | 岗位ID列表 | ### 6.2 缓存失效 **触发时机:** - 用户部门变更(assignDepts、setMainDept) - 用户岗位变更(assignPosts) - 用户信息变更(update) - 用户删除 **失效逻辑:** ```java private void evictUserCache(Long userId) { Long tenantId = AuthUtil.getTenantId(); redisUtil.del(String.format("user:agg:%s:%s", tenantId, userId)); redisUtil.del(String.format("user:dept:%s:%s", tenantId, userId)); redisUtil.del(String.format("user:post:%s:%s", tenantId, userId)); } ``` ### 6.3 缓存穿透防护 - 查询不到数据时,缓存空值(TTL 1分钟) - 使用布隆过滤器(可选,初期可不用) --- ## 7. 查询逻辑 ### 7.1 单用户聚合查询 ```java @Cacheable(value = "user:agg", key = "#tenantId + ':' + #userId") public UserAggregateVO getUserAggregate(Long userId, Long tenantId) { // 1. 查询用户基础信息 User user = userMapper.selectById(userId); if (user == null) { return null; } // 2. 查询部门(带名称) List depts = userDeptMapper.selectDeptListByUserId(userId); // 3. 查询岗位(带名称) List posts = userPostMapper.selectPostListByUserId(userId); // 4. 组装 UserAggregateVO vo = new UserAggregateVO(); BeanUtils.copyProperties(user, vo); vo.setDepts(depts); vo.setPosts(posts); // 6. 提取主部门 depts.stream() .filter(UserDeptVO::getMain) .findFirst() .ifPresent(main -> { vo.setMainDeptId(main.getDeptId()); vo.setMainDeptName(main.getDeptName()); }); return vo; } ``` ### 7.2 批量列表查询优化 **问题:** 列表页有100条数据,不能每条都查一次数据库 **方案:** 批量 IN 查询 ```java public Page listUserAggregate(Page page, QueryWrapper query) { // 1. 查询用户基础数据 Page userPage = userMapper.selectPage(page, query); List users = userPage.getRecords(); if (users.isEmpty()) { return new Page<>(); } // 2. 提取所有用户ID List userIds = users.stream() .map(User::getId) .collect(Collectors.toList()); // 3. 批量查询部门(一次查询) Map> deptMap = userDeptMapper .selectDeptListByUserIds(userIds) .stream() .collect(Collectors.groupingBy(UserDeptVO::getUserId)); // 4. 批量查询岗位(一次查询) Map> postMap = userPostMapper .selectPostListByUserIds(userIds) .stream() .collect(Collectors.groupingBy(UserPostVO::getUserId)); // 5. 组装 List records = users.stream().map(user -> { UserAggregateVO vo = new UserAggregateVO(); BeanUtils.copyProperties(user, vo); vo.setDepts(deptMap.getOrDefault(user.getId(), Collections.emptyList())); vo.setPosts(postMap.getOrDefault(user.getId(), Collections.emptyList())); // 提取主部门 vo.getDepts().stream() .filter(UserDeptVO::getMain) .findFirst() .ifPresent(main -> { vo.setMainDeptId(main.getDeptId()); vo.setMainDeptName(main.getDeptName()); }); return vo; }).collect(Collectors.toList()); // 7. 构建分页结果 Page result = new Page<>(); result.setCurrent(userPage.getCurrent()); result.setSize(userPage.getSize()); result.setTotal(userPage.getTotal()); result.setRecords(records); return result; } ``` **SQL 示例(批量查询部门):** ```xml ``` --- ## 8. 性能优化点 ### 8.1 数据库层面 1. **索引优化**:确保 `uc_user_dept.user_id` 和 `uc_user_post.user_id` 有索引 2. **批量查询**:使用 IN 代替循环查询 3. **按需加载**:列表页只加载必要的字段 ### 8.2 缓存层面 1. **单用户缓存**:详情页使用缓存,10分钟TTL 2. **列表页不缓存**:列表数据变化频繁,直接查数据库 3. **缓存预热**:系统启动时可选择预热(可选) ### 8.3 代码层面 1. **并行查询**:单用户查询时,部门、岗位、详情可并行(CompletableFuture) 2. **懒加载**:如果前端不需要详情,可以不加载 `uc_user_detail` --- ## 9. 边界情况处理 ### 9.1 用户无部门/岗位 - `depts` 返回空列表 `[]` - `posts` 返回空列表 `[]` - `mainDeptId` 和 `mainDeptName` 为 `null` ### 9.2 缓存穿透 - 用户不存在时,缓存空值(TTL 1分钟) - 使用 `Optional` 包装返回 ### 9.3 缓存雪崩 - TTL 加随机偏移:`10分钟 + random(0, 60)秒` - 使用互斥锁(可选) ### 9.4 数据更新同步 - 所有更新操作后主动失效缓存 - 使用事务确保数据库和缓存一致性 --- ## 10. 兼容性 ### 10.1 向后兼容 - 现有接口完全保留 - 新增 `/aggregate` 接口,不影响旧接口 - 前端可以逐步迁移 ### 10.2 前端迁移路径 1. **第一阶段**:新增聚合接口,前端详情页切换到新接口 2. **第二阶段**:列表页切换到批量查询 3. **第三阶段**:废弃旧接口(可选) --- ## 11. 安全考虑 1. **权限校验**:复用现有 `@AutoPermission("uc:user")` 2. **数据隔离**:所有查询自动加上 `tenant_id` 条件 3. **敏感信息**:密码字段不返回 --- ## 12. 监控与日志 1. **缓存命中率**:监控 `user:agg:*` 的命中情况 2. **查询耗时**:记录批量查询的执行时间 3. **慢查询**:超过100ms的查询记录日志 --- ## 13. 风险评估 | 风险 | 概率 | 影响 | 缓解措施 | |------|------|------|----------| | 缓存数据不一致 | 中 | 中 | 更新时主动失效缓存 | | 批量查询性能差 | 低 | 高 | 索引优化 + 分页 | | 内存占用过高 | 低 | 中 | 控制缓存TTL + 分页大小 | --- ## 14. 附录 ### 14.1 涉及文件清单 **新增文件:** - `rui-service-user/src/main/java/com/rui/service/user/vo/UserAggregateVO.java` - `rui-service-user/src/main/java/com/rui/service/user/vo/UserDeptVO.java` - `rui-service-user/src/main/java/com/rui/service/user/vo/UserPostVO.java` - `rui-service-user/src/main/java/com/rui/service/user/dto/LoginAccountDTO.java` - `rui-service-user/src/main/java/com/rui/service/user/enums/AccountType.java` **修改文件:** - `rui-service-user/src/main/java/com/rui/service/user/entity/User.java`(添加 phone 字段) - `rui-service-user/src/main/java/com/rui/service/user/entity/UserDetail.java`(移除 phone 字段) - `rui-service-user/src/main/java/com/rui/service/user/service/IUserService.java` - `rui-service-user/src/main/java/com/rui/service/user/service/impl/UserServiceImpl.java` - `rui-service-user/src/main/java/com/rui/service/user/controller/UserController.java` - `rui-service-user/src/main/java/com/rui/service/user/controller/inner/UserInnerController.java` - `rui-service-user/src/main/java/com/rui/service/user/mapper/UserDeptMapper.java` - `rui-service-user/src/main/java/com/rui/service/user/mapper/UserPostMapper.java` - `rui-service-user/src/main/resources/mapper/UserDeptMapper.xml` - `rui-service-user/src/main/resources/mapper/UserPostMapper.xml` - `rui-common/rui-common-oauth2/src/main/java/com/rui/common/oauth2/feign/UserAuthFeign.java` **SQL 脚本:** - `sql/upgrade-v2.x-add-phone-to-user.sql`(新增) ### 14.2 数据库变更 #### 14.2.1 uc_user 表添加 phone 字段 ```sql -- 添加 phone 字段 ALTER TABLE rui_uc_user ADD COLUMN phone VARCHAR(20) DEFAULT NULL COMMENT '手机号' AFTER username; -- 添加唯一索引(按租户) ALTER TABLE rui_uc_user ADD UNIQUE KEY uk_phone (tenant_id, phone); -- 添加普通索引(用于查询) ALTER TABLE rui_uc_user ADD INDEX idx_phone (phone); ``` #### 14.2.2 uc_user_detail 表移除 phone 字段 ```sql -- 迁移数据(如果有) -- UPDATE rui_uc_user u -- JOIN rui_uc_user_detail d ON u.id = d.user_id -- SET u.phone = d.phone -- WHERE u.phone IS NULL AND d.phone IS NOT NULL; -- 移除 phone 字段 ALTER TABLE rui_uc_user_detail DROP COLUMN phone; ``` #### 14.2.3 索引检查 ```sql -- uc_user_dept 表 CREATE INDEX idx_user_dept_user_id ON uc_user_dept(user_id); CREATE INDEX idx_user_dept_dept_id ON uc_user_dept(dept_id); -- uc_user_post 表 CREATE INDEX idx_user_post_user_id ON uc_user_post(user_id); CREATE INDEX idx_user_post_post_id ON uc_user_post(post_id); ``` --- ## 15. 决策记录 | 决策 | 选择 | 理由 | |------|------|------| | 是否改表结构 | 是 | 手机号是登录凭证,应提升到 uc_user 表 | | 手机号位置 | uc_user 表 | 便于认证查询,支持唯一约束 | | 认证接口方式 | POST + JSON + 枚举 | 支持多种登录方式,便于扩展 | | 列表页是否缓存 | 否 | 数据变化频繁,直接查库更可靠 | | 单用户查询方式 | 并行查询 | 减少RT,提升用户体验 | | 缓存失效策略 | 主动失效 | 数据更新时立即失效,保证一致性 | | 是否返回密码 | 否 | 安全考虑,聚合数据不包含敏感字段 |