在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


本节重点


从本节开始我们将进行项目第三阶段 —— 团队空间的开发,让项目能够面向 B 端(企业)提供服务,比如作为团队共享素材、团队活动相册等,增强项目的商业价值。

本节先给项目增加团队共享空间的能力,大纲:

  • 团队空间需求分析
  • 团队空间方案设计
  • 团队空间后端开发

本节学完后,你应该能够掌握一个团队协作系统的方案设计和开发。

⭐️ 友情提示,本节涉及的后端新技术较多,学习难度略大,而且细节很多,请务必仔细学习!


一、需求分析


之前我们已经完成了私有空间模块,团队空间和它类似,我们可以拆分为 4 个需求:

1. 创建团队共享空间

  • 用户可以创建最多一个团队共享空间,用于团队协作和资源共享;
  • 空间管理员拥有私有空间的所有能力,包括自由上传图片、检索图片、管理图片、分析空间等。

2. 空间成员管理

  • 成员邀请:空间管理员可以邀请新成员加入团队,共享空间内的图片。
  • 设置权限:空间管理员可以为成员设置不同的角色(如查看者、编辑者、管理员),控制成员的权限范围。

3. 空间成员权限控制

  • 仅特定角色的成员,可访问或操作团队空间内的图片。

4. 空间数据管理

  • 考虑到团队空间的图片数量可能比较多,可以对特定空间的数据进行单独的管理,而不是和公共图库、私有空间的图片混在一起。

二、方案设计


让我们先依次分析上述需求,并思考对应的解决方案。


创建团队共享空间


之前已经开发了空间模块,团队空间可以直接复用私有空间的大多数能力

因此可以给空间表新增一个 spaceType 字段,用于区分私有和团队空间。

ALTER TABLE space
ADD COLUMN spaceType int default 0 not null comment '空间类型:0-私有 1-团队';
CREATE INDEX idx_spaceType ON space (spaceType);

空间成员管理


1、业务流程


为了让项目更容易扩展,减少原有代码的修改,我们约定 只有团队空间才有成员的概念

  1. 成员邀请:空间管理员可以直接输入成员 id 来添加新成员,无需该用户确认,这样可以提高开发效率。
  2. 设置权限:空间管理员可以为已加入成员设置不同的角色,控制成员的权限范围,类似于编辑成员信息。

2、库表设计


由于空间和用户是多对多的关系,还要同时记录用户在某空间的角色,所以需要新建关联表:

-- 空间成员表
create table if not exists space_user(
    id         bigint auto_increment comment 'id' primary key,
    spaceId    bigint                                 not null comment '空间 id',
    userId     bigint                                 not null comment '用户 id',
    spaceRole  varchar(128) default 'viewer'          null comment '空间角色:viewer/editor/admin',
    createTime datetime     default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime datetime     default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    -- 索引设计
    UNIQUE KEY uk_spaceId_userId (spaceId, userId), -- 唯一索引,用户在一个空间中只能有一个角色
    INDEX idx_spaceId (spaceId),                    -- 提升按空间查询的性能
    INDEX idx_userId (userId)                       -- 提升按用户查询的性能
) comment '空间用户关联' collate = utf8mb4_unicode_ci;

注意几个细节:

  • spaceIduserId 添加唯一索引,确保同一用户在同一空间中只能有一个角色(不能重复加入)。由于有唯一键,不需要使用逻辑删除字段,否则无法退出后再重新加入。
  • 给关联字段添加索引,提高查询效率。
  • 为了跟用户自身在项目中的角色 userRole 区分开,空间角色的名称使用 spaceRole
  • 为保证逻辑的统一,创建团队空间时要自动将创建人作为空间管理员,保存到空间成员表中。

空间成员权限控制


仅特定角色的成员可访问或操作团队空间内的图片。

团队空间的权限管理比私有空间的权限复杂得多,除了创建人外还有其他成员,涉及到查看图片、上传图片、管理空间图片、管理空间等多种不同的权限。


1、RBAC 权限控制


对于复杂的权限控制场景,我们可以采用经典的 RBAC 权限控制模型基于角色的访问控制Role-Based Access Control);

核心概念包括用户、角色、权限

  • 一个用户可以有多个角色
  • 一个角色可以有多个权限

这样一来,就可以灵活地配置用户具有的权限了。

mermaid (1)

一般来说,标准的 RBAC 实现需要 5 张表:

  1. 用户表
  2. 角色表
  3. 权限表
  4. 用户角色关联表
  5. 角色权限关联表

还是有一定开发成本的。

由于我们的项目中,团队空间不需要那么多角色,可以简化 RBAC 的实现方式,比如将角色和权限直接定义到配置文件中


2、角色和权限定义


本项目的角色:

角色 描述
浏览者 仅可查看空间中的图片内容
编辑者 可查看、上传和编辑图片内容
管理员 拥有管理空间和成员的所有权限

本项目的权限:

权限键 功能名称 描述
spaceUser:manage 成员管理 管理空间成员,添加或移除成员
picture:view 查看图片 查看空间中的图片内容
picture:upload 上传图片 上传图片到空间中
picture:edit 修改图片 编辑已上传的图片信息
picture:delete 删除图片 删除空间中的图片

角色与权限映射:

角色 对应权限键 可执行功能
浏览者 picture:view 查看图片
编辑者 picture:view, picture:upload, picture:edit, picture:delete 查看图片、上传图片、修改图片、删除图片
管理员 spaceUser:manage, picture:view, picture:upload, picture:edit, picture:delete 成员管理、查看图片、上传图片、修改图片、删除图片

3、权限校验实现方案


RBAC 只是一种权限设计模型,我们在 Java 代码中如何实现权限校验呢?

1. **最直接的方案是像之前校验私有空间权限一样,封装个团队空间的权限校验方法;或者类似用户权限校验一样,写个注解 + AOP 切面。**
2. **对于复杂的角色和权限管理,可以选用现成的第三方权限校验框架来实现,编写一套权限校验规则代码后,就能整体管理系统的权限校验逻辑了。**

其实在本项目中,由于角色和权限不多,采用方案 1 实现会更方便一些,也建议大家优先选择这种方案。方案 2 的代码量虽然未必比方案 1 少,但是会让整个系统的权限校验逻辑更加清晰,为了让大家后续能够应对更复杂的权限管理需求,此处给大家讲解方案 2,并选用国内主流的权限校验框架 Sa-Token实现。


空间数据管理


考虑到团队空间的图片数量可能比较多,可以对特定空间的数据进行单独的管理

如何对数据进行单独的管理呢?


1、图片信息数据


可以给每个团队空间单独创建一张图片表 picture_{spaceId},也就是分库分表中的 分表,而不是和公共图库、私有空间的图片混在一起。

这样不仅查询空间内的图片效率更高,还便于整体管理和清理空间。但是要注意,仅对旗舰版空间生效,否则分表的数量会特别多,反而可能影响性能

注意,我们要实现的,还不是普通的静态分表,而是会随着新增空间不断增加分表数量动态分表,会使用分库分表框架 Apache ShardingSphere带大家实现。


2、图片文件数据


已经将每个空间的图片存到不同的路径中了,实现了隔离,无需额外开发。

image-20250801095651529

💡 你会发现,我们在设计上就将团队空间和私有空间隔离,仅对团队空间应用成员管理、权限控制、动态分表。这样可以尽量减少对原有代码的改动,避免出现问题。


三、后端开发


创建团队共享空间


1、数据模型


在 XML 中增加 spaceType 字段:

image-20250801100112989


SpaceSpaceVOSpaceAddRequestSpaceQueryRequest 补充 spaceType 字段:

/**
 * 空间类型:0-私有 1-团队
 */
private Integer spaceType;

定义空间类型枚举:

image-20250801100442935

@Getter
public enum SpaceTypeEnum {
    PRIVATE("私有空间", 0),
    TEAM("团队空间", 1);

    private final String text;
    private final int value;

    SpaceTypeEnum(String text, int value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     */
    public static SpaceTypeEnum getEnumByValue(Integer value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (SpaceTypeEnum spaceTypeEnum : SpaceTypeEnum.values()) {
            if (spaceTypeEnum.value == value) {
                return spaceTypeEnum;
            }
        }
        return null;
    }
}

2、新建团队空间


1. 可以直接复用创建空间的方法,只需要做一些改动即可。

image-20250801111944351

更新代码 15~17

@Override
// @Transactional // 13. 如果使用这个注解, 可能会导致锁释放后, 事务还未被提交

public long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {
    // (1) 填充参数默认值
    // (2) 参数校验
    // (3) 校验权限, 非管理员只能普通级别的空间
    // (4) 控制同一个用户只能创建一个私有空间

    // 1. 转换实体类和 DTO
    Space space = new Space();
    BeanUtil.copyProperties(spaceAddRequest, space);

    // 2. 填充参数默认值
    if (StrUtil.isBlank(space.getSpaceName())) {
        space.setSpaceName("默认空间");
    }
    if (space.getSpaceLevel() == null) {
        space.setSpaceLevel(SpaceLevelEnum.COMMON.getValue());
    }
    if(space.getSpaceType() == null){
        // 15. 虽然数据库已经设置过默认值了, 但是业务层面也补充一下
        space.setSpaceType(SpaceTypeEnum.PRIVATE.getValue());
    }

    // 3. 填充空间容量和大小
    this.fillSpaceBySpaceLevel(space);

    // 4. 创建时校验参数
    this.validSpace(space, true);

    // 5. 从登录用户中获取用户 ID, 并设置给空间
    Long userId = loginUser.getId();
    space.setUserId(userId);

    // 6. 对用户进行权限校验, 非管理员只能创建普通级别的空间
    if (SpaceLevelEnum.COMMON.getValue() != space.getSpaceLevel() && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限创建指定级别的空间");
    }

    // 7. 控制同一用户只能创建一个私有空间
    // 13. 还要控制一个用户只能创建一个团队空间
    String lock = String.valueOf(userId).intern();
    // 根据用户 ID 生成一个锁, Java8 后定义了字符串常量池的概念, 相同的值有一个相同且固定的存储空间
    // 同一个用户, 可以多次调用该接口, 生成不同的 String 对象 (趁着系统不注意创建多个空间)
    // 为了保证锁对象是同样的一把锁, 通过 intern() 取到不同 String 对象的同一个值(同一片空间)

    // 8. 对创建空间的代码进行加锁, 既保证了数据一致性,又避免了不必要的性能损耗
    synchronized (lock) {
        // 锁的粒度不是整个方法, 而是创建空间的代码, 是为了尽可能地减少锁的持有时间、降低锁冲突概率、提高并发性能

        // 14. 将锁操作全部封装到, 编程式事务管理器 transactionTemplate 中, 返回值和事务内的返回值相同
        Long newSpaceId = transactionTemplate.execute(status -> {
            // 9. 判断是否已经创建过私有空间/团队空间
            boolean exists = this.lambdaQuery()
                    .eq(Space::getUserId, userId)
                    // 16. spaceType: 0 私有 / 1 团队,
                    .eq(Space::getSpaceType, space.getSpaceType())
                    .exists();
            // 17 是多拼接了一个条件, SELECT 1 表示只要找到一条符合条件的记录, 立刻返回
            /*
            SELECT EXISTS (
            SELECT 1
            FROM space
            WHERE user_id = ? AND space_type = ?
            )
            */

            // 10. 如果已有空间, 则不能再次创建
            ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户每类空间仅能创建一个");

            // 11. 创建空间
            boolean result = this.save(space);
            // save() 对应数据库的 insert 操作, 会根据 space 属性的值, 对数据库对应字段赋值
            ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "保留空间到数据库失败");
            // 12. 返回新写的数据的 id
            return space.getId();
        });

        // return newSpaceId;
        return Optional.ofNullable(newSpaceId).orElse(-1L);
        // 处理直接 return newSpaceId; 代码报警告的 npe 问题 (可以直接返回)
    }
}

注解 15:

  • 创建空间时为空间类型指定默认值;

注解 16~17:

  • 限制每个普通用户仅能创建一个团队空间(管理员可以创建多个),由于普通用户也仅能创建一个私有空间,相当于 普通用户每类空间只能创建 1 个
  • 因此,只要在判断是否已创建空间时,补充 spaceType 作为查询条件即可;
  • 当然,这里的逻辑你可以自由调整,比如不允许用户创建团队空间,需要联系管理员或付费开通。

2. validSpace 方法补充对空间类型的校验:

image-20250801100731449

更新代码:7~9

