【智能协同云图库】智能协同云图库第六弹:空间模块开发

发布于:2025-07-25 ⋅ 阅读:(19) ⋅ 点赞:(0)

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


空间模块开发


本节重点:之前我们已经完成了公共图库的开发。

为了进一步增加系统的应用价值,可以让每个用户都能创建自己的私有空间,打造自己的图片云盘、个人相册。

本节教程不涉及新技术,重点学习业务经验和扩展系统的开发技巧,能够让大家学会更快更稳地给系统增加新的功能。


一、需求分析


对于空间模块,通常要有以下功能:

  1. 管理空间(仅管理员可用):可以对整个系统中的空间进行管理,比如搜索空间、编辑空间、删除空间。
  2. 用户创建私有空间:用户可以创建最多一个私有空间,并且在私有空间内自由上传和管理图片。
  3. 私有空间权限控制:用户仅能访问和管理自己的私有空间和其中的图片,私有空间的图片不会展示在公共图库,也不需要管理员审核。
  4. 空间级别和限额控制:每个空间有不同的级别(如普通版和专业版),对应了不同的容量和图片数量限制,如果超出限制则无法继续上传图片。

二、方案设计


从需求分析中,我们也能感受到,细节比较多。为了更好地把控这些细节,需要先对系统进行一个整体的方案设计。思考下面的问题:

为什么要有 “空间” 的概念?如果没有 “空间” 的概念,怎么实现让用户自由管理自己的私有图片呢

  • 问题 1:这不就相当于 “查看我的图片” 功能嘛,直接支持用户查询自己创建过的图片不就可以了?

    • 回答:如果这样做,会存在一个很大的问题:用户私有图片是需要隐私的,不需要被管理员审核,也不能被其他人公开查看。这和现在的公共图库平台的逻辑不一致。想象一下,图片表中只有 userId 字段,无法区分图片到底是私有的还是公开的。
  • 问题 2:那如果允许用户上传私有图片呢?比如设置图片可见范围为 “仅自己可见”?

    • 回答:这的确是可行的,对于内容占用存储空间不大的平台,很适合采用这种方案,像我们的 代码小抄 就支持上传仅自己可见的代码。但是,对于图库平台,图片占用的存储空间会直接产生存储费用,因此需要对用户上传的图片大小和数量进行限制。类似于给你分配了一个电脑硬盘,它就是你的,用满了就不能再传图了。所以使用 “空间” 的概念会更符合这种应用场景,可以针对空间进行限制和分析,也更便于管理。
  • 此外,从项目可扩展性的角度来讲,抽象 “空间” 的概念还有 2 个优势:

    • 和之前的公共图库完全分开,尽量只额外增加空间相关的逻辑和代码,减少对代码的修改
    • 以后我们要开发团队共享空间,需要对空间进行成员管理,也是需要 “空间” 概念的。所以目前设计的空间表,要能够兼容之后的共享空间,便于后续扩展。
  • 总结:这就是一种可扩展性的设计,当你发现系统逻辑较为复杂或产生冲突时,就抽象一个中间层(也就是 “空间”),使得新老逻辑分离,让项目更易于维护和扩展


表设计


空间表


表名:space(空间表)

根据需求可以做出如下 SQL 设计:

-- 空间表
create table if not exists space(
    id         bigint auto_increment comment 'id' primary key,
    spaceName  varchar(128)                       null comment '空间名称',
    spaceLevel int      default 0                 null comment '空间级别:0-普通版 1-专业版 2-旗舰版',
    maxSize    bigint   default 0                 null comment '空间图片的最大总大小',
    maxCount   bigint   default 0                 null comment '空间图片的最大数量',
    totalSize  bigint   default 0                 null comment '当前空间下图片的总大小',
    totalCount bigint   default 0                 null comment '当前空间下的图片数量',
    userId     bigint                             not null comment '创建用户 id',
    createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    editTime   datetime default CURRENT_TIMESTAMP not null comment '编辑时间',
    updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete   tinyint  default 0                 not null comment '是否删除',
    -- 索引设计
    index idx_userId (userId),        -- 提升基于用户的查询效率
    index idx_spaceName (spaceName),  -- 提升基于空间名称的查询效率
    index idx_spaceLevel (spaceLevel) -- 提升按空间级别查询的效率
) comment '空间' collate = utf8mb4_unicode_ci;

设计要点:

  1. 空间级别字段

    • 空间级别包括普通版、专业版和旗舰版,是可枚举的;
    • 因此使用整型来节约空间、提高查询效率。
  2. 空间限额字段

    • 除了级别字段外,增加 maxSizemaxCount 字段用于限制空间的图片总大小与数量,而不是在代码中根据级别读取限额
    • 这样管理员可以单独设置限额,不用完全和级别绑定,利于扩展;而且查询限额时也更方便。
  3. 索引设计

    • 为高频查询的字段(如空间名称、空间级别、用户 id)添加索引,提高查询效率。
    • 空间表的写操作(如创建空间)频率远低于图片表,因此对空间字段建立多个索引所带来的写性能损耗可以忽略不计;我们更应关注如何通过合理索引,来显著提升“查询空间”这类读操作的效率。

图片表


由于一张图片只能属于一个空间,可以在图片表 picture 中新增字段 spaceId,实现图片与空间的关联,同时增加索引以提高查询性能。SQL 如下:

-- 添加新列
ALTER TABLE picture
    ADD COLUMN spaceId bigint NULL COMMENT '空间 id(为空表示公共空间)';

-- 创建索引
CREATE INDEX idx_spaceId ON picture (spaceId);

默认情况下,spaceId 为空,表示图片上传到了公共图库。


公共图库和空间的关系


有同学可能会这么想:

公共图库不就是系统管理员创建的一个空间么?既然有了空间表,要不要把公共图库也当做一个默认的空间来设计呢?或者在空间表创建一条公共图库的记录?

有这个想法是好的,但此处为了确保公共图库与私有空间的独立性,必须进行单独的设计,并避免将两者混合。原因如下:

  1. 公共图库的访问权限与私有空间不同

    • 公共图库中的图片无需登录就能查看,任何人都可以访问,不需要进行用户认证或成员管理。
    • 私有空间则要求用户登录,且访问权限严格控制,通常只有空间管理员(或团队成员)才能查看或修改空间内容。
  2. 公共图库没有额度限制

    • 私有空间会有图片大小、数量等方面的限制,从而管理用户的存储资源和空间配额;
    • 而公共图库完全不受这些限制。

公共图库和私有空间在数据结构、图片存储、权限控制、额度管理等方面存在本质区别,如果混合设计,会增加系统的复杂度并影响维护与扩展性。

举个例子:公共图库应该上传到对象存储的 public 目录,该目录里的文件可以公开访问;但私有图片应该上传到单独的 space 目录,该目录里的文件可以进一步设置访问权限。

因此我们会使用 “公共图库” 而不是 “公共空间” 来表述,也能让整个项目各个阶段的设计更加独立。

由于细节较多,关于具体功能的实现方案会在开发具体功能前进行讲解,便于对照方案进行开发。


三、后端开发


空间管理


先从相对简单的管理能力(增删改查)开始开发。


1. 数据模型


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

