智能云图库项目实战(4)---空间模块

‍♂️ 个人主页:@rain雨雨编程

微信公众号:rain雨雨编程

✍作者简介:持续分享机器学习,爬虫,数据分析
希望大家多多支持,我们一起进步!
如果文章对你有帮助的话,
欢迎评论 点赞 收藏 加关注+

目录

本节重点

一、需求分析

二、方案设计

空间的必要性

空间库表设计

1. 空间表

2、图片表

公共图库和空间的关系

三、后端开发

空间管理

1、数据模型

2、基础服务开发

3、接口开发

用户创建私有空间

1、创建空间流程

2、创建空间服务

扩展知识 - 本地锁优化

扩展

私有空间权限控制

1、图片表新增字段

2、上传和更新图片

2、删除图片

3、编辑图片

4、查询图片

空间级别和限额控制

1、上传图片时校验和更新额度

2、删除图片后更新额度

3、查询空间级别列表

扩展


本节重点

之前我们已经完成了公共图库的开发,为了进一步增加系统的应用价值,可以让每个用户都能创建自己的私有空间,打造自己的图片云盘、个人相册。

大纲:

  • 空间模块需求分析

  • 空间模块方案设计

  • 空间模块后端开发

  • 空间模块前端开发

一、需求分析

对于空间模块,通常要有这些功能:

  • 【管理员】管理空间

  • 用户创建私有空间

  • 私有空间权限控制

  • 空间级别和限额控制

看起来简单,但其实每个需求的细节都非常多,具体分析每个需求:

1)管理空间:仅管理员可用,可以对整个系统中的空间进行管理,比如搜索空间、编辑空间、删除空间。

2)用户创建私有空间:用户可以创建 最多一个 私有空间,并且在私有空间内自由上传和管理图片。

3)私有空间权限控制:用户仅能访问和管理自己的私有空间和其中的图片,私有空间的图片不会展示在公共图库,也不需要管理员审核。

4)空间级别和限额控制:每个空间有不同的级别(如普通版和专业版),对应了不同的容量和图片数量限制,如果超出限制则无法继续上传图片。

二、方案设计

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

思考下面的问题:

  1. 为什么要有 “空间” 的概念?

  2. 如何对空间进行库表设计?

  3. 公共图库和空间的关系?

空间的必要性

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

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

A:如果这样做,会存在一个很大的问题:用户私有图片是需要隐私 的,不需要被管理员审核,也不能被其他人公开查看。这和现在的公共图库平台的逻辑不一致。

想象一下,图片表中只有 userId 字段,无法区分图片到底是私有的还是公开的。

Q:那如果允许用户上传私有图片呢?比如设置图片可见范围为 “仅自己可见”?

A:这的确是可行的,对于内容占用存储空间不大的平台,很适合采用这种方案。但是,对于图库平台,图片占用的存储空间会直接产生存储费用,因此需要对用户上传的图片大小和数量进行限制。类似于给你分配了一个电脑硬盘,它就是你的,用满了就不能再传图了。

所以使用 “空间” 的概念会更符合这种应用场景,可以针对空间进行限制和分析,也更便于管理。

此外,从项目可扩展性的角度来讲,抽象 “空间” 的概念还有 2 个优势:

  1. 和之前的公共图库完全分开,尽量只额外增加空间相关的逻辑和代码,减少对代码的修改。

  2. 以后我们要开发团队共享空间,需要对空间进行成员管理,也是需要 “空间” 概念的。所以目前设计的空间表,要能够兼容之后的共享空间,便于后续扩展。

这就是一种可扩展性的设计,当你发现系统逻辑较为复杂或产生冲突时,就抽象一个中间层(也就是 “空间”),使得新老逻辑分离,让项目更易于维护和扩展。

空间库表设计

1. 空间表

表名: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)添加索引,提高查询效率。

2、图片表

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

SQL 如下:

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

-- 创建索引
CREATE INDEX idx_spaceId ON picture (spaceId);
默认情况下,spaceId 为空,表示图片上传到了公共图库。

公共图库和空间的关系

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

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

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

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

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

公共图库和私有空间在数据结构、图片存储、权限控制、额度管理等方面存在本质区别,如果混合设计,会增加系统的复杂度并影响维护与扩展性。举个例子:公共图库应该上传到对象存储的 public 目录,该目录里的文件可以公开访问;但私有图片应该上传到单独的 space 目录,该目录里的文件可以进一步设置访问权限。

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

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

三、后端开发

空间管理

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