@Override
public void validSpace(Space space, boolean add) {
    // 1. 校验空间参数
    ThrowUtils.throwIf(space == null, ErrorCode.PARAMS_ERROR);

    // 2. 从对象中取值, space.allget(), 并删除不需要校验的字段
    String spaceName = space.getSpaceName();
    Integer spaceLevel = space.getSpaceLevel();

    // 3. 将 spaceLevel 转为自定义空间枚举类对象, 方便后续校验
    SpaceLevelEnum spaceLevelEnum = SpaceLevelEnum.getEnumByValue(spaceLevel);

    // 7. 校验空间是团队空间, 还是私有空间
    Integer spaceType = space.getSpaceType();
    SpaceTypeEnum spaceTypeEnum = SpaceTypeEnum.getEnumByValue(spaceType);

    // 4. 创建空间前的校验
    if (add) {
        if (StrUtil.isBlank(spaceName)) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间名称不能为空");
        }

        if (spaceLevel == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间级别不能为空");
        }
        if (spaceType == null){
            // 8. 对原始字段 spaceType 进行校验
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间类别不能为空");
        }
    }

    // 5. 修改数据时, 对空间名称的校验
    if (spaceName != null && spaceName.length() > 30) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间名称过长");
    }

    // 6. 修改名称时, 对空间级别的校验
    if (spaceLevel != null && spaceLevelEnum == null) {
        // spaceLevelEnum 为空, 说明空间级别参数是乱传的
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间级别不存在");
    }

    // 9. 虽然空间类别是不能修改的, 但是也补上对空间类比的校验逻辑
    if (spaceType != null && spaceTypeEnum == null){
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间类别不存在");
    }
}

3、查询团队空间


SpaceServicegetQueryWrapper 方法补充 spaceType 的查询条件:

Integer spaceType = spaceQueryRequest.getSpaceType();
queryWrapper.eq(ObjUtil.isNotEmpty(spaceType), "spaceType", spaceType);

image-20250801112614142

之后前端就能够按照空间类别获取空间列表了。


空间成员管理


空间成员管理的开发比较简单,其实就是 “增删改查”。


1、数据模型


(1) 实体类

首先利用 MyBatisX 插件生成空间成员表相关的基础代码,包括实体类、Mapper、Service。

image-20250801113020482


注意,space_user 是一张关联表,对应的实体类的 id 的主键类型可以不设置为长整型:

image-20250801113946017

并且这张表不采用逻辑删除,不需要额外设置逻辑删除字段


(2) 请求类

每个操作都需要提供一个请求类,都放在 model.dto.spaceuser 包下。

image-20250801173448656

(1) 添加空间成员请求,给空间管理员使用:

@Data
public class SpaceUserAddRequest implements Serializable {
    /**
     * 空间 ID
     */
    private Long spaceId;
    /**
     * 用户 ID
     */
    private Long userId;
    /**
     * 空间角色:viewer/editor/admin
     */
    private String spaceRole;
    private static final long serialVersionUID = 1L;
}

(2) 编辑空间成员请求,给空间管理员使用,可以设置空间成员的角色:

@Data
public class SpaceUserEditRequest implements Serializable {
    /**
     * id
     */
    private Long id;
    /**
     * 空间角色:viewer/editor/admin
     */
    private String spaceRole;
    private static final long serialVersionUID = 1L;
}

(3) 查询空间成员请求,可以不用分页:

@Data
public class SpaceUserQueryRequest implements Serializable {
    /**
     * ID
     */
    private Long id;
    /**
     * 空间 ID
     */
    private Long spaceId;
    /**
     * 用户 ID
     */
    private Long userId;
    /**
     * 空间角色:viewer/editor/admin
     */
    private String spaceRole;
    private static final long serialVersionUID = 1L;
}

(3) 响应封装类

model.dto.vo 下新建空间成员的视图包装类,可以额外关联空间信息和创建空间的用户信息。还可以编写 SpaceUser 实体类和该 VO 类的转换方法,便于后续快速传值。

image-20250801173649521

@Data
public class SpaceUserVO implements Serializable {
    /**
     * id
     */
    private Long id;
    /**
     * 空间 id
     */
    private Long spaceId;
    /**
     * 用户 id
     */
    private Long userId;
    /**
     * 空间角色:viewer/editor/admin
     */
    private String spaceRole;
    /**
     * 创建时间
     */
    private Date createTime;
    /**
     * 更新时间
     */
    private Date updateTime;
    /**
     * 用户信息
     */
    private UserVO user;
    /**
     * 空间信息
     */
    private SpaceVO space;
    private static final long serialVersionUID = 1L;

    /**
     * 封装类转对象
     *
     * @param spaceUserVO
     * @return
     */
    public static SpaceUser voToObj(SpaceUserVO spaceUserVO) {
        if (spaceUserVO == null) {
            return null;
        }
        SpaceUser spaceUser = new SpaceUser();
        BeanUtils.copyProperties(spaceUserVO, spaceUser);
        return spaceUser;
    }

    /**
     * 对象转封装类
     *
     * @param spaceUser
     * @return
     */
    public static SpaceUserVO objToVo(SpaceUser spaceUser) {
        if (spaceUser == null) {
            return null;
        }
        SpaceUserVO spaceUserVO = new SpaceUserVO();
        BeanUtils.copyProperties(spaceUser, spaceUserVO);
        return spaceUserVO;
    }
}

(4) 枚举类

model.enums 包下新建空间角色枚举:

image-20250801174140924

@Getter
public enum SpaceRoleEnum {
    VIEWER("浏览者", "viewer"),
    EDITOR("编辑者", "editor"),
    ADMIN("管理员", "admin");

    private final String text;
    private final String value;

    SpaceRoleEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value 枚举值的 value
     * @return 枚举值
     */
    public static SpaceRoleEnum getEnumByValue(String value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (SpaceRoleEnum anEnum : SpaceRoleEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }

    /**
     * 获取所有枚举的文本列表
     *
     * @return 文本列表
     */
    public static List<String> getAllTexts() {
        return Arrays.stream(SpaceRoleEnum.values())
                .map(SpaceRoleEnum::getText)
                .collect(Collectors.toList());
    }

    /**
     * 获取所有枚举的值列表
     *
     * @return 值列表
     */
    public static List<String> getAllValues() {
        return Arrays.stream(SpaceRoleEnum.values())
                .map(SpaceRoleEnum::getValue)
                .collect(Collectors.toList());
    }
}

2、基础服务开发


可以参考图片服务的开发方法,完成 SpaceUserService 和实现类,大多数代码可以直接复用。我们主要开发下列方法:

image-20250801174626379

public interface SpaceUserService extends IService<SpaceUser> {

    /**
     * 添加空间成员
     * @param spaceUserAddRequest
     * @return
     */
    long addSpaceUser(SpaceUserAddRequest spaceUserAddRequest);

    /**
     * 校验空间成员对象
     * @param spaceUser
     * @param add
     */
    void validSpaceUser(SpaceUser spaceUser, boolean add);

    /**
     * 构造查询条件
     * @param spaceUserQueryRequest
     * @return
     */
    QueryWrapper<SpaceUser> getQueryWrapper(SpaceUserQueryRequest spaceUserQueryRequest);

    /**
     * 查询单个对象
     * @param spaceUser
     * @param request
     * @return
     */
    SpaceUserVO getSpaceUserVO(SpaceUser spaceUser, HttpServletRequest request);

    /**
     * 查询封装类列表
     * @param spaceUserList
     * @return
     */
    List<SpaceUserVO> getSpaceUserVOList(List<SpaceUser> spaceUserList)
}

(1) 添加空间成员

@Override
public long addSpaceUser(SpaceUserAddRequest spaceUserAddRequest) {
    // 1. 校验请求参数
    ThrowUtils.throwIf(spaceUserAddRequest == null, ErrorCode.PARAMS_ERROR);
    // 2. 新建空间成员对象
    SpaceUser spaceUser = new SpaceUser();
    // 3. 拷贝请求参数
    BeanUtil.copyProperties(spaceUserAddRequest, spaceUser);
    // 4. 对添加成员的信息进行校验
    validSpaceUser(spaceUser, true);
    // 5. 数据库操作
    boolean result = this.save(spaceUser);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
    return spaceUser.getId();
}

扩展:

  1. 如果要添加成员,要校验该成员是否不在空间内,否则添加失败;
  2. 如果要删除成员,要校验该成员是否在空间内,否则删除失败;

(2) 校验空间成员对象

增加 add 参数用来区分是创建数据时校验还是编辑时校验,判断条件是不一样的。

比如创建成员时要检查用户是否存在。

@Override
public void validSpaceUser(SpaceUser spaceUser, boolean add) {
    // 1. 校验当前空间操作者
    ThrowUtils.throwIf(spaceUser == null, ErrorCode.PARAMS_ERROR);

    // 2. 创建空间成员时, 用户 ID 和 空间 ID 必填
    Long userId = spaceUser.getUserId();
    Long spaceId = spaceUser.getSpaceId();
    if(add){
        // 校验 userId 和 spaceId 字段是否有值
        ThrowUtils.throwIf(ObjUtil.hasEmpty(userId, spaceId), ErrorCode.PARAMS_ERROR);

        // 校验 userId 、spaceId 对应的用户和空间是否存在
        User user = userService.getById(userId);
        ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR, "用户不存在");
        Space space = spaceService.getById(spaceId);
        ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
    }
    // 3. 管理员修改用户角色, 判断是否存在这个角色
    String spaceRole = spaceUser.getSpaceRole();
    SpaceRoleEnum spaceRoleEnum = SpaceRoleEnum.getEnumByValue(spaceRole);
    if(spaceRole == null || spaceRoleEnum == null){
        throw new BusinessException(ErrorCode.PARAMS_ERROR,"空间角色不存在");
    }
}

(3) 构造查询条件

将查询请求对象转换为 MyBatis-Plus 的查询封装对象:

@Override
public QueryWrapper<SpaceUser> getQueryWrapper(SpaceUserQueryRequest spaceUserQueryRequest) {
    QueryWrapper<SpaceUser> queryWrapper = new QueryWrapper<>();
    if (spaceUserQueryRequest == null) {
        return queryWrapper;
    }
    // 从对象中取值
    Long id = spaceUserQueryRequest.getId();
    Long spaceId = spaceUserQueryRequest.getSpaceId();
    Long userId = spaceUserQueryRequest.getUserId();
    String spaceRole = spaceUserQueryRequest.getSpaceRole();
    queryWrapper.eq(ObjUtil.isNotEmpty(id), "id", id);
    queryWrapper.eq(ObjUtil.isNotEmpty(spaceId), "spaceId", spaceId);
    queryWrapper.eq(ObjUtil.isNotEmpty(userId), "userId", userId);
    queryWrapper.eq(ObjUtil.isNotEmpty(spaceRole), "spaceRole", spaceRole);
    return queryWrapper;
}

(4) 获取空间成员封装类

获取空间成员封装类,需要关联查询用户和空间的信息。

查询单个封装类:

image-20250803102916635

@Override
public SpaceUserVO getSpaceUserVO(SpaceUser spaceUser, HttpServletRequest request) {
    // 1. 对 spaceUser 进行脱敏
    SpaceUserVO spaceUserVO = SpaceUserVO.objToVo(spaceUser);

    // 2. 关联查询用户信息
    Long userId = spaceUser.getUserId();
    if(userId != null && userId > 0){
        User user = userService.getById(userId);
        UserVO userVO = userService.getUserVO(user);
        spaceUserVO.setUser(userVO);
    }

    // 3, 关联查询空间信息
    Long spaceId = spaceUserVO.getSpaceId();
    if(spaceId != null && spaceId > 0){
        Space space = spaceService.getById(spaceId);
        SpaceVO spaceVO = spaceService.getSpaceVO(space, request);
        spaceUserVO.setSpace(spaceVO);
    }

    return spaceUserVO;
}

查询封装类列表:

