day17 团队空间后端完成

团队空间

一、需求分析

1、创建团队空间

用户最多只能创建一个团队空间,用户团队协作和资源共享,空间管理员拥有私有空间的所有能力,

包括上传图片,检索图片,管理类图片,分析图片

2、成员用户管理

  • 邀请成员加入队伍:空间管理员可以邀请新成员加入团队
  • 设置权限:空间管理员可以设置不同角色(如查看者、编辑者、管理员),控制成员的权限范围。

3、空间权限控制

仅特定成员可以访问或者操作空间图片

4、空间数据管理

考虑到团队空间图片的数量可能比较多,可以对旗舰版的团队空间的数据单独管理,而不是和公共图片、私有图片混在一起

二、方案设计

创建团队空间

给空间表增加一个字段spaceType,用于区分私有空间和团队空间

sql
复制代码
ALTER TABLE space ADD COLUMN spaceType int default 0 not null comment '空间类型:0-私有 1-团队'; CREATE INDEX idx_spaceType ON space (spaceType);

成员用户管理

分析:

为了减少对源代码的更改,我们约定只有团队空间从才有成员的概念

1)成员邀请:空间管理员可以؜直接输入成员 id⁢ 来添加新成员,无⁠需该用户确认,这样可以提高开发效率。

2)设置权‏限:空间管理员可以؜为已加入成员设置不⁢同的角色,控制成员⁠的权限范围,类似于编辑成员信息。

库设计表
sql
复制代码
-- 空间成员表 create table if not exists space_user ( id bigint auto_increment comment 'id' primary key, spaceId bigint not null comment '空间 id', userId bigint not null comment '用户 id', spaceRole varchar(128) default 'viewer' null comment '空间角色:viewer/editor/admin', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', -- 索引设计 UNIQUE KEY uk_spaceId_userId (spaceId, userId), -- 唯一索引,用户在一个空间中只能有一个角色 INDEX idx_spaceId (spaceId), -- 提升按空间查询的性能 INDEX idx_userId (userId) -- 提升按用户查询的性能 ) comment '空间用户关联' collate = utf8mb4_unicode_ci;

注意:

给spaceId和userId添加唯一索引,表示一个用户只能添加一次这个空间

这里就不用逻辑删除,因为添加了唯一索引,防止用户退出isDelect为1,但是库表还有这条数据,如果没有及时清理用户无法再次加入

成员用户管理

RBAC权限控制

对于复杂的权限控制场景,我们可以使用RBAC权限控制模型(基于角色的访问控制,Role-Based Accsee Control),核心概念包括:用户、角色、权限

我理解就是,一个用户多个角色,一个角色多个权限,不只是简简单单的权限逻辑

image-20250613134729343

一般来说,标准的 RBA‏C 实现需要 5 张表:用户表、角色表、权限表؜、用户角色关联表、角色权限关联表,还是有一定开发成⁢本的。由于我们的项目中,团队空间不需要那么⁠多角色,可以简化 RBAC 的实现方式,比如将角色和权限直接定义到配置文件中。

角色和权限的定义

本项目的角色:

角色描述
浏览者仅可查看空间中的图片内容
编辑者‏ 可查看、上传和编؜辑图片内容
管理员⁢拥有管理空间⁠和成员的所有权限

本项目的权‏限:       ؜         ⁢         ⁠       

权限键功能名称描述
spaceUser:manage成员管理管理空间成员,添加或移除成员
picture:view ‏查看图片查看空间中的图片内容
pictu؜re:upload上传图片上传图片到空⁢间中
picture:edit修改图片⁠编辑已上传的图片信息
picture:delete删除图片删除空间中的图片

角色与权限映射:

角色对应权限键可执行功能
浏览者picture:view查看图片
编辑者picture:view,‏ picture:upload, picture:edit, picture؜:delete查看图片、上传图片、修改图片、删除图片
管理员sp⁢aceUser:manage, picture:view, picture:⁠upload, picture:edit, picture:delete成员管理、查看图片、上传图片、修改图片、删除图片
权限校验实现方案

1)自定义注解+AOP切面

