Skip to content

智能云图库空间模块

一、需求分析

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

  • 【管理员】管理空间
  • 用户创建私有空间
  • 私有空间权限控制
  • 空间级别和限额控制

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

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

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

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

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

二、方案设计

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

思考下面的问题:

  1. 为什么要有 “空间” 的概念?
  2. 如何对空间进行库表设计?
  3. 公共图库和空间的关系?

空间的必要性

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

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

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

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

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

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

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

  1. 和之前的公共图库完全分开,尽量只额外增加空间相关的逻辑和代码,减少对代码的修改。
  2. 以后我们要开发团队共享空间,需要对空间进行成员管理,也是需要 “空间” 概念的。所以目前设计的空间表,要能够兼容之后的共享空间,便于后续扩展。

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

空间库表设计

1、空间表

表名:space(空间表)

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

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 如下:

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

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

默认情况下,spaceId 为空,表示图片上传到了公共图库。aBxepiWUEGZV9/dC1YWVpHGLHtNF4Mzxu+qzsN+DnFU=

公共图库和空间的关系

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

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

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

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

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

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

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

三、后端开发

空间管理

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

1、数据模型

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

用户模块中有讲解详细流程,此处不再赘述。

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

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

空间创建请求:

java
@Data
public class SpaceAddRequest implements Serializable {

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

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

    private static final long serialVersionUID = 1L;
}

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

java
@Data
public class SpaceEditRequest implements Serializable {

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

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

    private static final long serialVersionUID = 1L;
}

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

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

空间查询请求:

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

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

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

4)在 model.enums 包下新建空间级别枚举,定义每个级别的空间对应的限额:

java
@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 参数用来区分是创建数据时校验还是编辑时校验,判断条件是不一样的:

java
@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, "空间名称过长");
    }
}

2)在创建或更新空间时,需要根据空间级别自动填充限额数据,可以在服务中编写方法便于复用:

java
@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 类,大多数代码可以直接复用。

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

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

开发更新接口时,需要调用填充空间限额数据的方法:

java
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> 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、创建空间流程

流程如下:

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

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

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

2、创建空间服务

按照上述流程编写代码:

java
@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 来存储锁对象。

示例代码如下:

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

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

3)本地锁改为分布式锁,可以基于 Redisson 实现。鱼皮编程导航的 AI 答题应用平台项目 中有讲解。

私有空间权限控制

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

1、图片表新增字段

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

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

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

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

2、上传和更新图片

1)上传图片时支持指定空间 id,表示要将图片上传到哪个空间。

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

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

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

java
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, "没有空间权限");
    }
}

3)如果是更新图片,需要校验更新时传递的 spaceId 和已有图片的 spaceId 是否一致。如果更新时未传递 spaceId,则复用原有图片的 spaceId。代码如下:

java
// 如果是更新图片,需要校验图片是否存在
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 不一致");
        }
    }
}

4)之前是按用户划分图片上传目录的,现在如果有 spaceId,可以按照空间来划分图片上传目录。

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

5)插入 / 更新数据时,将 spaceId 设置到 Picture 对象中:

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

2、删除图片

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

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

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

删除图片方法代码如下:

java
@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。代码如下:

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

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

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

1)根据 id 查询接口

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

java
// 查询数据库
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);
}

2)分页查询接口

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

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

PictureQueryRequest 新增代码:

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

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

QueryWrapper 新增代码:

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

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

java
// 空间权限校验
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 个判断条件:

java
// 空间权限校验
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, "空间大小不足");
    }
}

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

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

java
// 开启事务
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 事务管理器,将删除图片和更新额度的数据库操作视为一个整体,避免删除图片后没释放额度的情况。

java
// 校验权限
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 封装类:

java
@Data
@AllArgsConstructor
public class SpaceLevel {

    private int value;

    private String text;

    private long maxCount;

    private long maxSize;
}

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

java
@GetMapping("/list/level")
public BaseResponse<List<SpaceLevel>> listSpaceLevel() {
    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);
}

扩展

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

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

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

四、前端开发