@Override
public List<SpaceUserVO> getSpaceUserVOList(List<SpaceUser> spaceUserList) {
    // 1. 判断输入列表是否为空
    if (ObjUtil.isNull(spaceUserList)){
        return new ArrayList<>();
    }

    // 2. List<SpaceUser> -> List<SpaceUserVO>
    List<SpaceUserVO> spaceUserVOList = spaceUserList.stream()
            .map(SpaceUserVO::objToVo)
            .collect(Collectors.toList());

    // 3. 收集需要关联查询的用户 ID 和空间 ID
    Set<Long> userIdSet = spaceUserList.stream()
            .map(SpaceUser::getUserId)
            .collect(Collectors.toSet());

    Set<Long> spaceIdSet = spaceUserList.stream()
            .map(SpaceUser::getSpaceId)
            .collect(Collectors.toSet());

    // 4. 批量查询用户和空间
    Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet)
            .stream()
            .collect(Collectors.groupingBy(User::getId));

    Map<Long, List<Space>> spaceIdSpaceListMap = spaceService.listByIds(spaceIdSet)
            .stream()
            .collect(Collectors.groupingBy(Space::getId));

    // 5. 填充 SpaceUserVO 的用户和空间信息
    spaceUserVOList.forEach(spaceUserVO -> {
        Long userId = spaceUserVO.getUserId();
        Long spaceId = spaceUserVO.getSpaceId();
        User user = null;
        if(userIdUserListMap.containsKey(userId)){
            user = userIdUserListMap.get(userId).get(0);
        }
        spaceUserVO.setUser(userService.getUserVO(user));
        Space space = null;
        if(spaceIdSpaceListMap.containsKey(spaceId)){
            space = spaceIdSpaceListMap.get(spaceId).get(0);
        }
        spaceUserVO.setSpace(SpaceVO.objToVo(space));
    });
    return spaceUserVOList;
}

3、接口开发


参考图片接口的开发方法,完成 SpaceUserController 类,大多数代码可以直接复用。需要开发的接口包括:

image-20250802112844543

由于我们后续会使用统一的权限管理框架,这个阶段可以先只实现功能,不进行权限校验。代码如下:

@RestController
@RequestMapping("/spaceUser")
@Slf4j
public class SpaceUserController {
    @Resource
    private SpaceUserService spaceUserService;
    @Resource
    private UserService userService;

    /**
     * 添加成员到空间
     */
    @PostMapping("/add")
    public BaseResponse<Long> addSpaceUser(@RequestBody SpaceUserAddRequest spaceUserAddRequest, HttpServletRequest request) {
        ThrowUtils.throwIf(spaceUserAddRequest == null, ErrorCode.PARAMS_ERROR);
        long id = spaceUserService.addSpaceUser(spaceUserAddRequest);
        return ResultUtils.success(id);
    }

    /**
     * 从空间移除成员
     */
    @PostMapping("/delete")
    public BaseResponse<Boolean> deleteSpaceUser(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
        if (deleteRequest == null || deleteRequest.getId() <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        long id = deleteRequest.getId();
        // 判断是否存在
        SpaceUser oldSpaceUser = spaceUserService.getById(id);
        ThrowUtils.throwIf(oldSpaceUser == null, ErrorCode.NOT_FOUND_ERROR);
        // 操作数据库
        boolean result = spaceUserService.removeById(id);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        return ResultUtils.success(true);
    }

    /**
     * 查询某个成员在某个空间的信息
     */
    @PostMapping("/get")
    public BaseResponse<SpaceUser> getSpaceUser(@RequestBody SpaceUserQueryRequest spaceUserQueryRequest) {
        // 参数校验
        ThrowUtils.throwIf(spaceUserQueryRequest == null, ErrorCode.PARAMS_ERROR);
        Long spaceId = spaceUserQueryRequest.getSpaceId();
        Long userId = spaceUserQueryRequest.getUserId();
        ThrowUtils.throwIf(ObjectUtil.hasEmpty(spaceId, userId), ErrorCode.PARAMS_ERROR);
        // 查询数据库
        SpaceUser spaceUser = spaceUserService.getOne(spaceUserService.getQueryWrapper(spaceUserQueryRequest));
        ThrowUtils.throwIf(spaceUser == null, ErrorCode.NOT_FOUND_ERROR);
        return ResultUtils.success(spaceUser);
    }

    /**
     * 查询成员信息列表
     */
    @PostMapping("/list")
    public BaseResponse<List<SpaceUserVO>> listSpaceUser(@RequestBody SpaceUserQueryRequest spaceUserQueryRequest, HttpServletRequest request) {
        ThrowUtils.throwIf(spaceUserQueryRequest == null, ErrorCode.PARAMS_ERROR);
        List<SpaceUser> spaceUserList = spaceUserService.list(spaceUserService.getQueryWrapper(spaceUserQueryRequest));
        return ResultUtils.success(spaceUserService.getSpaceUserVOList(spaceUserList));
    }

    /**
     * 编辑成员信息(设置权限)
     */
    @PostMapping("/edit")
    public BaseResponse<Boolean> editSpaceUser(@RequestBody SpaceUserEditRequest spaceUserEditRequest, HttpServletRequest request) {
        if (spaceUserEditRequest == null || spaceUserEditRequest.getId() <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        // 将实体类和 DTO 进行转换
        SpaceUser spaceUser = new SpaceUser();
        BeanUtils.copyProperties(spaceUserEditRequest, spaceUser);
        // 数据校验
        spaceUserService.validSpaceUser(spaceUser, false);
        // 判断是否存在
        long id = spaceUserEditRequest.getId();
        SpaceUser oldSpaceUser = spaceUserService.getById(id);
        ThrowUtils.throwIf(oldSpaceUser == null, ErrorCode.NOT_FOUND_ERROR);
        // 操作数据库
        boolean result = spaceUserService.updateById(spaceUser);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        return ResultUtils.success(true);
    }

    /**
     * 查询我加入的团队空间列表
     */
    @PostMapping("/list/my")
    public BaseResponse<List<SpaceUserVO>> listMyTeamSpace(HttpServletRequest request) {
        User loginUser = userService.getLoginUser(request);
        SpaceUserQueryRequest spaceUserQueryRequest = new SpaceUserQueryRequest();
        spaceUserQueryRequest.setUserId(loginUser.getId());
        List<SpaceUser> spaceUserList = spaceUserService.list(spaceUserService.getQueryWrapper(spaceUserQueryRequest));
        return ResultUtils.success(spaceUserService.getSpaceUserVOList(spaceUserList));
    }
}

4、创建团队空间时自动新增成员记录


根据需求,用户在创建团队空间时,会默认作为空间的管理员,需要在空间成员表中新增一条记录。

修改 addSpace 方法,在事务中补充插入空间成员记录:

更新代码:18~20

@Override
// @Transactional // 13. 如果使用这个注解, 可能会导致锁释放后, 事务还未被提交

public long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {
    // (1) 填充参数默认值
    // (2) 参数校验
    // (3) 校验权限, 非管理员只能普通级别的空间
    // (4) 控制同一个用户只能创建一个私有空间

    // 1. 转换实体类和 DTO
    Space space = new Space();
    BeanUtil.copyProperties(spaceAddRequest, space);

    // 2. 填充参数默认值
    if (StrUtil.isBlank(space.getSpaceName())) {
        space.setSpaceName("默认空间");
    }
    if (space.getSpaceLevel() == null) {
        space.setSpaceLevel(SpaceLevelEnum.COMMON.getValue());
    }
    if (space.getSpaceType() == null) {
        // 15. 虽然数据库已经设置过默认值了, 但是业务层面也补充一下
        space.setSpaceType(SpaceTypeEnum.PRIVATE.getValue());
    }

    // 3. 填充空间容量和大小
    this.fillSpaceBySpaceLevel(space);

    // 4. 创建时校验参数
    this.validSpace(space, true);

    // 5. 从登录用户中获取用户 ID, 并设置给空间
    Long userId = loginUser.getId();
    space.setUserId(userId);

    // 6. 对用户进行权限校验, 非管理员只能创建普通级别的空间
    if (SpaceLevelEnum.COMMON.getValue() != space.getSpaceLevel() && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限创建指定级别的空间");
    }

    // 7. 控制同一用户只能创建一个私有空间
    // 13. 还要控制一个用户只能创建一个团队空间
    String lock = String.valueOf(userId).intern();
    // 根据用户 ID 生成一个锁, Java8 后定义了字符串常量池的概念, 相同的值有一个相同且固定的存储空间
    // 同一个用户, 可以多次调用该接口, 生成不同的 String 对象 (趁着系统不注意创建多个空间)
    // 为了保证锁对象是同样的一把锁, 通过 intern() 取到不同 String 对象的同一个值(同一片空间)

    // 8. 对创建空间的代码进行加锁, 既保证了数据一致性,又避免了不必要的性能损耗
    synchronized (lock) {
        // 锁的粒度不是整个方法, 而是创建空间的代码, 是为了尽可能地减少锁的持有时间、降低锁冲突概率、提高并发性能

        // 14. 将锁操作全部封装到, 编程式事务管理器 transactionTemplate 中, 返回值和事务内的返回值相同
        Long newSpaceId = transactionTemplate.execute(status -> {
            // 9. 判断是否已经创建过私有空间/团队空间
            boolean exists = this.lambdaQuery()
                    .eq(Space::getUserId, userId)
                    // 16. spaceType: 0 私有 / 1 团队,
                    .eq(Space::getSpaceType, space.getSpaceType())
                    .exists();
            // 17 是多拼接了一个条件, SELECT 1 表示只要找到一条符合条件的记录, 立刻返回

            // 10. 如果已有空间, 则不能再次创建
            ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户仅能创建一个私有空间");

            // 11. 创建空间
            boolean result = this.save(space);
            // save() 对应数据库的 insert 操作, 会根据 space 属性的值, 对数据库对应字段赋值
            ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "保留空间到数据库失败");

            // 18. 创建团队空间时自动新增成员记录
            if (SpaceTypeEnum.TEAM.getValue() == space.getSpaceType()) {
                SpaceUser spaceUser = new SpaceUser();
                spaceUser.setSpaceId(space.getId());
                spaceUser.setUserId(userId);
                // 创建空间, 默认为管理员
                spaceUser.setSpaceRole(SpaceRoleEnum.ADMIN.getValue());
                // 引入 bean: SpaceUserService, 使用 save() 向数据库插入记录
                boolean save = spaceUserService.save(spaceUser);
                ThrowUtils.throwIf(!save, ErrorCode.OPERATION_ERROR,"创建团队成员记录失败");
            }
            // 12. 返回新写的数据的 id
            return space.getId();
        });

        // return newSpaceId;
        return Optional.ofNullable(newSpaceId).orElse(-1L);
        // 处理直接 return newSpaceId; 代码报警告的 npe 问题 (可以直接返回)
    }
}

注意,如果创建团队空间成功,但是创建团队空间时自动新增成员记录失败,由于我们使用了事务,那么创建团队空间也会失败,因为事务中只要有操作失败,就会回滚导致前面的操作都失败


5. 扩展


1. 添加成员到空间时,可以支持发送邀请和审批。实现思路:给空间成员表新增一个邀请确认状态的字段

2. 由于空间管理员可能有多个,空间成员表可以补充添加成员至空间的邀请人字段createUserId)。

3. 空间成员操作执行前可以补充一些校验,比如:

  • 只有已经是空间成员,才能被移除或编辑。
  • 如果编辑后的角色跟之前一致,就不用更新。

空间成员权限控制


引入团队空间后,需要给空间操作、图片操作、空间成员操作添加权限控制逻辑。

为了简化开发,同时防止一些空间重要信息的修改冲突,空间操作(空间信息的增删改查)仍然复用之前私有空间的校验逻辑 —— 仅创建人可操作

由于权限校验属于整个项目的公共服务,统一放在 manager.auth 包中。

image-20250802164501343


1、权限定义


根据 RBAC 权限模型,需要定义角色和权限

1. 此处选用 JSON 配置文件来定义角色、权限、角色和权限之间的关系,相比从数据库表中获取,实现更方便,查询也更高效。

resources/biz 目录下新建 JSON 配置文件 spaceUserAuthConfig.json

image-20250802164704746

{
  "permissions": [
    {
      "key": "spaceUser:manage",
      "name": "成员管理",
      "description": "管理空间成员,添加或移除成员"
    },
    {
      "key": "picture:view",
      "name": "查看图片",
      "description": "查看空间中的图片内容"
    },
    {
      "key": "picture:upload",
      "name": "上传图片",
      "description": "上传图片到空间中"
    },
    {
      "key": "picture:edit",
      "name": "修改图片",
      "description": "编辑已上传的图片信息"
    },
    {
      "key": "picture:delete",
      "name": "删除图片",
      "description": "删除空间中的图片"
    }
  ],
  "roles": [
    {
      "key": "viewer",
      "name": "浏览者",
      "permissions": [
        "picture:view"
      ],
      "description": "查看图片"
    },
    {
      "key": "editor",
      "name": "编辑者",
      "permissions": [
        "picture:view",
        "picture:upload",
        "picture:edit",
        "picture:delete"
      ],
      "description": "查看图片、上传图片、修改图片、删除图片"
    },
    {
      "key": "admin",
      "name": "管理员",
      "permissions": [
        "spaceUser:manage",
        "picture:view",
        "picture:upload",
        "picture:edit",
        "picture:delete"
      ],
      "description": "成员管理、查看图片、上传图片、修改图片、删除图片"
    }
  ]
}