2)使用第三方权限校验框架(Sa-Token

空间数据管理

图片信息数据表

可以给每个团队空间单独创建一张图片表 picture_{spaceId},也就是分库分表中的 分表,而不是和公共图库、私有空间的图片混在一起。这样不仅查询空间内的图片效率更高,还便于整体管理和清理空间。但是要注意,仅对旗舰版空间生效,否则分表的数量会特别多,反而可能影响性能。

使用Apache ShardingSphere

图片文件上传

已经对每个图片空间图片存到不同路径

三、后端开发

创建团队空间

1、数据模型

添加字段

java
复制代码
/** * 空间类型:0-私有 1-团队 */ private Integer spaceType;

设置枚举值

java
复制代码
/** * 空间类型枚举:0-私有空间 1-团队空间 */ @Getter public enum SpaceTypeEnum { PRIVATE(0, "私有空间"), TEAM(1, "团队空间"); private final Integer code; private final String description; SpaceTypeEnum(Integer code, String description) { this.code = code; this.description = description; } /** * 根据code获取描述 * * @param code * @return */ public static String getDescriptionByCode(Integer code) { for (SpaceTypeEnum type : SpaceTypeEnum.values()) { if (type.getCode().equals(code)) { return type.getDescription(); } } return null; } }
2、创建空间

dto

java
复制代码
/** * 空间类型:0-私有 1-团队 */ private Integer spaceType;

service

java
复制代码
Integer spaceLevel = space.getSpaceLevel(); if(spaceLevel == null){ space.setSpaceLevel(SpaceLevelEnum.COMMON.getKey()); } Integer spaceType = space.getSpaceType(); if(spaceType == null){ // 默认为普通版 space.setSpaceType(SpaceLevelEnum.COMMON.getKey()); } // 添加参数 this.fillSpaceBySpaceLevel(space);

修改校验参数

java
复制代码
public void validSpace(Space space, boolean isAdd) { ThrowUtils.throwIf(space==null, ErrorCode.PARAMS_ERROR); String spaceName = space.getSpaceName(); Integer spaceLevel = space.getSpaceLevel(); Integer spaceType = space.getSpaceType(); String spaceTypeEunm = SpaceTypeEnum.getDescriptionByCode(spaceType); SpaceLevelEnum spaceLevelEnum = SpaceLevelEnum.getEnumByKey(spaceLevel); // 要创建 if (isAdd) { if (StrUtil.isBlank(spaceName)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间名称不能为空"); } if (spaceLevel== null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间级别不能为空"); } if (spaceType == 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, "空间名称过长"); } if(spaceTypeEunm == null && spaceType != null){ throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间类型不存在"); } }

控制每个用户只能每类空间只能创建一个

java
复制代码
// 4、控制同一用户只能创建一个私有空间和团队空间 if (!userService.isAdmin(loginUser)) { boolean exists = this.lambdaQuery() .eq(Space::getUserId, loginUser.getId()) .eq(Space::getSpaceType, space.getSpaceType()) .exists(); ThrowUtils.throwIf(exists, ErrorCode.OPERATION_ERROR, "每个用户每类空间仅能创建一个"); } // 写入数据库 boolean result = save(space); ThrowUtils.throwIf(!result, ErrorCode.SYSTEM_ERROR, "添加空间失败"); return space.getId();
3、查询空间队伍
java
复制代码
// 查询条件 Long id = spaceQueryRequest.getId(); String spaceName = spaceQueryRequest.getSpaceName(); Integer spaceLevel = spaceQueryRequest.getSpaceLevel(); Long userId = spaceQueryRequest.getUserId(); Integer spaceType = spaceQueryRequest.getSpaceType(); String sortField = spaceQueryRequest.getSortField(); String sortOrder = spaceQueryRequest.getSortOrder(); // 拼接查询条件 queryWrapper.eq(ObjUtil.isNotEmpty(id), "id", id); queryWrapper.eq(ObjUtil.isNotEmpty(spaceType), "spaceType", spaceType); queryWrapper.like(StrUtil.isNotBlank(spaceName), "spaceName", spaceName); queryWrapper.eq(ObjUtil.isNotEmpty(spaceLevel), "spaceLevel", spaceLevel); queryWrapper.eq(ObjUtil.isNotEmpty(userId), "userId", userId);

空间成员管理

1、数据模型

2、基础服务开发
java
复制代码
package com.larly.larlypicturebackend.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.IService; import com.larly.larlypicturebackend.model.domain.Space; import com.larly.larlypicturebackend.model.domain.SpaceUser; import com.larly.larlypicturebackend.model.domain.User; import com.larly.larlypicturebackend.model.dto.spaceuser.SpaceUserAddRequest; import com.larly.larlypicturebackend.model.dto.spaceuser.SpaceUserQueryRequest; import com.larly.larlypicturebackend.model.vo.SpaceUserVO; import java.util.List; /** * @author 许颢达 * @description 针对表【space_user(空间用户关联)】的数据库操作Service * @createDate 2025-06-13 14:22:45 */ public interface SpaceUserService extends IService<SpaceUser> { /** * 用户加入队伍 * * @param spaceUserAddRequest 用户加入队伍请求 * @param userLogin 用户 * @return 用户队伍id */ Long joinSpace(SpaceUserAddRequest spaceUserAddRequest, User userLogin); /** * 获取查询条件 * * @param spaceUserQueryRequest 查询条件 * @return 查询条件 */ QueryWrapper<SpaceUser> getQueryWrapper(SpaceUserQueryRequest spaceUserQueryRequest); /** * 获取空间用户信息 * * @param spaceUser 空间用户 * @param loginUser 用户 * @return 空间用户信息 */ SpaceUserVO getSpaceUserVO(SpaceUser spaceUser, User loginUser); /** * 获取空间用户信息列表 * * @param spaceUserList 空间用户列表 * @return 空间用户信息列表 */ List<SpaceUserVO> getSpaceUserVOPage(List<SpaceUser> spaceUserList); /** * 校验参数 * * @param spaceuser 空间 * @param isAdd 是否添加 */ void validSpaceUser(SpaceUser spaceuser, boolean isAdd); }
java
复制代码
package com.larly.larlypicturebackend.service.impl; import cn.hutool.core.util.ObjUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.larly.larlypicturebackend.enums.SpaceRoleEnum; import com.larly.larlypicturebackend.exception.BusinessException; import com.larly.larlypicturebackend.exception.ErrorCode; import com.larly.larlypicturebackend.exception.ThrowUtils; import com.larly.larlypicturebackend.model.domain.Space; import com.larly.larlypicturebackend.model.domain.SpaceUser; import com.larly.larlypicturebackend.mapper.SpaceUserMapper; import com.larly.larlypicturebackend.model.domain.User; import com.larly.larlypicturebackend.model.dto.spaceuser.SpaceUserAddRequest; import com.larly.larlypicturebackend.model.dto.spaceuser.SpaceUserQueryRequest; import com.larly.larlypicturebackend.model.vo.SpaceUserVO; import com.larly.larlypicturebackend.model.vo.SpaceVO; import com.larly.larlypicturebackend.service.SpaceService; import com.larly.larlypicturebackend.service.SpaceUserService; import com.larly.larlypicturebackend.service.UserService; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.*; import java.util.stream.Collectors; /** * @author 许颢达 * @description 针对表【space_user(空间用户关联)】的数据库操作Service实现 * @createDate 2025-06-13 14:22:45 */ @Service public class SpaceUserServiceImpl extends ServiceImpl<SpaceUserMapper, SpaceUser> implements SpaceUserService { @Resource private UserService userService; @Resource private SpaceService spaceService; /** * 加入空间 * * @param spaceUserAddRequest * @param userLogin * @return */ @Override public Long joinSpace(SpaceUserAddRequest spaceUserAddRequest, User userLogin) { // 参数校验 ThrowUtils.throwIf(spaceUserAddRequest == null, ErrorCode.PARAMS_ERROR); SpaceUser spaceUser = new SpaceUser(); BeanUtils.copyProperties(spaceUserAddRequest, spaceUser); validSpaceUser(spaceUser, true); // 数据库操作 boolean result = this.save(spaceUser); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return spaceUser.getId(); } @Override public QueryWrapper<SpaceUser> getQueryWrapper(SpaceUserQueryRequest spaceUserQueryRequest) { QueryWrapper<SpaceUser> queryWrapper = new QueryWrapper<>(); if( spaceUserQueryRequest == null){ return queryWrapper; } // 获取所有字段 Long id = spaceUserQueryRequest.getId(); Long spaceId = spaceUserQueryRequest.getSpaceId(); Long userId = spaceUserQueryRequest.getUserId(); String spaceRole = spaceUserQueryRequest.getSpaceRole(); // 模糊查询 queryWrapper.eq(ObjUtil.isNotNull(id), "id", id); queryWrapper.eq(ObjUtil.isNotNull(spaceId), "spaceId", spaceId); queryWrapper.eq(ObjUtil.isNotNull(userId), "userId", userId); queryWrapper.eq(ObjUtil.isNotEmpty(spaceRole), "spaceRole", spaceRole); return queryWrapper; } @Override public SpaceUserVO getSpaceUserVO(SpaceUser spaceUser, User loginUser) { if(spaceUser == null){ return null; } SpaceUserVO spaceUserVO = SpaceUserVO.ObjToVO(spaceUser); Long spaceId = spaceUser.getSpaceId(); Space space = spaceService.getById(spaceId); spaceUserVO.setSpaceVO(spaceService.getSpaceVO(space, loginUser)); Long userId = spaceUser.getUserId(); User user = userService.getById(userId); spaceUserVO.setUserVO(userService.userToUserVo(user)); return spaceUserVO; } @Override public List<SpaceUserVO> getSpaceUserVOPage(List<SpaceUser> spaceUserList) { if (spaceUserList == null){ return new ArrayList<>(); } // 对象列表 => 封装对象列表 List<SpaceUserVO> spaceUserVOList = spaceUserList.stream().map(SpaceUserVO::ObjToVO).collect(Collectors.toList()); // 获取userId和spaceId的Set Set<Long> userIdSet = spaceUserList.stream().map(SpaceUser::getUserId).collect(Collectors.toSet()); Set<Long> spaceIdSet = spaceUserList.stream().map(SpaceUser::getSpaceId).collect(Collectors.toSet()); Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet).stream().collect(Collectors.groupingBy(User::getId)); Map<Long, List<Space>> spaceIdSpaceListMap = spaceService.listByIds(spaceIdSet).stream().collect(Collectors.groupingBy(Space::getId)); // 填充数据 spaceUserVOList.forEach(spaceUserVO -> { Long userId = spaceUserVO.getUserId(); // 填充用户信息 User user = null; if (userIdUserListMap.containsKey(userId)) { user = userIdUserListMap.get(userId).get(0); } spaceUserVO.setUserVO(userService.userToUserVo(user)); // 填充空间信息 Space space = null; if (spaceIdSpaceListMap.containsKey(space.getId())) { space = spaceIdSpaceListMap.get(space.getId()).get(0); } spaceUserVO.setSpaceVO(SpaceVO.ObjToVO(space)); }); return spaceUserVOList; } /** * 校验参数 * * @param spaceuser 空间 * @param isAdd 是否添加 */ @Override public void validSpaceUser(SpaceUser spaceuser, boolean isAdd) { ThrowUtils.throwIf(spaceuser == null, ErrorCode.PARAMS_ERROR); Long id = spaceuser.getId(); Long spaceId = spaceuser.getSpaceId(); Long userId = spaceuser.getUserId(); String spaceRole = spaceuser.getSpaceRole(); // 要创建 if (isAdd) { // 创建时候必须传递空间id和用户id ThrowUtils.throwIf(ObjUtil.hasEmpty(spaceId, userId), ErrorCode.PARAMS_ERROR); // 创建时,角色不能为空 User user = userService.getById(userId); ThrowUtils.throwIf(user == null, ErrorCode.PARAMS_ERROR); // 空间不能为空 Space space = spaceService.getById(spaceId); ThrowUtils.throwIf(space == null, ErrorCode.PARAMS_ERROR); } // 修改数据 String spaceRoleEunm = SpaceRoleEnum.getDescriptionByCode(spaceRole); if (id != null && spaceRoleEunm == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间角色不存在"); } } }
3、接口开发

这里不需要权限校验

需要开发的接口包括:

  • 添加成员到空间:仅拥有成员管理权限的用户可使用。
  • 从空间移除成员:仅拥有成员管理权限的用户可使用。
  • 查询某个成员在空间的信息:仅拥有成员管理权限的用户可使用。
  • 查询空间成员列表:仅拥有成员管理权限的用户可使用。
  • 编辑成员信息:仅拥有成员管理权限的用户可使用。
  • 查询我加入的团队空间列表:所有已登录用户可使用。
java
复制代码
@RestController @RequestMapping("/spaceUser") public class SpaceUserConatroller { @Resource private SpaceUserService spaceUserService; @Resource private UserService userService; /** * 用户加入团队空间 * * @param spaceUserAddRequest * @param request * @return */ @PostMapping("/add") public BaseResponse<Long> addSpaceUser(@RequestBody SpaceUserAddRequest spaceUserAddRequest, HttpServletRequest request) { ThrowUtils.throwIf(spaceUserAddRequest == null, ErrorCode.PARAMS_ERROR); User loginUser = userService.getLoginUser(request); Long l = spaceUserService.joinSpace(spaceUserAddRequest, loginUser); return ResultUtils.success(l); } /** * 移除空间成员(管理员) * * @param deleteRequest * @param request * @return */ @PostMapping("/delete") public BaseResponse<Boolean> deleteSpaceUser(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) { ThrowUtils.throwIf(deleteRequest == null || deleteRequest.getId() <= 0, ErrorCode.PARAMS_ERROR); Long id = deleteRequest.getId(); // 判断有没有该数据 SpaceUser spaceUser = spaceUserService.getById(id); ThrowUtils.throwIf(spaceUser == null, ErrorCode.NOT_FOUND_ERROR,"没有该成员加入队伍信息"); // 操作数据库 boolean result = spaceUserService.removeById(id); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return ResultUtils.success(true); } /** * 查询某个成员在空间的信息(管理员) * * @param id * @return */ @GetMapping("/get") public BaseResponse<SpaceUser> getSpaceUserById(long id) { ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR); SpaceUser spaceUser = spaceUserService.getById(id); ThrowUtils.throwIf(spaceUser == null, ErrorCode.NOT_FOUND_ERROR); return ResultUtils.success(spaceUser); } /** * 获取用户空间列表(管理员) * * @param spaceUserQueryRequest * @return */ @PostMapping("/list") public BaseResponse<List<SpaceUserVO>> listSpaceUser(@RequestBody SpaceUserQueryRequest spaceUserQueryRequest) { ThrowUtils.throwIf(spaceUserQueryRequest == null, ErrorCode.PARAMS_ERROR); QueryWrapper<SpaceUser> queryWrapper = spaceUserService.getQueryWrapper(spaceUserQueryRequest); List<SpaceUser> spaceUserList = spaceUserService.list(queryWrapper); List<SpaceUserVO> result = spaceUserService.getSpaceUserList(spaceUserList); return ResultUtils.success(result); } /** * 编辑成员信息(设置权限) */ @PostMapping("/edit") public BaseResponse<Boolean> editSpaceUser(@RequestBody SpaceUserEditRequest spaceUserEditRequest, HttpServletRequest request) { if (spaceUserEditRequest == null || spaceUserEditRequest.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } // 将实体类和 DTO 进行转换 SpaceUser spaceUser = new SpaceUser(); BeanUtils.copyProperties(spaceUserEditRequest, spaceUser); // 数据校验 spaceUserService.validSpaceUser(spaceUser, false); // 判断是否存在 long id = spaceUserEditRequest.getId(); SpaceUser oldSpaceUser = spaceUserService.getById(id); ThrowUtils.throwIf(oldSpaceUser == null, ErrorCode.NOT_FOUND_ERROR); // 操作数据库 boolean result = spaceUserService.updateById(spaceUser); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return ResultUtils.success(true); } /** * 查询我加入的团队空间列表 */ @PostMapping("/list/my") public BaseResponse<List<SpaceUserVO>> listMyTeamSpace(HttpServletRequest request) { User loginUser = userService.getLoginUser(request); SpaceUserQueryRequest spaceUserQueryRequest = new SpaceUserQueryRequest(); spaceUserQueryRequest.setUserId(loginUser.getId()); List<SpaceUser> spaceUserList = spaceUserService.list( spaceUserService.getQueryWrapper(spaceUserQueryRequest) ); return ResultUtils.success(spaceUserService.getSpaceUserList(spaceUserList)); } }
4、修改创建空间逻辑

如果创建的是团队空间,创建人就是admin

java
复制代码
// 如果创建的是团队空间,默认角色为管理员 SpaceUser spaceUser = new SpaceUser(); if(space.getSpaceType().equals(SpaceTypeEnum.TEAM.getCode())){ spaceUser.setSpaceId(space.getId()); spaceUser.setUserId(loginUser.getId()); spaceUser.setSpaceRole(SpaceRoleEnum.ADMIN.getCode()); } boolean save = spaceUserService.save(spaceUser); ThrowUtils.throwIf(!save, ErrorCode.OPERATION_ERROR, "创建空间用户信息失败");
扩展

1)添加成员到空间时,可以支持发送邀请和审批。sOhs061JZxqyrARRaCJ4cm833ysiuDZnEJDv4hrxDjg=

实现思路:给空间成员表新增一个邀请确认状态的字段

2)由于空‏间管理员可能有多个؜,空间成员表可以补⁢充添加成员至空间的⁠邀请人字段(createUserId)

3)空间成员操作执行前可以补充一些校验,比如:iNKoDGqbIh805lN5ucH8qGFcOl4BY1c1X2HNtgHSLRs=

  • 只有已经是空间成员,才能被移除或编辑
  • 如果编辑后的角色跟之前一致,就不用更新

空间成员权限控制

1、定义权限

根据 RBAC 权限模型,需要定义角色和权限。

1)此处选用‏ JSON 配置文件来؜定义角色、权限、角色和⁢权限之间的关系,相比从⁠数据库表中获取,实现更方便,查询也更高效。

text
复制代码
{ "permissions": [ { "key": "spaceUser:manage", "name": "成员管理", "description": "管理空间成员,添加或移除成员" }, { "key": "picture:view", "name": "查看图片", "description": "查看空间中的图片内容" }, { "key": "picture:upload", "name": "上传图片", "description": "上传图片到空间中" }, { "key": "picture:edit", "name": "修改图片", "description": "编辑已上传的图片信息" }, { "key": "picture:delete", "name": "删除图片", "description": "删除空间中的图片" } ], "roles": [ { "key": "viewer", "name": "浏览者", "permissions": [ "picture:view" ], "description": "查看图片" }, { "key": "editor", "name": "编辑者", "permissions": [ "picture:view", "picture:upload", "picture:edit", "picture:delete" ], "description": "查看图片、上传图片、修改图片、删除图片" }, { "key": "admin", "name": "管理员", "permissions": [ "spaceUser:manage", "picture:view", "picture:upload", "picture:edit", "picture:delete" ], "description": "成员管理、查看图片、上传图片、修改图片、删除图片" } ] }

2)数据模型

java
复制代码
@Data public class SpaceUserAuthConfig implements Serializable { /** * 权限列表 */ private List<SpaceUserPermission> permissions; /** * 角色列表 */ private List<SpaceUserRole> roles; private static final long serialVersionUID = 1L; }

空间成员权限

java
复制代码
@Data public class SpaceUserPermission implements Serializable { /** * 权限键 */ private String key; /** * 权限名称 */ private String name; /** * 权限描述 */ private String description; private static final long serialVersionUID = 1L; }

空间成员角色

java
复制代码
@Data public class SpaceUserRole implements Serializable { /** * 角色键 */ private String key; /** * 角色名称 */ private String name; /** * 权限键列表 */ private List<String> permissions; /** * 角色描述 */ private String description; private static final long serialVersionUID = 1L; }

3)定义空间成员权限常量类,便于后续校验权限使用

java
复制代码
public interface SpaceUserPermissionConstant { /** * 空间用户管理权限 */ String SPACE_USER_MANAGE = "spaceUser:manage"; /** * 图片查看权限 */ String PICTURE_VIEW = "picture:view"; /** * 图片上传权限 */ String PICTURE_UPLOAD = "picture:upload"; /** * 图片编辑权限 */ String PICTURE_EDIT = "picture:edit"; /** * 图片删除权限 */ String PICTURE_DELETE = "picture:delete"; }

4)SpaceUserAuthManager,可加载配置文件到对象,并提供根据角色获取权限列表的方法。