image-20250720165137446


修改实体类的主键生成策略,并指定逻辑删除字段:

image-20250720165917315


(1) Space 实体类

image-20250720170108761

@TableName(value = "space")
@Data
public class Space implements Serializable {

    /** id */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    /** 空间名称 */
    private String spaceName;

    /** 空间级别:0-普通版 1-专业版 2-旗舰版 */
    private Integer spaceLevel;

    /** 空间图片的最大总大小 */
    private Long maxSize;

    /** 空间图片的最大数量 */
    private Long maxCount;

    /** 当前空间下图片的总大小 */
    private Long totalSize;

    /** 当前空间下的图片数量 */
    private Long totalCount;

    /** 创建用户 id */
    private Long userId;

    /** 创建时间 */
    private Date createTime;

    /** 编辑时间 */
    private Date editTime;

    /** 更新时间 */
    private Date updateTime;

    /** 是否删除 */
    @TableLogic
    private Integer isDelete;
	
    // 增加序列号
    @TableField(exist = false)
    private static final long serialVersionUID = 1L;
}

(2) 请求类(DTO)

统一放在:model.dto.space

image-20250720170724454


空间创建请求:

@Data
public class SpaceAddRequest implements Serializable {

    /**
     * 空间名称
     */
    private String spaceName;

    /**
     * 空间级别:0-普通版 1-专业版 2-旗舰版
     */
    private Integer spaceLevel;

    private static final long serialVersionUID = 1L;
}

空间编辑请求,给用户使用,目前仅允许编辑空间名称:

@Data
public class SpaceEditRequest implements Serializable {

    /**
     * 空间 id
     */
    private Long id;

    /**
     * 空间名称
     */
    private String spaceName;

    private static final long serialVersionUID = 1L;
}

空间更新请求,给管理员使用,可以修改空间级别和限额:

@Data
public class SpaceUpdateRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 空间名称
     */
    private String spaceName;

    /**
     * 空间级别:0-普通版 1-专业版 2-旗舰版
     */
    private Integer spaceLevel;

    /**
     * 空间图片的最大总大小
     */
    private Long maxSize;

    /**
     * 空间图片的最大数量
     */
    private Long maxCount;

    private static final long serialVersionUID = 1L;
}

空间查询请求

@EqualsAndHashCode(callSuper = true)
@Data
public class SpaceQueryRequest extends PageRequest implements Serializable {
	// 继承我们自己开发的通用的分页请求类
    
    /**
     * id
     */
    private Long id;

    /**
     * 用户 id
     */
    private Long userId;

    /**
     * 空间名称
     */
    private String spaceName;

    /**
     * 空间级别:0-普通版 1-专业版 2-旗舰版
     */
    private Integer spaceLevel;

    private static final long serialVersionUID = 1L;
}

用户删除空间请求:直接调用通用的删除请求类,传入 id 即可实现删除操作;

image-20250720171753094


(3) 视图包装类(VO)

位置:model.dto.vo.SpaceVO

image-20250720172319543

@Data
public class SpaceVO implements Serializable {

    /** id */
    private Long id;

    /** 空间名称 */
    private String spaceName;

    /** 空间级别:0-普通版 1-专业版 2-旗舰版 */
    private Integer spaceLevel;

    /** 空间图片的最大总大小 */
    private Long maxSize;

    /** 空间图片的最大数量 */
    private Long maxCount;

    /** 当前空间下图片的总大小 */
    private Long totalSize;

    /** 当前空间下的图片数量 */
    private Long totalCount;

    /** 创建用户 id */
    private Long userId;

    /** 创建时间 */
    private Date createTime;

    /** 编辑时间 */
    private Date editTime;

    /** 更新时间 */
    private Date updateTime;

    /** 创建用户信息(关联查询) */
    private UserVO user;

    private static final long serialVersionUID = 1L;

    /* ---------- 转换工具 ---------- */
    public static Space voToObj(SpaceVO spaceVO) {
        if (spaceVO == null) return null;
        Space space = new Space();
        BeanUtils.copyProperties(spaceVO, space);
        return space;
    }

    public static SpaceVO objToVo(Space space) {
        if (space == null) return null;
        SpaceVO spaceVO = new SpaceVO();
        BeanUtils.copyProperties(space, spaceVO);
        return spaceVO;
    }
}

(4) 空间级别枚举

根据表字段空间级别的设定,我们要写一个枚举类来枚举空间级别:

image-20250720172524729


位置:model.enums.SpaceLevelEnum

image-20250720172615825

@Getter
public enum SpaceLevelEnum {

    COMMON("普通版", 0, 100, 100L * 1024 * 1024),
    PROFESSIONAL("专业版", 1, 1000, 1000L * 1024 * 1024),
    FLAGSHIP("旗舰版", 2, 10000, 10000L * 1024 * 1024);

    private final String text;
    private final int value;
    private final long maxCount;
    private final long maxSize;

    SpaceLevelEnum(String text, int value, long maxCount, long maxSize) {
        this.text = text;
        this.value = value;
        this.maxCount = maxCount;
        this.maxSize = maxSize;
    }

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

image-20250720173202432

💡 另一种限额方式:把配置放在外部 JSON / properties 文件,通过单独类读取,便于后期无代码修改。


2. 服务开发


实现接口

image-20250722100117496

public interface SpaceService extends IService<Space> {
    /**
     * 空间数据校验
     *
     * @param space
     */
    void validSpace(Space space);

    /**
     * 获取单张空间
     *
     * @param space
     * @param request
     * @return
     */
    SpaceVO getSpaceVO(Space space, HttpServletRequest request);

    /**
     * 分页获取多个空间
     *
     * @param spacePage
     * @param request
     * @return
     */
    Page<SpaceVO> getSpaceVOPage(Page<Space> spacePage, HttpServletRequest request);

    /**
     * 将查询请求转为 QueryWrapper 对象
     *
     * @param spaceQueryRequest
     * @return
     */
    QueryWrapper<Space> getQueryWrapper(SpaceQueryRequest spaceQueryRequest);
}

image-20250722100041880


(1) 数据校验

空间校验规则

  1. 前置校验

    • 无论创建还是修改,Space 参数本身不能为空,否则立即抛出 PARAMS_ERROR

    • 对校验接口新增参数 boolean add,用于判断该接口是创建空间前校验,还是更新空间信息前校验

    • /**
       * 空间数据校验
       *
       * @param space
       */
      void validSpace(Space space, boolean add);
      
  2. 字段级校验

    • 创建场景(add= true
      • spaceName 不能为空且长度 ≤ 30。
      • spaceLevel 不能为空,且必须是合法的枚举值。
    • 修改场景(add= false
      • 仅当字段被显式赋值时才校验:
        • spaceName 若提供,则不能为空且长度 ≤ 30。
        • spaceLevel 若提供,则必须是合法的枚举值。
  3. 枚举合法性

    • 只要 spaceLevelnull,就必须存在于 SpaceLevelEnum,否则抛出 PARAMS_ERROR

image-20250722100141701

@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);

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

        if(spaceLevel == null){
            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, "空间级别不存在");
    }
}

(2) 获取空间脱敏后的封装类

可以继续使用全局替换,复用之前的代码:

@Override
public Page<SpaceVO> getSpaceVOPage(Page<Space> spacePage, HttpServletRequest request) {
    // 1. 取出分页对象中的值 spacePage.getRecords()
    List<Space> spaceList = spacePage.getRecords();

    // 2. 创建 Page<SpaceVO>, 调用 Page(当前页, 每页尺寸, 总数据量) 的构造方法
    Page<SpaceVO> spaceVOPage = new Page<>(spacePage.getCurrent(), spacePage.getSize(), spacePage.getTotal());

    // 3. 判断存放分页对象值的列表是否为空
    if (CollUtil.isEmpty(spaceList)) {
        return spaceVOPage;
    }

    // 4. 对象列表 => 封装对象列表
    List<SpaceVO> spaceVOList = spaceList.stream().map(SpaceVO::objToVo).collect(Collectors.toList());
    // spaceList.stream():将 spaceList 转换为流。
    //.map(SpaceVO::objToVo):使用 SpaceVO.objToVo() 方法, 将流中的每个 Space 对象转换为 SpaceVO 对象。
    //.collect(Collectors.toList()):将转换后的 SpaceVO 对象收集到一个新的 List 中。

    // 5. 关联查询用户信息
    Set<Long> userIdSet = spaceList.stream().map(Space::getUserId).collect(Collectors.toSet());
    // .map(Space::getUserId) 取出封装空间列表中, 所有用户的 Id, 并将这些 id 收集为一个新的 Set 集合

    // 6. 将一个用户列表, 按照用户 ID 分组, Map<userId, 具有相同 userId 的用户列表>
    Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet).stream()
            .collect(Collectors.groupingBy(User::getId));
    // userService.listByIds(userIdSet): 根据 userIdSet 查询出对应的用户列表,  返回值是一个List<User>,包含所有匹配的User 对象
    // Collectors.groupingBy() : 收集器, 对流中的 User 对象进行分组
    // User::getId : 一个方法引用, 表示以 User 对象的 id 属性作为分组依据。

    // 7. 填充空间封装对象 spaceVO 中, 关于作者信息的属性 user
    // 遍历封装的空间列表
    spaceVOList.forEach(spaceVO -> {
        // 获取当前空间的用户ID
        Long userId = spaceVO.getUserId();
        // 初始化用户对象为 null
        User user = null;
        // 检查 Map<userId, List<User>> 中是否存在该 userId 对应的用户列表
        if (userIdUserListMap.containsKey(userId)) {
            // 如果存在,获取该 userId 对应的用户列表,并取第一个用户对象
            user = userIdUserListMap.get(userId).get(0);
        }
        // 将用户对象转换为 UserVO,并设置到当前 spaceVO 的 user 属性中
        spaceVO.setUser(userService.getUserVO(user));
    });

    // 8. 将处理好的空间封装列表, 重新赋值给分页对象的具体值
    spaceVOPage.setRecords(spaceVOList);

    return spaceVOPage;
}

(3) 生成查询条件对象

接下来,我们需要将空间查询请求体参数 SpaceQueryRequest 转为 Mybatis-plus 支持 QueryWrapper 类的对象

(根据之前的代码复用并 调整)

@Override
public QueryWrapper<Space> getQueryWrapper(SpaceQueryRequest spaceQueryRequest) {
    QueryWrapper<Space> queryWrapper = new QueryWrapper<>();
    if (spaceQueryRequest == null) {
        return queryWrapper;
    }
    // 从对象中取值
    Long id = spaceQueryRequest.getId();
    Long userId = spaceQueryRequest.getUserId();
    String spaceName = spaceQueryRequest.getSpaceName();
    Integer spaceLevel = spaceQueryRequest.getSpaceLevel();
    String sortField = spaceQueryRequest.getSortField();
    String sortOrder = spaceQueryRequest.getSortOrder();

    queryWrapper.eq(ObjUtil.isNotEmpty(id), "id", id);
    queryWrapper.eq(ObjUtil.isNotEmpty(userId), "userId", userId);
    queryWrapper.like(StrUtil.isNotBlank(spaceName), "spaceName", spaceName);
    queryWrapper.eq(ObjUtil.isNotEmpty(spaceLevel), "spaceLevel", spaceLevel);

    // 排序
    queryWrapper.orderBy(StrUtil.isNotEmpty(sortField), sortOrder.equals("ascend"), sortField);
    return queryWrapper;
}

(4) 根据级别自动填充限额

image-20250722100147364

@Override
public void fillSpaceBySpaceLevel(Space space) {
    SpaceLevelEnum spaceLevelEnum = SpaceLevelEnum.getEnumByValue(space.getSpaceLevel());
    if(spaceLevelEnum != null){
        // 如果管理员没有设置 maxSize, maxCount, 才根据空间级别枚举, 设置 maxSize, maxCount
        Long maxSize = spaceLevelEnum.getMaxSize();
        if(space.getMaxSize() == null){
            space.setMaxSize(maxSize); 
        }
        long maxCount = spaceLevelEnum.getMaxCount();
        if(space.getMaxCount() == null){
            space.setMaxCount(maxCount);
        }
        // 这样的设置能保证管理员在创建空间时, 自定义更大的空间容量
    }
}

3. 接口开发


参考图片接口的开发方法,完成 SpaceController 类,大多数代码可以直接复用。
需要重点关注接口的权限:

image-20250722112246078

接口 权限说明
创建空间 所有用户都可以使用
删除空间 仅允许空间创建人或管理员删除
更新空间 仅管理员可用,允许更新空间级别
编辑空间 允许空间创建人使用,但注意可编辑的字段(不能编辑空间级别)

image-20250722113500889


(1) 删除空间

/**
 * 删除空间
 *
 * @param deleteRequest
 * @param request
 * @return
 */
@PostMapping("/delete")
public BaseResponse<Boolean> deleteSpace(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
    // 1. DeleteRequest 类定义在 common 中, 而不在 service 中, 因为删除逻辑对于删除用户、删除空间, 都是类似的
    if (deleteRequest == null || deleteRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }

    // 2. 根据 HttpServletRequest 参数, 获取登录用户信息
    User loginUser = userService.getLoginUser(request);

    // 3. 判断空间是否存在
    Long id = deleteRequest.getId();

    // 4. 调用数据库 getById(), 如果空间存在, 定义为 oldSpace 对象
    Space oldSpace = spaceService.getById(id);

    // 5. 空间不存在
    ThrowUtils.throwIf(oldSpace == null, ErrorCode.NOT_FOUND_ERROR);

    // 6. 删除空间权限: 管理员、空间作者
    if (!oldSpace.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
    }

    // 7. 操作数据库删除空间
    boolean result = spaceService.removeById(id);
    ThrowUtils.throwIf(result == false, ErrorCode.OPERATION_ERROR);

    // 8. 只要接口没抛异常, 就一定删除成功了
    return ResultUtils.success(true);
}

(2) 更新空间(仅管理员)