2. 在 auth.model 包下新建数据模型,用于接收配置文件的值。

权限配置类:

image-20250802165853209

@Data
public class SpaceUserAuthConfig implements Serializable {
    /**
     * 权限列表
     */
    private List<SpaceUserPermission> permissions;

    /**
     * 角色列表
     */
    private List<SpaceUserRole> roles;

    private static final long serialVersionUID = 1L;
}

空间成员权限:

@Data
public class SpaceUserPermission implements Serializable {
    /**
     * 权限键
     */
    private String key;

    /**
     * 权限名称
     */
    private String name;

    /**
     * 权限描述
     */
    private String description;

    private static final long serialVersionUID = 1L;
}

image-20250802165647088


空间成员角色:

@Data
public class SpaceUserRole implements Serializable {
    /**
     * 角色键
     */
    private String key;

    /**
     * 角色名称
     */
    private String name;

    /**
     * 权限键列表
     */
    private List<String> permissions;

    /**
     * 角色描述
     */
    private String description;

    private static final long serialVersionUID = 1L;
}

image-20250802165819015


3. 定义空间成员权限常量类,便于后续校验权限时使用:

image-20250802170012731

public interface SpaceUserPermissionConstant {
    /**
     * 空间用户管理权限
     */
    String SPACE_USER_MANAGE = "spaceUser:manage";

    /**
     * 图片查看权限
     */
    String PICTURE_VIEW = "picture:view";

    /**
     * 图片上传权限
     */
    String PICTURE_UPLOAD = "picture:upload";

    /**
     * 图片编辑权限
     */
    String PICTURE_EDIT = "picture:edit";

    /**
     * 图片删除权限
     */
    String PICTURE_DELETE = "picture:delete";
}

image-20250802172124793


4. 在 auth 包下新建 SpaceUserAuthManager,可加载配置文件到对象(对应实体类),并提供根据角色获取权限列表的方法。

image-20250802170215264

/**
 * 空间成员角色管理器
 */
@Component  // 1. 定义为一个组件
public class SpaceUserAuthManager {

    // 2. 接收 JSON 配置文件的值,并加载至内存中
    public static final SpaceUserAuthConfig SPACE_USER_AUTH_CONFIG;
    // 默认不赋值, 因为是 final 类型的, 只能赋值一次

    // 3. 设置一个静态代码块, 当项目启动, 类加载时, 让代码块执行, 将 JSON 读出来
    static {

        // 4. 读取配置文件
        String json = ResourceUtil.readUtf8Str("biz/spaceUserAuthConfig.json");
        // 使用 Hutool 工具类, 指定配置文件的目录, 可以读取配置文件的内容

        // 5. 将读取的配置信息, 赋值给 final 对象的 SPACE_USER_AUTH_CONFIG
        SPACE_USER_AUTH_CONFIG = JSONUtil.toBean(json, SpaceUserAuthConfig.class);
        // 转换配置文件类型为 SPACE_USER_AUTH_CONFIG, final 对象一旦赋值, 不可以被更改
    }

    /**
     * 一个角色可能有多个权限, 根据角色查询权限列表(这是一个公共的功能, 因此方法写在公共类中)
     * @param spaceUserRole
     * @return
     */
    public List<String> getPermissionsByRole(String spaceUserRole){
        // 1. 校验参数
        if (spaceUserRole == null){
            return new ArrayList<>();
        }

        // 2. 遍历 SPACE_USER_AUTH_CONFIG(配置文件信息), 直到找到对应的权限
        SpaceUserRole role = SPACE_USER_AUTH_CONFIG.getRoles()
                // 获得一个 role 列表
                .stream()
                .filter(tmpRole -> tmpRole.getKey().equals(spaceUserRole))
                // 在遍历时过滤, 取出 key 等于参数的 tmpRole
                .findFirst()
                // 获取第一个 role 对应的信息就返回
                .orElse(null);
                // 否则返回 null

        // 3. 遍历配置文件后无和参数相同的 role
        if (role == null){
            return new ArrayList<>();
        }

        return role.getPermissions();
    }
}

2、Sa-Token 入门


Sa-Token 是一个轻量级 Java 权限认证框架,相比 Spring Security 等更加简单易学,用作者的话说,使用该框架可以让鉴权变得简单、优雅。

框架的学习并不难,参考官方文档就好,等下我们要学习实战 Sa-Token 的主流特性和高级用法。


1. 引入 Sa-Token

注:如果你使用的是 SpringBoot 3.x,只需要将 sa-token-spring-boot-starter 修改为 sa-token-spring-boot3-starter 即可。(本项目为 SpringBoot 2)

<!-- Sa-Token 权限认证 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.39.0</version>
</dependency>

Sa-Token 默认将数据(比如用户登录态)保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但缺点是重启后数据会丢失、无法在分布式环境中共享数据

我们项目中既然已经使用了 Redis,那么可以参考官方文档 让 Sa-Token 整合 Redis,将用户的登录态等内容保存在 Redis 中

image-20250802175927741

此处选择 jackson 序列化方式整合 Redis,这样存到 Redis 的数据是可读的:

<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.39.0</version>
</dependency>

<!-- 提供Redis连接池 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

2. 了解 Sa-Token 的基本用法

Sa-Token 的使用方式比较简单,首先是用户登录时调用 login 方法,产生一个新的会话:

StpUtil.login(10001);

image-20250802180707638


还可以给会话保存一些信息,比如登录用户的信息:

StpUtil.getSession().set("user", user);

接下来你就可以判断用户是否登录、获取用户信息了,可以通过代码进行判断:

// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();

// 获取用户信息
StpUtil.getSession().get("user");

也可以参考官方文档—注解鉴权,使用注解进行鉴权:

// 登录校验:只有登录之后才能进入该方法
@SaCheckLogin
@RequestMapping("info")
public String info() {
    return "查询用户信息";
}

这是 Sa-Token 最基本的用法,下面我们正式在项目中使用 Sa-Token。


3、新建空间账号体系


目前,我们的项目中其实存在两套权限校验体系。

  • 一套是最开始就有的,对 user 表的角色进行校验,分为普通用户和管理员;
  • 另一套是本节新学习的,对团队空间的权限进行校验。
  • 为了更轻松地扩展项目,减少对原有代码的改动,我们原有的 user 表权限校验依然使用自定义注解 + AOP 的方式实现。而团队空间权限校验,采用 Sa-Token 来管理。
  • 相当于我们不是整个项目都交给 Sa-Token,只是把 Sa-Token 当做实现团队空间权限管理的工具罢了。

这种同一项目有多账号体系的情况下,不建议使用 Sa-Token 默认的账号体系,而是使用 Sa-Token 提供的多账号认证特性,可以将多套账号的授权给区分开,让它们互不干扰

image-20250802181801646

  • 假如说我们的 user表 和 admin表 都有一个 id=10001 的账号,它们对应的登录代码:StpUtil.login(10001) 是一样的, 那么问题来了:在StpUtil.getLoginId()获取到的账号id如何区分它是User用户,还是Admin用户?
  • 你可能会想到为他们加一个固定前缀,比如StpUtil.login("User_" + 10001)StpUtil.login("Admin_" + 10001),这样确实是可以解决问题的, 但是同样的:你需要在StpUtil.getLoginId()时再裁剪掉相应的前缀才能获取真正的账号id,这样一增一减就让我们的代码变得无比啰嗦。

image-20250802182849028


(1) kit 模式实现多账号认证

可以参考官方文档,使用 Kit 模式实现多账号认证:

image-20250803100459174


auth 包下新建 StpKit.java,定义空间账号体系:

image-20250803100637144

/**
 * StpLogic 门面类,管理项目中所有的 StpLogic 账号体系
 * 添加 @Component 注解的目的是确保静态属性 DEFAULT 和 SPACE 被初始化
 */
@Component
public class StpKit {
    public static final String SPACE_TYPE = "space";

    /**
     * 默认原生会话对象,项目中目前没使用到
     */
    public static final StpLogic DEFAULT = StpUtil.stpLogic;

    /**
     * Space 会话对象,管理 Space 表所有账号的登录、权限认证
     */
    public static final StpLogic SPACE = new StpLogic(SPACE_TYPE);
}

之后就可以在代码中使用账号体系,以下是示例代码:

// 在当前会话进行 Space 账号登录
StpKit.SPACE.login(10001);

// 检测当前会话是否以 Space 账号登录,并具有 picture:edit 权限
StpKit.SPACE.checkPermission("picture:edit");

// 获取当前 Space 会话的 Session 对象,并进行写值操作
StpKit.SPACE.getSession().set("user", "小雷");

(2) 修改用户服务的 userLogin 方法

用户登录成功后,保存登录态到 Sa-Token 的空间账号体系中:

@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {
    
    // .......

    request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE, user);
    
    // 4. 记录用户登录态到 Sa-token,便于空间鉴权时使用,注意保证该用户信息与 SpringSession 中的信息过期时间一致
    StpKit.SPACE.login(user.getId());
    // 以用户 ID 作为登录 ID
    StpKit.SPACE.getSession().set(UserConstant.USER_LOGIN_STATE, user);
    // getSession() 返回值是一个 map, 我们将当前登录用户信息设置进来
    
    return this.getLoginUserVO(user);
}

4、权限认证逻辑


(1) 开发权限认证类

Sa-Token 开发的核心是编写权限认证类,我们需要在该类中实现 “如何根据登录用户 id 获取到用户已有的角色和权限列表” 方法。

当要判断某用户是否有某个角色或权限时,Sa-Token 会先执行我们编写的方法,得到该用户的角色或权限列表,然后跟需要的角色权限进行比对

参考 官方文档,示例权限认证类如下:

/**
 * 自定义权限加载接口实现类
 */
@Component
public class StpInterfaceImpl implements StpInterface {
    /**
     * 返回一个账号所拥有的权限码集合
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // loginType 为当前账号登录体系
        // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限
        List<String> list = new ArrayList<>();
        list.add("user.add");
        list.add("user.update");
        list.add("user.get");
        list.add("art.*");
        return list;
    }

    /**
     * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
        List<String> list = new ArrayList<>();
        list.add("admin");
        list.add("super-admin");
        return list;
    }
}

Sa-Token 支持按照角色和权限校验:

  • 对于权限不多的项目,基于角色校验(只实现 getRoleList() )即可;
  • 对于权限较多的项目,建议根据权限校验(只实现 getPermissionList() )

对于本项目,虽然权限并不多,但是考虑到扩展性,还是选择更细粒度的权限校验,业务含义会更明确。


(2) ID 参数动态定位权限校验体系

观察上述代码我们会发现,getPermissionList 方法只提供了 loginId(登录用户 id)和 loginType(账号体系)两个参数。这会给我们造成很大的难度:

  • 我们光有用户 id 是没办法进行权限校验的,因为我们要给图片操作和空间成员操作增加权限校验逻辑,还需要获取到空间 id,才知道用户是否具有某个团队空间的权限。那么我们如何获取到空间 id 呢
  • 如果要进行统一的权限校验,也包括了公共图库和私有空间,更要命的是,公共图库是没有空间 id 的!这就意味着要根据操作的图片情况动态判断

所以我们要解决的关键问题有 2 个:

1. 如何在 Sa-Token 中获取当前请求操作的参数?
2. 如何编写一套权限校验逻辑,同时兼容公共图库、私有空间和团队空间?

(1) 先看第一个问题,使用 Sa-Token 有 2 种方式 —— 注解式和编程式

  • 如果使用注解式,那么在接口被调用时就会立刻触发 Sa-Token 的权限校验,此时参数只能通过 Servlet 的请求对象传递。(还没调用到我们自己的业务代码 getPermissionList(),就已经被注解鉴权了,这时候就不能从业务代码中获取所需参数,进行后续逻辑,要想获得参数,只能从请求对象中获取)image-20250803105112591

  • 如果使用编程式,可以在代码任意位置执行权限校验,只要在执行前将参数放到当前线程的上下文 ThreadLocal 对象中,就能在鉴权时获取到了。image-20250803105606914


(3) 定义上下文类接收所有可能的校验参数

为了后续我们给接口添加鉴权更直观方便,我们选择注解式鉴权

那就有一个关键问题,不同接口的请求参数是不同的,有的请求参数有 spaceId、有的只有 pictureId,怎么办呢?

我们可以定义一个上下文类,用于统一接收请求中传递来的参数,权限管理需要的所有参数,都放在这个上下文类中

image-20250803110844776

/**
 * SpaceUserAuthContext
 * 表示用户在特定空间内的授权上下文,包括关联的图片、空间和用户信息。
 */
@Data
public class SpaceUserAuthContext {
    /**
     * 临时参数,不同请求对应的 id 可能不同
     */
    private Long id;