java
复制代码
@Component public class SpaceUserAuthManager { @Resource private SpaceUserService spaceUserService; @Resource private UserService userService; public static final SpaceUserAuthConfig SPACE_USER_AUTH_CONFIG; static { String json = ResourceUtil.readUtf8Str("biz/spaceUserAuthConfig.json"); SPACE_USER_AUTH_CONFIG = JSONUtil.toBean(json, SpaceUserAuthConfig.class); } /** * 根据角色获取权限列表 */ public List<String> getPermissionsByRole(String spaceUserRole) { if (StrUtil.isBlank(spaceUserRole)) { return new ArrayList<>(); } // 找到匹配的角色 SpaceUserRole role = SPACE_USER_AUTH_CONFIG.getRoles().stream() .filter(r -> spaceUserRole.equals(r.getKey())) .findFirst() .orElse(null); if (role == null) { return new ArrayList<>(); } return role.getPermissions(); } }
2、Sa-Token

官方文档

1)引入

xml
复制代码
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot-starter</artifactId> <version>1.44.0</version> </dependency>

Sa-Token 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:

  1. 重启后数据会丢失。
  2. 无法在分布式环境中共享数据。

为此,Sa-Token 提供了扩展接口,你可以轻松将会话数据存储在一些专业的缓存中间件上(比如 Redis), 做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性。

Redis

xml
复制代码
<!-- Sa-Token 整合 RedisTemplate --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-redis-template</artifactId> <version>1.44.0</version> </dependency> <!-- 提供 Redis 连接池 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>

了解

需要配置redis

yml
复制代码
spring: # redis配置 redis: # Redis数据库索引(默认为0) database: 1 # Redis服务器地址 host: 127.0.0.1 # Redis服务器连接端口 port: 6379 # Redis服务器连接密码(默认为空) # password: # 连接超时时间 timeout: 10s lettuce: pool: # 连接池最大连接数 max-active: 200 # 连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms # 连接池中的最大空闲连接 max-idle: 10 # 连接池中的最小空闲连接 min-idle: 0
java
复制代码
// 保存用户登陆态 // request.getSession().setAttribute(UserConstants.USER_LOGIN_STATE, user); StpUtil.login(user.getId()); StpUtil.getSession().set(UserConstants.USER_LOGIN_STATE, user);

image-20250613172310323

2)基本用法