空间管理页面

首先从最好开发的管理页面做起,流程和其他的管理页面完全一致。

1、新建路由和菜单

首先新建 admin/SpaceManagePage.vue 页面文件,在 router/index.ts 中定义路由:

typescript
{
  path: '/admin/spaceManage',
  name: '空间管理',
  component: SpaceManagePage,
},

在 GlobalHeader 组件中补充菜单:

typescript
{
  key: '/admin/spaceManage',
  label: '空间管理',
  title: '空间管理',
},

由于之前已经编写了权限校验逻辑,地址以 /admin 开头的页面都会限制为仅管理员可见和可用,所以无需再编写额外的权限校验代码。

2、定义空间常量

和后端一样,前端也有很多地方要用到空间级别信息,可以定义为一个常量。

constants 目录下新建 space.ts 常量文件,定义枚举信息、对应的中文映射、以及后续筛选空间级别时要用到的选项数组:

typescript
// 空间级别枚举
export const SPACE_LEVEL_ENUM = {
  COMMON: 0,
  PROFESSIONAL: 1,
  FLAGSHIP: 2,
} as const;

// 空间级别文本映射
export const SPACE_LEVEL_MAP: Record<number, string> = {
  0: '普通版',
  1: '专业版',
  2: '旗舰版',
};

// 空间级别选项映射
export const SPACE_LEVEL_OPTIONS = Object.keys(SPACE_LEVEL_MAP).map((key) => {
  const value = Number(key); // Convert string key to number
  return {
    label: SPACE_LEVEL_MAP[value],
    value,
  };
});

💡 这种代码完全可以利用 AI 生成。1GTBnr2KWjD02HAL89XX8V8KAx8IS08Lv7koMGQAUwk=

3、开发管理页面

跟用户管理页面类似,页面的上方是搜索栏,下方是表格,表格需要支持分页。

大多数的内容可以直接复用其他管理页面的代码,可以先复制过来,再进行修改。

1)给表格定义展示的列:

typescript
const columns = [
  {
    title: 'id',
    dataIndex: 'id',
    width: 80,
  },
  {
    title: '空间名称',
    dataIndex: 'spaceName',
  },
  {
    title: '空间级别',
    dataIndex: 'spaceLevel',
  },
  {
    title: '使用情况',
    dataIndex: 'spaceUseInfo',
  },
  {
    title: '用户 id',
    dataIndex: 'userId',
    width: 80,
  },
  {
    title: '创建时间',
    dataIndex: 'createTime',
  },
  {
    title: '编辑时间',
    dataIndex: 'editTime',
  },
  {
    title: '操作',
    key: 'action',
  },
]

2)从后端获取数据,并支持搜索和分页:U5B+YQj7OIpsUYlJ0xM2ifW28LcI7JZxuylB/f4UqCk=

typescript
// 数据
const dataList = ref([])
const total = ref(0)

// 搜索条件
const searchParams = reactive<API.SpaceQueryRequest>({
  current: 1,
  pageSize: 10,
  sortField: 'createTime',
  sortOrder: 'descend',
})

// 分页参数
const pagination = computed(() => {
  return {
    current: searchParams.current ?? 1,
    pageSize: searchParams.pageSize ?? 10,
    total: total.value,
    showSizeChanger: true,
    showTotal: (total) => `共 ${total} 条`,
  }
})

// 获取数据
const fetchData = async () => {
  const res = await listSpaceByPageUsingPost({
    ...searchParams,
  })
  if (res.data.data) {
    dataList.value = res.data.data.records ?? []
    total.value = res.data.data.total ?? 0
  } else {
    message.error('获取数据失败,' + res.data.message)
  }
}

// 页面加载时请求一次
onMounted(() => {
  fetchData()
})

// 获取数据
const doSearch = () => {
  // 重置搜索条件
  searchParams.current = 1
  fetchData()
}

// 表格变化处理
const doTableChange = (page: any) => {
  searchParams.current = page.current
  searchParams.pageSize = page.pageSize
  fetchData()
}

