空间模块开发
本节重点:之前我们已经完成了公共图库的开发。
为了进一步增加系统的应用价值,可以让每个用户都能创建自己的私有空间,打造自己的图片云盘、个人相册。
本节教程不涉及新技术,重点学习业务经验和扩展系统的开发技巧,能够让大家学会更快更稳地给系统增加新的功能。
一、需求分析
对于空间模块,通常要有以下功能:
管理空间(仅管理员可用)
:可以对整个系统中的空间进行管理,比如搜索空间、编辑空间、删除空间。用户创建私有空间
:用户可以创建最多一个私有空间,并且在私有空间内自由上传和管理图片。私有空间权限控制
:用户仅能访问和管理自己的私有空间和其中的图片,私有空间的图片不会展示在公共图库,也不需要管理员审核。空间级别和限额控制
:每个空间有不同的级别(如普通版和专业版),对应了不同的容量和图片数量限制,如果超出限制则无法继续上传图片。
二、方案设计
从需求分析中,我们也能感受到,细节比较多。为了更好地把控这些细节,需要先对系统进行一个整体的方案设计。思考下面的问题:
为什么要有 “空间” 的概念?如果没有 “空间” 的概念,怎么实现让用户自由管理自己的私有图片呢
?
问题 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 picture
ADD 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;
/** 是否删除 */
@TableLogic
private 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 -> {
// 获取当前空间的用户ID
Long userId = spaceVO.getUserId();
// 初始化用户对象为 null
User 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, maxCount
Long 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 参数 false
spaceService.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. 转换实体类和 DTO
Space 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. 返回新写的数据的 id
return 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 picture
add 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,则复用原有图片的 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 不一致");
}
}
}
(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 转为 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);
}
简化 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();
// 没有指定 spaceId
if(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. 如果传入的请求不为空, 才获取请求中的图片 ID
pictureId = pictureUploadRequest.getId();
}
// 4. 图片 ID 不为空, 查数据库中是否有对应的图片 ID
// 新增条件 pictureId > 0, 仅当有 id (id >0)才检查
// todo
if (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. 指定空间 id
picture.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 参数,但是要注意做好权限控制,仅管理员可以为别人创建空间。
目前更新上传图片的逻辑还是存在一些问题的。比如更新图片时,并没有删除原有图片、也没有减少原有图片占用的空间和额度,可以通过事务中补充逻辑或者通过定时任务扫描删除。