空间模块
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、用户创建私有空间
流程如下:
- 填充参数默认值
- 校验参数
- 校验权限,非管理员只能创建普通级别的空间
- 控制同一用户只能创建一个私有空间
/**
* 创建空间
*
* @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);
}
}
}