用户登录时候调用login

java
复制代码
StpUtil.login(10001);

还可以给会话保存信息

java
复制代码
StpUtil.getSession().set("user", user)

可以根据用户是否登录来获取信息

java
复制代码
// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException` StpUtil.checkLogin(); // 获取用户信息 StpUtil.getSession().get("user");

也支持注解进行鉴权:

java
复制代码
// 登录校验:只有登录之后才能进入该方法 @SaCheckLogin @RequestMapping("info") public String info() { return "查询用户信息"; }
3、新建空间账号体系

有的时候,我们会在一个项目中设计两套账号体系,比如一个电商系统的 user表admin表, 在这种场景下,如果两套账号我们都使用 StpUtil 类的API进行登录鉴权,那么势必会发生逻辑冲突。

假如说我们的 user表 和 admin表 都有一个 id=10001 的账号,它们对应的登录代码:StpUtil.login(10001) 是一样的, 那么问题来了:在StpUtil.getLoginId()获取到的账号id如何区分它是User用户,还是Admin用户?

你可能会想到为他们加一个固定前缀,比如StpUtil.login("User_" + 10001)StpUtil.login("Admin_" + 10001),这样确实是可以解决问题的, 但是同样的:你需要在StpUtil.getLoginId()时再裁剪掉相应的前缀才能获取真正的账号id,这样一增一减就让我们的代码变得无比啰嗦。