注意:获取数据时,调用的是仅管理员可用的查询接口 listSpaceByPageUsingPost,不是给用户使用的查询包装类接口。

3)自定义列的展示,比如空间级别、使用情况、创建时间、编辑时间等:

vue
<template #bodyCell="{ column, record }">
  <!-- 空间级别 -->
  <template v-if="column.dataIndex === 'spaceLevel'">
    <a-tag>{{ SPACE_LEVEL_MAP[record.spaceLevel] }}</a-tag>
  </template>
  <!-- 使用情况 -->
  <template v-if="column.dataIndex === 'spaceUseInfo'">
    <div>大小:{{ formatSize(record.totalSize) }} / {{ formatSize(record.maxSize) }}</div>
    <div>数量:{{ record.totalCount }} / {{ record.maxCount }}</div>
  </template>
  <template v-else-if="column.dataIndex === 'createTime'">
    {{ dayjs(record.createTime).format('YYYY-MM-DD HH:mm:ss') }}
  </template>
  <template v-else-if="column.dataIndex === 'editTime'">
    {{ dayjs(record.editTime).format('YYYY-MM-DD HH:mm:ss') }}
  </template>
  <template v-else-if="column.key === 'action'">
    <a-space wrap>
      <a-button type="link" :href="`/add_space?id=${record.id}`" target="_blank">
        编辑
      </a-button>
      <a-button type="link" danger @click="doDelete(record.id)">删除</a-button>
    </a-space>
  </template>
</template>

4)开发搜索表单,支持按照空间名称、空间级别、用户 id 搜索:

vue
<a-form layout="inline" :model="searchParams" @finish="doSearch">
  <a-form-item label="空间名称" name="spaceName">
    <a-input v-model:value="searchParams.spaceName" placeholder="请输入空间名称" allow-clear />
  </a-form-item>
  <a-form-item label="空间级别" name="spaceLevel">
    <a-select
      v-model:value="searchParams.spaceLevel"
      :options="SPACE_LEVEL_OPTIONS"
      placeholder="请输入空间级别"
      style="min-width: 180px"
      allow-clear
    />
  </a-form-item>
  <a-form-item label="用户 id" name="userId">
    <a-input v-model:value="searchParams.userId" placeholder="请输入用户 id" allow-clear />
  </a-form-item>
  <a-form-item>
    <a-button type="primary" html-type="submit">搜索</a-button>
  </a-form-item>
</a-form>

5)补充操作按钮。

可以在搜索表单上新增一行,展示标题和创建空间按钮,点击按钮会打开创建空间页面:

vue
<a-flex justify="space-between">
  <h2>空间管理</h2>
  <a-space>
    <a-button type="primary" href="/add_space" target="_blank">+ 创建空间</a-button>
  </a-space>
</a-flex>

在表格操作列中,也要补充编辑按钮,点击后打开编辑空间页面:

vue
<a-space wrap>
  <a-button type="link" :href="`/add_space?id=${record.id}`" target="_blank">
    编辑
  </a-button>
  <a-button type="link" danger @click="doDelete(record.id)">删除</a-button>
</a-space>

创建空间页面

1、新建路由

首先新建 AddSpacePage.vue 页面文件,在 router/index.ts 中定义路由:

typescript
{
  path: '/add_space',
  name: '创建空间',
  component: AddSpacePage,
},

2、开发表单

该页面的主体是表单,可以直接复制批量创建图片页面,略作修改即可。

1)先修改表单项,允许填写空间名称、空间级别:

vue
<a-form layout="vertical" :model="formData" @finish="handleSubmit">
  <a-form-item label="空间名称" name="spaceName">
    <a-input v-model:value="formData.spaceName" placeholder="请输入空间名称" allow-clear />
  </a-form-item>
  <a-form-item label="空间级别" name="spaceLevel">
    <a-select
      v-model:value="formData.spaceLevel"
      :options="SPACE_LEVEL_OPTIONS"
      placeholder="请输入空间级别"
      style="min-width: 180px"
      allow-clear
    />
  </a-form-item>
  <a-form-item>
    <a-button type="primary" html-type="submit" style="width: 100%" :loading="loading">
      提交
    </a-button>
  </a-form-item>
