feat(plugin/tenant): 新增多租户插件模块 (#175)

This commit is contained in:
小熊
2025-07-10 20:38:59 +08:00
committed by GitHub
parent 72493f8161
commit ed6dd65a51
70 changed files with 3539 additions and 65 deletions

View File

@@ -19,6 +19,7 @@ package top.continew.admin.auth;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.extra.spring.SpringUtil;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -36,10 +37,13 @@ import top.continew.admin.system.service.DeptService;
import top.continew.admin.system.service.OptionService;
import top.continew.admin.system.service.RoleService;
import top.continew.admin.system.service.UserService;
import top.continew.starter.core.util.ServletUtils;
import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.core.util.validation.Validator;
import top.continew.starter.core.util.ServletUtils;
import top.continew.starter.extension.tenant.TenantHandler;
import top.continew.starter.extension.tenant.context.TenantContextHolder;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -90,10 +94,21 @@ public abstract class AbstractLoginHandler<T extends LoginReq> implements LoginH
protected String authenticate(UserDO user, ClientResp client) {
// 获取权限、角色、密码过期天数
Long userId = user.getId();
CompletableFuture<Set<String>> permissionFuture = CompletableFuture.supplyAsync(() -> roleService
.listPermissionByUserId(userId), threadPoolTaskExecutor);
CompletableFuture<Set<RoleContext>> roleFuture = CompletableFuture.supplyAsync(() -> roleService
.listByUserId(userId), threadPoolTaskExecutor);
Long tenantId = TenantContextHolder.getTenantId();
CompletableFuture<Set<String>> permissionFuture = CompletableFuture.supplyAsync(() -> {
Set<String> permissions = new HashSet<>();
SpringUtil.getBean(TenantHandler.class).execute(tenantId, () -> {
permissions.addAll(roleService.listPermissionByUserId(userId));
});
return permissions;
}, threadPoolTaskExecutor);
CompletableFuture<Set<RoleContext>> roleFuture = CompletableFuture.supplyAsync(() -> {
Set<RoleContext> roles = new HashSet<>();
SpringUtil.getBean(TenantHandler.class).execute(tenantId, () -> {
roles.addAll(roleService.listByUserId(userId));
});
return roles;
}, threadPoolTaskExecutor);
CompletableFuture<Integer> passwordExpirationDaysFuture = CompletableFuture.supplyAsync(() -> optionService
.getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name()));
CompletableFuture.allOf(permissionFuture, roleFuture, passwordExpirationDaysFuture);
@@ -108,6 +123,7 @@ public abstract class AbstractLoginHandler<T extends LoginReq> implements LoginH
userContext.setClientType(client.getClientType());
loginParameter.setExtra(CLIENT_ID, client.getClientId());
userContext.setClientId(client.getClientId());
userContext.setTenantId(tenantId);
// 登录并缓存用户信息
StpUtil.login(userContext.getId(), loginParameter.setExtraData(BeanUtil
.beanToMap(new UserExtraContext(ServletUtils.getRequest()))));

View File

@@ -123,4 +123,5 @@ public class AuthServiceImpl implements AuthService {
});
return BeanUtil.copyToList(treeList, RouteResp.class);
}
}

View File

