空间模块

tim-qtp...大约 8 分钟云图库项目

1、对于空间模块,通常得有这些功能:

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

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

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

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

2、空间库表设计

1、空间表

-- 空间表
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;

2、图片表

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

SQL 如下:

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

-- 创建索引
CREATE INDEX idx_spaceId ON picture (spaceId); --我们经常用空间id查询有多少条图片的

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

3、空间管理

1、数据模型

1)利用 MyBatisX 插件生成空间表相关的基础代码,包括实体类、Mapper、Service,然后修改实体类的主键生成策略并指定逻辑删除字段,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;
}

2)每个操作都需要提供一个请求类,都放在 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;
}

3)在 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;
    }
}

4)在 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、基础服务开发

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

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

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

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

4、用户创建私有空间

流程如下:

  1. 填充参数默认值
  2. 校验参数
  3. 校验权限,非管理员只能创建普通级别的空间
  4. 控制同一用户只能创建一个私有空间
/**
     * 创建空间
     *
     * @param spaceAddRequest
     * @param loginUser
     * @return
     */
@Override
public long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {
    // 1. 填充参数默认值
    // 转换实体类和 DTO
    Space space = new Space();
    BeanUtils.copyProperties(spaceAddRequest, space);
    if (StrUtil.isBlank(space.getSpaceName())) {
        space.setSpaceName("默认空间");
    }
    if (space.getSpaceLevel() == null) {
        space.setSpaceLevel(SpaceLevelEnum.COMMON.getValue());
    }
    if (space.getSpaceType() == null) {
        space.setSpaceType(SpaceTypeEnum.PRIVATE.getValue());
    }
    // 填充容量和大小
    this.fillSpaceBySpaceLevel(space);
    // 2. 校验参数
    this.validSpace(space, true);
    // 3. 校验权限,非管理员只能创建普通级别的空间
    Long userId = loginUser.getId();
    space.setUserId(userId);
    if (SpaceLevelEnum.COMMON.getValue() != space.getSpaceLevel() && !userService.isAdmin(loginUser)) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限创建指定级别的空间");
    }
    // 4. 控制同一用户只能创建一个私有空间、以及一个团队空间
    String lock = String.valueOf(userId).intern();
    synchronized (lock) {
        Long newSpaceId = transactionTemplate.execute(status -> {
            // 判断是否已有空间
            boolean exists = this.lambdaQuery()
                .eq(Space::getUserId, userId)
                .eq(Space::getSpaceType, space.getSpaceType())
                .exists();
            // 如果已有空间,就不能再创建
            ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户每类空间只能创建一个");
            // 创建
            boolean result = this.save(space);
            ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "保存空间到数据库失败");
            // 创建成功后,如果是团队空间,关联新增团队成员记录
            if (SpaceTypeEnum.TEAM.getValue() == space.getSpaceType()) {
                SpaceUser spaceUser = new SpaceUser();
                spaceUser.setSpaceId(space.getId());
                spaceUser.setUserId(userId);
                spaceUser.setSpaceRole(SpaceRoleEnum.ADMIN.getValue());
                result = spaceUserService.save(spaceUser);
                ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "创建团队成员记录失败");
            }
            //                // 创建分表(仅对团队空间生效)为方便部署,暂时不使用
            //                dynamicShardingManager.createSpacePictureTable(space);
            // 返回新写入的数据 id
            return space.getId();
        });
        return Optional.ofNullable(newSpaceId).orElse(-1L);
    }
}

注意,上述代码中,我们使用本地 synchronized 锁对 userId 进行加锁,这样不同的用户可以拿到不同的锁,对性能的影响较低。在加锁的代码中,我们使用 Spring 的 编程式事务管理器 transactionTemplate 封装跟数据库有关的查询和插入操作,而不是使用 @Transactional 注解来控制事务,这样可以保证事务的提交在加锁的范围内。

💡 只要涉及到事务操作,其实测试时自己 new 个运行时异常来验证是否会回滚就会知道。

扩展 - 本地锁优化

上述代码中,我们是对字符串常量池(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);
        }
    }
}