</a-form>

2)定义表单项结构和 loading 变量:

typescript
const formData = reactive<API.SpaceAddRequest | API.SpaceUpdateRequest>({
  spaceName: '',
  spaceLevel: SPACE_LEVEL_ENUM.COMMON,
})
const loading = ref(false)

3)编写提交函数,创建成功后会输出信息并跳转到新创建的空间详情页:

typescript
const handleSubmit = async (values: any) => {
  loading.value = true;
  const res = await addSpaceUsingPost({
    ...formData,
  })
  if (res.data.code === 0 && res.data.data) {
    message.success("创建成功")
    router.push({
      path: `/space/${res.data.data}`,
    })
  } else {
    message.error('创建失败,' + res.data.message)
  }
  loading.value = false;
}

3、展示空间级别信息

无论是用户和管理员,都需要了解空间级别信息;而且目前用户只能开通普通版空间,这个信息也要同步给用户。由于系统不支持支付,可以先让有需求的用户主动联系管理员,这是最快的盈利方式。

所以可以在表单下新增展示这些信息的卡片。

1)先利用组件库的 卡片组件 开发页面内容:

vue
<a-card title="空间级别介绍">
  <a-typography-paragraph>
    * 目前仅支持开通普通版,如需升级空间,请联系
    <a href="https://codefather.cn" target="_blank">程序员鱼皮</a>。
  </a-typography-paragraph>
  <a-typography-paragraph v-for="spaceLevel in spaceLevelList">
    {{ spaceLevel.text }}: 大小 {{ formatSize(spaceLevel.maxSize) }}, 数量
    {{ spaceLevel.maxCount }}
  </a-typography-paragraph>
</a-card>

2)请求后端获取空间级别列表:

typescript
const spaceLevelList = ref<API.SpaceLevel[]>([])

// 获取空间级别
const fetchSpaceLevelList = async () => {
  const res = await listSpaceLevelUsingGet()
  if (res.data.code === 0 && res.data.data) {
    spaceLevelList.value = res.data.data
  } else {
    message.error('加载空间级别失败,' + res.data.message)
  }
}

onMounted(() => {
  fetchSpaceLevelList()
})

4、更新空间页面

仅管理员可以更新空间,从空间管理页面可以跳转到编辑空间页面。由于编辑页面和创建页面都是表单项,结构一致,可以复用创建页面。U5B+YQj7OIpsUYlJ0xM2ifW28LcI7JZxuylB/f4UqCk=

1)跟之前开发的上传图片页面类似,可以利用 url 的 querystring 传递要修改的 spaceId 参数,在页面中获取到已有空间数据并填充表单项。

typescript
const route = useRoute()
const oldSpace = ref<API.SpaceVO>()

// 获取老数据
const getOldSpace = async () => {
  // 获取数据
  const id = route.query?.id
  if (id) {
    const res = await getSpaceVoByIdUsingGet({
      id: id,
    })
    if (res.data.code === 0 && res.data.data) {
      const data = res.data.data
      oldSpace.value = data
      formData.spaceName = data.spaceName
      formData.spaceLevel = data.spaceLevel
    }
  }
}

// 页面加载时,请求老数据
onMounted(() => {
  getOldSpace()
})

2)修改提交函数,根据是否有 spaceId 决定是调用更新接口还是创建接口:

typescript
const handleSubmit = async (values: any) => {
  const spaceId = oldSpace.value?.id
  loading.value = true
  let res
  // 更新
  if (spaceId) {
    res = await updateSpaceUsingPost({
      id: spaceId,
      ...formData,
    })
  } else {
    // 创建
    res = await addSpaceUsingPost({
      ...formData,
    })
  }
  if (res.data.code === 0 && res.data.data) {
    message.success('操作成功')
    let path = `/space/${spaceId ?? res.data.data}`
    router.push({
      path,
    })
  } else {
    message.error('操作失败,' + res.data.message)
  }
  loading.value = false
}