对于我们的项目,有两套权限验证体系。对user表角色校验;另一套就是团队空间用户校验

为了更轻松地扩展‏项目,减少对原有代码的改动,我们؜原有的 user 表权限校验依然⁢使用自定义注解 + AOP 的方⁠式实现。而团队空间权限校验,采用 Sa-Token 来管理。

相当于我们‏不是整个项目都交给 ؜Sa-Token,只⁢是把 Sa-Toke⁠n 当做实现团队空间权限管理的工具罢了

使用Sa-Token的多账号体系

1)Kit模式

每个 StpLogic 实例代表一个独立的账号体系,它们之间互不影响、互不干扰,因此: 每个账号体系都需要独立完成登录操作才能进行后续鉴权。

java
复制代码
/** * StpLogic 门面类,管理项目中所有的 StpLogic 账号体系 */ public class StpKit { public static final String SPACE_TYPE = "space"; /** * 默认原生会话对象, 原生的会话对象,默认使用 StpUtil.stpLogic */ public static final StpLogic DEFAULT = StpUtil.stpLogic; /** * Space 会话对象,管理 Space 表所有账号的登录、权限认证 */ public static final StpLogic SPACE = new StpLogic(SPACE_TYPE); }
java
复制代码
// 保存用户登陆态 request.getSession().setAttribute(UserConstants.USER_LOGIN_STATE, user); // StpUtil.login(user.getId()); // StpUtil.getSession().set(UserConstants.USER_LOGIN_STATE, user); // 每套账号体系都要登录,才能鉴权 StpKit.SPACE.login(user.getId()); StpKit.SPACE.getSession().set(UserConstants.USER_LOGIN_STATE, user); return result;

注意这里的session两个都要写的

image-20250613172901630

前端cookie种下的是satoken和session,但是后端获取的时候不一样

session

java
复制代码
User loginUser = (User) request.getSession().getAttribute("yourKey");

satoken

java
复制代码
StpUtil.getSession().get("yourKey");
4、权限认证逻辑

Sa-Token有两种鉴权逻辑:

1、返回一个账号所拥有的权限码集合(通过权限)

2、返回一个账号所拥有的角色标识集合 (权限与角色可分开校验) (通过角色)

权限一般来说划分的更细,角色一般是用来权限校验不多

这里使用通过权限

java
复制代码
/** * 自定义权限加载接口实现类 */ @Component // 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展 public class StpInterfaceImpl implements StpInterface { /** * 返回一个账号所拥有的权限码集合 */ @Override public List<String> getPermissionList(Object loginId, String loginType) { // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限 List<String> list = new ArrayList<String>(); list.add("101"); list.add("user.add"); list.add("user.update"); list.add("user.get"); // list.add("user.delete"); list.add("art.*"); return list; } /** * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验) */ @Override public List<String> getRoleList(Object loginId, String loginType) { // 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色 List<String> list = new ArrayList<String>(); list.add("admin"); list.add("super-admin"); return list; } }

观察上述代码我们会发现,getPermissionList 方法只提供了 loginId(登录用户 id)和 loginType(账号体系)两个参数。这会给我们造成很大的难度:

  • 我们光有用户 id 是没办法进行权限校验的,因为我们要给图片操作和空间成员操作增加权限校验逻辑,还需要获取到空间 id,才知道用户是否具有某个团队空间的权限。那么如何获取到空间 id 呢?
  • 如果要进行统一的权限校验,也包括了公共图库和私有空间,更要命的是,公共图库是没有空间 id 的!这就意味着要根据操作的图片情况动态判断。

所以我们要解决的关键问题有 2 个:

  1. 如何在 Sa-Token 中获取当前请求操作的参数?
  2. 如何编写一套权限校验逻辑,同时兼容公共图库、私有空间和团队空间?

1**)第一个问题,使用Sa-Token有两种方式 ——注解式和编程式**

如果使用注解式,请求参数只能通过Servlet的请求对象拿到参数

如果使用编程式,可以在代码任意位置执行权限校验,只要在执行前将参数放到当前线程的上下文ThreadLocal对象中,就能在鉴权时候获取

为了后续我们给接‏口添加鉴权更直观方便,我们选择注؜解式鉴权。那就有一个关键问题,不⁢同接口的请求参数是不同的,有的请⁠求参数有 spaceId、有的只有 pictureId,怎么办呢?

我们可以定义一个 上下文类,用于统一接收请求中传递来的参数:

java
复制代码
/** * SpaceUserAuthContext * 表示用户在特定空间内的授权上下文,包括关联的图片、空间和用户信息。 */ @Data public class SpaceUserAuthContext { /** * 临时参数,不同请求对应的 id 可能不同 */ private Long id; /** * 图片 ID */ private Long pictureId; /** * 空间 ID */ private Long spaceId; /** * 空间用户 ID */ private Long spaceUserId; /** * 图片信息 */ private Picture picture; /** * 空间信息 */ private Space space; /** * 空间用户信息 */ private SpaceUser spaceUser; }

可以根据访问地址区分id字段都表示什么意思

