diff --git a/superpowers/specs/2026-06-06-user-aggregate-query-design.md b/superpowers/specs/2026-06-06-user-aggregate-query-design.md new file mode 100644 index 0000000..36b3ab5 --- /dev/null +++ b/superpowers/specs/2026-06-06-user-aggregate-query-design.md @@ -0,0 +1,522 @@ +# 用户聚合查询设计规格 + +> **日期**: 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 个请求 +- **数据一致性难保证**:多个请求可能部分失败 + +### 1.3 约束条件 + +- 一个用户通常关联 **1个主部门 + 少量岗位** +- 需要支持 **多租户**(tenantId) +- 必须 **保持向后兼容** +- 需要 **缓存优化** + +--- + +## 2. 设计目标 + +1. **减少前端请求**:从3个减少到1个 +2. **优化列表页性能**:批量查询,避免 N+1 +3. **引入缓存**:Redis 缓存用户聚合数据 +4. **保持兼容性**:现有接口不受影响 + +--- + +## 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 UserDetailVO detail; + + @Schema(description = "部门列表") + private List depts; + + @Schema(description = "岗位列表") + private List posts; + + @Schema(description = "主部门ID") + private Long mainDeptId; + + @Schema(description = "主部门名称") + private String mainDeptName; +} +``` + +### 4.2 部门/岗位 VO + +```java +@Data +@Schema(description = "用户部门信息") +public class UserDeptVO { + @Schema(description = "部门ID") + private Long deptId; + + @Schema(description = "部门名称") + private String deptName; + + @Schema(description = "是否主部门") + private Boolean main; +} + +@Data +@Schema(description = "用户岗位信息") +public class UserPostVO { + @Schema(description = "岗位ID") + private Long postId; + + @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", + "userNo": "U001", + "userType": 2, + "status": 1, + "detail": { + "nickname": "管理员", + "realName": "系统管理员", + "email": "admin@example.com", + "phone": "13800138000" + }, + "depts": [ + { + "deptId": 1, + "deptName": "技术部", + "main": true + }, + { + "deptId": 2, + "deptName": "产品部", + "main": false + } + ], + "posts": [ + { + "postId": 1, + "postName": "Java开发工程师" + } + ], + "mainDeptId": 1, + "mainDeptName": "技术部" + } +} +``` + +#### 5.1.2 用户列表(增强版) + +复用现有 `/user/admin/user` 列表接口,在响应中增加 `depts` 和 `posts` 字段。 + +**实现方式:** +- 列表查询时,先查询用户基础数据 +- 批量查询所有用户的部门和岗位 +- 组装到响应中 + +### 5.2 现有接口保持不变 + +以下接口继续保留,不受影响: + +- `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) + +--- + +## 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. 查询用户详情 + UserDetail detail = userDetailMapper.selectOne( + new LambdaQueryWrapper() + .eq(UserDetail::getUserId, userId) + ); + + // 3. 查询部门(带名称) + List depts = userDeptMapper.selectDeptListByUserId(userId); + + // 4. 查询岗位(带名称) + List posts = userPostMapper.selectPostListByUserId(userId); + + // 5. 组装 + UserAggregateVO vo = new UserAggregateVO(); + BeanUtils.copyProperties(user, vo); + vo.setDetail(convertDetail(detail)); + 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. 批量查询详情(一次查询) + Map detailMap = userDetailMapper + .selectList( + new LambdaQueryWrapper() + .in(UserDetail::getUserId, userIds) + ) + .stream() + .collect(Collectors.toMap(UserDetail::getUserId, Function.identity())); + + // 6. 组装 + 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.setDetail(convertDetail(detailMap.get(user.getId()))); + + // 提取主部门 + 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/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/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`(修改) + +### 14.2 数据库索引检查 + +确保以下索引存在: + +```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); + +-- uc_user_detail 表 +CREATE INDEX idx_user_detail_user_id ON uc_user_detail(user_id); +``` + +--- + +## 15. 决策记录 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 是否改表结构 | 否 | 关联表设计合理,保持灵活性 | +| 列表页是否缓存 | 否 | 数据变化频繁,直接查库更可靠 | +| 单用户查询方式 | 并行查询 | 减少RT,提升用户体验 | +| 缓存失效策略 | 主动失效 | 数据更新时立即失效,保证一致性 | +| 是否返回密码 | 否 | 安全考虑,聚合数据不包含敏感字段 |