扩展

1)支持管理员填写空间大小、空间条数,从而实现类似 “资源扩容包” 的功能。

2)选择空间级别时自动填充空间大小、空间条数这两个表单项。

用户创建私有空间

现在已经有了创建空间的页面,只需要补充进入到该页面的入口即可。

1、展示私有空间入口

预期效果是:左侧新增目录栏,展示 “公共图库” 和 “我的空间” 菜单项,点击 “我的空间” 后,如果没有创建过空间,则自动跳转到创建空间页面。

1)可以基于组件库的 Layout Sider 组件 实现 “顶部 - 侧边布局”:

跟 GlobalHeader 全局顶部栏组件一样,我们单独封装一个 GlobalSider 全局侧边栏组件,在 Sider 组件内使用 内嵌菜单组件,先利用 Demo 把页面结构撑起来:

vue
<div id="globalSider">
  <a-layout-sider class="sider" width="200">
    <a-menu
      mode="inline"
      v-model:selectedKeys="current"
      :items="menuItems"
      @click="doMenuClick"
    />
  </a-layout-sider>
</div>

在 BasicLayout 全局布局中引入侧边栏组件:

vue
<a-layout>
  <GlobalSider class="sider" />
  <a-layout-content class="content">
    <router-view />
  </a-layout-content>
</a-layout>

优化一下样式,隐藏多余的边框,下列代码仅展示了新增或修改的样式:

css
#basicLayout .header {
  margin-bottom: 1px;
}

#basicLayout .content {
  padding: 28px;
}

#basicLayout .sider {
  background: #fff;
  padding-top: 20px;
  border-right: 0.5px solid #eee;
}

#basicLayout :deep(.ant-menu-root) {
  border-bottom: none !important;
  border-inline-end: none !important;
}

2)开发 GlobalSider 组件。跟 GlobalHeader 组件一样,需要定义菜单项、实现点击跳转、根据路由自动高亮。

typescript
// 菜单列表
const menuItems = [
  {
    key: '/',
    label: '公共图库',
    icon: () => h(PictureOutlined),
  },
  {
    key: '/my_space',
    label: '我的空间',
    icon: () => h(UserOutlined),
  },
]

const router = useRouter()

// 当前选中菜单
const current = ref<string[]>([])
// 监听路由变化,更新当前选中菜单
router.afterEach((to, from, failure) => {
  current.value = [to.path]
})

// 路由跳转事件
const doMenuClick = ({ key }: { key: string }) => {
  router.push({
    path: key,
  })
}

3)优化侧边栏的展示

可以给组件增加条件判断,如果用户未登录,则不用显示侧边栏:

vue
<a-layout-sider v-if="loginUserStore.loginUser.id" class="sider" width="200">
</a-layout-sider>
const loginUserStore = useLoginUserStore()

还可以使用 响应式布局,屏幕尺寸小于 lg 时,自动折叠侧边栏:

vue
<a-layout-sider v-if="loginUserStore.loginUser.id" 
  class="sider"
  width="200" 
  breakpoint="lg"
  collapsed-width="0"
>
</a-layout-sider>

4)可以在 GlobalHeader 右边头像的下拉菜单中也添加 “我的空间” 跳转:

vue
<a-menu-item>
  <router-link to="/my_space">
    <UserOutlined />
    我的空间
  </router-link>
</a-menu-item>

2、我的空间页面

我的空间页面是一个 “中间页”,作用是根据用户是否已有空间,重定向 到对应的页面。

先梳理业务流程,跳转到该页面时:

  • 用户未登录,则直接跳转到登录页面
  • 如果用户已登录,会获取该用户已创建的空间
  • 如果有,则进入第一个空间
  • 如果没有,则跳转到创建空间页面

1)新建文件和路由:

typescript
{
  path: '/my_space',
  name: '我的空间',
  component: MySpacePage,
},

2)编写页面:

vue
<template>
  <div id="mySpace">
    <p>正在跳转,请稍候...</p>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listSpaceVoByPageUsingPost } from '@/api/spaceController'
