【智能协同云图库】智能协同云图库第六弹:空间模块开发
空间模块开发
本节重点:之前我们已经完成了公共图库的开发。
为了进一步增加系统的应用价值,可以让每个用户都能创建自己的私有空间,打造自己的图片云盘、个人相册。
本节教程不涉及新技术,重点学习业务经验和扩展系统的开发技巧,能够让大家学会更快更稳地给系统增加新的功能。
一、需求分析
对于空间模块,通常要有以下功能:
管理空间(仅管理员可用)
:可以对整个系统中的空间进行管理,比如搜索空间、编辑空间、删除空间。用户创建私有空间
:用户可以创建最多一个私有空间,并且在私有空间内自由上传和管理图片。私有空间权限控制
:用户仅能访问和管理自己的私有空间和其中的图片,私有空间的图片不会展示在公共图库,也不需要管理员审核。空间级别和限额控制
:每个空间有不同的级别(如普通版和专业版),对应了不同的容量和图片数量限制,如果超出限制则无法继续上传图片。
二、方案设计
从需求分析中,我们也能感受到,细节比较多。为了更好地把控这些细节,需要先对系统进行一个整体的方案设计。思考下面的问题:
为什么要有 “空间” 的概念?如果没有 “空间” 的概念,怎么实现让用户自由管理自己的私有图片呢
?
-
问题 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;
设计要点:
-
空间级别字段
:- 空间级别包括普通版、专业版和旗舰版,是可枚举的;
- 因此使用整型来节约空间、提高查询效率。
-
空间限额字段
:- 除了级别字段外,增加
maxSize
和maxCount
字段用于限制空间的图片总大小与数量,而不是在代码中根据级别读取限额
。 - 这样管理员可以单独设置限额,不用完全和级别绑定,利于扩展;而且查询限额时也更方便。
- 除了级别字段外,增加
-
索引设计
:- 为高频查询的字段(如空间名称、空间级别、用户 id)添加索引,提高查询效率。
- 空间表的
写操作(如创建空间)
频率远低于图片表,因此对空间字段建立多个索引所带来的写性能损耗
可以忽略不计;我们更应关注如何通过合理索引
,来显著提升“查询空间”这类读操作的效率。
图片表
由于一张图片只能属于一个空间,可以在图片表 picture
中新增字段 spaceId
,实现图片与空间的关联,同时增加索引以提高查询性能。SQL 如下:
-- 添加新列
ALTER TABLE pictureADD COLUMN spaceId bigint NULL COMMENT '空间 id(为空表示公共空间)';-- 创建索引
CREATE INDEX idx_spaceId ON picture (spaceId);
默认情况下,spaceId
为空,表示图片上传到了公共图库。
公共图库和空间的关系
有同学可能会这么想:
公共图库不就是系统管理员创建的一个空间么?
既然有了空间表,要不要把公共图库也当做一个默认的空间来设计呢?或者在空间表创建一条公共图库的记录?
有这个想法是好的,但此处为了确保公共图库与私有空间的独立性,必须进行单独的设计,并避免将两者混合。
原因如下:
-
公共图库的访问权限与私有空间不同
公共图库中的图片无需登录就能查看
,任何人都可以访问,不需要进行用户认证或成员管理。私有空间则要求用户登录,且访问权限严格控制
,通常只有空间管理员(或团队成员)才能查看或修改空间内容。
-
公共图库没有额度限制
私有空间会有图片大小、数量等方面的限制
,从而管理用户的存储资源和空间配额;- 而公共图库完全不受这些限制。
公共图库和私有空间在数据结构、图片存储、权限控制、额度管理等方面存在本质区别
,如果混合设计,会增加系统的复杂度并影响维护与扩展性。
举个例子:
公共图库应该上传到对象存储的 public 目录
,该目录里的文件可以公开访问;但私有图片应该上传到单独的 space 目录
,该目录里的文件可以进一步设置访问权限。
因此我们会使用 “公共图库”
而不是 “公共空间”
来表述,也能让整个项目各个阶段的设计更加独立。
由于细节较多,关于具体功能的实现方案会在开发具体功能前进行讲解,便于对照方案进行开发。
三、后端开发
空间管理
先从相对简单的管理能力(增删改查)开始开发。
1. 数据模型
首先利用 MyBatisX 插件 生成空间表相关的基础代码,包括实体类、Mapper、Service。
修改实体类的主键生成策略
,并指定逻辑删除
字段:
(1) 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;/** 是否删除 */@TableLogicprivate Integer isDelete;// 增加序列号@TableField(exist = false)private static final long serialVersionUID = 1L;
}
(2) 请求类(DTO)
统一放在: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;
}
用户删除空间请求:直接调用通用的删除请求类,传入 id 即可实现删除操作;
(3) 视图包装类(VO)
位置:model.dto.vo.SpaceVO
@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) 空间级别枚举
根据表字段空间级别的设定,我们要写一个枚举类来枚举空间级别:
位置:model.enums.SpaceLevelEnum
@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;}
}
💡 另一种限额方式:把配置放在外部 JSON / properties 文件,通过单独类读取,便于后期无代码修改。
2. 服务开发
实现接口
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);
}
(1) 数据校验
空间校验规则
-
前置校验
-
无论创建还是修改,
Space
参数本身不能为空,否则立即抛出PARAMS_ERROR
。 -
对校验接口新增参数
boolean add
,用于判断该接口是创建空间前校验
,还是更新空间信息前校验
; -
/*** 空间数据校验** @param space*/ void validSpace(Space space, boolean add);
-
-
字段级校验
- 创建场景(
add= true
)spaceName
不能为空且长度 ≤ 30。spaceLevel
不能为空,且必须是合法的枚举值。
- 修改场景(
add= false
)- 仅当字段被显式赋值时才校验:
spaceName
若提供,则不能为空且长度 ≤ 30。spaceLevel
若提供,则必须是合法的枚举值。
- 仅当字段被显式赋值时才校验:
- 创建场景(
-
枚举合法性
- 只要
spaceLevel
非null
,就必须存在于SpaceLevelEnum
,否则抛出PARAMS_ERROR
。
- 只要
@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 -> {// 获取当前空间的用户IDLong userId = spaceVO.getUserId();// 初始化用户对象为 nullUser 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) 根据级别自动填充限额
@Override
public void fillSpaceBySpaceLevel(Space space) {SpaceLevelEnum spaceLevelEnum = SpaceLevelEnum.getEnumByValue(space.getSpaceLevel());if(spaceLevelEnum != null){// 如果管理员没有设置 maxSize, maxCount, 才根据空间级别枚举, 设置 maxSize, maxCountLong maxSize = spaceLevelEnum.getMaxSize();if(space.getMaxSize() == null){space.setMaxSize(maxSize); }long maxCount = spaceLevelEnum.getMaxCount();if(space.getMaxCount() == null){space.setMaxCount(maxCount);}// 这样的设置能保证管理员在创建空间时, 自定义更大的空间容量}
}
3. 接口开发
参考图片接口的开发方法,完成 SpaceController
类,大多数代码可以直接复用。
需要重点关注接口的权限:
接口 | 权限说明 |
---|---|
创建空间 | 所有用户都可以使用 |
删除空间 | 仅允许空间创建人或管理员删除 |
更新空间 | 仅管理员可用,允许更新空间级别 |
编辑空间 | 允许空间创建人使用,但注意可编辑的字段(不能编辑空间级别) |
(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 参数 falsespaceService.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);
}
后续需要增加权限校验的接口,代码在增加权限校验后补充:
用户创建私有空间
用户可以自主创建私有空间,但是必须要加限制,最多只能创建一个
。
1. 创建空间流程
- 填充参数默认值
- 校验参数
- 校验权限,
非管理员只能创建普通级别的空间
- 控制
同一用户只能创建一个私有空间
如何保证同一用户只能创建一个私有空间?
- 最粗暴的方式是给空间表的
userId
加上唯一索引,但由于后续用户还可以创建团队空间,这种方式不利于扩展。- 所以我们采用
加锁 + 事务
的方式实现。
2. 创建空间服务
/*** 用户创建空间* @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. 转换实体类和 DTOSpace 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. 返回新写的数据的 idreturn space.getId();});// return newSpaceId;// 15. 处理直接 return newSpaceId; 代码报警告的 npe 问题 (可以直接返回)return Optional.ofNullable(newSpaceId).orElse(-1L);}
}
💡 注意事项
- 上述代码中,我们使用本地
synchronized
锁对userId
进行加锁,这样不同的用户可以拿到不同的锁,对性能的影响较低。 - 在加锁的代码中,我们使用 Spring 的
编程式事务管理器 transactionTemplate
封装跟数据库有关的查询和插入操作,而不是使用@Transactional
注解来控制事务,这样可以保证事务的提交在加锁的范围内。 - 如果一定要使用
@Transactional
,就需要将addSpace()
中的数据库单独封装为一个方法,对这个封装的方法使用@Transactional
即可; 只要涉及到事务操作,建议大家测试时自己 new 个运行时异常来验证是否会回滚。
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 全局字符串常量池里的同一份字符串
,造成的后果:
锁得太粗
:任何线程、任何业务只要intern("123")
,就会拿到同一把锁。
- userId 本身不会重复,但
字符串常量池不区分业务
。- 极端例子:
- 线程 A 在“创建空间”里
intern("123")
并加锁;- 线程 B 在“订单模块”里做
synchronized("123".intern()) { … }
;- 这两段完全不相干的代码就串行起来了,哪怕你只是在“创建空间”这个业务里用,别的线程/业务如果也恰好
intern()
了"123"
,它们就会跟你抢同一把锁,这就造成了“跨业务干扰”。- 所以,
锁得太粗
,就是因为池子里的字符串,是全局共享的,同一个 JVM 里所有线程、所有业务都会跟它打交道;任何线程、任何业务只要intern("123")
,就会拿到同一把锁。
- 池子会膨胀:
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);}}
}
锁按用户分家
lockMap.computeIfAbsent(userId, k -> new Object())
给每个 userId 只生成一把专用锁对象。锁对象是每个 userId 单独 new 出来的 Object
,只存当前业务的 Map 里。- 张三用张三的锁,李四用李四的锁;
两个用户之间完全并行,互不影响
。并发量从「全局串行」
变成「按用户并行」
。
锁生命周期可控
- 在
finally
里lockMap.remove(userId)
,用完即扔。锁对象只存在于真正需要它的那几百毫秒,不会长期占内存; - GC 很快就能回收,不会出现
常量池那种「只增不减」的泄漏
。
- 在
Map 本身线程安全
ConcurrentHashMap
保证computeIfAbsent
的原子性,也就是说,ConcurrentHashMap
保证同一 userId 永远只创建一把锁,线程安全。
总结:把“全局大锁”
拆成“用户级小锁”
,既避免跨业务抢锁
,又防止常量池膨胀
,锁竞争
和内存压力
都大幅下降。
4. 扩展
- 用户注册成功时,可以自动创建空间。即使创建失败了,也可以手动创建作为兜底;
- 管理员可以为某个用户创建空间(目前没啥必要);
- 本地锁改为分布式锁,可以基于 Redisson 实现(AI 答题应用平台项目),改为分布式锁的好处是,这个项目如果部署到多个服务器上,也不会出现锁冲突,但是现在我们是单机应用,使用单机锁即可,后续逐步扩展。
5. 接口开发
@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 字段,默认为 null 表示公共图库。
-- 添加新列, 前面已经执行过了
alter table pictureadd column spaceId bigint null comment '空间 id(为空表示公共空间)';
同步修改 PictureMapper.xml、Picture 实体类、PictureVO 响应视图,补充空间 id 字段:
/*** 空间 id*/
private Long spaceId;
2. 上传和更新图片
在 PictureUploadRequest
中新增字段:
private Long spaceId;
在 uploadPicture
方法中增加校验:
更新代码:注入 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
方法中增加校验:
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,则复用原有图片的 spaceIdif (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 实现的;
因此,我们在接口中自己写校验逻辑,再进一步地提取校验逻辑为一个公共方法;
/*** 公共校验权限方法* @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
方法
更新代码: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
方法
/*** 删除图片接口* @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 仅做转发
/*** 编辑图片接口* @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 转为 stringpicture.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);
}
更新图片接口目前仅管理员使用,可暂不修改。
5. 查询图片
- 用户无法查看私有空间图片,只能查询公共图库。
- 单条查询与分页查询均须添加空间权限校验。
(1) 根据 id 查询接口:getPictureVOById
如果查询出的图片有 spaceId,则运用跟删除图片一样的校验逻辑,仅空间管理员可以查看:
@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
查询请求增加 spaceId 参数,不传则表示查公共图库;
传参则表示查询特定空间 id 下的图片,此时登录用户必须是空间的管理员(其他用户无法查看别人空间的图片),并且不需要指定审核条件(私有空间没有审核机制)。
先给请求封装类 PictureQueryRequest
和 QueryWrapper
补充空间 id 的查询条件。
① PictureQueryRequest 新增代码:
/** 空间 id */
private Long spaceId;/** 是否只查询 spaceId 为 null 的数据 */
private boolean nullSpaceId;
当前方案必须保留 nullSpaceId
,原因如下:
- “公共图库”需要显式标记
若仅用
spaceId IS NULL
表示公共图库,会导致查询语义错误:
- 条件
WHERE space_id = NULL
在 SQL 中恒为假,无法返回任何记录。- 若改用
WHERE space_id IS NULL
,则会把“未关联空间”的图片(可能包含异常数据)误判为公共图库图片,且无法区分“已关联空间”和“公共图库”两类数据。
- 专用字段避免歧义
nullSpaceId
作为布尔标志位(如is_public = true
),可明确标识公共图库图片,确保:
- 查询公共图库:
WHERE is_public = true
(精准返回预期数据)。- 查询其他空间:
WHERE space_id = ? AND is_public = false
(避免污染结果)。
- 业务逻辑与数据一致性
通过专用字段,将“无空间”这一业务状态与数据库的
NULL
语义解耦,确保:
- 公共图库查询无需依赖
NULL
的模糊处理。- 未来扩展空间类型(如“私有空间”“团队空间”)时,无需重构历史数据。
②QueryWrapper
新增条件
从图片查询请求中,获取空间 ID 和 nullSpaceId
注意:nullSpaceId 是 boolean 类型,所以无法通过
get()
方法获取,而是使用isNullSpaceId()
获取;
新增查询条件:
因为查询接口的逻辑是,普通用户只能查询公共图库的图片(nullSpaceId == true),而指定 spaceId 进行某个空间的查询的操作需要管理员权限;
理清逻辑后,我们继续拼接查询条件:
queryWrapper.eq(ObjUtil.isNotEmpty(spaceId), "spaceId", spaceId);// 下面这个条件的逻辑是: 如果用户指定了 nullSpaceId 的 isNull 为 true, 就要在数据库中查询 spaceId 列的值为 null 的记录
queryWrapper.isNull(nullSpaceId, "spaceId");
③ 然后给 listPictureVOByPage
接口增加权限校验,针对公开图库和私有空间设置不同的查询条件:
@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();// 没有指定 spaceIdif(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
表示废弃。
空间级别与额度控制
1. 上传图片时校验与更新额度
我们发现,目前上传图片的代码已经比较复杂了,如果想要再增加非常严格精确的校验逻辑,需要在上传图片到对象存储前自己解析文件的大小、再计算是否超额,可能还要加锁,想想都头疼!
这时你会怎么做呢?
当技术实现比较复杂时,我们不妨思考一下能否对业务进行优化。
比如:
- 单张图片最大才 2M,那么即使空间满了再允许上传一张图片,影响也不大
- 即使有用户在超额前的瞬间大量上传图片,对系统的影响也并不大。后续可以通过
限流 + 定时任务
检测空间等策略,尽早发现这些特殊情况再进行定制处理。
这样一来,就利用业务设计巧妙节约了开发成本。
更新代码: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. 如果传入的请求不为空, 才获取请求中的图片 IDpictureId = pictureUploadRequest.getId();}// 4. 图片 ID 不为空, 查数据库中是否有对应的图片 ID// 新增条件 pictureId > 0, 仅当有 id (id >0)才检查// todoif (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. 指定空间 idpicture.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)保存图片记录时,需要使用事务更新额度,如果额度更新失败,也不用将图片记录保存。
依然是使用 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. 删除图片后更新额度
注意,这里有可能出现对象存储上的图片文件实际没被清理的情况。但是对于用户来说,不应该感受到 “删了图片空间却没有增加”,所以没有将这一步添加到事务中。可以通过定时任务检测作为补偿措施。
// 校验权限
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;
}
(2)在 SpaceController 中编写接口,将枚举转换为空间级别对象列表:
@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. 扩展
删除空间时,关联删除空间内的图片
- 管理员创建空间:管理员可以为指定用户创建空间。可以在创建空间时多传一个 userId 参数,但是要注意做好权限控制,仅管理员可以为别人创建空间。
目前更新上传图片的逻辑还是存在一些问题的。比如更新图片时,并没有删除原有图片、也没有减少原有图片占用的空间和额度,可以通过事务中补充逻辑或者通过定时任务扫描删除。