@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> updateSpace(@RequestBody SpaceUpdateRequest spaceUpdateRequest, HttpServletRequest request) {
    if (spaceUpdateRequest == null || spaceUpdateRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }

    Space space = new Space();
    // // 将实体类和 DTO 进行转换
    BeanUtil.copyProperties(spaceUpdateRequest, space);

    // 新增 1 : 需要根据空间级别, 自动填充数据
    spaceService.fillSpaceBySpaceLevel(space);
	
    // 新增 2 : 对空间的数据校验, 需要补充 add 参数
    spaceService.validSpace(space, false);
	
    // 判断是否存在
    Long id = spaceUpdateRequest.getId();
    Space oldSpace = spaceService.getById(id);
    ThrowUtils.throwIf(oldSpace == null, ErrorCode.NOT_FOUND_ERROR);

    // 操作数据库
    boolean result = spaceService.updateById(space);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
    return ResultUtils.success(true);
}

(3) 获取空间所有信息(仅管理员)

/**
 * 根据 id 获取空间(仅管理员可用)
 */
@GetMapping("/get")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Space> getSpaceById(long id, HttpServletRequest request) {
    ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
    // 查询数据库
    Space space = spaceService.getById(id);
    ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR);
    // 获取封装类
    return ResultUtils.success(space);
}

/**
 * 分页获取空间列表(仅管理员可用)
 */
@PostMapping("/list/page")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<Space>> listSpaceByPage(@RequestBody SpaceQueryRequest spaceQueryRequest) {
    long current = spaceQueryRequest.getCurrent();
    long size = spaceQueryRequest.getPageSize();
    // 查询数据库
    Page<Space> spacePage = spaceService.page(new Page<>(current, size),
            spaceService.getQueryWrapper(spaceQueryRequest));
    return ResultUtils.success(spacePage);
}

(4) 编辑空间

/**
 * 编辑空间(给用户使用)
 */
@PostMapping("/edit")
public BaseResponse<Boolean> editSpace(@RequestBody SpaceEditRequest spaceEditRequest, HttpServletRequest request) {
    if (spaceEditRequest == null || spaceEditRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    // 在此处将实体类和 DTO 进行转换
    Space space = new Space();
    BeanUtils.copyProperties(spaceEditRequest, space);
    // 新增 1 : 根据空间级别填充数据
    spaceService.fillSpaceBySpaceLevel(space);
    // 设置编辑时间
    space.setEditTime(new Date());
    // 新增 2 : 编辑空间时的校验, 新增 add 参数 false
    spaceService.validSpace(space, false);
    User loginUser = userService.getLoginUser(request);

    // 判断是否存在
    long id = spaceEditRequest.getId();
    Space oldSpace = spaceService.getById(id);
    ThrowUtils.throwIf(oldSpace == null, ErrorCode.NOT_FOUND_ERROR);
    // 仅本人或管理员可编辑
    if (!oldSpace.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
    }
    // 操作数据库
    boolean result = spaceService.updateById(space);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
    return ResultUtils.success(true);
}

后续需要增加权限校验的接口,代码在增加权限校验后补充:

image-20250722115607338


用户创建私有空间


用户可以自主创建私有空间,但是必须要加限制,最多只能创建一个


1. 创建空间流程


  1. 填充参数默认值
  2. 校验参数
  3. 校验权限,非管理员只能创建普通级别的空间
  4. 控制同一用户只能创建一个私有空间

如何保证同一用户只能创建一个私有空间?

  • 最粗暴的方式是给空间表的 userId 加上唯一索引,但由于后续用户还可以创建团队空间,这种方式不利于扩展。
  • 所以我们采用 加锁 + 事务 的方式实现。

2. 创建空间服务


image-20250722120048525

/**
 * 用户创建空间
 * @param spaceAddRequest 创建空间请求
 * @param loginUser  用户登录信息
 * @return
 */
long addSpace(SpaceAddRequest spaceAddRequest, User loginUser);

/**
 * 用户创建空间
 * @param spaceAddRequest 创建空间请求
 * @param loginUser  用户登录信息
 * @return
 */
@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());
    }

    // 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. 控制同一用户只能创建一个私有空间
    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)
                    .exists();

            // 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;
        
        // 15. 处理直接 return newSpaceId; 代码报警告的 npe 问题 (可以直接返回)
        return Optional.ofNullable(newSpaceId).orElse(-1L);
    }
}

💡 注意事项

  • 上述代码中,我们使用本地 synchronized 锁对 userId 进行加锁,这样不同的用户可以拿到不同的锁,对性能的影响较低。
  • 在加锁的代码中,我们使用 Spring 的 编程式事务管理器 transactionTemplate 封装跟数据库有关的查询和插入操作,而不是使用 @Transactional 注解来控制事务,这样可以保证事务的提交在加锁的范围内。
  • 如果一定要使用@Transactional,就需要将addSpace()中的数据库单独封装为一个方法,对这个封装的方法使用@Transactional即可;
  • 只要涉及到事务操作,建议大家测试时自己 new 个运行时异常来验证是否会回滚。image-20250722173111094

3. 扩展知识:本地锁优化


上述代码中,我们是对字符串常量池(intern)进行加锁的,数据并不会及时释放。

如果还要使用本地锁,可以按需选用另一种方式 —— 采用 ConcurrentHashMap 来存储锁对象。

示例代码:

Map<Long, Object> lockMap = new ConcurrentHashMap<>();

public long addSpace(SpaceAddRequest spaceAddRequest, User user) {
    Long userId = user.getId();
    Object lock = lockMap.computeIfAbsent(userId, key -> new Object());
    synchronized (lock) {
        try {
            // 数据库操作
        } finally {
            // 防止内存泄漏
            lockMap.remove(userId);
        }
    }
}

(1) 原来的加锁方法:

synchronized (String.valueOf(userId).intern()) {}

锁对象是 JVM 全局字符串常量池里的同一份字符串,造成的后果:

  1. 锁得太粗:任何线程、任何业务只要 intern("123"),就会拿到同一把锁。
  • userId 本身不会重复,但字符串常量池不区分业务
  • 极端例子:
    • 线程 A 在“创建空间”里 intern("123") 并加锁;
    • 线程 B 在“订单模块”里做 synchronized("123".intern()) { … }
    • 这两段完全不相干的代码就串行起来了,哪怕你只是在“创建空间”这个业务里用,别的线程/业务如果也恰好 intern()"123",它们就会跟你抢同一把锁,这就造成了“跨业务干扰”。
  • 所以,锁得太粗,就是因为池子里的字符串,是全局共享的,同一个 JVM 里所有线程、所有业务都会跟它打交道;任何线程、任何业务只要 intern("123"),就会拿到同一把锁。
  1. 池子会膨胀:intern() 会把字符串长期留在常量池,用户越多,池子越大,GC 也清理不掉,内存慢慢被吃掉。

(2)引入 ConcurrentHashMap<Long, Object> 后:

Map<Long, Object> lockMap = new ConcurrentHashMap<>();