import { message } from 'ant-design-vue'
import { useLoginUserStore } from '@/stores/useLoginUserStore'

const router = useRouter()
const loginUserStore = useLoginUserStore()

// 检查用户是否有个人空间
const checkUserSpace = async () => {
  const loginUser = loginUserStore.loginUser
  if (!loginUser?.id) {
    router.replace('/user/login')
    return
  }
  // 获取用户空间信息
  const res = await listSpaceVoByPageUsingPost({
    userId: loginUser.id,
    current: 1,
    pageSize: 1,
  })
  if (res.data.code === 0) {
    if (res.data.data?.records?.length > 0) {
      const space = res.data.data.records[0]
      router.replace(`/space/${space.id}`)
    } else {
      router.replace('/add_space')
      message.warn('请先创建空间')
    }
  } else {
    message.error('加载我的空间失败,' + res.data.message)
  }
}

// 在页面加载时检查用户空间
onMounted(() => {
  checkUserSpace()
})
</script>

上述代码的核心是 checkUserSpace 函数,在页面加载时会检查用户是否登录、是否已有空间,并使用 router.replace 重定向页面,这样点击浏览器的后退按钮时,不会回到中间页。

扩展

参考 组件库的 Demo,固定全局侧边栏和全局顶部栏:

用户使用私有空间(空间详情页)

空间详情页主要是展示空间信息、并展示空间内的图片列表,其结构和公开图库的主页非常相似,可以复用组件。

1、封装图片列表组件

先封装图片列表组件 PictureList,该组件只负责数据的展示、不负责数据的查询,因此要把分页组件单独拉出来。

1)开发图片列表组件,大多数的代码都是从主页复制来的,关键是定义属性,接受 dataList 数据列表和 loading 加载状态:

vue
<template>
  <div class="picture-list">
    <!-- 图片列表 -->
    <a-list
      :grid="{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 6 }"
      :data-source="dataList"
      :loading="loading"
    >
      <template #renderItem="{ item: picture }">
        <a-list-item style="padding: 0">
          <!-- 单张图片 -->
          <a-card hoverable @click="doClickPicture(picture)">
            <template #cover>
              <img
                style="height: 180px; object-fit: cover"
                :alt="picture.name"
                :src="picture.thumbnailUrl ?? picture.url"
                loading="lazy"
              />
            </template>
            <a-card-meta :title="picture.name">
              <template #description>
                <a-flex>
                  <a-tag color="green">
                    {{ picture.category ?? '默认' }}
                  </a-tag>
                  <a-tag v-for="tag in picture.tags" :key="tag">
                    {{ tag }}
                  </a-tag>
                </a-flex>
              </template>
            </a-card-meta>
          </a-card>
        </a-list-item>
      </template>
    </a-list>
  </div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router'

interface Props {
  dataList?: API.PictureVO[]
  loading?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  dataList: () => [],
  loading: false,
})

// 跳转至图片详情
const router = useRouter()
const doClickPicture = (picture) => {
  router.push({
    path: `/picture/${picture.id}`,
  })
}
</script>

<style scoped></style>

2)修改主页的图片列表代码,使用图片列表组件,并补充 分页组件

vue
<!-- 图片列表 -->
<PictureList :dataList="dataList" :loading="loading" />
<a-pagination
  style="text-align: right"
  v-model:current="searchParams.current"
  v-model:pageSize="searchParams.pageSize"
  :total="total"
  @change="onPageChange"
/>

2、开发空间详情页

该页面的结构是:空间信息 + 展示空间下的图片列表1GTBnr2KWjD02HAL89XX8V8KAx8IS08Lv7koMGQAUwk=

1)新建 SpaceDetailPage.vue 和路由,跟图片详情页类似,路由要能接受动态参数,表示要加载的空间 id:

typescript
{
  path: '/space/:id',
  name: '空间详情',
  component: SpaceDetailPage,
  props: true,
},

2)编写页面脚本,主要是获取空间详情数据和获取空间下的图片列表数据:

typescript
const props = defineProps<{
  id: string | number
}>()
const space = ref<API.SpaceVO>({})

// 获取空间详情
const fetchSpaceDetail = async () => {
  try {
    const res = await getSpaceVoByIdUsingGet({
      id: props.id,
    })
    if (res.data.code === 0 && res.data.data) {
      space.value = res.data.data
    } else {
      message.error('获取空间详情失败,' + res.data.message)
    }
  } catch (e: any) {
    message.error('获取空间详情失败:' + e.message)
  }
}

onMounted(() => {
  fetchSpaceDetail()
})

// 数据
const dataList = ref([])
const total = ref(0)
const loading = ref(true)

// 搜索条件
const searchParams = reactive<API.PictureQueryRequest>({
  current: 1,
  pageSize: 12,
  sortField: 'createTime',
  sortOrder: 'descend',
})

// 分页参数
const onPageChange = (page, pageSize) => {
  searchParams.current = page
  searchParams.pageSize = pageSize
  fetchData()
}

// 获取数据
const fetchData = async () => {
  loading.value = true
  // 转换搜索参数
  const params = {
    spaceId: props.id,
    ...searchParams,
  }
  const res = await listPictureVoByPageUsingPost(params)
  if (res.data.data) {
    dataList.value = res.data.data.records ?? []
    total.value = res.data.data.total ?? 0
  } else {
    message.error('获取数据失败,' + res.data.message)
  }
  loading.value = false
}

// 页面加载时请求一次
onMounted(() => {
  fetchData()
})

3)开发空间信息展示栏,展示空间名称,并使用 进度条组件 展示空间限额:

vue
<!-- 空间信息 -->
<a-flex justify="space-between">
  <h2>{{ space.spaceName }}(私有空间)</h2>
  <a-space size="middle">
    <a-button type="primary" :href="`/add_picture?spaceId=${id}`" target="_blank">
      + 创建图片
    </a-button>
    <a-tooltip
      :title="`占用空间 ${formatSize(space.totalSize)} / ${formatSize(space.maxSize)}`"
    >
      <a-progress
        type="circle"
        :percent="((space.totalSize * 100) / space.maxSize).toFixed(1)"
        :size="42"
      />
    </a-tooltip>
  </a-space>
</a-flex>

4)开发图片列表和分页,直接使用已封装的组件:

vue
<!-- 图片列表 -->
<PictureList :dataList="dataList" :loading="loading" />
<a-pagination
  style="text-align: right"
  v-model:current="searchParams.current"
  v-model:pageSize="searchParams.pageSize"
  :total="total"
  :show-total="() => `图片总数 ${total} / ${space.maxCount}`"
  @change="onPageChange"
/>

在分页组件中,我们要展示图片总数和最大数量,让用户知晓。

已经能够查看私有空间详情了,我们再依次开发对空间内图片的 “增删改查” 操作。CZm7H7UzXaYwPA7yiJL6QwQvTpEobkVBL2T/0TYHiQM=

3、上传图片到私有空间

1)修改创建图片页面,支持通过 url 查询参数中的 spaceId 传参,在页面中可以获取到 spaceId:

typescript
// 空间 id
const spaceId = computed(() => {
  return route.query?.spaceId
})

2)提交时,补充 spaceId 参数:

typescript
const res = await editPictureUsingPost({
  id: pictureId,
  spaceId: spaceId.value,
  ...values,
})

3)在页面中展示当前的 spaceId:

vue
<a-typography-paragraph v-if="spaceId" type="secondary">
  保存至空间:<a :href="`/space/${spaceId}`" target="_blank">{{ spaceId }}</a>
</a-typography-paragraph>

4)修改图片上传组件**(包括本地文件上传和 URL 上传)**,支持上传时传递 spaceId:

typescript
interface Props {
  picture?: API.PictureVO
  spaceId?: number
  onSuccess?: (newPicture: API.PictureVO) => void
}

// 上传时传递 spaceId
const params: API.PictureUploadRequest = props.picture ? { id: props.picture.id } : {}
params.spaceId = props.spaceId;
const res = await uploadPictureUsingPost(params, {}, file)