业务逻辑

  1. 校验登陆类型,space直接返回空列表
  2. 如果用户是系统管理员没返回全部权限
  3. 获取上下文对象,如果是空就是访问公共空间,返回全部权限,因为是公共图库的获取数据(首页)
  4. 获取登陆状态:没有登陆抛异常,获取userId
  5. 上下文获取spaceUserId,获取用户角色调用方法,获取权限列表
  6. 从上下文中优先获取 SpaceUser 对象:如果上下文中存在 SpaceUser 对象,直接根据其角色获取权限码列表。
  7. 通过 spaceUserId 获取空间用户信息:如果上下文中存在 spaceUserId
  • 查询对应的 SpaceUser 数据。如果未找到,抛出数据未找到异常。
  • 校验当前登录用户是否属于该空间,如果不是,返回空权限列表。
  • 否则,根据登录用户在该空间的角色,返回相应的权限码列表。
  1. 通过 spaceIdpictureId 获取空间或图片信息
  • 如果 spaceId 不存在:使用 pictureId 查询图片信息,并通过图片的 spaceId 继续判断权限;如果 pictureIdspaceId 均为空,默认视为管理员权限。这里可能是编辑或者删除。
  • 对于公共图库:如果图片是当前用户上传的,或者当前用户为管理员,返回管理员权限列表;如果图片不是当前用户上传的,返回仅允许查看的权限码。
  1. 获取 Space 对象并判断空间类型:查询 Space 信息,如果未找到空间数据,抛出数据未找到异常。否则根据空间类型进行判断
  • 私有空间:仅空间所有者和管理员有权限(即返回全部权限),其他用户返回空权限列表。
  • 团队空间:查询登录用户在该空间的角色,并返回对应的权限码列表。如果用户不属于该空间,返回空权限列表。
java
复制代码
/** * 获取用户权限列表 * * @param loginId 登录ID * @param loginType 登录类型 * @return 权限列表 */ @Override public List<String> getPermissionList(Object loginId, String loginType) { // 判断 loginType,仅对类型为 "space" 进行权限校验 if (!StpKit.SPACE_TYPE.equals(loginType)) { return new ArrayList<>(); } // 登录校验 User user =(User) StpKit.SPACE.getSessionByLoginId(loginId).get(UserConstants.USER_LOGIN_STATE); ThrowUtils.throwIf(user == null,ErrorCode.NOT_LOGIN_ERROR); // 获取用户ID Long userId = user.getId(); // 全部权限 List<String> ADMIN_PREMISSIONS = spaceUserAuthManager.getPermissionsByRole(SpaceRoleEnum.ADMIN.getCode()); // 获取上下文 SpaceUserAuthContext authContext = spaceUserAuthManager.getSpaceUserAuthContext(); // 如果对象字段为空,则表示是公共空间获取列表 if(isAllFieldsNull(authContext)){ return ADMIN_PREMISSIONS; } // 上下文获取SpaceUser SpaceUser spaceUser = authContext.getSpaceUser(); if(ObjectUtil.isNotNull(spaceUser)){ // 获取SpaceUserRole String spaceRole = spaceUser.getSpaceRole(); return spaceUserAuthManager.getPermissionsByRole(spaceRole); } // 上下文获取spaceUserId Long spaceUserId = authContext.getSpaceUserId(); if(ObjectUtil.isNotNull(spaceUserId)){ // 根据spaceUserId获取SpaceUser spaceUser = spaceUserService.getById(spaceUserId); if(ObjectUtil.isNotNull(spaceUser)){ // 获取SpaceUserRole String spaceRole = spaceUser.getSpaceRole(); return spaceUserAuthManager.getPermissionsByRole(spaceRole); } } // 通过spaceId和pictureId获取权限 Long spaceId = authContext.getSpaceId(); Long pictureId = authContext.getPictureId(); if(ObjectUtil.isNull(spaceId)){ // spaceId为null, if(pictureId == null){ // 表示没有参数不是编辑,删除,返回全部权限即可 return new ArrayList<>(); } // 通过pictureId获取spaceId Picture picture = pictureService.getById(pictureId); ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR,"图片不存在"); spaceId = picture.getSpaceId(); if(spaceId == null){ // 公共图库,仅管理员和图片创建者可操作 if(userService.isAdmin(user) || picture.getUserId().equals(userId)){ return ADMIN_PREMISSIONS; }else{ // 返回观察者权限 return Collections.singletonList(SpaceUserPermissionConstant.PICTURE_VIEW); } } } // 获取 Space 对象 Space space = spaceService.getById(spaceId); if (space == null) { throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "未找到空间信息"); } // 根据 Space 类型判断权限 if (space.getSpaceType() == SpaceTypeEnum.PRIVATE.getCode()) { // 私有空间,仅本人或管理员有权限 if (space.getUserId().equals(userId) || userService.isAdmin(user)) { return ADMIN_PREMISSIONS; } else { return new ArrayList<>(); } } else { // 团队空间,查询 SpaceUser 并获取角色和权限 spaceUser = spaceUserService.lambdaQuery() .eq(SpaceUser::getSpaceId, spaceId) .eq(SpaceUser::getUserId, userId) .one(); if (spaceUser == null) { return new ArrayList<>(); } return spaceUserAuthManager.getPermissionsByRole(spaceUser.getSpaceRole()); } }
java
复制代码
/** * 判断对象所有字段是否为空 * @param obj * @return */ public static boolean isAllFieldsNull(Object obj) { if(obj == null){ return true; } // 动态获取对象的所有字段,反射作用:动态获取对象中所有字段的值 return Arrays.stream(ReflectUtil.getFields(obj.getClass())) .map(field -> ReflectUtil.getFieldValue(obj, field)) .allMatch(ObjectUtil::isEmpty); }
5、权限校验注解

默认情况下使用注解式鉴权,但是由于我们使用了多账号体系,我们要使用注解合并方式

java
复制代码
@Configuration public class SaTokenConfigure implements WebMvcConfigurer { // 注册 Sa-Token 拦截器,打开注解式鉴权功能 @Override public void addInterceptors(InterceptorRegistry registry) { // 注册 Sa-Token 拦截器,打开注解式鉴权功能 registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**"); } @PostConstruct public void rewriteSaStrategy() { // 重写Sa-Token的注解处理器,增加注解合并功能 SaAnnotationStrategy.instance.getAnnotation = (element, annotationClass) -> { return AnnotatedElementUtils.getMergedAnnotation(element, annotationClass); }; } }

新建账号体系注解,官网

text
复制代码
/** * 空间权限认证:必须具有指定权限才能进入该方法 * <p> 可标注在函数、类上(效果等同于标注在此类的所有方法上) */ @SaCheckPermission(type = StpKit.SPACE_TYPE) @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) public @interface SaSpaceCheckPermission { /** * 需要校验的权限码 * * @return 需要校验的权限码 */ @AliasFor(annotation = SaCheckPermission.class) String[] value() default {}; /** * 验证模式:AND | OR,默认AND * * @return 验证模式 */ @AliasFor(annotation = SaCheckPermission.class) SaMode mode() default SaMode.AND; /** * 在权限校验不通过时的次要选择,两者只要其一校验成功即可通过校验 * * <p> * 例1:@SaCheckPermission(value="user-add", orRole="admin"), * 代表本次请求只要具有 user-add权限 或 admin角色 其一即可通过校验。 * </p> * * <p> * 例2: orRole = {"admin", "manager", "staff"},具有三个角色其一即可。 <br> * 例3: orRole = {"admin, manager, staff"},必须三个角色同时具备。 * </p> * * @return / */ @AliasFor(annotation = SaCheckPermission.class) String[] orRole() default {}; }
6、应用注解

修改图片和空间用户的controller在合适的位置添加注解

java
复制代码
// 上传图片(可重新上传) @PostMapping("/upload") @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_UPLOAD) public BaseResponse<PictureVO> uploadPicture() { } // 通过 URL 上传图片(可重新上传) @PostMapping("/upload/url") @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_UPLOAD) public BaseResponse<PictureVO> uploadPictureByUrl() { } // 删除图片 @PostMapping("/delete") @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_DELETE) public BaseResponse<Boolean> deletePicture() { } // 编辑图片(给用户使用) @PostMapping("/edit") @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT) public BaseResponse<Boolean> editPicture() { } // 根据颜色搜索图片 @PostMapping("/search/color") @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_VIEW) public BaseResponse<List<PictureVO>> searchPictureByColor() { } // 批量编辑图片 @PostMapping("/edit/batch") @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT) public BaseResponse<Boolean> editPictureByBatch() { } // 创建 AI 扩图任务 @PostMapping("/out_painting/create_task") @SaSpaceCheckPermission(value = SpaceUserPermissionConstant.PICTURE_EDIT) public BaseResponse<CreateOutPaintingTaskResponse> createPictureOutPaintingTask() { }

这里我们也可以使用编程式

JAVA
复制代码
boolean result = StpKit.SPACE.hasPermission(SpaceUserPermissionConstant.PICTURE_VIEW); ThrowUtils.throwIf(!result, ErrorCode.NO_AUTH_ERROR);
7、全局异常处理

我们要将Sa-Token的自定义注解换成我们自己封装的业务注解

java
复制代码
@ExceptionHandler(NotLoginException.class) public BaseResponse<?> notLoginException(NotLoginException e) { log.error("NotLoginException", e); return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, e.getMessage()); } @ExceptionHandler(NotPermissionException.class) public BaseResponse<?> notPermissionExceptionHandler(NotPermissionException e) { log.error("NotPermissionException", e); return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, e.getMessage()); }
8、补充获取权限的接口