@@ -29,12 +29,14 @@ import org.springframework.stereotype.Service;
import top.continew.admin.auth.model.query.OnlineUserQuery;
import top.continew.admin.auth.model.resp.OnlineUserResp;
import top.continew.admin.auth.service.OnlineUserService;
import top.continew.admin.common.config.properties.TenantProperties;
import top.continew.admin.common.context.UserContext;
import top.continew.admin.common.context.UserContextHolder;
import top.continew.admin.common.context.UserExtraContext;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
import top.continew.starter.extension.tenant.context.TenantContextHolder;
import java.time.LocalDateTime;
import java.util.*;
@@ -50,6 +52,8 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class OnlineUserServiceImpl implements OnlineUserService {
private final TenantProperties tenantProperties;
@Override
@AutoOperate(type = OnlineUserResp.class, on = "list")
public PageResp<OnlineUserResp> page(OnlineUserQuery query, PageQuery pageQuery) {
@@ -88,6 +92,12 @@ public class OnlineUserServiceImpl implements OnlineUserService {
.isMatchClientId(query.getClientId(), userContext)) {
continue;
}
//租户数据过滤
if (tenantProperties.isEnabled()) {
if (!TenantContextHolder.getTenantId().equals(userContext.getTenantId())) {
continue;
}
}
List<LocalDateTime> loginTimeList = query.getLoginTime();
entry.getValue().parallelStream().forEach(token -> {
UserExtraContext extraContext = UserContextHolder.getExtraContext(token);

View File

@@ -16,6 +16,8 @@
package top.continew.admin.system.mapper;
import com.baomidou.dynamic.datasource.annotation.DS;
import top.continew.admin.common.constant.SysConstants;
import org.apache.ibatis.annotations.Mapper;
import top.continew.admin.system.model.entity.StorageDO;
import top.continew.starter.data.mapper.BaseMapper;
@@ -26,6 +28,7 @@ import top.continew.starter.data.mapper.BaseMapper;
* @author Charles7c
* @since 2023/12/26 22:09
*/
@DS(SysConstants.DEFAULT_DATASOURCE)
@Mapper
public interface StorageMapper extends BaseMapper<StorageDO> {
}

View File

@@ -108,4 +108,10 @@ public class UserDO extends BaseDO {
* 部门 ID
*/
private Long deptId;
/**
* 租户 ID
*/
@TableField(select = false)
private Long tenantId;
}

View File

@@ -25,6 +25,7 @@ import top.continew.starter.data.enums.QueryType;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* 菜单查询条件
@@ -56,4 +57,12 @@ public class MenuQuery implements Serializable {
public MenuQuery(DisEnableStatusEnum status) {
this.status = status;
}
/**
* 排除的菜单
*/
@Schema(description = "排除的菜单")
@Query(columns = "id", type = QueryType.NOT_IN)
private List<Long> excludeMenuIdList;
}

View File

@@ -56,4 +56,12 @@ public interface DeptService extends BaseService<DeptResp, DeptResp, DeptQuery,
* @return 部门数量
*/
int countByNames(List<String> deptNames);
/**
* 初始化租户部门
*
* @param deptName
* @return 部门ID
*/
Long initTenantDept(String deptName);
}

View File

@@ -34,6 +34,13 @@ import java.util.Set;
*/
public interface MenuService extends BaseService<MenuResp, MenuResp, MenuQuery, MenuReq>, IService<MenuDO> {
/**
* 查询全部菜单
*
* @return 菜单列表
*/
List<MenuResp> listAll(Long tenantId);
/**
* 根据用户 ID 查询
*
@@ -43,10 +50,35 @@ public interface MenuService extends BaseService<MenuResp, MenuResp, MenuQuery,
Set<String> listPermissionByUserId(Long userId);
/**
* 根据角色 ID 查询
* 根据角色id查询
*
* @param roleId 角色 ID
* @param roleId 角色id
* @return 菜单列表
*/
List<MenuResp> listByRoleId(Long roleId);
/**
* 递归初始化菜单
*
* @param menuList 需要初始化的菜单ID
* @param oldParentId 原来的父级ID
* @param newParentId 新的父级ID
*/
void menuInit(List<MenuDO> menuList, Long oldParentId, Long newParentId);
/**
* 删除租户菜单
*
* @param menuList
*/
void deleteTenantMenus(List<MenuDO> menuList);
/**
* 新增租户菜单
*
* @param menu 新增的菜单
* @param pMenu 新增菜单的父级别
*/
void addTenantMenu(MenuDO menu, MenuDO pMenu);
}

View File

@@ -16,6 +16,9 @@
package top.continew.admin.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import top.continew.admin.system.model.entity.RoleMenuDO;
import java.util.List;
/**
@@ -24,7 +27,7 @@ import java.util.List;
* @author Charles7c
* @since 2023/2/19 10:40
*/
public interface RoleMenuService {
public interface RoleMenuService extends IService<RoleMenuDO> {
/**
* 新增

View File

@@ -100,4 +100,12 @@ public interface RoleService extends BaseService<RoleResp, RoleDetailResp, RoleQ
* @return 角色数量
*/
int countByNames(List<String> roleNames);
/**
* 初始化租户角色
*
* @return 角色ID
*/
Long initTenantRole();
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.service;
/**
* @description: 多租户系统数据接口
* @author: 小熊
* @create: 2024-12-02 20:08
*/
public interface TenantSysDataService {
/**
* 清除所有系统数据
*/
void clear();
}

View File

@@ -155,4 +155,13 @@ public interface UserService extends BaseService<UserResp, UserDetailResp, UserQ
* @return 用户数量
*/
Long countByDeptIds(List<Long> deptIds);
/**
* 初始化租户管理员
*
* @param username
* @param password
* @return 管理员id
*/
Long initTenantUser(String username, String password, Long deptId);
}

View File

@@ -214,4 +214,25 @@ public class DeptServiceImpl extends BaseServiceImpl<DeptMapper, DeptDO, DeptRes
}
baseMapper.updateById(list);
}
/**
* 初始化租户部门
*
* @param deptName
* @return 部门ID
*/
@Override
public Long initTenantDept(String deptName) {
//部门添加
DeptDO deptDO = new DeptDO();
deptDO.setName(deptName);
deptDO.setParentId(0l);
deptDO.setAncestors("0");
deptDO.setDescription("系统初始部门");
deptDO.setSort(1);
deptDO.setStatus(DisEnableStatusEnum.ENABLE);
baseMapper.insert(deptDO);
return deptDO.getId();
}
}

View File

@@ -19,6 +19,7 @@ package top.continew.admin.system.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.alicp.jetcache.anno.Cached;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -28,15 +29,20 @@ import top.continew.admin.common.constant.SysConstants;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.enums.MenuTypeEnum;
import top.continew.admin.system.mapper.MenuMapper;
import top.continew.admin.system.mapper.RoleMapper;
import top.continew.admin.system.model.entity.MenuDO;
import top.continew.admin.system.model.entity.RoleDO;
import top.continew.admin.system.model.entity.RoleMenuDO;
import top.continew.admin.system.model.query.MenuQuery;
import top.continew.admin.system.model.req.MenuReq;
import top.continew.admin.system.model.resp.MenuResp;
import top.continew.admin.system.service.MenuService;
import top.continew.admin.system.service.RoleMenuService;
import top.continew.starter.cache.redisson.util.RedisUtils;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.util.validation.CheckUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -50,6 +56,9 @@ import java.util.Set;
@RequiredArgsConstructor
public class MenuServiceImpl extends BaseServiceImpl<MenuMapper, MenuDO, MenuResp, MenuResp, MenuQuery, MenuReq> implements MenuService {
private final RoleMenuService roleMenuService;
private final RoleMapper roleMapper;
@Override
public Long create(MenuReq req) {
String title = req.getTitle();
@@ -90,12 +99,17 @@ public class MenuServiceImpl extends BaseServiceImpl<MenuMapper, MenuDO, MenuRes
RedisUtils.deleteByPattern(CacheConstants.ROLE_MENU_KEY_PREFIX + StringConstants.ASTERISK);
}
@Override
@Cached(key = "'ALL' + #tenantId", name = CacheConstants.ROLE_MENU_KEY_PREFIX)
public List<MenuResp> listAll(Long tenantId) {
return super.list(new MenuQuery(DisEnableStatusEnum.ENABLE), null);
}
@Override
public Set<String> listPermissionByUserId(Long userId) {
return baseMapper.selectPermissionByUserId(userId);
}
@Override
@Cached(key = "#roleId", name = CacheConstants.ROLE_MENU_KEY_PREFIX)
public List<MenuResp> listByRoleId(Long roleId) {
if (SysConstants.SUPER_ROLE_ID.equals(roleId)) {
@@ -107,6 +121,63 @@ public class MenuServiceImpl extends BaseServiceImpl<MenuMapper, MenuDO, MenuRes
return list;
}
@Override
public void menuInit(List<MenuDO> menuList, Long oldParentId, Long newParentId) {
List<MenuDO> children = menuList.stream().filter(menuDO -> menuDO.getParentId().equals(oldParentId)).toList();
for (MenuDO menuDO : children) {
Long oldId = menuDO.getId();
menuDO.setId(null);
menuDO.setParentId(newParentId);
save(menuDO);
menuInit(menuList, oldId, menuDO.getId());
}
}
@Override
public void deleteTenantMenus(List<MenuDO> menuList) {
if (!menuList.isEmpty()) {
List<Long> delIds = new ArrayList<>();
for (MenuDO menuDO : menuList) {
MenuDO tMenu = getOne(Wrappers.query(MenuDO.class)
.eq(menuDO.getType().equals(MenuTypeEnum.BUTTON.getValue()), "CONCAT(title,permission)", menuDO
.getTitle() + menuDO.getPermission())
.eq(!menuDO.getType().equals(MenuTypeEnum.BUTTON.getValue()), "name", menuDO.getName()));
if (tMenu != null) {
delIds.add(tMenu.getId());
}
}
if (!delIds.isEmpty()) {
//菜单删除
delete(delIds);
//绑定关系删除
roleMenuService.remove(Wrappers.lambdaQuery(RoleMenuDO.class).in(RoleMenuDO::getMenuId, delIds));
}
}
}
@Override
public void addTenantMenu(MenuDO menu, MenuDO pMenu) {
Long pId = 0l;
if (pMenu != null) {
MenuDO tPMenu = getOne(Wrappers.query(MenuDO.class)
.eq(pMenu.getType().equals(MenuTypeEnum.BUTTON.getValue()), "CONCAT(title,permission)", pMenu
.getTitle() + pMenu.getPermission())
.eq(!pMenu.getType().equals(MenuTypeEnum.BUTTON.getValue()), "name", pMenu.getName()));
pId = tPMenu.getId();
}
menu.setId(null);
menu.setParentId(pId);
//菜单新增
save(menu);
//管理员绑定菜单
RoleDO roleDO = roleMapper.selectOne(Wrappers.lambdaQuery(RoleDO.class)
.eq(RoleDO::getCode, SysConstants.TENANT_ADMIN_CODE));
RoleMenuDO roleMenuDO = new RoleMenuDO();
roleMenuDO.setRoleId(roleDO.getId());
roleMenuDO.setMenuId(menu.getId());
roleMenuService.save(roleMenuDO);
}
/**
* 标题是否存在
*

View File

@@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
import top.continew.admin.system.mapper.RoleMenuMapper;
import top.continew.admin.system.model.entity.RoleMenuDO;
import top.continew.admin.system.service.RoleMenuService;
import top.continew.starter.data.service.impl.ServiceImpl;
import java.util.ArrayList;
import java.util.List;
@@ -36,7 +37,7 @@ import java.util.stream.Collectors;
*/
@Service
@RequiredArgsConstructor
public class RoleMenuServiceImpl implements RoleMenuService {
public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenuDO> implements RoleMenuService {
private final RoleMenuMapper baseMapper;

View File

@@ -69,6 +69,8 @@ public class RoleServiceImpl extends BaseServiceImpl<RoleMapper, RoleDO, RoleRes
CheckUtils.throwIf(this.isNameExists(name, null), "新增失败,[{}] 已存在", name);
String code = req.getCode();
CheckUtils.throwIf(this.isCodeExists(code, null), "新增失败,[{}] 已存在", code);
// 防止租户添加超管
CheckUtils.throwIf(SysConstants.SUPER_ROLE_CODE.equals(code), "新增失败,[{}] 禁止使用", code);
// 新增信息
Long roleId = super.create(req);
// 保存角色和部门关联
@@ -247,4 +249,24 @@ public class RoleServiceImpl extends BaseServiceImpl<RoleMapper, RoleDO, RoleRes
}
});
}
/**
* 初始化租户角色
*
* @return 角色ID
*/
@Override
public Long initTenantRole() {
RoleDO roleDO = new RoleDO();
roleDO.setName("系统管理员");
roleDO.setCode(SysConstants.TENANT_ADMIN_CODE);
roleDO.setDataScope(DataScopeEnum.ALL);
roleDO.setDescription("系统初始角色");
roleDO.setSort(1);
roleDO.setIsSystem(true);
roleDO.setMenuCheckStrictly(false);
roleDO.setDeptCheckStrictly(false);
baseMapper.insert(roleDO);
return roleDO.getId();
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import top.continew.admin.system.mapper.*;
import top.continew.admin.system.mapper.user.UserMapper;
import top.continew.admin.system.mapper.user.UserPasswordHistoryMapper;
import top.continew.admin.system.mapper.user.UserSocialMapper;
import top.continew.admin.system.model.entity.user.UserDO;
import top.continew.admin.system.service.FileService;
import top.continew.admin.system.service.TenantSysDataService;
import top.continew.starter.extension.crud.model.entity.BaseIdDO;
import java.util.List;
/**
* @description: 多租户系统数据接口
* @author: 小熊
* @create: 2024-12-02 20:12
*/
@RequiredArgsConstructor
@Service
public class TenantSysDataServiceImpl implements TenantSysDataService {
private final DeptMapper deptMapper;
private final FileService fileService;
private final LogMapper logMapper;
private final MenuMapper menuMapper;
private final MessageMapper messageMapper;
private final MessageMapper messageUserMapper;
private final NoticeMapper noticeMapper;
private final RoleMapper roleMapper;
private final RoleDeptMapper roleDeptMapper;
private final RoleMenuMapper roleMenuMapper;
private final UserMapper userMapper;
private final UserPasswordHistoryMapper userPasswordHistoryMapper;
private final UserRoleMapper userRoleMapper;
private final UserSocialMapper userSocialMapper;
@Override
@Transactional
public void clear() {
//所有用户退出
List<UserDO> userDOS = userMapper.selectList(null);
for (UserDO userDO : userDOS) {
StpUtil.logout(userDO.getId());
}
Wrapper dw = Wrappers.query().eq("1", 1);
//部门清除
deptMapper.delete(dw);
//文件清除
List<Long> fileIds = fileService.list().stream().map(BaseIdDO::getId).toList();
if (!fileIds.isEmpty()) {
fileService.delete(fileIds);
}
//日志清除
logMapper.delete(dw);
//菜单清除
menuMapper.delete(dw);
//消息清除
messageMapper.delete(dw);
messageUserMapper.delete(dw);
//通知清除
noticeMapper.delete(dw);
//角色相关数据清除
roleMapper.delete(dw);
roleDeptMapper.delete(dw);
roleMenuMapper.delete(dw);
//用户数据清除
userMapper.delete(dw);
userPasswordHistoryMapper.delete(dw);
userRoleMapper.delete(dw);
userSocialMapper.delete(dw);
}
}

View File

@@ -23,10 +23,7 @@ import cn.hutool.core.io.file.FileNameUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.EnumUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.*;
import cn.hutool.extra.validation.ValidationUtil;
import cn.hutool.http.ContentType;
import cn.hutool.json.JSONUtil;
@@ -57,6 +54,7 @@ import org.springframework.web.multipart.MultipartFile;
import top.continew.admin.auth.service.OnlineUserService;
import top.continew.admin.common.base.service.BaseServiceImpl;
import top.continew.admin.common.constant.CacheConstants;
import top.continew.admin.common.constant.RegexConstants;
import top.continew.admin.common.constant.SysConstants;
import top.continew.admin.common.context.UserContext;
import top.continew.admin.common.context.UserContextHolder;
@@ -80,8 +78,10 @@ import top.continew.admin.system.service.*;
import top.continew.starter.cache.redisson.util.RedisUtils;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.exception.BusinessException;
import top.continew.starter.core.util.ExceptionUtils;
import top.continew.starter.core.util.FileUploadUtils;
import top.continew.starter.core.util.validation.CheckUtils;
import top.continew.starter.core.util.validation.ValidationUtils;
import top.continew.starter.extension.crud.model.query.PageQuery;
import top.continew.starter.extension.crud.model.query.SortQuery;
import top.continew.starter.extension.crud.model.resp.PageResp;
@@ -734,6 +734,26 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, UserDO, UserRes
}
}
@Override
public Long initTenantUser(String username, String password, Long deptId) {
//密码验证
String rawPassword = ExceptionUtils.exToNull(() -> SecureUtils.decryptByRsaPrivateKey(password));
ValidationUtils.throwIfNull(rawPassword, "密码解密失败");
ValidationUtils.throwIf(!ReUtil
.isMatch(RegexConstants.PASSWORD, rawPassword), "密码长度为 8-32 个字符,支持大小写字母、数字、特殊字符,至少包含字母和数字");
UserDO userDO = new UserDO();
userDO.setUsername(username);
userDO.setNickname("系统管理员");
userDO.setPassword(rawPassword);
userDO.setGender(GenderEnum.UNKNOWN);
userDO.setDescription("系统初始用户");
userDO.setStatus(DisEnableStatusEnum.ENABLE);
userDO.setIsSystem(true);
userDO.setDeptId(deptId);
baseMapper.insert(userDO);
return userDO.getId();
}
/**
* 根据 ID 获取用户信息(数据权限)
*