feat(tenant): 授予当前租户cms建站权限

- 新增接口支持根据参考租户复制cms权限菜单到当前租户
- 支持请求参数指定参考租户ID,默认为配置项或企业模板
- 复制cms:*权限标识菜单及其祖先,保证菜单层级完整
- 实现菜单去重与接口幂等,避免重复插入菜单
- 将复制菜单绑定到当前租户超级管理员角色
- 添加相关服务层逻辑及事务保障
- 新增配置项websoft.cms.ref-tenant-id,指定默认参考租户ID
This commit is contained in:
2026-08-06 02:00:35 +08:00
parent c4177f167d
commit 39d3c4c894
3 changed files with 203 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
@@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -49,6 +51,9 @@ public class TenantController extends BaseController {
private RedisUtil redisUtil;
@Resource
private UserService userService;
// cms权限参考租户, 默认取平台租户
@Value("${websoft.cms.ref-tenant-id:10257}")
private Integer cmsRefTenantId;
@Operation(summary = "分页查询租户")
@GetMapping("/page")
@@ -215,6 +220,40 @@ public class TenantController extends BaseController {
return success(menus);
}
@OperationLog
@Operation(summary = "授予当前租户cms建站权限")
@PostMapping("/grantCmsPermission")
public ApiResult<?> grantCmsPermission(@RequestBody(required = false) Map<String, Object> body) {
final Integer curTenant = getTenantId();
if (curTenant == null) {
return fail("获取当前租户失败, 请重新登录");
}
// 参考租户优先级: 请求参数 > 企业当前使用的租户模板 > 配置项websoft.cms.ref-tenant-id
Integer refTenant = null;
final Object refTenantId = body == null ? null : body.get("refTenantId");
if (refTenantId instanceof Number) {
refTenant = ((Number) refTenantId).intValue();
} else if (refTenantId != null && refTenantId.toString().matches("\\d+")) {
refTenant = Integer.valueOf(refTenantId.toString());
}
if (refTenant == null) {
final Company company = getCompany();
if (company != null && company.getPlanId() != null) {
refTenant = company.getPlanId();
}
}
if (refTenant == null) {
refTenant = cmsRefTenantId;
}
if (refTenant == null) {
return fail("未找到参考租户, 请配置websoft.cms.ref-tenant-id");
}
if (refTenant.equals(curTenant)) {
return fail("参考租户不能与当前租户相同");
}
return tenantService.grantCmsPermission(refTenant, curTenant);
}
@Operation(summary = "创建租户")
@PostMapping("/saveByPhone")
public ApiResult<?> saveByPhone(@RequestBody Tenant tenant) {

View File

@@ -1,6 +1,7 @@
package com.gxwebsoft.common.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Company;
import com.gxwebsoft.common.system.entity.Tenant;
@@ -45,4 +46,14 @@ public interface TenantService extends IService<Tenant> {
boolean destructionAll(Integer tenantId);
Tenant getByCodeRel(String code);
/**
* 授予当前租户cms建站权限
* 从参考租户复制cms:*菜单到当前租户, 并绑定到当前租户的超级管理员角色
*
* @param refTenant 参考租户id(已配置好cms菜单的租户)
* @param curTenant 当前租户id
* @return ApiResult<?>
*/
ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant);
}

View File

@@ -2,21 +2,34 @@ package com.gxwebsoft.common.system.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.utils.CommonUtil;
import com.gxwebsoft.common.core.utils.DomainUtil;
import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.system.entity.*;
import com.gxwebsoft.common.system.mapper.MenuMapper;
import com.gxwebsoft.common.system.mapper.TenantMapper;
import com.gxwebsoft.common.system.param.MenuParam;
import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.common.system.param.TenantParam;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 租户Service实现
@@ -26,12 +39,18 @@ import java.util.List;
*/
@Service
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
// cms建站权限标识前缀
private static final String CMS_AUTHORITY_PREFIX = "cms:";
@Resource
private CompanyService companyService;
@Resource
private MenuService menuService;
@Resource
private MenuMapper menuMapper;
@Resource
private RoleMenuService roleMenuService;
@Resource
private RoleService roleService;
@Resource
private UserRoleService userRoleService;
@@ -632,4 +651,138 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
return param.getOne(baseMapper.selectListRel(param));
}
@Override
@Transactional(rollbackFor = {Exception.class})
public ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant) {
// 1.当前租户的超级管理员角色, 必须带租户条件
final Role superAdmin = roleService.getOne(new LambdaQueryWrapper<Role>()
.eq(Role::getRoleCode, "superAdmin")
.eq(Role::getTenantId, curTenant)
.last("limit 1"));
if (superAdmin == null) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "当前租户没有superAdmin角色, 无法授权");
}
final Integer roleId = superAdmin.getRoleId();
// 2.跨租户读取参考租户的全部未删除菜单
final MenuParam refParam = new MenuParam();
refParam.setTenantId(refTenant);
refParam.setDeleted(0);
final List<Menu> refMenus = menuMapper.getMenuByClone(refParam);
if (CollectionUtils.isEmpty(refMenus)) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的菜单");
}
final Map<Integer, Menu> refMenuMap = new HashMap<>();
for (Menu menu : refMenus) {
refMenuMap.put(menu.getMenuId(), menu);
}
// 3.筛选cms:*子树, 权限标识以cms:开头的节点 + 其全部祖先, 保证父级完整
final Set<Integer> cmsMenuIds = new HashSet<>();
for (Menu menu : refMenus) {
if (menu.getAuthority() == null || !menu.getAuthority().startsWith(CMS_AUTHORITY_PREFIX)) {
continue;
}
Menu current = menu;
// add返回false说明该节点及其祖先已收录, 无需继续向上追溯
while (current != null && cmsMenuIds.add(current.getMenuId())) {
final Integer parentId = current.getParentId();
current = (parentId == null || parentId == 0) ? null : refMenuMap.get(parentId);
}
}
if (cmsMenuIds.isEmpty()) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有cms权限菜单");
}
// 4.当前租户已有菜单, 用于去重, 保证接口幂等
final MenuParam curParam = new MenuParam();
curParam.setTenantId(curTenant);
curParam.setDeleted(0);
final Map<String, Integer> curMenuKeys = new HashMap<>();
for (Menu menu : menuMapper.getMenuByClone(curParam)) {
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
}
// 5.按层级复制菜单, 父菜单先于子菜单插入
final List<Menu> sources = refMenus.stream()
.filter(d -> cmsMenuIds.contains(d.getMenuId()))
.sorted(Comparator.comparingInt((Menu d) -> getMenuDepth(d, refMenuMap)))
.collect(Collectors.toList());
final Map<Integer, Integer> menuIdMapping = new HashMap<>();
int menuCount = 0;
for (Menu source : sources) {
// 已存在的菜单不重复插入, 直接复用其菜单id作为子菜单的父级
final Integer existsMenuId = curMenuKeys.get(getMenuKey(source));
if (existsMenuId != null) {
menuIdMapping.put(source.getMenuId(), existsMenuId);
continue;
}
final Menu menu = new Menu();
menu.setParentId(source.getParentId() == null ? 0 : menuIdMapping.getOrDefault(source.getParentId(), 0));
menu.setTitle(source.getTitle());
menu.setPath(source.getPath());
menu.setComponent(source.getComponent());
menu.setModules(source.getModules());
menu.setModulesUrl(source.getModulesUrl());
menu.setMenuType(source.getMenuType());
menu.setSortNumber(source.getSortNumber());
menu.setAuthority(source.getAuthority());
menu.setIcon(source.getIcon());
menu.setHide(source.getHide());
menu.setMeta(source.getMeta());
menu.setTenantId(curTenant);
menuService.save(menu);
menuIdMapping.put(source.getMenuId(), menu.getMenuId());
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
menuCount++;
}
// 6.绑定cms菜单到超级管理员角色, 已绑定的跳过
final Set<Integer> boundMenuIds = roleMenuService.list(new LambdaQueryWrapper<RoleMenu>()
.eq(RoleMenu::getRoleId, roleId))
.stream().map(RoleMenu::getMenuId).collect(Collectors.toSet());
final List<RoleMenu> roleMenus = new ArrayList<>();
for (Integer menuId : menuIdMapping.values()) {
if (menuId == null || boundMenuIds.contains(menuId)) {
continue;
}
final RoleMenu roleMenu = new RoleMenu();
roleMenu.setRoleId(roleId);
roleMenu.setMenuId(menuId);
roleMenu.setTenantId(curTenant);
roleMenus.add(roleMenu);
}
if (!roleMenus.isEmpty()) {
roleMenuService.saveBatch(roleMenus);
}
final Map<String, Object> data = new HashMap<>();
data.put("refTenantId", refTenant);
data.put("tenantId", curTenant);
data.put("roleId", roleId);
data.put("menuTotal", menuIdMapping.size());
data.put("menuCreated", menuCount);
data.put("roleMenuCreated", roleMenus.size());
return new ApiResult<>(Constants.RESULT_OK_CODE, "授权成功", data);
}
// 菜单去重标识, 优先使用权限标识, 目录类菜单权限标识为空时按标题+路由匹配
private String getMenuKey(Menu menu) {
if (StrUtil.isNotBlank(menu.getAuthority())) {
return "authority:" + menu.getAuthority();
}
return "menu:" + StrUtil.nullToEmpty(menu.getTitle()) + "@" + StrUtil.nullToEmpty(menu.getPath());
}
// 菜单层级, 用于保证父菜单先于子菜单插入
private int getMenuDepth(Menu menu, Map<Integer, Menu> menuMap) {
int depth = 0;
Menu current = menu;
while (current != null && current.getParentId() != null && current.getParentId() != 0 && depth < 20) {
current = menuMap.get(current.getParentId());
depth++;
}
return depth;
}
}