1、数据模型
  1. 首先利用 MyBatisX 插件生成空间表相关的基础代码,包括实体类、MapperService

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

@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;
}
  1. 每个操作都需要提供一个请求类,都放在 model.dto.space 包下。

空间创建请求:

@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;
}
  1. model.dto.vo 下新建空间的视图包装类,可以额外关联创建空间的用户信息。还可以编写 Space 实体类和该 VO 类的转换方法,便于后续快速传值。

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

    /**
     * 封装类转对象
     *
     * @param spaceVO
     * @return
     */
    public static Space voToObj(SpaceVO spaceVO) {
        if (spaceVO == null) {
            return null;
        }
        Space space = new Space();
        BeanUtils.copyProperties(spaceVO, space);
        return space;
    }

    /**
     * 对象转封装类
     *
     * @param space
     * @return
     */
    public static SpaceVO objToVo(Space space) {
        if (space == null) {
            return null;
        }
        SpaceVO spaceVO = new SpaceVO();
        BeanUtils.copyProperties(space, spaceVO);
        return spaceVO;
    }
}
  1. model.enums 包下新建空间级别枚举,定义每个级别的空间对应的限额:

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


    /**
     * @param text 文本
     * @param value 值
     * @param maxSize 最大图片总大小
     * @param maxCount 最大图片总数量
     */
    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 spaceLevelEnum : SpaceLevelEnum.values()) {
            if (spaceLevelEnum.value == value) {
                return spaceLevelEnum;
            }
        }
        return null;
    }
}

还有另外一种定义空间级别限额的方式,比如将空间限额配置存储在外部文件(如 JSON 文件或 properties 文件),并创建一个单独的类来接收参数。这样后期如果有变动,修改配置文件即可,而不必修改代码。

2、基础服务开发

可以参考图片服务的开发方法,完成 SpaceService 和实现类,大多数代码可以直接复用。

由于创建空间的逻辑比较复杂,可以先定义个接口占坑。我们主要开发下列方法:

  1. 需要开发校验空间数据的方法,增加 add 参数用来区分是创建数据时校验还是编辑时校验,判断条件是不一样的:

@Override
public void validSpace(Space space, boolean add) {
    ThrowUtils.throwIf(space == null, ErrorCode.PARAMS_ERROR);
    // 从对象中取值
    String spaceName = space.getSpaceName();
    Integer spaceLevel = space.getSpaceLevel();
    SpaceLevelEnum spaceLevelEnum = SpaceLevelEnum.getEnumByValue(spaceLevel);
    // 要创建
    if (add) {
        if (StrUtil.isBlank(spaceName)) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间名称不能为空");
        }
        if (spaceLevel == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间级别不能为空");
        }
    }
    // 修改数据时,如果要改空间级别
    if (spaceLevel != null && spaceLevelEnum == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间级别不存在");
    }
    if (StrUtil.isNotBlank(spaceName) && spaceName.length() > 30) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间名称过长");
    }
}
  1. 在创建或更新空间时,需要根据空间级别自动填充限额数据,可以在服务中编写方法便于复用:

@Override
public void fillSpaceBySpaceLevel(Space space) {
    // 根据空间级别,自动填充限额
    SpaceLevelEnum spaceLevelEnum = SpaceLevelEnum.getEnumByValue(space.getSpaceLevel());
    if (spaceLevelEnum != null) {
        long maxSize = spaceLevelEnum.getMaxSize();
        if (space.getMaxSize() == null) {
            space.setMaxSize(maxSize);
        }
        long maxCount = spaceLevelEnum.getMaxCount();
        if (space.getMaxCount() == null) {
            space.setMaxCount(maxCount);
        }
    }
}

如果空间本身没有设置限额,才会自动填充,保证了灵活性。

3、接口开发

参考图片接口的开发方法,完成 SpaceController 类,大多数代码可以直接复用。

需要重点关注接口的权限:

  • 创建空间:所有用户都可以使用

  • 删除空间:仅允许空间创建人或管理员删除

  • 更新空间:仅管理员可用,允许更新空间级别

  • 编辑空间:允许空间创建人使用,但注意可编辑的字段(不能编辑空间级别) 开发更新接口时,需要调用填充空间限额数据的方法:

@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse updateSpace(@RequestBody SpaceUpdateRequest spaceUpdateRequest) {
    if (spaceUpdateRequest == null || spaceUpdateRequest.getId() <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    // 将实体类和 DTO 进行转换
    Space space = new Space();
    BeanUtils.copyProperties(spaceUpdateRequest, space);
    // 自动填充数据
    spaceService.fillSpaceBySpaceLevel(space);
    // 数据校验
    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);
}

