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

18 KiB
Raw Blame History

用户聚合查询设计规格

日期: 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. 手机号迁移:将 phoneuc_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 对象

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

@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

响应示例:

{
  "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 列表接口,在响应中增加 deptsposts 字段。

实现方式:

  • 列表查询时,先查询用户基础数据
  • 批量查询所有用户的部门和岗位
  • 组装到响应中

5.1.3 统一用户认证查询(内部接口)

POST /user/inner/auth/load

请求体:

{
  "account": "13800138000",
  "loginType": "PHONE"
}

AccountType 枚举:

public enum AccountType {
    USERNAME("用户名"),
    PHONE("手机号"),
    EMAIL("邮箱");
    
    private final String description;
    
    AccountType(String description) {
        this.description = description;
    }
}

响应: 与现有 loadByUsername 保持一致,返回用户认证信息

实现逻辑:

@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
  • 用户删除

失效逻辑:

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 单用户聚合查询

@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 查询

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 示例(批量查询部门):

<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_iduc_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 返回空列表 []
  • mainDeptIdmainDeptNamenull

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 字段

-- 添加 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 字段

-- 迁移数据(如果有)
-- 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 索引检查

-- 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,提升用户体验
缓存失效策略 主动失效 数据更新时立即失效,保证一致性
是否返回密码 安全考虑,聚合数据不包含敏感字段