public long addSpace(SpaceAddRequest spaceAddRequest, User user) {\
    // .....
    Long userId = user.getId();
    Object lock = lockMap.computeIfAbsent(userId, key -> new Object());
    synchronized (lock) {
        try {
            // 数据库操作
        } finally {
            // 防止内存泄漏
            lockMap.remove(userId);
        }
    }
}
  1. 锁按用户分家
    • lockMap.computeIfAbsent(userId, k -> new Object()) 给每个 userId 只生成一把专用锁对象。
    • 锁对象是每个 userId 单独 new 出来的 Object,只存当前业务的 Map 里。
    • 张三用张三的锁,李四用李四的锁;两个用户之间完全并行,互不影响。并发量从「全局串行」变成「按用户并行」
  2. 锁生命周期可控
    • finallylockMap.remove(userId),用完即扔。锁对象只存在于真正需要它的那几百毫秒,不会长期占内存;
    • GC 很快就能回收,不会出现常量池那种「只增不减」的泄漏
  3. Map 本身线程安全
    ConcurrentHashMap 保证 computeIfAbsent 的原子性,也就是说,ConcurrentHashMap 保证同一 userId 永远只创建一把锁,线程安全。

总结:把“全局大锁”拆成“用户级小锁”,既避免跨业务抢锁,又防止常量池膨胀锁竞争内存压力都大幅下降。


4. 扩展


  1. 用户注‏册成功时,可以自动‏创建空间。即使创建‏失败了,也可以手动‏创建作为兜底;
  2. 管理员可以为某个用户创建空间(目前没啥必要);
  3. 本地锁改为分布式锁,可以基于 Redisson 实现(AI 答题应用平台项目),改为分布式锁的好处是,这个项目如果部署到多个服务器上,也不会出现锁冲突,但是现在我们是单机应用,使用单机锁即可,后续逐步扩展。

5. 接口开发


image-20250722174035880

@PostMapping("/add")
public BaseResponse<Long> addSpace(@RequestBody SpaceAddRequest spaceAddRequest, HttpServletRequest request){
    ThrowUtils.throwIf(spaceAddRequest == null , ErrorCode.PARAMS_ERROR);
    User loginUser = userService.getLoginUser(request);
    long newId = spaceService.addSpace(spaceAddRequest, loginUser);
    return ResultUtils.success(newId);
}

私有空间权限控制


私有空间权限与公共图库不同,需对所有图片操作增加空间权限校验逻辑


1. 图片表新增字段


图片表增加‏ spaceId ‏字段,默认为 nu‏ll 表示公共图库‏。

-- 添加新列, 前面已经执行过了
alter table picture
    add column spaceId bigint null comment '空间 id(为空表示公共空间)';

同步修改 PictureMapper.xml、Picture 实体类、PictureVO 响应视图,补充空间 id 字段:

image-20250722180259809

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

image-20250722180227240


2. 上传和更新图片


PictureUploadRequest 中新增字段:

image-20250724112110248

private Long spaceId;

uploadPicture 方法中增加校验:

image-20250724100341585

更新代码:注入 SpaceService、 16~24

@Resource
private SpaceService spaceService;

@Override
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {

// 1. 校验参数, 用户未登录, 抛出没有权限的异常
ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);

// 16. 判断空间是否存在
Long spaceId = pictureUploadRequest.getSpaceId();
if(pictureUploadRequest!=null){
    Space space = spaceService.getById(spaceId);
    ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");

    // 17. 校验是否有空间权限, 仅空间管理员才可以上传
    if(!loginUser.getId().equals(space.getUserId())){
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间权限");
    }
}

	// .....
    if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
    }

    // 18. 如果是更新操作, 校验当前空间是否与原图片空间一致
    if(spaceId == null){
        // 19. 如果更新时没有传入 spaceId, 则更新时复用图片原 spaceId(这样也兼容了公共图库)
        if(oldPicture.getSpaceId() != null){
            spaceId = oldPicture.getSpaceId();
        }
    }else{
        // 20. 用户传了 spaceId, 必须和原图片的 spaceId 一致
        if(ObjUtil.notEqual(spaceId, oldPicture.getSpaceId())){
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间 id 不一致");
        }
    }
}

// 21. 按照用户 id 划分目录 => 按照空间划分目录
String uploadPathPrefix;
if(spaceId == null){
    // 22. 用户上传图片, 此时是创建图片, 如果未传 spaceId, 则判断为上传图片至公共图库
    uploadPathPrefix = String.format("public/%s", loginUser.getId());

}else{
    // 23. 如果用户创建图片时指定了 spaceId, 则判断上传图片至指定空间
    uploadPathPrefix = String.format("public/%s", spaceId);
}

// 7. 定义上传文件的前缀 public/登录用户 ID
// String uploadPathPrefix = String.format("public/%s", loginUser.getId());
// 根据用户划分前缀, 当前的图片文件上传到公共图库, 因此前缀定义为 public

// 8. 上传图片, 上传图片 API 需要的参数(原始文件 + 文件前缀), 获取上传文件结果对象
PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
if (inputSource instanceof String) {
    pictureUploadTemplate = urlPictureUpload;
}

//......

picture.setName(picName);
// 24. 指定空间 id
picture.setSpaceId(spaceId);
picture.setPicSize(uploadPictureResult.getPicSize());
picture.setPicWidth(uploadPictureResult.getPicWidth());

// .....
}

(1) 上传图片

uploadPicture 方法中增加校验:

image-20250724100341585

ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);
// 校验空间是否存在
Long spaceId = pictureUploadRequest.getSpaceId();
if (spaceId != null) {
    Space space = spaceService.getById(spaceId);
    ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
    // 必须空间创建人(管理员)才能上传
    if (!loginUser.getId().equals(space.getUserId())) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间权限");
    }
}

当前调用上传图片的接口,如果上传图片不是创建图片,而是更新图片,那么可能会存在一种情况:更新图片的 SpaceId 和创建图片的 SpaceId 不同,这种情况可能会出 bug;

所以如果当前上传图片的请求逻辑是更新图片,我们还需要进行进一步的校验


(2) 更新图片

  • 校验图片是否存在
  • 校验图片归属与权限
  • 校验 spaceId 一致性
// 如果是更新图片,需要校验图片是否存在
if (pictureId != null) {
    Picture oldPicture = this.getById(pictureId);
    ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");

    // 仅本人或管理员可编辑
    if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
    }

    // 校验空间是否一致
    // 没传 spaceId,则复用原有图片的 spaceId
    if (spaceId == null) {
        if (oldPicture.getSpaceId() != null) {
            spaceId = oldPicture.getSpaceId();
        }
    } else {
        // 传了 spaceId,必须和原有图片一致
        if (ObjUtil.notEqual(spaceId, oldPicture.getSpaceId())) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间 id 不一致");
        }
    }
}

(3) 上传目录按空间划分

之前统一将图片传入公共图库 public/userId 目录,现在要完成: 按照用户 id 划分目录 => 按照空间划分目录

// 之前:按用户 id 划分目录
// 现在:按空间划分目录
String uploadPathPrefix;
if (spaceId == null) {
    uploadPathPrefix = String.format("public/%s", loginUser.getId());
} else {
    uploadPathPrefix = String.format("space/%s", spaceId);
}

(4) 入库时设置 spaceId

// 构造要入库的图片信息
Picture picture = new Picture();
// 补充设置 spaceId
picture.setSpaceId(spaceId);

3. 删除图片


  • 若图片有 spaceId → 私有空间图片
  • 仅空间管理员(创建者)可删除
  • 系统管理员 不能 随意删除私有空间图片