为了方便前端展示,我们在获取空间信息和图片信息时候封装一个字段权限列表

java
复制代码
/** * 权限列表 */ private List<String> permissionList = new ArrayList<>();
java
复制代码
/** * 获取权限列表(返回前端的) * * @param space * @param loginUser * @return */ public List<String> getPermissionList(Space space, User loginUser) { if (loginUser == null) { return new ArrayList<>(); } // 管理员权限 List<String> ADMIN_PERMISSIONS = getPermissionsByRole(SpaceRoleEnum.ADMIN.getCode()); // 如果是管理员直接返回全部权限 if (userService.isAdmin(loginUser)) { return ADMIN_PERMISSIONS; } // 公共图库 if (space == null) { if (userService.isAdmin(loginUser)) { return ADMIN_PERMISSIONS; } return new ArrayList<>(); } // 获取spaceType SpaceTypeEnum enumByCode = SpaceTypeEnum.getSpaceTypeEnumByCode(space.getSpaceType()); switch (enumByCode){ case PRIVATE: // 私有空间,仅本人或管理员有所有权限 if (space.getUserId().equals(loginUser.getId()) || userService.isAdmin(loginUser)) { return ADMIN_PERMISSIONS; } else { return Collections.singletonList(SpaceUserPermissionConstant.PICTURE_VIEW); } case TEAM: // 根据用户id和spaceId查询用户角色 SpaceUser spaceUser = spaceUserService.lambdaQuery() .eq(SpaceUser::getSpaceId, space.getId()) .eq(SpaceUser::getUserId, loginUser.getId()) .one(); if (spaceUser == null) { return new ArrayList<>(); } else { return getPermissionsByRole(spaceUser.getSpaceRole()); } } return new ArrayList<>(); }
java
复制代码
// 如果创建的是团队空间,默认角色为管理员 SpaceUser spaceUser = new SpaceUser(); if(space.getSpaceType().equals(SpaceTypeEnum.TEAM.getCode())){ spaceUser.setSpaceId(space.getId()); spaceUser.setUserId(loginUser.getId()); spaceUser.setSpaceRole(SpaceRoleEnum.ADMIN.getCode()); } // 校验参数 spaceUserService.validSpaceUser(spaceUser, true); boolean save = spaceUserService.save(spaceUser); ThrowUtils.throwIf(!save, ErrorCode.OPERATION_ERROR, "创建空间用户信息失败"); // 创建分表 dynamicShardingManager.createSpacePictureTable(space); return space.getId();

修改getSpaceV⁢OById、getPi⁠ctureVOById

注意getPi⁠ctureVOById可能没有空间id也获取权限

空间数据管理

我们要对期间团队空间进行分表

1、什么是分库分表

分库分表shi一种将数据拆分到多个数据库或数据表中的设计策略。可以减小单个表的数据量,提高查询和写入的效率,增加高并发的能力;降低单点故障的风险

2、分库分表实现

简单来说,‏就是将数据写到不同؜的表、并且从相同的⁢表读取数据,其实通⁠过给 SQL 表名拼接动态参数就能实现:

sql
复制代码
select * from table_${分片唯一标识}
3、ShardingSphere 分库分表

中文网

Apache ShardingSphere分为两种方式:

Shardin؜gSphere-JDBC :适合java项目

ShardingSph⁠ere-Proxy:适合跨语言的大型项目

引入依赖

xml
复制代码
<!-- 分库分表 --> <dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.2.0</version> </dependency>
4、静态分表
yml
复制代码
rules: sharding: tables: picture: actualDataNodes: ds0.picture_${0..2} # 3张分表:picture_0, picture_1, picture_2 tableStrategy: standard: shardingColumn: pictureId # 按 pictureId 分片 shardingAlgorithmName: pictureIdMod shardingAlgorithms: pictureIdMod: type: INLINE props: algorithm-expression: picture_${pictureId % 3} # 分片表达式
5、动态分表

假设根据时间动态分表

java
复制代码
String tableName = "picture_" + LocalDate.now().format( DateTimeFormatter.ofPattern("yyyy_MM") );

自定义分表算法

java
复制代码
public class PictureShardingAlgorithm implements StandardShardingAlgorithm<Long> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> preciseShardingValue) { // 编写分表逻辑,返回实际要查询的表名 // picture_0 物理表,picture 逻辑表 } @Override public Collection<String> doSharding(Collection<String> collection, RangeShardingValue<Long> rangeShardingValue) { return new ArrayList<>(); } @Override public Properties getProps() { return null; } @Override public void init(Properties properties) { } }
6、动态分表算法开发
yml
复制代码
spring: rules: sharding: tables: picture: actual-data-nodes: larly_picture.picture # 动态分表 table-strategy: standard: sharding-column: spaceId sharding-algorithm-name: picture_sharding_algorithm # 使用自定义分片算法 sharding-algorithms: picture_sharding_algorithm: type: CLASS_BASED props: strategy: standard algorithmClassName: com.larly.larlypicturebackend.manager.sharing.PictureShardingAlgorithm

注意:这里actual-data-nodes一般情况下是指定一段分表的范围,但是id是long类型,太长了,所以后面根据项目动态生成

java
复制代码
public class PictureShardingAlgorithm implements StandardShardingAlgorithm<Long> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> preciseShardingValue) { Long spaceId = preciseShardingValue.getValue(); String logicTableName = preciseShardingValue.getLogicTableName(); // spaceId 为 null 表示查询所有图片 if (spaceId == null) { return logicTableName; } // 根据 spaceId 动态生成分表名 String realTableName = "picture_" + spaceId; if (availableTargetNames.contains(realTableName)) { return realTableName; } else { return logicTableName; } } @Override public Collection<String> doSharding(Collection<String> collection, RangeShardingValue<Long> rangeShardingValue) { return new ArrayList<>(); } @Override public Properties getProps() { return null; } @Override public void init(Properties properties) { } }

这里如果可用分表没有包含,就是使用原表,我们只做了旗舰版团队空间的分表,因为虽然我们设计上؜只对团队空间进行分库分表⁢,但是一旦引入了分库分表⁠框架,查询 picture 表时就会触发分表逻辑,我们要做一个降级。

创建分表
java
复制代码
@Component @Slf4j public class DynamicShardingManager { @Resource private DataSource dataSource; @Resource private SpaceService spaceService; @Resource private JdbcTemplate jdbcTemplate; private static final String LOGIC_TABLE_NAME = "picture"; private static final String DATABASE_NAME = "logic_db"; // 配置文件中的数据库名称 @PostConstruct public void initialize() { log.info("初始化动态分表配置..."); updateShardingTableNodes(); } /** * 获取所有动态表名,包括初始表 picture 和分表 picture_{spaceId} */ private Set<String> fetchAllPictureTableNames() { // 为了测试方便,直接对所有团队空间分表(实际上线改为仅对旗舰版生效) Set<Long> spaceIds = spaceService.lambdaQuery() .eq(Space::getSpaceType, SpaceTypeEnum.TEAM.getCode()) .list() .stream() .map(Space::getId) .collect(Collectors.toSet()); Set<String> tableNames = spaceIds.stream() .map(spaceId -> LOGIC_TABLE_NAME + "_" + spaceId) .collect(Collectors.toSet()); tableNames.add(LOGIC_TABLE_NAME); // 添加初始逻辑表 return tableNames; } /** * 更新 ShardingSphere 的 actual-data-nodes 动态表名配置 */ private void updateShardingTableNodes() { Set<String> tableNames = fetchAllPictureTableNames(); String newActualDataNodes = tableNames.stream() .map(tableName -> "yu_picture." + tableName) // 确保前缀合法 .collect(Collectors.joining(",")); log.info("动态分表 actual-data-nodes 配置: {}", newActualDataNodes); ContextManager contextManager = getContextManager(); ShardingSphereRuleMetaData ruleMetaData = contextManager.getMetaDataContexts() .getMetaData() .getDatabases() .get(DATABASE_NAME) .getRuleMetaData(); Optional<ShardingRule> shardingRule = ruleMetaData.findSingleRule(ShardingRule.class); if (shardingRule.isPresent()) { ShardingRuleConfiguration ruleConfig = (ShardingRuleConfiguration) shardingRule.get().getConfiguration(); List<ShardingTableRuleConfiguration> updatedRules = ruleConfig.getTables() .stream() .map(oldTableRule -> { if (LOGIC_TABLE_NAME.equals(oldTableRule.getLogicTable())) { ShardingTableRuleConfiguration newTableRuleConfig = new ShardingTableRuleConfiguration(LOGIC_TABLE_NAME, newActualDataNodes); newTableRuleConfig.setDatabaseShardingStrategy(oldTableRule.getDatabaseShardingStrategy()); newTableRuleConfig.setTableShardingStrategy(oldTableRule.getTableShardingStrategy()); newTableRuleConfig.setKeyGenerateStrategy(oldTableRule.getKeyGenerateStrategy()); newTableRuleConfig.setAuditStrategy(oldTableRule.getAuditStrategy()); return newTableRuleConfig; } return oldTableRule; }) .collect(Collectors.toList()); ruleConfig.setTables(updatedRules); contextManager.alterRuleConfiguration(DATABASE_NAME, Collections.singleton(ruleConfig)); contextManager.reloadDatabase(DATABASE_NAME); log.info("动态分表规则更新成功!"); } else { log.error("未找到 ShardingSphere 的分片规则配置,动态分表更新失败。"); } } /** * 获取 ShardingSphere ContextManager */ private ContextManager getContextManager() { try (ShardingSphereConnection connection = dataSource.getConnection().unwrap(ShardingSphereConnection.class)) { return connection.getContextManager(); } catch (SQLException e) { throw new RuntimeException("获取 ShardingSphere ContextManager 失败", e); } } public void createSpacePictureTable(Space space) { // 动态创建分表 // 仅为旗舰版团队空间创建分表 if (space.getSpaceType() == SpaceTypeEnum.TEAM.getCode() && space.getSpaceLevel() == SpaceLevelEnum.PREMIUM.getKey()) { Long spaceId = space.getId(); String tableName = "picture_" + spaceId; // 创建新表 String createTableSql = "CREATE TABLE " + tableName + " LIKE picture"; try { jdbcTemplate.execute(createTableSql); // 更新分表 updateShardingTableNodes(); } catch (Exception e) { log.error("创建图片空间分表失败,空间 id = {}", space.getId(),e); } } } }

注意:将管理器注册为 Bean,通过 @PostConstruct 注解,在 Bean 加载后获取所有的分表并更新配置。也就是项目初始化时候,会获取表中的旗舰团版团队空间,在查询时候符合条件的空间图片,没有就查询主表,并且更改分片规则配置

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
SmileXu
下载 APP