Files
rui-docs/superpowers/specs/2026-06-06-user-aggregate-query-design.md
T
vifo de78c21799 docs(spec): 更新用户聚合查询设计规格
- 添加 phone 字段迁移到 uc_user 表的设计
- 新增统一认证接口 /user/inner/auth/load(POST)
- 支持 AccountType 枚举:USERNAME/PHONE/EMAIL
- 废弃旧的 loadByUsername 接口
- 添加数据库变更 SQL 脚本
2026-06-06 13:30:23 +08:00

640 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 用户聚合查询设计规格
> **日期**: 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<UserDeptVO> depts;
@Schema(description = "岗位列表")
private List<UserPostVO> 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<JSONObject> 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_detailphone 字段将移除)
---
## 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<UserDeptVO> depts = userDeptMapper.selectDeptListByUserId(userId);
// 3. 查询岗位(带名称)
List<UserPostVO> 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<UserAggregateVO> listUserAggregate(Page<User> page, QueryWrapper<User> query) {
// 1. 查询用户基础数据
Page<User> userPage = userMapper.selectPage(page, query);
List<User> users = userPage.getRecords();
if (users.isEmpty()) {
return new Page<>();
}
// 2. 提取所有用户ID
List<Long> userIds = users.stream()
.map(User::getId)
.collect(Collectors.toList());
// 3. 批量查询部门(一次查询)
Map<Long, List<UserDeptVO>> deptMap = userDeptMapper
.selectDeptListByUserIds(userIds)
.stream()
.collect(Collectors.groupingBy(UserDeptVO::getUserId));
// 4. 批量查询岗位(一次查询)
Map<Long, List<UserPostVO>> postMap = userPostMapper
.selectPostListByUserIds(userIds)
.stream()
.collect(Collectors.groupingBy(UserPostVO::getUserId));
// 5. 组装
List<UserAggregateVO> 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<UserAggregateVO> result = new Page<>();
result.setCurrent(userPage.getCurrent());
result.setSize(userPage.getSize());
result.setTotal(userPage.getTotal());
result.setRecords(records);
return result;
}
```
**SQL 示例(批量查询部门):**
```xml
<select id="selectDeptListByUserIds" resultType="com.rui.service.user.vo.UserDeptVO">
SELECT
ud.user_id as userId,
ud.dept_id as deptId,
d.name as deptName,
ud.is_main as main
FROM uc_user_dept ud
INNER JOIN uc_dept d ON ud.dept_id = d.id
WHERE ud.user_id IN
<foreach collection="userIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND ud.deleted = 0
AND d.deleted = 0
</select>
```
---
## 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,提升用户体验 |
| 缓存失效策略 | 主动失效 | 数据更新时立即失效,保证一致性 |
| 是否返回密码 | 否 | 安全考虑,聚合数据不包含敏感字段 |