(1) 公共权限校验方法

无论是上传图片(创建、更新),或是删除图片,这两个操作都需要校验当前用户是否有权限;

我们还发现,两个操作的校验权限的逻辑是相同,并且校验是不太合适使用 AOP 实现的;

因此,我们在接口中自己写校验逻辑,再进一步地提取校验逻辑为一个公共方法;

image-20250724114527426


/**
 * 公共校验权限方法
 * @param loginUser 当前登录用户
 * @param picture   当前操作图片
 */
void checkPictureAuth(User loginUser, Picture picture);

@Override
public void checkPictureAuth(User loginUser, Picture picture) {
    Long spaceId = picture.getSpaceId();
    if (spaceId == null) {
        // 公共图库:仅本人或管理员可操作
        if (!picture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
    } else {
        // 私有空间:仅空间管理员可操作
        if (!picture.getUserId().equals(loginUser.getId())) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
    }
}

(2) 删除图片 Controller 方法

image-20250724115423750

更新代码:10

@PostMapping("/delete")
public BaseResponse<Boolean> deletePicture(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
    // 1. DeleteRequest 类定义在 common 中, 而不在 service 中, 因为删除逻辑对于删除用户、删除图片, 都是类似的
    if (deleteRequest == null || deleteRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }

    // 2. 根据 HttpServletRequest 参数, 获取登录用户信息
    User loginUser = userService.getLoginUser(request);

    // 3. 判断图片是否存在
    Long id = deleteRequest.getId();

    // 4. 调用数据库 getById(), 如果图片存在, 定义为 oldPicture 对象
    Picture oldPicture = pictureService.getById(id);

    // 5. 图片不存在
    ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);

    // 6. 删除图片权限: 管理员、图片作者
//        if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
//            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
//        }

    // 10. 调用公共权限校验方法,代替原来的权限校验逻辑
    pictureService.checkPictureAuth(loginUser, oldPicture);

    // 7. 操作数据库删除图片
    boolean result = pictureService.removeById(id);
    ThrowUtils.throwIf(result == false, ErrorCode.OPERATION_ERROR);

    // 9. 清理图片资源
    pictureService.clearPictureFile(oldPicture);

    // 8. 只要接口没抛异常, 就一定删除成功了
    return ResultUtils.success(true);
}

因为当前的 Controller 中的逻辑已经有些复杂了,我们为删除图片开发 Service 方法;


(3) 删除图片 Service 方法

image-20250724100341585

/**
 * 删除图片接口
 * @param pictureId  删除图片的 ID
 * @param loginUser  当前登录用户信息
 */
void deletePicture(long pictureId, User loginUser);

@Override
public void deletePicture(long pictureId, User loginUser) {
    ThrowUtils.throwIf(pictureId <= 0, ErrorCode.PARAMS_ERROR);
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);

    // 判断是否存在
    Picture oldPicture = this.getById(pictureId);
    ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);

    // 校验权限
    checkPictureAuth(loginUser, oldPicture);

    // 操作数据库
    boolean result = this.removeById(pictureId);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);

    // 异步清理文件
    this.clearPictureFile(oldPicture);
}

同步修改 Controller:将原先写在 Controller 里的删除逻辑全部迁移到 Service,并调用 deletePicture(...)

@PostMapping("/delete")
public BaseResponse<Boolean> deletePicture(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
    // DeleteRequest 类定义在 common 中, 而不在 service 中, 因为删除逻辑对于删除用户、删除图片, 都是类似的
    if (deleteRequest == null || deleteRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);
    pictureService.deletePicture(deleteRequest.getId(), loginUser);
    return ResultUtils.success(true);
}

4. 编辑图片


  • 权限校验逻辑与删除图片 完全一致
  • editPicture 方法抽象到 Service,Controller 仅做转发

image-20250724100341585

/**
 * 编辑图片接口
 * @param pictureEditRequest  编辑图片请求
 * @param loginUser           当前登录用户信息
 */
void editPicture(PictureEditRequest pictureEditRequest, User loginUser);

@Override
public void editPicture(PictureEditRequest pictureEditRequest, User loginUser) {
    // 在此处将实体类和 DTO 进行转换
    Picture picture = new Picture();
    BeanUtils.copyProperties(pictureEditRequest, picture);

    // 注意将 list 转为 string
    picture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));

    // 设置编辑时间
    picture.setEditTime(new Date());

    // 数据校验
    this.validPicture(picture);

    // 判断是否存在
    long id = pictureEditRequest.getId();
    Picture oldPicture = this.getById(id);
    ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);

    // 校验权限
    checkPictureAuth(loginUser, oldPicture);

    // 补充审核参数
    this.fillReviewParams(picture, loginUser);

    // 操作数据库
    boolean result = this.updateById(picture);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
}

简化 Controller

@PostMapping("/edit")
public BaseResponse<Boolean> editPicture(@RequestBody PictureEditRequest pictureEditRequest, HttpServletRequest request) {
    if (pictureEditRequest == null || pictureEditRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);
    pictureService.editPicture(pictureEditRequest, loginUser);
    return ResultUtils.success(true);
}

更新图片接口目前仅管理员使用,可暂不修改。

image-20250724120916876


5. 查询图片


  • 用户无法查看私有空间图片,只能查询公共图库。
  • 单条查询与分页查询均须添加空间权限校验。

(1) 根据 id 查询接口:getPictureVOById

如果查询出‏的图片有 spac‏eId,则运用跟删‏除图片一样的校验逻‏辑,仅空间管理员可‌以查看:

@GetMapping("/get/vo")
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();
    if(spaceId != null){
        User loginUser = userService.getLoginUser(request);
        pictureService.checkPictureAuth(loginUser,picture);
    }
    // 获取封装类
    return ResultUtils.success(pictureService.getPictureVO(picture, request));
}

(2) 分页查询接口:listPictureVOByPage

查询请求增加 spa‏ceId 参数,不传则表示查公共图库;

传‏参则表示查询特定空间 id 下的图片,此‏时登录用户必须是空间的管理员(其他用户无‏法查看别人空间的图片),并且不需要指定审‌核条件(私有空间没有审核机制)。

先给请求封‏装类 Picture‏QueryReque‏stQuery‏Wrapper 补充‌空间 id 的查询条件。


① PictureQueryRequest 新增代码:

image-20250724122149632

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

/** 是否只查询 spaceId 为 null 的数据 */
private boolean nullSpaceId;

当前方案必须保留 nullSpaceId,原因如下:

  1. “公共图库”需要显式标记

若仅用 spaceId IS NULL 表示公共图库,会导致查询语义错误:

  • 条件 WHERE space_id = NULL 在 SQL 中恒为假,无法返回任何记录。
  • 若改用 WHERE space_id IS NULL,则会把“未关联空间”的图片(可能包含异常数据)误判为公共图库图片,且无法区分“已关联空间”和“公共图库”两类数据。
  1. 专用字段避免歧义

nullSpaceId 作为布尔标志位(如 is_public = true),可明确标识公共图库图片,确保:

  • 查询公共图库:WHERE is_public = true(精准返回预期数据)。
  • 查询其他空间:WHERE space_id = ? AND is_public = false(避免污染结果)。
  1. 业务逻辑与数据一致性