    /**
     * 图片 ID
     */
    private Long pictureId;

    /**
     * 空间 ID
     */
    private Long spaceId;

    /**
     * 空间用户 ID
     */
    private Long spaceUserId;

    /**
     * 图片信息
     */
    private Picture picture;

    /**
     * 空间信息
     */
    private Space space;

    /**
     * 空间用户信息
     */
    private SpaceUser spaceUser;
}

如何知道哪个请求包含了哪些字段呢?

别忘了,我们每类操作(图片 / 空间成员)的请求前缀都是固定的,可以从请求路径中提取到要访问的是哪个 Controller,而每类 Controller 的请求参数,都是一致的。

举个例子,如果访问地址是 /api/picture/xxx,那么一定是要调用 PictureController 的接口,这些接口的 id 字段都表示 pictureId

image-20250803110541525


image-20250803110759082

/**
 * 自定义权限加载接口实现类
 */
@Component
public class StpInterfaceImpl implements StpInterface {
    /**
     * 返回一个账号所拥有的权限码集合
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        return new ArrayList<>();
    }

    /**
     * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
        // 本项目不用
        return new ArrayList<>();
    }
}

等一下我们需要在 getPermissionList() 方法中,完善根据登录 id 和请求参数进行的一系列鉴权的逻辑;所以获取请求参数的方法,是可以直接写在 StpInterfaceImpl 类中的,可以实现高内聚,低耦合(把所有获取权限的逻辑放在一个类中,统一管理);


(4) 根据 URL 前缀动态定位定位体系 ID

我们就可以通过访问地址,来决定应该给上下文传递哪些字段,代码如下:

image-20250803110759082

// 1. 获取当前请求上下文的路径, 进而从 url 中获取到指定的前缀
@Value("${server.servlet.context-path}")
// 默认是 /api
private String contextPath;

private SpaceUserAuthContext getAuthContextByRequest(){
    // 2. 获取请求
    HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

    // 3. 获取请求类型, 根据不同的请求 GET/POST, 采取不同的方法获取请求参数
    String contentType = request.getHeader(Header.CONTENT_TYPE.getValue());
    // Header 是 Hutool 包的

    SpaceUserAuthContext authRequest;

    // 4. 获取请求参数
    if(ContentType.JSON.getValue().equals(contentType)){
        // 5. 如果请求参数是 JSON, 根据 Hutool 工具类 ServletUtil 根据请求对象, 获取请求体
        String body = ServletUtil.getBody(request);

        // 6. 如果是 JSON , 可以直接转换为我们需要的对象 SpaceUserAuthContext
        authRequest = JSONUtil.toBean(body, SpaceUserAuthContext.class);
    } else{
        // 7. 如果是 GET 请求, 是没有 JSON 对象的, 我们需要换一种方式
        Map<String, String> paramMap = ServletUtil.getParamMap(request);
        // getParamMap() 获取所有请求参数的 Map 集合

        // 8. 将 Map 类型也转为 SpaceUserAuthContext
        authRequest = BeanUtil.toBean(paramMap, SpaceUserAuthContext.class);
    }

    // 9. 根据请求路径, 区分 id 字段的含义
    // 10. 先判断请求参数是否有 id, 有 id 则根据请求路径区分 id 含义
    Long id = authRequest.getId();
    if(ObjUtil.isNotNull(id)){
        // 11. 获取到请求路径 /api/picture/aaa?b=1
        String requestURI = request.getRequestURI();

        // 12. 获取到请求路径的业务前缀
        String partURI = requestURI.replace(contextPath + "/", "");
        // 替换掉 contextPath (默认是 /api) 为空串, 剩余的前缀即为所求
        String moduleName = StrUtil.subBefore(partURI, "/", false);
        // 取字符串分割符前面的部分, 第三个参数为, 是否以字符串最后的 / 为分割符, false 则取第一个 / 为分割符

        // 13. 根据获取的业务前缀, 来确定 id 该给 SpaceUserAuthContext 中的哪个属性 id 赋值
        switch (moduleName) {
            case "picture":
                authRequest.setPictureId(id);
                break;
            case "spaceUser":
                authRequest.setSpaceUserId(id);
                break;
            case "space":
                authRequest.setSpaceId(id);
                break;
            default:
        }
    }
    // 14. 将获得的上下文参数传递出去
    return authRequest;
}

注意,上述代码中,我们使用 Hutool 的工具类 ServletUtilHttpServletRequest 中获取到了参数信息;

但是坑爹的是,HttpServletRequest 的 body 值是个流,只支持读取一次,读完就没了!


(5) 实现请求体的多次读取

所以为了解决这个问题,我们还要在 config 包下自定义请求包装类和请求包装类过滤器。这些就是样板代码了,大家直接复制粘贴即可,不用编码。

image-20250803155923973

RequestWrapper 请求包装类(样板代码):

/**
 * 包装请求,使 InputStream 可以重复读取
 *
 * @author pine
 */
@Slf4j
public class RequestWrapper extends HttpServletRequestWrapper {
    private final String body;

    public RequestWrapper(HttpServletRequest request) {
        super(request);
        StringBuilder stringBuilder = new StringBuilder();
        try (InputStream inputStream = request.getInputStream(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
            char[] charBuffer = new char[128];
            int bytesRead = -1;
            while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
                stringBuilder.append(charBuffer, 0, bytesRead);
            }
        } catch (IOException ignored) {
        }
        body = stringBuilder.toString();
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
            }

            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }
        };
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }

    public String getBody() {
        return this.body;
    }
}

HttpRequestWrapperFilter 请求包装过滤器:

image-20250803160420289

/**
 * 请求包装过滤器
 *
 * @author pine
 */
@Order(1)
@Component
public class HttpRequestWrapperFilter implements Filter {
    // Filter 导入 servlet 包
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
        if (request instanceof HttpServletRequest) {
            HttpServletRequest servletRequest = (HttpServletRequest) request;
            String contentType = servletRequest.getHeader(Header.CONTENT_TYPE.getValue());
            if (ContentType.JSON.getValue().equals(contentType)) {
                // 可以再细粒度一些,只有需要进行空间权限校验的接口才需要包一层
                chain.doFilter(new RequestWrapper(servletRequest), response);
            } else {
                chain.doFilter(request, response);
            }
        }
    }
}

这样我们就能正常获取到请求参数了~


(6) 编写通用的权限校验逻辑

编写通用的权限校验逻辑,兼容公共图库、私有空间和团队空间

image-20250803163503154

这个没啥好说的,就是写业务逻辑,而且是比较复杂的业务逻辑,所以建议一定要先把业务流程梳理清楚,再编写代码。业务流程如下:

**image-20250803164758348

  1. 校验登录类型:如果 loginType 不是 "space",直接返回空权限列表。

  2. 管理员权限处理:如果当前用户为管理员,直接返回管理员权限列表。

  3. 获取上下文对象:从请求中获取 SpaceUserAuthContext 上下文,检查上下文字段是否为空。如果上下文中所有字段均为空(如没有空间或图片信息),视为公共图库操作,直接返回管理员权限列表

  4. 校验登录状态:通过 loginId 获取当前登录用户信息。如果用户未登录,抛出未授权异常;否则获取用户的唯一标识 userId,用于后续权限判断。

  5. 从上下文中优先获取 SpaceUser 对象:如果上下文中存在 SpaceUser 对象,直接根据其角色获取权限码列表。

  6. 通过 spaceUserId 获取空间用户信息:如果上下文中存在 spaceUserId

    • 查询对应的 SpaceUser 数据。如果未找到,抛出数据未找到异常。
    • 校验当前登录用户是否属于该空间,如果不是,返回空权限列表。
    • 否则,根据登录用户在该空间的角色,返回相应的权限码列表。
  7. 通过 spaceIdpictureId 获取空间或图片信息:

    • 如果 spaceId 不存在:使用 pictureId 查询图片信息,并通过图片的 spaceId 继续判断权限;
    • 如果 pictureIdspaceId 均为空,默认视为管理员权限。
    • 对于公共图库:如果图片是当前用户上传的,或者当前用户为管理员,返回管理员权限列表;如果图片不是当前用户上传的,返回仅允许查看的权限码。
  8. 获取 Space 对象并判断空间类型:查询 Space 信息,如果未找到空间数据,抛出数据未找到异常。否则根据空间类型进行判断:

    • 私有空间:仅空间所有者和管理员有权限(即返回全部权限),其他用户返回空权限列表。
    • 团队空间:查询登录用户在该空间的角色,并返回对应的权限码列表。如果用户不属于该空间,返回空权限列表。

根据业务流程编写代码(可以直接粘贴):

@Resource
private UserService userService;

@Resource
private SpaceService spaceService;

@Resource
private SpaceUserService spaceUserService;

@Resource
private SpaceUserAuthManager spaceUserAuthManager;

@Resource
private PictureService pictureService;

@Override
public List<String> getPermissionList(Object loginId, String loginType) {
    // 判断 loginType,仅对类型为 "space" 进行权限校验
    if (!StpKit.SPACE_TYPE.equals(loginType)) {
        return new ArrayList<>();
    }

    // 管理员权限,表示权限校验通过
    List<String> ADMIN_PERMISSIONS = spaceUserAuthManager.getPermissionsByRole(SpaceRoleEnum.ADMIN.getValue());

    // 获取上下文对象
    SpaceUserAuthContext authContext = getAuthContextByRequest();

    // 如果所有字段都为空,表示查询公共图库,可以通过
    if (isAllFieldsNull(authContext)) {
        return ADMIN_PERMISSIONS;
    }

    // 获取 userId
    User loginUser = (User) StpKit.SPACE.getSessionByLoginId(loginId).get(USER_LOGIN_STATE);
    if (loginUser == null) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "用户未登录");
    }
    Long userId = loginUser.getId();

    // 优先从上下文中获取 SpaceUser 对象
    SpaceUser spaceUser = authContext.getSpaceUser();
    if (spaceUser != null) {
        return spaceUserAuthManager.getPermissionsByRole(spaceUser.getSpaceRole());
    }

    // 如果有 spaceUserId,必然是团队空间,通过数据库查询 SpaceUser 对象
    Long spaceUserId = authContext.getSpaceUserId();
    if (spaceUserId != null) {
        spaceUser = spaceUserService.getById(spaceUserId);
        if (spaceUser == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到空间用户信息");
        }

        // 取出当前登录用户对应的 spaceUser
        SpaceUser loginSpaceUser = spaceUserService.lambdaQuery()
                .eq(SpaceUser::getSpaceId, spaceUser.getSpaceId())
                .eq(SpaceUser::getUserId, userId)
                .one();
        if (loginSpaceUser == null) {
            return new ArrayList<>();
        }

        // 这里会导致管理员在私有空间没有权限,可以再查一次库处理
        return spaceUserAuthManager.getPermissionsByRole(loginSpaceUser.getSpaceRole());
    }

    // 如果没有 spaceUserId,尝试通过 spaceId 或 pictureId 获取 Space 对象并处理
    Long spaceId = authContext.getSpaceId();
    if (spaceId == null) {
        // 如果没有 spaceId,通过 pictureId 获取 Picture 对象和 Space 对象
        Long pictureId = authContext.getPictureId();
        // 图片 id 也没有,则默认通过权限校验
        if (pictureId == null) {
            return ADMIN_PERMISSIONS;
        }

        Picture picture = pictureService.lambdaQuery()
                .eq(Picture::getId, pictureId)
                .select(Picture::getId, Picture::getSpaceId, Picture::getUserId)
                .one();
        if (picture == null) {
            throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到图片信息");
        }

        spaceId = picture.getSpaceId();

        // 公共图库,仅本人或管理员可操作
        if (spaceId == null) {
            if (picture.getUserId().equals(userId) || userService.isAdmin(loginUser)) {
                return ADMIN_PERMISSIONS;
            } else {
                // 不是自己的图片,仅可查看
                return Collections.singletonList(SpaceUserPermissionConstant.PICTURE_VIEW);
            }
        }
    }

    // 获取 Space 对象
    Space space = spaceService.getById(spaceId);
    if (space == null) {
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到空间信息");
    }

    // 根据 Space 类型判断权限
    if (space.getSpaceType() == SpaceTypeEnum.PRIVATE.getValue()) {
        // 私有空间,仅本人或管理员有权限
        if (space.getUserId().equals(userId) || userService.isAdmin(loginUser)) {
            return ADMIN_PERMISSIONS;
        } else {
            return new ArrayList<>();
        }
    } else {
        // 团队空间,查询 SpaceUser 并获取角色和权限
        spaceUser = spaceUserService.lambdaQuery()
                .eq(SpaceUser::getSpaceId, spaceId)
                .eq(SpaceUser::getUserId, userId)
                .one();
        if (spaceUser == null) {
            return new ArrayList<>();
        }
        return spaceUserAuthManager.getPermissionsByRole(spaceUser.getSpaceRole());
    }
}

