♂️ 个人主页:@rain雨雨编程
微信公众号:rain雨雨编程
✍作者简介:持续分享机器学习,爬虫,数据分析
希望大家多多支持,我们一起进步!
如果文章对你有帮助的话,
欢迎评论 点赞 收藏 加关注+
目录
本节重点
一、需求分析
二、方案设计
空间的必要性
空间库表设计
1. 空间表
2、图片表
公共图库和空间的关系
三、后端开发
空间管理
1、数据模型
2、基础服务开发
3、接口开发
用户创建私有空间
1、创建空间流程
2、创建空间服务
扩展知识 - 本地锁优化
扩展
私有空间权限控制
1、图片表新增字段
2、上传和更新图片
2、删除图片
3、编辑图片
4、查询图片
空间级别和限额控制
1、上传图片时校验和更新额度
2、删除图片后更新额度
3、查询空间级别列表
扩展
之前我们已经完成了公共图库的开发,为了进一步增加系统的应用价值,可以让每个用户都能创建自己的私有空间,打造自己的图片云盘、个人相册。
大纲:
空间模块需求分析
空间模块方案设计
空间模块后端开发
空间模块前端开发
对于空间模块,通常要有这些功能:
【管理员】管理空间
用户创建私有空间
私有空间权限控制
空间级别和限额控制
看起来简单,但其实每个需求的细节都非常多,具体分析每个需求:
1)管理空间:仅管理员可用,可以对整个系统中的空间进行管理,比如搜索空间、编辑空间、删除空间。
2)用户创建私有空间:用户可以创建 最多一个 私有空间,并且在私有空间内自由上传和管理图片。
3)私有空间权限控制:用户仅能访问和管理自己的私有空间和其中的图片,私有空间的图片不会展示在公共图库,也不需要管理员审核。
4)空间级别和限额控制:每个空间有不同的级别(如普通版和专业版),对应了不同的容量和图片数量限制,如果超出限制则无法继续上传图片。
从需求分析中,我们也能感受到,细节比较多,为了更好地把控这些细节,需要先对系统进行一个整体的方案设计。
思考下面的问题:
为什么要有 “空间” 的概念?
如何对空间进行库表设计?
公共图库和空间的关系?
如果没有 “空间” 的概念,怎么实现让用户自由管理自己的私有图片呢?
Q:这不就相当于 “查看我的图片” 功能嘛,直接支持用户查询自己创建过的图片不就可以了?
A:如果这样做,会存在一个很大的问题:用户私有图片是需要隐私 的,不需要被管理员审核,也不能被其他人公开查看。这和现在的公共图库平台的逻辑不一致。
想象一下,图片表中只有 userId 字段,无法区分图片到底是私有的还是公开的。
Q:那如果允许用户上传私有图片呢?比如设置图片可见范围为 “仅自己可见”?
A:这的确是可行的,对于内容占用存储空间不大的平台,很适合采用这种方案。但是,对于图库平台,图片占用的存储空间会直接产生存储费用,因此需要对用户上传的图片大小和数量进行限制。类似于给你分配了一个电脑硬盘,它就是你的,用满了就不能再传图了。
所以使用 “空间” 的概念会更符合这种应用场景,可以针对空间进行限制和分析,也更便于管理。
此外,从项目可扩展性的角度来讲,抽象 “空间” 的概念还有 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 目录,该目录里的文件可以进一步设置访问权限。
因此使用 “公共图库” 而不是 “公共空间” 来表述,也能让我们整个项目各个阶段的设计更加独立。
由于细节较多,关于具体功能的实现方案会在开发具体功能前进行讲解,便于对照方案进行开发。
先从相对简单的管理能力(增删改查)开始开发。
首先利用 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;
}
每个操作都需要提供一个请求类,都放在 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;
}
在 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;
}
}
在 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
文件),并创建一个单独的类来接收参数。这样后期如果有变动,修改配置文件即可,而不必修改代码。
可以参考图片服务的开发方法,完成 SpaceService
和实现类,大多数代码可以直接复用。
由于创建空间的逻辑比较复杂,可以先定义个接口占坑。我们主要开发下列方法:
需要开发校验空间数据的方法,增加 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, "空间名称过长");
}
}
在创建或更新空间时,需要根据空间级别自动填充限额数据,可以在服务中编写方法便于复用:
@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);
}
}
}
如果空间本身没有设置限额,才会自动填充,保证了灵活性。
参考图片接口的开发方法,完成 SpaceController
类,大多数代码可以直接复用。
需要重点关注接口的权限:
创建空间:所有用户都可以使用
删除空间:仅允许空间创建人或管理员删除
更新空间:仅管理员可用,允许更新空间级别
编辑空间:允许空间创建人使用,但注意可编辑的字段(不能编辑空间级别) 开发更新接口时,需要调用填充空间限额数据的方法:
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse updateSpace(@RequestBody SpaceUpdateRequest spaceUpdateRequest) {
if (spaceUpdateRequest == null || spaceUpdateRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 将实体类和 DTO 进行转换
Space space = new Space();
BeanUtils.copyProperties(spaceUpdateRequest, space);
// 自动填充数据
spaceService.fillSpaceBySpaceLevel(space);
// 数据校验
spaceService.validSpace(space, false);
// 判断是否存在
long id = spaceUpdateRequest.getId();
Space oldSpace = spaceService.getById(id);
ThrowUtils.throwIf(oldSpace == null, ErrorCode.NOT_FOUND_ERROR);
// 操作数据库
boolean result = spaceService.updateById(space);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
return ResultUtils.success(true);
}
用户可以自主创建私有空间,但是必须要加限制,最多只能创建一个。
需要开发创建空间服务,该服务较为复杂,我们要先整理下流程。
流程如下:
填充参数默认值
校验参数
校验权限,非管理员只能创建普通级别的空间
控制同一用户只能创建一个私有空间
如何保证同一用户只能创建一个私有空间呢?
最粗暴的方式是给空间表的 userId
加上唯一索引,但由于后续用户还可以创建团队空间,这种方式不利于扩展。所以我们采用 加锁 + 事务 的方式实现。
按照上述流程编写代码:
@Resource
private TransactionTemplate transactionTemplate;
@Override
public long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {
// 在此处将实体类和 DTO 进行转换
Space space = new Space();
BeanUtils.copyProperties(spaceAddRequest, space);
// 默认值
if (StrUtil.isBlank(spaceAddRequest.getSpaceName())) {
space.setSpaceName("默认空间");
}
if (spaceAddRequest.getSpaceLevel() == null) {
space.setSpaceLevel(SpaceLevelEnum.COMMON.getValue());
}
// 填充数据
this.fillSpaceBySpaceLevel(space);
// 数据校验
this.validSpace(space, true);
Long userId = loginUser.getId();
space.setUserId(userId);
// 权限校验
if (SpaceLevelEnum.COMMON.getValue() != spaceAddRequest.getSpaceLevel() && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限创建指定级别的空间");
}
// 针对用户进行加锁
String lock = String.valueOf(userId).intern();
synchronized (lock) {
Long newSpaceId = transactionTemplate.execute(status -> {
boolean exists = this.lambdaQuery().eq(Space::getUserId, userId).exists();
ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户仅能有一个私有空间");
// 写入数据库
boolean result = this.save(space);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
// 返回新写入的数据 id
return space.getId();
});
// 返回结果是包装类,可以做一些处理
return Optional.ofNullable(newSpaceId).orElse(-1L);
}
}
注意,上述代码中,我们使用本地 synchronized
锁对 userId
进行加锁,这样不同的用户可以拿到不同的锁,对性能的影响较低。
在加锁的代码中,我们使用 Spring
的 编程式事务管理器 transactionTemplate
封装跟数据库有关的查询和插入操作,而不是使用 @Transactional
注解来控制事务,这样可以保证事务的提交在加锁的范围内。
只要涉及到事务操作,建议大家测试时自己 new
个运行时异常来验证是否会回滚。
上述代码中,我们是对字符串常量池(intern
)进行加锁的,数据并不会及时释放。如果还要使用本地锁,可以按需选用另一种方式 —— 采用 ConcurrentHashMap
来存储锁对象。
示例代码如下:
Map lockMap = new ConcurrentHashMap<>();
public long addSpace(SpaceAddRequest spaceAddRequest, User user) {
Long userId = user.getId();
Object lock = lockMap.computeIfAbsent(userId, key -> new Object());
synchronized (lock) {
try {
// 数据库操作
} finally {
// 防止内存泄漏
lockMap.remove(userId);
}
}
}
1)用户注册成功时,可以自动创建空间。即使创建失败了,也可以手动创建作为兜底。
2)管理员可以为某个用户创建空间(目前没啥必要)
3)本地锁改为分布式锁,可以基于 Redisson
实现。
私有空间的权限和公共图库是不同的,我们需要对之前 所有的图片操作 都添加和空间有关的权限校验逻辑。
图片表增加 spaceId
字段,默认为 null
表示公共图库。
同步修改 PictureMapper.xml
、Picture
实体类、PictureVO
响应视图,补充空间 id
字段:
/**
* 空间 id
*/
private Long spaceId;
下面我们依次给 “增删改查” 图片操作增加权限校验逻辑。
上传图片时支持指定空间 id
,表示要将图片上传到哪个空间。
给 PictureUploadRequest
请求封装类补充 spaceId
字段。
修改上传图片方法 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
是否一致。如果更新时未传递 spaceId
,则复用原有图片的 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 不一致");
}
}
}
之前是按用户划分图片上传目录的,现在如果有 spaceId
,可以按照空间来划分图片上传目录。
// 按照用户 id 划分目录 => 按照空间划分目录
String uploadPathPrefix;
if (spaceId == null) {
uploadPathPrefix = String.format("public/%s", loginUser.getId());
} else {
uploadPathPrefix = String.format("space/%s", spaceId);
}
插入 / 更新数据时,将 spaceId 设置到 Picture 对象中:
// 构造要入库的图片信息
Picture picture = new Picture();
// 补充设置 spaceId
picture.setSpaceId(spaceId);
如果要删除的图片有空间 id
,表示是用户上传到私有空间中的图片,那么登录用户必须是空间的管理员(也就是创建者),系统管理员也不能随意删除私有空间的图片。
因为删除图片和编辑图片的权限控制是一样的(有删除权限就有编辑权限),可以将这段权限校验逻辑封装为一个方法:
@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);
}
}
}
原本删除图片 deletePicture
逻辑很简单,直接写到了 Controller
中,现在有了更多逻辑,建议封装为 service
,并同步修改 Controller
来调用 Service
。
删除图片方法代码如下:
@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);
}
跟删除图片的权限校验逻辑一样,如果要编辑的图片有空间 id
,表示是用户上传到私有空间中的图片,那么登录用户必须是空间的管理员(也就是创建者),系统管理员也不能随意编辑私有空间的图片。
将 editPicture
方法抽象到 Service
中,并同步修改 Controller
来调用 Service
。代码如下:
@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);
}
由于更新图片是给管理员使用的接口,可以暂时不修改。
用户无法查询到私有空间的图片,只能查询公共图库,单条查询和分页查询都要添加这个逻辑。
根据 id
查询接口 getPictureVOById
如果查询出的图片有 spaceId
,则运用跟删除图片一样的校验逻辑,仅空间管理员可以查看:
// 查询数据库
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);
}
分页查询接口 listPictureVOByPage
查询请求增加 spaceId
参数,不传则表示查公共图库;传参则表示查询特定空间 id
下的图片,此时登录用户必须是空间的管理员(其他用户无法查看别人空间的图片),并且不需要指定审核条件(私有空间没有审核机制)。
先给请求封装类 PictureQueryRequest
和 QueryWrapper
补充空间 id
的查询条件。
PictureQueryRequest
新增代码:
/**
* 空间 id
*/
private Long spaceId;
/**
* 是否只查询 spaceId 为 null 的数据
*/
private boolean nullSpaceId;
QueryWrapper 新增代码:
queryWrapper.eq(ObjUtil.isNotEmpty(spaceId), "spaceId", spaceId);
queryWrapper.isNull(nullSpaceId, "spaceId");
然后给接口增加权限校验,针对公开图库和私有空间设置不同的查询条件:
// 空间权限校验
Long spaceId = pictureQueryRequest.getSpaceId();
// 公开图库
if (spaceId == null) {
// 普通用户默认只能查看已过审的公开数据
pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
pictureQueryRequest.setNullSpaceId(true);
} else {
// 私有空间
User loginUser = userService.getLoginUser(request);
Space space = spaceService.getById(spaceId);
ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
if (!loginUser.getId().equals(space.getUserId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间权限");
}
}
考虑到私有空间的图片更新频率不好把握,之前编写的缓存分页查询图片接口可以暂不使用和修改,可以将该接口标记为 @Deprecated
表示已废弃。
需求:每次上传图片前,都要校验空间剩余额度是否足够;每次上传和删除图片时,都要更新已使用的额度。
我们发现,目前上传图片的代码已经比较复杂了,如果想要再增加非常严格精确的校验逻辑,需要在上传图片到对象存储前自己解析文件的大小、再计算是否超额,可能还要加锁,想想都头疼!
这时你会怎么做呢?
当技术实现比较复杂时,我们不妨思考一下能否对业务进行优化。
比如:
单张图片最大才 2M
,那么即使空间满了再允许上传一张图片,影响也不大
即使有用户在超额前的瞬间大量上传图片,对系统的影响也并不大。后续可以通过限流 + 定时任务检测空间等策略,尽早发现这些特殊情况再进行定制处理。
这样一来,就利用业务设计巧妙节约了开发成本。
修改 uploadPicture
方法,编写校验代码,只需要增加 2 个判断条件:
// 空间权限校验
Long spaceId = pictureUploadRequest.getSpaceId();
if (spaceId != null) {
Space space = spaceService.getById(spaceId);
ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
// 必须空间创建人(管理员)才能上传
if (!loginUser.getId().equals(space.getUserId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间权限");
}
// 校验额度
if (space.getTotalCount() >= space.getMaxCount()) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间条数不足");
}
if (space.getTotalSize() >= space.getMaxSize()) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "空间大小不足");
}
}
保存图片记录时,需要使用事务更新额度,如果额度更新失败,也不用将图片记录保存。
依然是使用 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;
});
删除图片时,要释放额度。同样使用 transactionTemplate
事务管理器,将删除图片和更新额度的数据库操作视为一个整体,避免删除图片后没释放额度的情况。
// 校验权限
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);
注意,这里有可能出现对象存储上的图片文件实际没被清理的情况。但是对于用户来说,不应该感受到 “删了图片空间却没有增加”,所以没有将这一步添加到事务中。可以通过定时任务检测作为补偿措施。
最后,我们再编写一个接口,用于给前端展示所有的空间级别信息。
新建 SpaceLevel
封装类:
@Data
@AllArgsConstructor
public class SpaceLevel {
private int value;
private String text;
private long maxCount;
private long maxSize;
}
在 SpaceController
中编写接口,将枚举转换为空间级别对象列表:
@GetMapping("/list/level")
public BaseResponse> listSpaceLevel() {
List spaceLevelList = Arrays.stream(SpaceLevelEnum.values()) // 获取所有枚举
.map(spaceLevelEnum -> new SpaceLevel(
spaceLevelEnum.getValue(),
spaceLevelEnum.getText(),
spaceLevelEnum.getMaxCount(),
spaceLevelEnum.getMaxSize()))
.collect(Collectors.toList());
return ResultUtils.success(spaceLevelList);
}
删除空间时,关联删除空间内的图片
管理员创建空间:管理员可以为指定用户创建空间。可以在创建空间时多传一个 userId
参数,但是要注意做好权限控制,仅管理员可以为别人创建空间。
目前更新上传图片的逻辑还是存在一些问题的。比如更新图片时,并没有删除原有图片、也没有减少原有图片占用的空间和额度,可以通过事务中补充逻辑或者通过定时任务扫描删除。
文章持续跟新,可以微信搜一搜公众号 [ rain雨雨编程 ],第一时间阅读,涉及数据分析,机器学习,Java编程,爬虫,实战项目等。