4、图片组件增加快捷操作

可以给空间详情页的图片卡片的下方增加快捷操作栏,提高管理效率,参考 卡片组件示例

1)修改图片列表组件,补充操作栏。

vue
<template #actions>
  <a-space @click="e => doEdit(picture, e)">
    <edit-outlined />
    编辑
  </a-space>
  <a-space @click="e => doDelete(picture, e)">
    <delete-outlined />
    删除
  </a-space>
</template>

2)由于该组件是主页和空间详情页公用的,主页不需要展示操作栏、私有空间才展示,所以需要使用 showOp 属性来控制:

typescript
interface Props {
  dataList?: API.PictureVO[]
  loading?: boolean
  showOp?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  dataList: () => [],
  loading: false,
  showOp: false,
})

页面结构增加条件:

vue
<template v-if="showOp" #actions>
</template>

3)补充编辑和删除函数,doEdit、doDelete 的代码可以直接从图片详情页复制过来,但是需要用 e.stopPropagation 阻止事件传播,否则会同时触发卡片点击事件,跳转到图片详情页。

typescript
// 编辑
const doEdit = (picture, e) => {
  e.stopPropagation()
  router.push({
    path: '/add_picture',
    query: {
      id: picture.id,
      spaceId: picture.spaceId,
    },
  })
}

// 删除
const doDelete = async (picture, e) => {
  e.stopPropagation()
  const id = picture.id
  if (!id) {
    return
  }
  const res = await deletePictureUsingPost({ id })
  if (res.data.code === 0) {
    message.success('删除成功')
    // 让外层刷新
    props?.onReload()
  } else {
    message.error('删除失败')
  }
}

4)注意,删除图片之后,可以 reload 重新触发数据的加载。给图片列表组件增加 onReload 属性:

typescript
interface Props {
  dataList?: API.PictureVO[]
  loading?: boolean
  showOp?: boolean
  onReload?: () => void
}

空间详情页传递属性:

vue
<!-- 图片列表 -->
<PictureList :dataList="dataList"
  :loading="loading"
  showOp 
  :onReload="fetchData"
/>

5、查看私有空间的图片

直接复用图片详情页 + 后端接口的权限校验,就已经实现了,无需开发。

6、编辑私有空间的图片

之前我们已经完成了更新图片页面的开发(其实是复用了图片创建页)。从空间详情页或图片详情页进入图片创建页时,只要给 url 查询参数携带 spaceId,图片创建页就能自动识别出 spaceId 并将图片保存到空间。

修改图片详情页的编辑图片函数,跳转页面时携带 spaceId:

typescript
// 编辑
const doEdit = () => {
  router.push({
    path: '/add_picture',
    query: {
      id: picture.value.id,
      spaceId: picture.value.spaceId
    }
  })
}

7、删除私有空间的图片

通过后端接口已经实现了权限校验逻辑,无需前端开发。

8、私有图片的权限控制

大多数权限校验已经通过后端实现,比如主页不传 spaceId 默认就查的是公共图库,无需修改前端代码;访问图片详情页时,也会通过后端进行权限校验。

但图片管理页面由于调用的是仅管理员可用的获取图片列表接口(listPictureByPage),看到的是全部的图片,其实只需要看公共空间的进行审核,可以给查询条件增加参数 nullSpaceId: true

typescript
const res = await listPictureByPageUsingPost({
  ...searchParams,
  nullSpaceId: true,
})

扩展

1)空间详情页增加空间标志的展示,比如尊贵的旗舰版展示个钻石 💎。

2)在空间内上传图片时可以使用弹窗组件,而不是打开新页面,更轻量。

3)上传图片到空间时,上传页面可以展示出空间名称、容量等信息,并且前端也可以判断是否具有权限,优化用户体验。

4)除了图片列表组件外,还可以封装单个图片的组件,复用更灵活。

5)图片详情页、空间详情页前端增加权限判断逻辑,相比单纯靠后端进行权限校验,可以给用户更好的体验。

Released under the MIT License.