上述代码依赖 “判断所有字段都为空” 的方法,通过反射获取对象的所有字段,进行判空:

private boolean isAllFieldsNull(Object object) {
    if (object == null) {
        return true; // 对象本身为空
    }
    // 获取所有字段并判断是否所有字段都为空
    return Arrays.stream(ReflectUtil.getFields(object.getClass()))
            // 获取字段值
            .map(field -> ReflectUtil.getFieldValue(object, field))
            // 检查是否所有字段都为空
            .allMatch(ObjectUtil::isEmpty);
}

OK,这就是 Sa-Token 动态权限校验的核心代码,你会发现编写一套统一的权限校验逻辑并不容易,所以实际项目中要 按需使用 第三方权限校验框架。


💡 注意,采用注解式鉴权 + 通过请求对象获取参数时,可能会重复查询数据库

比如业务代码中已经有根据 id 查询空间信息的代码了,但为了权限校验,也查库获取了一次空间信息,会对性能造成影响。

如果想更灵活、更高性能地实现鉴权,可以考虑使用编程式鉴权。获取权限的方法和上下文类都是可以复用的,只需要将 getAuthContextByRequest 方法的逻辑改为从 ThreadLocal 上下文中获取即可。

基于 ThreadLocal 实现上下文管理的示例代码:

public class SaTokenContextHolder {
    private static final ThreadLocal<Map<String, Object>> CONTEXT = ThreadLocal.withInitial(HashMap::new);

    // 设置上下文数据
    public static void set(String key, Object value) {
        CONTEXT.get().put(key, value);
    }

    // 获取上下文数据
    public static Object get(String key) {
        return CONTEXT.get().get(key);
    }

    // 清理上下文数据(防止内存泄漏)
    public static void clear() {
        CONTEXT.remove();
    }
}

5、权限校验注解


默认情况下使用注解式鉴权,需要新建配置类:

image-20250803174742205

但由于我们使用了多账号体系,每次使用注解时都要指定账号体系的 loginType,会比较麻烦:

@SaCheckLogin(type = StpUserUtil.TYPE)

所以可以参考官方文档,使用注解合并简化代码。

auth.annotation 包下新建 Sa-Token 配置类,开启注解鉴权和注解合并(官方文档的样板代码,直接 CV):

image-20250803175020500

@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
    // 注册 Sa-Token 拦截器,打开注解式鉴权功能
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
    }

    @PostConstruct
    public void rewriteSaStrategy() {
        // 重写Sa-Token的注解处理器,增加注解合并功能
        SaAnnotationStrategy.instance.getAnnotation = (element, annotationClass) -> {
            return AnnotatedElementUtils.getMergedAnnotation(element, annotationClass);
        };
    }
}

然后参考官方提供的示例代码,修改一下下面的内容,即可为我们自己的体系提供注解合并的鉴权功能:

image-20250803180104949


auth.annotation 包下新建空间账号体系的鉴权注解:

image-20250803175631109

/**
 * 空间权限认证:必须具有指定权限才能进入该方法
 * <p> 可标注在函数、类上(效果等同于标注在此类的所有方法上) </p>
 */
@SaCheckPermission(type = StpKit.SPACE_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface SaSpaceCheckPermission {
    /**
     * 需要校验的权限码
     *
     * @return 需要校验的权限码
     */
    @AliasFor(annotation = SaCheckPermission.class)
    String[] value() default {};

    /**
     * 验证模式:AND | OR,默认AND
     *
     * @return 验证模式
     */
    @AliasFor(annotation = SaCheckPermission.class)
    SaMode mode() default SaMode.AND;

    /**
     * 在权限校验不通过时的次要选择,两者只要其一校验成功即可通过校验
     * <p>
     * 例1:@SaCheckPermission(value="user-add", orRole="admin"),
     * 代表本次请求只要具有 user-add权限 或 admin角色 其一即可通过校验。
     * </p>
     * <p>
     * 例2: orRole = {"admin", "manager", "staff"},具有三个角色其一即可。
     * <br> 例3: orRole = {"admin, manager, staff"},必须三个角色同时具备。
     * </p>
     *
     * @return /
     */
    @AliasFor(annotation = SaCheckPermission.class)
    String[] orRole() default {};
}

之后就可以直接使用该注解了。


6、应用权限注解


认真核对一遍各个操作接口的代码、以及接口调用的 Service 代码,包括图片操作 PictureControllerPictureService、空间成员操作 SpaceUserControllerSpaceUserService

(1) 给 Controller 接口补充上合适的权限注解,PictureController 图片接口:

// 上传图片(可重新上传)
@PostMapping("/upload")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_UPLOAD)
public BaseResponse<PictureVO> uploadPicture() {}

// 通过 URL 上传图片(可重新上传)
@PostMapping("/upload/url")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_UPLOAD)
public BaseResponse<PictureVO> uploadPictureByUrl() {}

// 删除图片
@PostMapping("/delete")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_DELETE)
public BaseResponse<Boolean> deletePicture() {}

// 编辑图片(给用户使用)
@PostMapping("/edit")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT)
public BaseResponse<Boolean> editPicture() {}

// 根据颜色搜索图片
@PostMapping("/search/color")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_VIEW)
public BaseResponse<List<PictureVO>> searchPictureByColor() {}

// 批量编辑图片
@PostMapping("/edit/batch")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT)
public BaseResponse<Boolean> editPictureByBatch() {}

// 创建 AI 扩图任务
@PostMapping("/out_painting/create_task")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT)
public BaseResponse<CreateOutPaintingTaskResponse> createPictureOutPaintingTask() {}

SpaceUserController 接口:

// 添加成员到空间
@PostMapping("/add")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.SPACE_USER_MANAGE)
public BaseResponse<Long> addSpaceUser() {}

// 从空间移除成员
@PostMapping("/delete")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.SPACE_USER_MANAGE)
public BaseResponse<Boolean> deleteSpaceUser() {}

// 查询某个成员在某个空间的信息
@PostMapping("/get")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.SPACE_USER_MANAGE)
public BaseResponse<SpaceUser> getSpaceUser() {}

// 查询成员信息列表
@PostMapping("/list")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.SPACE_USER_MANAGE)
public BaseResponse<List<SpaceUserVO>> listSpaceUser() {}

// 编辑成员信息(设置权限)
@PostMapping("/edit")
@SaSpaceCheckPermission(value = SpaceUserPermissionConstant.SPACE_USER_MANAGE)
public BaseResponse<Boolean> editSpaceUser() {}

(2) 移除这些接口和相关服务原本的权限校验逻辑:

image-20250803183457056

还有 PictureServiceImpluploadPicture 方法中的权限校验,也要注释掉:


(3) 使用编程式鉴权的接口:

image-20250803185018427


7、全局异常处理


如果 Sa-Token 校验用户没有符合要求的权限、或者用户未登录,就会抛出它定义的异常,参考文档

需要将框架的异常全局处理为我们自己定义的业务异常,在全局异常处理器中添加代码:

image-20250803185533017

@ExceptionHandler(NotLoginException.class)
public BaseResponse<?> notLoginException(NotLoginException e) {
    log.error("NotLoginException", e);
    return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, e.getMessage());
}

@ExceptionHandler(NotPermissionException.class)
public BaseResponse<?> notPermissionExceptionHandler(NotPermissionException e) {
    log.error("NotPermissionException", e);
    return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, e.getMessage());
}

8、补充获取权限的接口


前面写的都是后端权限校验的代码,但对于用户来说,如果没有空间图片的编辑权限,进入空间详情页时不应该能看到编辑按钮。也就是说,前端也需要根据用户的权限来进行一些页面内容的展示和隐藏

因此,后端需要将用户具有的权限返回给前端,帮助前端进行判断,这样就不用让前端编写复杂的角色和权限校验逻辑了。

思考下具体的使用场景:如果是团队空间(空间详情页)或团队空间的图片(图片详情页),返回给前端用户具有的权限(比如能否编辑、能否上传、能否删除、能否管理成员)。


1. 比起新写一个获取权限的接口,我们可以直接在返回图片或空间详情时,额外传递权限列表。

SpaceVOPictureVO 新增权限列表字段:

image-20250803234356234

/**
 * 权限列表
 */
private List<String> permissionList = new ArrayList<>();

2. 在 SpaceUserAuthManager 中新增获取权限列表的方法:

image-20250803234452532

注意要区分公共图库、私有空间和团队空间,对于有权限的情况,可以返回 “管理员权限” 列表。

public List<String> getPermissionList(Space space, User loginUser) {
    if (loginUser == null) {
        return new ArrayList<>();
    }
    // 管理员权限
    List<String> ADMIN_PERMISSIONS = getPermissionsByRole(SpaceRoleEnum.ADMIN.getValue());
    // 公共图库
    if (space == null) {
        if (userService.isAdmin(loginUser)) {
            return ADMIN_PERMISSIONS;
        }
        // 不是管理员, 只返回浏览权限
        return Collections.singletonList(SpaceUserPermissionConstant.PICTURE_VIEW);
    }
    SpaceTypeEnum spaceTypeEnum = SpaceTypeEnum.getEnumByValue(space.getSpaceType());
    if (spaceTypeEnum == n            return Collections.singletonList(SpaceUserPermissionConstant.PICTURE_VIEW);
        return Collections.singletonList(SpaceUserPermissionConstant.PICTURE_VIEW);ull) {
        return new ArrayList<>();
    }
    // 根据空间获取对应的权限
    switch (spaceTypeEnum) {
        case PRIVATE:
            // 私有空间,仅本人或管理员有所有权限
            if (space.getUserId().equals(loginUser.getId()) || userService.isAdmin(loginUser)) {
                return ADMIN_PERMISSIONS;
            } else {
                return new ArrayList<>();
            }
        case TEAM:
            // 团队空间,查询 SpaceUser 并获取角色和权限
            SpaceUser spaceUser = spaceUserService.lambdaQuery()
                    .eq(SpaceUser::getSpaceId, space.getId())
                    .eq(SpaceUser::getUserId, loginUser.getId())
                    .one();
            if (spaceUser == null) {
                return new ArrayList<>();
            } else {
                return getPermissionsByRole(spaceUser.getSpaceRole());
            }
    }
    return new ArrayList<>();
}

3. 修改获取空间详情和图片详情的接口 getSpaceVOByIdgetPictureVOById,增加获取权限列表的逻辑。

获取空间详情接口:

image-20250804003412942

@GetMapping("/get/vo")
public BaseResponse<SpaceVO> getSpaceVOById(long id, HttpServletRequest request) {
    ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
    // 查询数据库
    Space space = spaceService.getById(id);
    ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR);
    // 修改1: 先把 space 转为 spaceVO
    SpaceVO spaceVO = spaceService.getSpaceVO(space, request);
    // 修改2: 增加获取权限列表的逻辑
    User loginUser = userService.getLoginUser(request);
    List<String> permissionList = spaceUserAuthManager.getPermissionList(space, loginUser);
    spaceVO.setPermissionList(permissionList);
    // 获取封装类
    return ResultUtils.success(spaceVO);
}

获取图片详情接口,注意即使空间 id 不存在(公共图库)也要获取权限列表,管理员会获取到全部权限,这样前端才能顺利展示出操作按钮:

image-20250804003433232

更新代码:修改1~修改5