用户创建私有空间

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

需要开发创建空间服务,该服务较为复杂,我们要先整理下流程。

1、创建空间流程

流程如下:

  • 填充参数默认值

  • 校验参数

  • 校验权限,非管理员只能创建普通级别的空间

  • 控制同一用户只能创建一个私有空间

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

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

2、创建空间服务

按照上述流程编写代码:

@Resource
private TransactionTemplate transactionTemplate;

@Override
public long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {
    // 在此处将实体类和 DTO 进行转换
    Space space = new Space();
    BeanUtils.copyProperties(spaceAddRequest, space);
    // 默认值
    if (StrUtil.isBlank(spaceAddRequest.getSpaceName())) {
        space.setSpaceName("默认空间");
    }
    if (spaceAddRequest.getSpaceLevel() == null) {
        space.setSpaceLevel(SpaceLevelEnum.COMMON.getValue());
    }
    // 填充数据
    this.fillSpaceBySpaceLevel(space);
    // 数据校验
    this.validSpace(space, true);
    Long userId = loginUser.getId();
    space.setUserId(userId);
    // 权限校验
    if (SpaceLevelEnum.COMMON.getValue() != spaceAddRequest.getSpaceLevel() && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限创建指定级别的空间");
    }
    // 针对用户进行加锁
    String lock = String.valueOf(userId).intern();
    synchronized (lock) {
        Long newSpaceId = transactionTemplate.execute(status -> {
            boolean exists = this.lambdaQuery().eq(Space::getUserId, userId).exists();
            ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户仅能有一个私有空间");
            // 写入数据库
            boolean result = this.save(space);
            ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
            // 返回新写入的数据 id
            return space.getId();
        });
        // 返回结果是包装类,可以做一些处理
        return Optional.ofNullable(newSpaceId).orElse(-1L);
    }
}

注意,上述代码中,我们使用本地 synchronized 锁对 userId 进行加锁,这样不同的用户可以拿到不同的锁,对性能的影响较低。

在加锁的代码中,我们使用 Spring 编程式事务管理器 transactionTemplate 封装跟数据库有关的查询和插入操作,而不是使用 @Transactional 注解来控制事务,这样可以保证事务的提交在加锁的范围内。

只要涉及到事务操作,建议大家测试时自己 new 个运行时异常来验证是否会回滚。

扩展知识 - 本地锁优化

上述代码中,我们是对字符串常量池(intern)进行加锁的,数据并不会及时释放。如果还要使用本地锁,可以按需选用另一种方式 —— 采用 ConcurrentHashMap 来存储锁对象。

示例代码如下:

Map 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)用户注册成功时,可以自动创建空间。即使创建失败了,也可以手动创建作为兜底。

2)管理员可以为某个用户创建空间(目前没啥必要)

3)本地锁改为分布式锁,可以基于 Redisson 实现。

私有空间权限控制

私有空间的权限和公共图库是不同的,我们需要对之前 所有的图片操作 都添加和空间有关的权限校验逻辑。

1、图片表新增字段

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

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

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

下面我们依次给 “增删改查” 图片操作增加权限校验逻辑。

2、上传和更新图片
  1. 上传图片时支持指定空间 id,表示要将图片上传到哪个空间。

PictureUploadRequest 请求封装类补充 spaceId 字段。

  1. 修改上传图片方法 uploadPicture,校验空间是否存在;如果存在,还要校验是否有空间权限,仅空间的管理员才能上传。

现阶段空间的管理员就是空间的创建人

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, "没有空间权限");
    }
}
  1. 如果是更新图片,需要校验更新时传递的 spaceId 和已有图片的 spaceId 是否一致。如果更新时未传递 spaceId,则复用原有图片的 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 不一致");
        }
    }
}
  1. 之前是按用户划分图片上传目录的,现在如果有 spaceId,可以按照空间来划分图片上传目录。

// 按照用户 id 划分目录 => 按照空间划分目录
String uploadPathPrefix;
if (spaceId == null) {
    uploadPathPrefix = String.format("public/%s", loginUser.getId());
} else {
    uploadPathPrefix = String.format("space/%s", spaceId);
}
  1. 插入 / 更新数据时,将 spaceId 设置到 Picture 对象中:

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

如果要删除的图片有空间 id,表示是用户上传到私有空间中的图片,那么登录用户必须是空间的管理员(也就是创建者),系统管理员也不能随意删除私有空间的图片。

  1. 因为删除图片和编辑图片的权限控制是一样的(有删除权限就有编辑权限),可以将这段权限校验逻辑封装为一个方法:

@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);
        }
    }
}
  1. 原本删除图片 deletePicture 逻辑很简单,直接写到了 Controller 中,现在有了更多逻辑,建议封装为 service,并同步修改 Controller 来调用 Service

删除图片方法代码如下:

@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);
}
3、编辑图片

跟删除图片的权限校验逻辑一样,如果要编辑的图片有空间 id,表示是用户上传到私有空间中的图片,那么登录用户必须是空间的管理员(也就是创建者),系统管理员也不能随意编辑私有空间的图片。

editPicture 方法抽象到 Service 中,并同步修改 Controller 来调用 Service。代码如下:

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

由于更新图片是给管理员使用的接口,可以暂时不修改。

4、查询图片

用户无法查询到私有空间的图片,只能查询公共图库,单条查询和分页查询都要添加这个逻辑。

  1. 根据 id 查询接口 getPictureVOById

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

// 查询数据库
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);
}
  1. 分页查询接口 listPictureVOByPage

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

先给请求封装类 PictureQueryRequestQueryWrapper 补充空间 id 的查询条件。

PictureQueryRequest 新增代码:

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

/**
 * 是否只查询 spaceId 为 null 的数据
 */
private boolean nullSpaceId;
QueryWrapper 新增代码:

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

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

// 空间权限校验
Long spaceId = pictureQueryRequest.getSpaceId();
// 公开图库
if (spaceId == null) {
    // 普通用户默认只能查看已过审的公开数据
    pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
    pictureQueryRequest.setNullSpaceId(true);
} else {
    // 私有空间
    User loginUser = userService.getLoginUser(request);
    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, "没有空间权限");
    }
}

考虑到私有空间的图片更新频率不好把握,之前编写的缓存分页查询图片接口可以暂不使用和修改,可以将该接口标记为 @Deprecated 表示已废弃。

空间级别和限额控制

需求:每次上传图片前,都要校验空间剩余额度是否足够;每次上传和删除图片时,都要更新已使用的额度。

1、上传图片时校验和更新额度

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

这时你会怎么做呢?

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

比如:

  • 单张图片最大才 2M,那么即使空间满了再允许上传一张图片,影响也不大

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

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

  1. 修改 uploadPicture 方法,编写校验代码,只需要增加 2 个判断条件:

// 空间权限校验
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, "没有空间权限");
    }
    // 校验额度
    if (space.getTotalCount() >= space.getMaxCount()) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间条数不足");
    }
    if (space.getTotalSize() >= space.getMaxSize()) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间大小不足");
    }
}
  1. 保存图片记录时,需要使用事务更新额度,如果额度更新失败,也不用将图片记录保存。

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

// 开启事务
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、删除图片后更新额度

删除图片时,要释放额度。同样使用 transactionTemplate 事务管理器,将删除图片和更新额度的数据库操作视为一个整体,避免删除图片后没释放额度的情况。

// 校验权限
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、查询空间级别列表

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

  1. 新建 SpaceLevel 封装类:

@Data
@AllArgsConstructor
public class SpaceLevel {

    private int value;

    private String text;

    private long maxCount;

    private long maxSize;
}
  1. SpaceController 中编写接口,将枚举转换为空间级别对象列表:

@GetMapping("/list/level")
public BaseResponse> listSpaceLevel() {
    List spaceLevelList = Arrays.stream(SpaceLevelEnum.values()) // 获取所有枚举
            .map(spaceLevelEnum -> new SpaceLevel(
                    spaceLevelEnum.getValue(),
                    spaceLevelEnum.getText(),
                    spaceLevelEnum.getMaxCount(),
                    spaceLevelEnum.getMaxSize()))
            .collect(Collectors.toList());
    return ResultUtils.success(spaceLevelList);
}

扩展

  1. 删除空间时,关联删除空间内的图片

  2. 管理员创建空间:管理员可以为指定用户创建空间。可以在创建空间时多传一个 userId参数,但是要注意做好权限控制,仅管理员可以为别人创建空间。

  3. 目前更新上传图片的逻辑还是存在一些问题的。比如更新图片时,并没有删除原有图片、也没有减少原有图片占用的空间和额度,可以通过事务中补充逻辑或者通过定时任务扫描删除。

文章持续跟新,可以微信搜一搜公众号  rain雨雨编程 ],第一时间阅读,涉及数据分析,机器学习,Java编程,爬虫,实战项目等。

你可能感兴趣的:(项目实战,权限管理,锁机制,事务,云图库,Spring)