通过专用字段,将“无空间”这一业务状态与数据库的 NULL 语义解耦,确保:

  • 公共图库查询无需依赖 NULL 的模糊处理。
  • 未来扩展空间类型(如“私有空间”“团队空间”)时,无需重构历史数据。

QueryWrapper 新增条件

image-20250724123506027

从图片查询请求中,获取空间 ID 和 nullSpaceId

image-20250724123834079

注意:nullSpaceId 是 boolean 类型,所以无法通过 get() 方法获取,而是使用 isNullSpaceId() 获取;


新增查询条件:

因为查询接口的逻辑是,普通用户只能查询公共图库的图片(nullSpaceId == true),而指定 spaceId 进行某个空间的查询的操作需要管理员权限;

理清逻辑后,我们继续拼接查询条件:

image-20250724124640154

queryWrapper.eq(ObjUtil.isNotEmpty(spaceId), "spaceId", spaceId);

// 下面这个条件的逻辑是: 如果用户指定了 nullSpaceId 的 isNull 为 true, 就要在数据库中查询 spaceId 列的值为 null 的记录
queryWrapper.isNull(nullSpaceId, "spaceId");

③ 然后给 listPictureVOByPage 接口‏增加权限校验,针对‏公开图库和私有空间‏设置不同的查询条件‏:

image-20250724125130328

@PostMapping("/list/page/vo")
public BaseResponse<Page<PictureVO>> listPictureVOByPage(@RequestBody PictureQueryRequest pictureQueryRequest,
                                                         HttpServletRequest request) {
    long current = pictureQueryRequest.getCurrent();
    long size = pictureQueryRequest.getPageSize();
    // 限制爬虫
    ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
    // 普通用户默认只能查询审核通过的数据
    pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
    // 空间权限校验
    Long spaceId = pictureQueryRequest.getSpaceId();
    // 没有指定 spaceId
    if(spaceId == null){
        // 普通用户默认只能查看审核通过, 并且在公共图库的图片
        pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
        pictureQueryRequest.setNullSpaceId(true);
    }else{
        // 私有空间, 校验权限
        User loginUser = userService.getLoginUser(request);
        // 在数据库中根据 spaceId 找对应的空间
        Space space = spaceService.getById(spaceId);
        ThrowUtils.throwIf(space==null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
        // 空间存在, 仅空间的管理员可以访问
        if(!loginUser.getId().equals(space.getUserId())){
            // 当前登录用户不是空间的创建者, 无权限
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间权限")
        }
    }
    // 查询数据库
    Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
            pictureService.getQueryWrapper(pictureQueryRequest));
    // 获取封装类
    return ResultUtils.success(pictureService.getPictureVOPage(picturePage, request));
}

考虑到私有空间图片更新频率不确定,之前编写的缓存分页查询图片接口可暂不使用,将其标记为 @Deprecated 表示废弃。

image-20250724131302821


空间级别与额度控制


1. 上传图片时校验与更新额度


我们发现,目前上‏传图片的代码已经比较复杂了,如果‏想要再增加非常严格精确的校验逻辑‏,需要在上传图片到对象存储前自己‏解析文件的大小、再计算是否超额,‌可能还要加锁,想想都头疼!

这时你会怎么做呢?

当技术实现比较复杂时,我们不妨思考一下能否对业务进行优化。

比如:

  • 单张图片最大才 2M,那么即使空间满了再允许上传一张图片,影响也不大
  • 即使有用户在超额前的瞬间大量上传图片,对系统的影响也并不大。后续可以通过限流 + 定时任务检测空间等策略,尽早发现这些特殊情况再进行定制处理。

这样一来,就利用业务设计巧妙节约了开发成本。


image-20250724134504147

更新代码:25~30, 新增编程式事务 bean

@Resource
private TransactionTemplate transactionTemplate;

@Override
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {

    // 1. 校验参数, 用户未登录, 抛出没有权限的异常
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);

    // 15. 判断空间是否存在
    Long spaceId = pictureUploadRequest.getSpaceId();
    if(pictureUploadRequest!=null){
        Space space = spaceService.getById(spaceId);
        ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");

        // 16. 校验是否有空间权限, 仅空间管理员才可以上传
        if(!loginUser.getId().equals(space.getUserId())){
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间权限");
        }

        // 25. 如果传了空间 id, 我们需要先判断空间 id 是否有额度
        if(space.getTotalCount() >= space.getMaxCount()){
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间条数不足");
        }

        if(space.getTotalSize() >= space.getMaxSize()){
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间大小不足");
        }
    }

    // 2. 判断是新增图片, 还是更新图片, 所以先判断图片是否存在
    Long pictureId = null;
    if (pictureUploadRequest != null) {
        // 3. 如果传入的请求不为空, 才获取请求中的图片 ID
        pictureId = pictureUploadRequest.getId();
    }

    // 4. 图片 ID 不为空, 查数据库中是否有对应的图片 ID
    // 新增条件 pictureId > 0, 仅当有 id (id >0)才检查
    // todo
    if (pictureId != null && pictureId > 0) {

        Picture oldPicture = this.getById(pictureId);
        ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");

        // 修改 2: 仅本人和管理员可以编辑图片
        // Long 类型包装类最好也用 equals 判断
        if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }

        // 17. 如果是更新操作, 校验当前空间是否与原图片空间一致
        if(spaceId == null){
            // 18. 如果更新时没有传入 spaceId, 则更新时复用图片原 spaceId(这样也兼容了公共图库)
            if(oldPicture.getSpaceId() != null){
                spaceId = oldPicture.getSpaceId();
            }
        }else{
            // 19. 用户传了 spaceId, 必须和原图片的 spaceId 一致
            if(ObjUtil.notEqual(spaceId, oldPicture.getSpaceId())){
                throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间 id 不一致");
            }
        }
    }

    // 19. 按照用户 id 划分目录 => 按照空间划分目录
    String uploadPathPrefix;
    if(spaceId == null){
        // 20. 用户上传图片, 此时是创建图片, 如果未传 spaceId, 则判断为上传图片至公共图库
        uploadPathPrefix = String.format("public/%s", loginUser.getId());

    }else{
        // 21. 如果用户创建图片时指定了 spaceId, 则判断上传图片至指定空间
        uploadPathPrefix = String.format("public/%s", spaceId);
    }

    // 7. 定义上传文件的前缀 public/登录用户 ID
    // String uploadPathPrefix = String.format("public/%s", loginUser.getId());
    // 根据用户划分前缀, 当前的图片文件上传到公共图库, 因此前缀定义为 public

    // 8. 上传图片, 上传图片 API 需要的参数(原始文件 + 文件前缀), 获取上传文件结果对象
    PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
    if (inputSource instanceof String) {
        pictureUploadTemplate = urlPictureUpload;
    }

    UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);
    // UploadPictureResult uploadPictureResult = fileManager.uploadPicture(multipartFile, uploadPathPrefix);

    // 9. 构造要入库的图片信息(样板代码)
    Picture picture = new Picture();
    picture.setUrl(uploadPictureResult.getUrl());
    // 15. 从上传结果中获取缩略图 url, 并设置到数据库中
    picture.setThumbnailUrl(uploadPictureResult.getThumbnailUrl());
    String picName = uploadPictureResult.getPicName();
    if (pictureUploadRequest != null && StrUtil.isNotBlank(pictureUploadRequest.getPicName())) {
        // 图片更新请求不为空, 并且图片更新请求中的图片名称属性不为空, 以更新请求的图片名称, 代替图片解析结果的名称
        // pictureUploadRequest 的 PicName 属性是允许用户传递的
        picName = pictureUploadRequest.getPicName();
    }
    picture.setName(picName);
    // 22. 指定空间 id
    picture.setSpaceId(spaceId);
    picture.setPicSize(uploadPictureResult.getPicSize());
    picture.setPicWidth(uploadPictureResult.getPicWidth());
    picture.setPicHeight(uploadPictureResult.getPicHeight());
    picture.setPicScale(uploadPictureResult.getPicScale());
    picture.setPicFormat(uploadPictureResult.getPicFormat());
    picture.setUserId(loginUser.getId());

    this.fillReviewParams(picture, loginUser);
    // 10. 操作数据库, 如果 pictureId 不为空, 表示更新图片, 否则为新增图片
    if (pictureId != null) {
        // 11. 如果是更新, 需要补充 id 和编辑时间
        picture.setId(pictureId);
        picture.setEditTime(new Date());
    }

    // 26.  更新空间额度需要先开启事务(要引入编程式事务的 bean)
    // 28. 定义一个确定的 finalSpaceId 用于后续拼接 sql 条件
    Long finalSpaceId = spaceId;
    // 因为 spaceId 在上面的代码一直变化, 直接使用 spaceId 拼接第一个 eq 条件会报错(alt+enter, 找到 copy)
    transactionTemplate.execute(status -> {
        // 12. 利用 MyBatis 框架的 API,根据实体对象 picture 是否存在 ID 值, 来决定是执行插入操作还是更新操作
        boolean result = this.saveOrUpdate(picture);

        // 13. result 返回 false, 表示数据库不存在该图片, 不能调用图片上传(更新)接口
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败, 数据库操作失败");

        // 27. 更新空间的使用额度(更新空间表)
        boolean update = spaceService.lambdaUpdate()
                .eq(Space::getId, finalSpaceId)
                .setSql("totalSize = totalSize +" + picture.getPicSize())
                .setSql("totalCount = totalCount + 1")
                .update();
        // 29. 更新失败, 回滚, 抛异常
        ThrowUtils.throwIf(!update, ErrorCode.OPERATION_ERROR, "额度更新失败");

        // 30. 这个事务的返回值用不到, 随便返回一个对象
        return picture;
    });

    // 14. 对数据进行脱敏, 并返回
    return PictureVO.objToVo(picture);
}