@GetMapping("/get/vo")
// @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_VIEW)
public BaseResponse<PictureVO> getPictureVOById(long id, HttpServletRequest request) {
    ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
    // 查询数据库
    Picture picture = pictureService.getById(id);
    ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR);
    // 空间权限校验
    Long spaceId = picture.getSpaceId();
    // 修改1:
    Space space = null;
    if (spaceId != null) {
        boolean hasPermission = StpKit.SPACE.hasPermission(SpaceUserPermissionConstant.PICTURE_VIEW);
        ThrowUtils.throwIf(!hasPermission, ErrorCode.NO_AUTH_ERROR);
        // User loginUser = userService.getLoginUser(request);
        // pictureService.checkPictureAuth(loginUser, picture);

        // 修改2: 先获取空间信息
        space = spaceService.getById(spaceId);
        ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
    }
    // 修改3: 增加获取权限列表的逻辑
    List<String> permissionList = spaceUserAuthManager.getPermissionList(space, userService.getLoginUser(request));
    // 修改4: 获取封装类
    PictureVO pictureVO = pictureService.getPictureVO(picture, request);
    // 修改5: 将权限列表赋值到 pictureVO 对象中
    pictureVO.setPermissionList(permissionList);

    return ResultUtils.success(pictureVO);
}

9、接口测试


终于开发完了,我们会发现,细节实在是太多了,所以 一定要进行严格的测试 !!!

用不同权限的用户去验证不同的空间类别(公共图库、私有空间、团队空间)。

如何测试呢?

大家用的比较多的就是单元测试,但是单元测试想要测试携带登录态的 Controller 接口是比较麻烦的。所以我们可以采用自动化接口测试,比如 Postman 等。

此处为了方便,我们直接使用 IDEA 自带的 REST API 测试,可以将测试参数和测试接口保存为文件,每次修改代码后,改改参数,执行文件就能整体测试了。

至此,空间成员权限控制开发完成,大家会发现还是挺麻烦的。其实如果没有公共图库的概念的话,开发起来就轻松很多。

因此 Sa-Token 等权限框架要 按需使用,更适合复杂的、企业内部的权限管理系统。如果你想开发起来更轻松一些,推荐其他的实现方式:

  • 直接封装权限校验方法,在业务代码中调用
  • 将团队空间图片的增删改查提取为独立的接口,单独进行权限校验,不影响公共图库

扩展:可以给空间操作(SpaceController)、空间分析操作(SpaceAnalyzeController)增加统一的权限校验。


空间数据管理


根据需求和方案设计,我们要将旗舰版团队空间的图片数据进行单独管理,每个团队空间的图片数据存储到一张单独的表中,也就是分表。


1、什么是分库分表?


分库分表是一种将数据拆分到多个数据库或数据表中的设计策略,主要用于解决随着业务数据量和访问量增长带来的数据库性能问题。

通过分库分表,可以减小单库或单表数据量和访问压力,从而提高查询和写入效率、增强系统的高并发能力、优化大数据量下的性能表现;同时降低单点故障风险,实现更好的系统扩展性和容灾能力。


2、分库分表实现


如果让我们自己实现分库分表,应该怎么做呢?

思路主要是基于业务需求设计数据分片规则,将数据按一定策略(如取模、哈希、范围或时间)分散存储到多个库或表中,同时`开发路由逻辑来决定查询或写入操作的目标库表。

image-20250804010109436


简单来说,就是将数据写到不同的表、并且从相同的表读取数据,其实通过给 SQL 表名拼接动态参数就能实现:

select * from table_${分片唯一标识}

但这只是最简单的情况,实际上,分库分表还涉及跨库表查询、事务一致性、分页聚合等复杂场景,还可能需要配套设计监控、扩容和迁移方案以确保系统的可维护性和扩展性。

所以,不建议自己实现分库分表。本项目中,将使用主流的分库分表框架 Apache ShardingSphere带大家实现。


3、ShardingSphere 分库分表


Apache ShardingSphere 提供了开箱即用的分片策略、灵活的配置能力以及对跨库查询、事务一致性、读写分离等复杂功能的全面支持。

它又分为 2 大核心模块 ShardingSphere-JDBCShardingSphere-Proxy,我用一张表格来列举 2 者的区别:

维度 ShardingSphere-JDBC ShardingSphere-Proxy
运行方式 嵌入式运行在应用内部 独立代理,运行在应用与数据库之间
性能 低网络开销,性能较高 引入网络开销,性能略低
支持语言 仅支持 Java 支持多语言(Java、Python、Go 等)
配置管理 分布式配置较复杂 支持集中配置和动态管理
扩展性 随着应用扩展,需单独调整配置 代理服务集中化管理,扩展性强
适用场景 单体或小型系统,对性能要求高的场景 多语言、大型分布式系统或需要统一管理的场景

对大多数 Java 项目来说,选择 ShardingSphere-JDBC 就足够了;

对于跨语言的大型分布式项目、或者公司内有技术部门统一管理基础设施的情况下,再考虑使用 ShardingSphere-Proxy。


本项目也将使用 ShardingSphere-JDBC,在依赖文件中引入:

<!-- 分库分表 -->
<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
    <version>5.2.0</version>
</dependency>

4、分库分表策略 - 静态分表分库


分表的策略总体分为 2 类:静态分表和动态分表,下面先讲静态分表。

在设计阶段,分表的数量和规则就是固定的,不会根据业务增长动态调整,比如 picture_0picture_1。分片规则通常基于某一字段(如图片 id)通过简单规则(如取模、范围)来决定数据存储在哪个表或库中。

这种方式的优点是简单、好理解;缺点是不利于扩展,随着数据量增长,可能需要手动调整分表数量并迁移数据。举个例子,图片表按图片 id 对 4 取模拆分:

String tableName = "picture_" + (pictureId % 4); // picture_0 ~ picture_3

静态分表的实现很简单,直接在 application.yml 中编写 ShardingSphere 的配置就能完成分库分表,比如:

rules:
  sharding:
    tables:
      picture:
        actualDataNodes: ds0.picture_${0..2} # 3张分表:picture_0, picture_1, picture_2
        tableStrategy:
          standard:
            shardingColumn: pictureId       # 按 pictureId 分片
            shardingAlgorithmName: pictureIdMod
    shardingAlgorithms:
      pictureIdMod:
        type: INLINE
        props:
          algorithm-expression: picture_${pictureId % 3} # 分片表达式

你甚至不需要修改任何业务代码,在查询 picture 表(一般叫逻辑表)时,框架会自动帮你修改 SQL,根据 pictureId 将查询请求路由到不同的表中。


如果要进行分页多条数据查询,你只需要写一条查询逻辑表的 SQL 语句即可:

SELECT * FROM picture;

实际上,ShardingSphere 将查询逻辑表 picture 的请求自动路由到所有实际分表 picture_1picture_2picture_N,获取到数据后,在中间件层自动合并结果并返回给应用程序


5、分库分表策略 - 动态分表


动态分表是指分表的数量可以根据业务需求或数据量动态增加,表的结构和规则是运行时动态生成的。

举个例子,根据时间动态创建 picture_2025_01picture_2025_02

String tableName = "picture_" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy_MM"));
  • 显然,动态分表更灵活、扩展性强,适合数据量快速增长的场景;
  • 但缺点是实现更复杂,需要动态生成表并维护表的元信息。如果没有控制好,说不定分表特别多,反而影响了数据库的性能。

动态分表的实现就比较麻烦了,首先要自定义分表算法类,还要在代码中编写动态创建表的逻辑


自定义分表算法类:

public class PictureShardingAlgorithm implements StandardShardingAlgorithm<Long> {
    @Override
    public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> preciseShardingValue) {
        // 编写分表逻辑,返回实际要查询的表名
        // picture_0 物理表,picture 逻辑表
        // 如果我们要查的是所有的表, 而不是特定的表, 就让该方法返回 picture 逻辑表, ShardingSphere 框架会进行所有表的查询, 并聚合结果 
    }

    @Override
    public Collection<String> doSharding(Collection<String> collection, RangeShardingValue<Long> rangeShardingValue) {
        return new ArrayList<>();
    }

    @Override
    public Properties getProps() {
        return null;
    }

    @Override
    public void init(Properties properties) {
    }
}

对于我们的项目,由于空间是用户动态创建的,显然要使用动态分表,下面来实现。


6、动态分表算法开发


根据需求,我们希望将每个旗舰版空间的图片单独存放在一起,显然是按照 spaceId 分表,那么分表的名称规则为 picture_${spaceId}


(1) 动态分表的配置

首先编写动态分表的配置,包括数据库连接、分表规则、分表算法:

image-20250804104047293

spring:
  # 空间图片分表
  shardingsphere:
    datasource:
      names: yu_picture
      yu_picture:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/yu_picture
        username: root
        password: 123456
    rules:
      sharding:
        tables:
          picture:
            actual-data-nodes: yu_picture.picture  # 动态分表
            table-strategy:
              standard:
                sharding-column: spaceId
                sharding-algorithm-name: picture_sharding_algorithm  # 使用自定义分片算法
        sharding-algorithms:
          picture_sharding_algorithm:
            type: CLASS_BASED  # 基于类定义算法
            props:
              strategy: standard
              algorithmClassName: com.yupi.yupicturebackend.manager.sharding.PictureShardingAlgorithm
    props:
      sql-show: true   # 开发环境建议开启, 会答应实际执行的 SQL, 生产环境则关闭

其中,有几个细节需要注意:

  • actual-data-nodes 一般情况下是指定一段分表的范围,比如 yu_picture.picture_${0…9999} 表示有 picture_0 ~ picture_9999 这 10000 张分表。ShardingSphere 在执行分表查询时会校验要查询的表(比如 picture_123456789)是否在 actual-data-nodes 的配置范围内,不在该范围会报错。但是由于 spaceId 是长整型,范围太大,无法通过指定范围将所有分表名称包含,导致无法通过框架内置的校验。所以此处将 actual-data-nodes 的值设置为逻辑表 yu_picture.picture
  • 指定分表字段为 spaceId、分表算法为自定义的分片算法 picture_sharding_algorithm
  • 配置自定义分片算法,采用基于自定义类的方式实现,算法的类名配置必须为类的绝对路径。

(2) 图片分表算法类

编写图片分表算法类,必须实现 StandardShardingAlgorithm 接口。

核心是编写 doSharding 方法,根据 spaceId 获取到实际要查询的分表名,如果 spaceId 不存在分表(比如是私有空间)或者 spaceId 为空(公共图库),那么就从原表(逻辑表)picture 查询。

之所以要做兼容,是因为虽然我们设计上只对团队空间进行分库分表,但是一旦引入了分库分表框架,查询 picture 表时就会触发分表逻辑。


manager.sharding 包下新建分表算法类:

image-20250804110113017

/**
 * 图片分表算法
 */
public class PictureShardingAlgorithm implements StandardShardingAlgorithm<Long> {
    @Override
    public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> preciseShardingValue) {
        // 参数 availableTargetNames 表示所以支持的分表, 由配置文件 actual-data-nodes: yu_picture.picture 决定

        // 配置文件 sharding-column: spaceId, 表示根据 spaceId 列分片, 所以参数 preciseShardingValue 的 value 就是 spaceId
        Long spaceId = preciseShardingValue.getValue();

        // 参数 preciseShardingValue 可以获取逻辑表的表名
        String logicTableName = preciseShardingValue.getLogicTableName();

        // spaceId 为 null 表示查询所有图片
        if (spaceId == null) {
            // 返回逻辑表, 会对所有关联表进行聚合查询
            return logicTableName;
        }

        // 根据 spaceId 动态生成分表名, 对应配置文件 sharding-column: spaceId
        String realTableName = "picture_" + spaceId;

        // 如果一张表查不到, 就把所有的表都查询
        if (availableTargetNames.contains(realTableName)) {
            return realTableName;
        } else {
            // 返回逻辑表, 会对所有关联表进行聚合查询
            return logicTableName;
        }
    }

    /**
     * 范围内的分片查询, 才实现该方法, 本项目没有根据多个 spaceId 进行查询的情况
     * @param collection
     * @param rangeShardingValue
     * @return
     */
    @Override
    public Collection<String> doSharding(Collection<String> collection, RangeShardingValue<Long> rangeShardingValue) {
        return new ArrayList<>();
    }

    @Override
    public Properties getProps() {
        return null;
    }

    @Override
    public void init(Properties properties) {
    }
}

(3) 分片代码 bug

光有上述代码还不能完成动态分表,因为 availableTargetNames(可用的分表)始终为逻辑表名 picture

原因在于 ShardingSphere 在分片逻辑初始化时,默认获取的是配置的 actual-data-nodes 中的目标表名,也就是我们写的固定值。

image-20250804110543765

  1. 按照 spaceId 分表,那么分表的名称规则为 picture_${spaceId}image-20250804113427545
  2. actual-data-nodes 一般情况下是指定一段分表的范围,比如 yu_picture.picture_${0…9999} 表示有 picture_0 ~ picture_9999 这 10000 张分表。ShardingSphere 在执行分表查询时会校验要查询的表(比如 picture_123456789)是否在 actual-data-nodes 的配置范围内,不在该范围会报错。
  3. 但是由于 spaceId 是长整型,范围太大,无法通过指定范围,将所有分表名称包含,导致无法通过框架内置的校验。所以此处将 actual-data-nodes 的值设置为逻辑表 ;
  4. 所以此处将 actual-data-nodes 的值设置为逻辑表 yu_picture.picture,这是一个固定值,而不是一个范围yu_picture.picture_${0,1....long},否则会分片出大量冗余的数据表;
  5. actual-data-nodes: yu_picture.picture 是一个固定的值,那么参数availableTargetNames 分片可用的表就从一个范围变成一个定死的值(始终为逻辑表名 picture)image-20250804112758451
  6. 并且,框架除了会应用我们的分表算法,同时也会执行自己的校验逻辑,如果框架发现我们要查询的表,不在 availableTargetNames 中,也就是不在 actual-data-nodes: yu_picture.picture 中,就无法通过 ShardingSphere 的查询校验,会报错;

综上所述,我们当前的代码还不能完成动态分表,不但无法通过我们自定义的分片算法的逻辑,也无法通过框架自身的查询校验;


(4) 动态维护分表功能开发

既然框架自身不支持动态维护分表,那我们可以写一个分表管理器,自己来维护分表列表,并更新到 ShardingSphere 的 actual-data-nodes 配置中。

manager.sharding 包下新建分表管理器类(样板代码,直接 CV):

image-20250804114449144

@Component
@Slf4j
public class DynamicShardingManager {
    @Resource
    private DataSource dataSource;
    @Resource
    private SpaceService spaceService;
    private static final String LOGIC_TABLE_NAME = "picture";
    private static final String DATABASE_NAME = "logic_db"; // 配置文件中的数据库名称

    @PostConstruct
    public void initialize() {
        log.info("初始化动态分表配置...");
        updateShardingTableNodes();
    }

    /**
     * 获取所有动态表名,包括初始表 picture 和分表 picture_{spaceId}
     */
    private Set<String> fetchAllPictureTableNames() {
        // 为了测试方便,直接对所有团队空间分表(实际上线改为仅对旗舰版生效)
        Set<Long> spaceIds = spaceService.lambdaQuery()
                .eq(Space::getSpaceType, SpaceTypeEnum.TEAM.getValue())
                .list()
                .stream()
                .map(Space::getId)
                .collect(Collectors.toSet());
        Set<String> tableNames = spaceIds.stream()
                .map(spaceId -> LOGIC_TABLE_NAME + "_" + spaceId)
                .collect(Collectors.toSet());
        tableNames.add(LOGIC_TABLE_NAME); // 添加初始逻辑表
        return tableNames;
    }

    /**
     * 更新 ShardingSphere 的 actual-data-nodes 动态表名配置
     */
    private void updateShardingTableNodes() {
        Set<String> tableNames = fetchAllPictureTableNames();
        String newActualDataNodes = tableNames.stream()
                .map(tableName -> "yu_picture." + tableName) // 确保前缀合法
                .collect(Collectors.joining(","));
        log.info("动态分表 actual-data-nodes 配置: {}", newActualDataNodes);
        ContextManager contextManager = getContextManager();
        ShardingSphereRuleMetaData ruleMetaData = contextManager.getMetaDataContexts()
                .getMetaData()
                .getDatabases()
                .get(DATABASE_NAME)
                .getRuleMetaData();
        Optional<ShardingRule> shardingRule = ruleMetaData.findSingleRule(ShardingRule.class);
        if (shardingRule.isPresent()) {
            ShardingRuleConfiguration ruleConfig = (ShardingRuleConfiguration) shardingRule.get().getConfiguration();
            List<ShardingTableRuleConfiguration> updatedRules = ruleConfig.getTables()
                    .stream()
                    .map(oldTableRule -> {
                        if (LOGIC_TABLE_NAME.equals(oldTableRule.getLogicTable())) {
                            ShardingTableRuleConfiguration newTableRuleConfig = new ShardingTableRuleConfiguration(LOGIC_TABLE_NAME, newActualDataNodes);
                            newTableRuleConfig.setDatabaseShardingStrategy(oldTableRule.getDatabaseShardingStrategy());
                            newTableRuleConfig.setTableShardingStrategy(oldTableRule.getTableShardingStrategy());
                            newTableRuleConfig.setKeyGenerateStrategy(oldTableRule.getKeyGenerateStrategy());
                            newTableRuleConfig.setAuditStrategy(oldTableRule.getAuditStrategy());
                            return newTableRuleConfig;
                        }
                        return oldTableRule;
                    })
                    .collect(Collectors.toList());
            ruleConfig.setTables(updatedRules);
            contextManager.alterRuleConfiguration(DATABASE_NAME, Collections.singleton(ruleConfig));
            contextManager.reloadDatabase(DATABASE_NAME);
            log.info("动态分表规则更新成功!");
        } else {
            log.error("未找到 ShardingSphere 的分片规则配置,动态分表更新失败。");
        }
    }

    /**
     * 获取 ShardingSphere ContextManager
     */
    private ContextManager getContextManager() {
        try (ShardingSphereConnection connection = dataSource.getConnection().unwrap(ShardingSphereConnection.class)) {
            return connection.getContextManager();
        } catch (SQLException e) {
            throw new RuntimeException("获取 ShardingSphere ContextManager 失败", e);
        }
    }
}

上述代码虽然看起来比较复杂,但其实不难理解,主要做了这么几件事:

image-20250804125757102

  1. 将管理器注册为 Bean ,通过 @PostConstruct 注解,在 Bean 加载后获取所有的分表并更新配置。
  2. 编写获取分表列表的方法 ,从数据库中查询符合要求的空间列表,再补充上逻辑表,就得到了完整的分表列表。
  3. 更新 ShardingSphere 的 actual-data-nodes 动态表名配置 。获取到 ShardingSphere 的 ContextManager,找到配置文件中的那条规则进行更新即可。

(5) 动态创建分表

ShardingSphere 只能帮我们完成在读写数据到表中时,路由表的位置;

但是当用户创建团队空间时,需要创建表,框架并不能帮我们完成动态创建分表的逻辑,需要我们自己编写创建分表的方法;


动态创建分表 在分表管理器中新增动态创建分表的方法:

image-20250804115412623

通过拼接 SQL 的方式创建出和 picture 表结构一样的分表,创建新的分表后记得更新分表节点。代码如下:

public void createSpacePictureTable(Space space) {
    // 动态创建分表
    // 仅为旗舰版团队空间创建分表
    if (space.getSpaceType() == SpaceTypeEnum.TEAM.getValue() && space.getSpaceLevel() == SpaceLevelEnum.FLAGSHIP.getValue()) {
        Long spaceId = space.getId();
        String tableName = "picture_" + spaceId;
        // 创建新表, LIKE picture 可用创建一个和 picture 字段一样的表
        String createTableSql = "CREATE TABLE " + tableName + " LIKE picture";
        try {
            SqlRunner.db().update(createTableSql);
            // 更新分表
            updateShardingTableNodes();
        } catch (Exception e) {
            log.error("创建图片空间分表失败,空间 id = {}", space.getId());
        }
    }
}

注意,想要使用 MyBatis Plus 的 SqlRunner,必须要开启配置:

mybatis-plus:
  global-config:
    enable-sql-runner: true

如果没有这个配置,在 7.测试 调用创建空间接口,创建团队旗舰空间时,会导致分表失败;


然后在创建空间时,调用该方法:

@Resource
private DynamicShardingManager dynamicShardingManager;

image-20250804130502914

至此,动态分表就开发完成了。

💡 其实 ShardingSphere 还提供了 hint 强制分表路由机制来实现动态分表,允许在代码中强制指定具体的物理表,从而解决动态分表问题。但缺点是需要在每次查询或者操作数据时都显式设置表名,会给代码增加很多额外逻辑,不够优雅。所以不采用,大家了解一下即可。


7、测试


分表是个对系统影响很大的操作,所以要进行严格的测试。

如果启动项目时出现了循环依赖:可以添加 @Lazy 注解解决:

@Resource
@Lazy
private DynamicShardingManager dynamicShardingManager;

image-20250804172847423

注意,想要使用 MyBatis Plus 的 SqlRunner,必须要开启配置:

mybatis-plus:
  global-config:
    enable-sql-runner: true

加上配置后,分表成功:

image-20250804173343463


  1. 单独查询某个图片,不指定 spaceId 查询条件时,会自动查所有的 picture 表:历史数据会自动兼容,只要查到的 spaceId 没有分表,都会查原来的 picture 表。只有指定 spaceId 且存在分表时,才会查询特定的单张分表。
  2. 查询图片列表,不指定 spaceIdnullSpaceId(查询 spaceIdnull 的值)时,会自动查所有的 picture 表。所以查询时间会随着分表数增加:image-20250804180834375
  3. 测试数据插入。插入时如果想往公共空间插入(不指定 spaceId),就会报错,因为 ShardingSphere 不知道要把数据插入到哪个表中。这就意味着,如果你要使用分表,spaceId 必须不能为 null !为了解决这个问题,插入时一定要指定 spaceId,可以约定公共空间的 spaceId 都为 0,并且在插入时为 spaceId 设置默认值 0。
// 补充空间 id,默认为 0
if (spaceId == null) {
    picture.setSpaceId(0L);
}

注意,增删改查时都要补充 spaceId,才能避免报错和多表查询影响效率。比如查询单个图片,改为通过 QueryWrapper 指定 spaceId 查询:

// 构造 QueryWrapper
QueryWrapper<Picture> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("id", id) // 根据主键 id 查询
        .eq("spaceId", spaceId); // 附加 spaceId 条件
// 执行查询
Picture picture = pictureService.getOne(queryWrapper);

构造图片分页查询条件时,如果查询公共图库,spaceId 改为 0:

queryWrapper.eq(nullSpaceId, "spaceId", 0);
// queryWrapper.isNull(nullSpaceId, "spaceId");

更新 / 批量更新图片时,设置 spaceId 作为查询条件:

// 构造 UpdateWrapper
UpdateWrapper<Picture> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", picture.getId()) // 指定主键条件,批量更新则使用 in 传递多条
        .eq("spaceId", xxx); // 补充条件 spaceId=xxx
// 执行更新
boolean result = pictureService.update(picture, updateWrapper);

删除图片时,设置 spaceId 作为查询条件:

// 构造 QueryWrapper
QueryWrapper<Picture> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("id", id) // 指定主键 ID
        .eq("spaceId", spaceId); // 附加 spaceId 条件
// 执行删除
boolean result = pictureService.remove(queryWrapper);

注意,分表后,picturespaceId 将不能修改!!!


经过开发和测试,你会发现动态分库分表的实现非常麻烦。某些单表的查询性能是高了,但整体查询的性能可能会减少,所以原则上非必要不分表,一定要找到合适的应用场景。

考虑到让更多同学后续直接部署项目,降低理解成本,教程中就不带大家实际执行上述改造细节了,并且我再教大家一种可以关闭分库分表的方法。


8、关闭分库分表(可选)


1. 启动类排除依赖(配置文件可以不注释):

@SpringBootApplication(exclude = {ShardingSphereAutoConfiguration.class})

2. 注释掉分库分表管理器组件 DynamicShardingManagerimage-20250804183840060

3. 注释掉使用 DynamicShardingManager 方法的代码,比如空间服务中对其的引用、创建分表的代码:

// @Resource
// @Lazy
// private DynamicShardingManager dynamicShardingManager;
// 创建分表
// dynamicShardingManager.createSpacePictureTable(space);

9、参考文章


至此,我们智能协同云图库最难的后端开发功能 — 团队空间就开发完毕咯~~~~


在这里插入图片描述

在这里插入图片描述

Logo

一起探索未来云端世界的核心,云原生技术专区带您领略创新、高效和可扩展的云计算解决方案,引领您在数字化时代的成功之路。

更多推荐