1)修改 uploadPicture 方法:增加额度判断

// 校验额度
if (space.getTotalCount() >= space.getMaxCount()) {
    throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间条数不足");
}
if (space.getTotalSize() >= space.getMaxSize()) {
    throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间大小不足");
}

2)保存图‏片记录时,需要使用‏事务更新额度,如果‏额度更新失败,也不‏用将图片记录保存。

依然是使用‏ transact‏ionTempla‏te 事务管理器,‏将所有数据库操作到‌一起即可:

Long finalSpaceId = spaceId;
transactionTemplate.execute(status -> {
    boolean result = this.saveOrUpdate(picture);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败");

    if (finalSpaceId != null) {
        boolean update = spaceService.lambdaUpdate()
                .eq(Space::getId, finalSpaceId)
                .setSql("totalSize = totalSize + " + picture.getPicSize())
                .setSql("totalCount = totalCount + 1")
                .update();
        ThrowUtils.throwIf(!update, ErrorCode.OPERATION_ERROR, "额度更新失败");
    }
    return picture;
});

2. 删除图片后更新额度


注意,这里有可能出‏现对象存储上的图片文件实际没被清理的情‏况。但是对于用户来说,不应该感‏受到 “删了图片空间却没有增加”,‏所以没有将这一步添加到事务中。可以‌通过定时任务检测作为补偿措施。

// 校验权限
checkPictureAuth(loginUser, oldPicture);

// 开启事务
transactionTemplate.execute(status -> {
    // 操作数据库
    boolean result = this.removeById(pictureId);
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);

    // 释放额度
    Long spaceId = oldPicture.getSpaceId();
    if (spaceId != null) {
        boolean update = spaceService.lambdaUpdate()
                .eq(Space::getId, spaceId)
                .setSql("totalSize = totalSize - " + oldPicture.getPicSize())
                .setSql("totalCount = totalCount - 1")
                .update();
        ThrowUtils.throwIf(!update, ErrorCode.OPERATION_ERROR, "额度更新失败");
    }
    return true;
});

// 异步清理文件
this.clearPictureFile(oldPicture);

注意,这里有可能出‏现对象存储上的图片文件实际没被清理的情‏况。但是对于用户来说,不应该感‏受到 “删了图片空间却没有增加”,‏所以没有将这一步添加到事务中。可以‌通过定时任务检测作为补偿措施。


3. 查询空间级别列表


最后,我们‏再编写一个接口,用‏于给前端展示所有的‏空间级别信息。

image-20250724140059434

(1)新建 SpaceLevel 封装类:

@Data
@AllArgsConstructor
public class SpaceLevel {

    private int value;

    private String text;

    private long maxCount;

    private long maxSize;
}

(2)在 S‏paceContr‏oller 中编写‏接口,将枚举转换为‏空间级别对象列表:

@GetMapping("/list/level")
// 纯查询, 用 get
public BaseResponse<List<SpaceLevel>> listSpaceLevel(){
    // values() 取出空间级别枚举类中所有的值, 返回的是一个数组, 数组不能直接调用 .stream() 转为流
    // Arrays.stream() 是 Java 8 的 API , 用于将数组转为 stream
    // map() 将每一个 spaceLevelEnum 映射为一个新的 SpaceLevel 对象
    // SpaceLevel 类中引入 @AllArgsConstructor 注解, 会生成所有参数组合的构造函数
    // collect(Collectors.toList()) 会把 spaceLevelEnum 映射的结果收集为对象
    List<SpaceLevel> spaceLevelList = Arrays.stream(SpaceLevelEnum.values()) // 获取所有枚举
            .map(spaceLevelEnum -> new SpaceLevel(
                    spaceLevelEnum.getValue(),
                    spaceLevelEnum.getText(),
                    spaceLevelEnum.getMaxCount(),
                    spaceLevelEnum.getMaxSize()))
            .collect(Collectors.toList());
    return ResultUtils.success(spaceLevelList);
}

4. 扩展


  1. 删除空间时,关联删除空间内的图片
  2. 管理员创建‏空间:管理员可以为指定用户‏创建空间。可以在创建空间时‏多传一个 userId 参‏数,但是要注意做好权限控制‌,仅管理员可以为别人创建空间。
  3. 目前更新上传‏图片的逻辑还是存在一些问题‏的。比如更新图片时,并没有删除原有‏图片、也没有减少原有图片占用的‏空间和额度,可以通过事务中补充‌逻辑或者通过定时任务扫描删除。

在这里插入图片描述

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到