feat(tenant):实现租户套餐功能及多租户架构完善

1. 多租户架构完善
     - TenantSqlInterceptor支持INSERT自动填充tenant_id
     - 修复isTenantTable()使用TenantConfig.needTenantFilter()
     - 唯一索引改造为租户内唯一(tenant_id + column)

  2. 租户套餐功能
     - 新增sys_tenant_package、sys_package_menu表
     - 实现套餐CRUD及菜单关联管理
     - SysMenuService实现套餐菜单过滤(角色菜单∩套餐菜单)
     - 租户管理页面增加套餐选择
     - 新增套餐管理页面
pull/1126/head
9264yf 2025-12-20 13:20:41 +08:00
parent 9a8ffdbb8f
commit 158e666ef2
16 changed files with 2811 additions and 20 deletions

22
docs/dev/README.md 100644
View File

@ -0,0 +1,22 @@
# 开发文档索引
本目录包含项目的开发相关文档。
## 文档列表
### 架构设计文档
- [TENANT_GUIDE.md](./TENANT_GUIDE.md) - 若依多租户架构实现指南
- 多租户SaaS架构概述与方案对比
- 核心组件实现原理TenantContext、拦截器、SQL改写
- 快速集成指南(依赖配置、代码集成、数据库改造)
- 使用指南与开发注意事项
- 最佳实践与性能优化
- 常见问题FAQ
## 文档规范
所有开发文档遵循以下规范:
- 文档命名格式:`{分类}_{英文大写}.md`,单词用 `_` 隔开
- 文档使用简体中文编写
- 言简意赅,避免长篇大论
- 及时更新本索引文件

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
package com.ruoyi.web.controller.system;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.SysTenantPackage;
import com.ruoyi.system.service.ISysTenantPackageService;
/**
* Controller
*
* @author ruoyi
* @date 2025-12-19
*/
@RestController
@RequestMapping("/system/package")
public class SysTenantPackageController extends BaseController
{
@Autowired
private ISysTenantPackageService packageService;
/**
*
*/
@PreAuthorize("@ss.hasPermi('system:package:list')")
@GetMapping("/list")
public TableDataInfo list(SysTenantPackage sysTenantPackage)
{
startPage();
List<SysTenantPackage> list = packageService.selectPackageList(sysTenantPackage);
return getDataTable(list);
}
/**
*
*/
@PreAuthorize("@ss.hasPermi('system:package:query')")
@GetMapping(value = "/{packageId}")
public AjaxResult getInfo(@PathVariable("packageId") Long packageId)
{
return success(packageService.selectPackageById(packageId));
}
/**
*
*/
@PreAuthorize("@ss.hasPermi('system:package:add')")
@Log(title = "租户套餐", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysTenantPackage sysTenantPackage)
{
return toAjax(packageService.insertPackage(sysTenantPackage));
}
/**
*
*/
@PreAuthorize("@ss.hasPermi('system:package:edit')")
@Log(title = "租户套餐", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysTenantPackage sysTenantPackage)
{
return toAjax(packageService.updatePackage(sysTenantPackage));
}
/**
*
*/
@PreAuthorize("@ss.hasPermi('system:package:remove')")
@Log(title = "租户套餐", businessType = BusinessType.DELETE)
@DeleteMapping("/{packageIds}")
public AjaxResult remove(@PathVariable Long[] packageIds)
{
return toAjax(packageService.deletePackageByIds(packageIds));
}
/**
* ID
*/
@PreAuthorize("@ss.hasPermi('system:package:query')")
@GetMapping(value = "/{packageId}/menus")
public AjaxResult getPackageMenus(@PathVariable("packageId") Long packageId)
{
List<Long> menuIds = packageService.selectMenuIdsByPackageId(packageId);
return success(menuIds);
}
}

View File

@ -1,20 +1,26 @@
package com.ruoyi.framework.interceptor;
import java.sql.Connection;
import java.util.List;
import java.util.Properties;
import com.ruoyi.common.exception.ServiceException;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
import net.sf.jsqlparser.expression.operators.relational.ItemsList;
import net.sf.jsqlparser.expression.operators.relational.MultiExpressionList;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.FromItem;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SubSelect;
import net.sf.jsqlparser.statement.update.Update;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
@ -114,10 +120,11 @@ public class TenantSqlInterceptor implements Interceptor {
* @return true-false-
*/
private boolean shouldIntercept(SqlCommandType sqlCommandType, String sql) {
// 拦截 SELECT、UPDATE、DELETE
// 拦截 SELECT、UPDATE、DELETE、INSERT
if (sqlCommandType != SqlCommandType.SELECT &&
sqlCommandType != SqlCommandType.UPDATE &&
sqlCommandType != SqlCommandType.DELETE) {
sqlCommandType != SqlCommandType.DELETE &&
sqlCommandType != SqlCommandType.INSERT) {
return false;
}
@ -152,6 +159,8 @@ public class TenantSqlInterceptor implements Interceptor {
return ((Update) statement).getTable().getName();
} else if (statement instanceof Delete) {
return ((Delete) statement).getTable().getName();
} else if (statement instanceof Insert) {
return ((Insert) statement).getTable().getName();
}
return null;
}
@ -163,8 +172,7 @@ public class TenantSqlInterceptor implements Interceptor {
* @return true-false-
*/
private boolean isTenantTable(String tableName) {
return TenantConfig.TENANT_TABLES.stream()
.anyMatch(t -> t.equalsIgnoreCase(tableName));
return TenantConfig.needTenantFilter(tableName);
}
/**
@ -233,6 +241,10 @@ public class TenantSqlInterceptor implements Interceptor {
return deleteStatement.toString();
}
else if (statement instanceof Insert) {
// 处理 INSERT 语句
return processInsert((Insert) statement, tenantId);
}
return sql;
}
@ -307,6 +319,63 @@ public class TenantSqlInterceptor implements Interceptor {
return equalsTo;
}
/**
* INSERT - tenant_id
*
* Reason: tenant_id
*
* @param insertStatement INSERT
* @param tenantId ID
* @return SQL
*/
private String processInsert(Insert insertStatement, Long tenantId) {
// 1. 获取表名
String tableName = insertStatement.getTable().getName();
// 2. 获取列定义
List<Column> columns = insertStatement.getColumns();
if (columns == null || columns.isEmpty()) {
// 无列名INSERT: INSERT INTO table VALUES (...)
log.warn("【租户INSERT】无列名语句无法自动填充tenant_id: {}", tableName);
return insertStatement.toString();
}
// 3. 检查是否已有tenant_id
boolean hasTenantId = columns.stream()
.anyMatch(col -> "tenant_id".equalsIgnoreCase(col.getColumnName()));
if (hasTenantId) {
log.debug("【租户INSERT】已包含tenant_id,跳过自动填充: {}", tableName);
return insertStatement.toString();
}
// 4. 自动添加tenant_id列
columns.add(new Column("tenant_id"));
// 5. 处理VALUES
ItemsList itemsList = insertStatement.getItemsList();
if (itemsList instanceof ExpressionList) {
// 单行INSERT
ExpressionList expressionList = (ExpressionList) itemsList;
expressionList.getExpressions().add(new LongValue(tenantId));
}
else if (itemsList instanceof MultiExpressionList) {
// 批量INSERT
MultiExpressionList multiList = (MultiExpressionList) itemsList;
for (ExpressionList expList : multiList.getExpressionLists()) {
expList.getExpressions().add(new LongValue(tenantId));
}
}
else if (itemsList instanceof SubSelect) {
// INSERT SELECT暂不支持
log.warn("【租户INSERT】INSERT SELECT语句暂不支持自动填充: {}", tableName);
return insertStatement.toString();
}
log.info("【租户INSERT拦截】自动填充tenant_id={}: {}", tenantId, tableName);
return insertStatement.toString();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);

View File

@ -0,0 +1,92 @@
package com.ruoyi.system.domain;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* sys_tenant_package
*
* @author ruoyi
* @date 2025-12-19
*/
public class SysTenantPackage extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 套餐ID */
private Long packageId;
/** 套餐名称 */
private String packageName;
/** 套餐编码 */
private String packageCode;
/** 状态0正常 1停用 */
private String status;
/** 删除标志0存在 2删除 */
private String delFlag;
/** 菜单ID列表非数据库字段 */
private Long[] menuIds;
public void setPackageId(Long packageId)
{
this.packageId = packageId;
}
public Long getPackageId()
{
return packageId;
}
public void setPackageName(String packageName)
{
this.packageName = packageName;
}
public String getPackageName()
{
return packageName;
}
public void setPackageCode(String packageCode)
{
this.packageCode = packageCode;
}
public String getPackageCode()
{
return packageCode;
}
public void setStatus(String status)
{
this.status = status;
}
public String getStatus()
{
return status;
}
public void setDelFlag(String delFlag)
{
this.delFlag = delFlag;
}
public String getDelFlag()
{
return delFlag;
}
public Long[] getMenuIds()
{
return menuIds;
}
public void setMenuIds(Long[] menuIds)
{
this.menuIds = menuIds;
}
}

View File

@ -0,0 +1,87 @@
package com.ruoyi.system.mapper;
import java.util.List;
import com.ruoyi.system.domain.SysTenantPackage;
import org.apache.ibatis.annotations.Param;
/**
* Mapper
*
* @author ruoyi
* @date 2025-12-19
*/
public interface SysTenantPackageMapper
{
/**
*
*
* @param packageId
* @return
*/
public SysTenantPackage selectPackageById(Long packageId);
/**
*
*
* @param sysTenantPackage
* @return
*/
public List<SysTenantPackage> selectPackageList(SysTenantPackage sysTenantPackage);
/**
*
*
* @param sysTenantPackage
* @return
*/
public int insertPackage(SysTenantPackage sysTenantPackage);
/**
*
*
* @param sysTenantPackage
* @return
*/
public int updatePackage(SysTenantPackage sysTenantPackage);
/**
*
*
* @param packageId
* @return
*/
public int deletePackageById(Long packageId);
/**
*
*
* @param packageIds
* @return
*/
public int deletePackageByIds(Long[] packageIds);
/**
* ID
*
* @param packageId ID
* @return ID
*/
public List<Long> selectMenuIdsByPackageId(Long packageId);
/**
*
*
* @param packageId ID
* @param menuIds ID
* @return
*/
public int batchInsertPackageMenu(@Param("packageId") Long packageId, @Param("menuIds") Long[] menuIds);
/**
*
*
* @param packageId ID
* @return
*/
public int deletePackageMenuByPackageId(Long packageId);
}

View File

@ -0,0 +1,71 @@
package com.ruoyi.system.service;
import java.util.List;
import com.ruoyi.system.domain.SysTenantPackage;
/**
* Service
*
* @author ruoyi
* @date 2025-12-19
*/
public interface ISysTenantPackageService
{
/**
*
*
* @param packageId
* @return
*/
public SysTenantPackage selectPackageById(Long packageId);
/**
*
*
* @param sysTenantPackage
* @return
*/
public List<SysTenantPackage> selectPackageList(SysTenantPackage sysTenantPackage);
/**
*
*
* @param sysTenantPackage
* @return
*/
public int insertPackage(SysTenantPackage sysTenantPackage);
/**
*
*
* @param sysTenantPackage
* @return
*/
public int updatePackage(SysTenantPackage sysTenantPackage);
/**
*
*
* @param packageIds
* @return
*/
public int deletePackageByIds(Long[] packageIds);
/**
*
*
* @param packageId
* @return
*/
public int deletePackageById(Long packageId);
/**
* ID
*
* Reason:
*
* @param packageId ID
* @return ID
*/
public List<Long> selectMenuIdsByPackageId(Long packageId);
}

View File

@ -8,6 +8,8 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.constant.Constants;
@ -16,14 +18,18 @@ import com.ruoyi.common.core.domain.TreeSelect;
import com.ruoyi.common.core.domain.entity.SysMenu;
import com.ruoyi.common.core.domain.entity.SysRole;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.domain.SysTenant;
import com.ruoyi.system.domain.vo.MetaVo;
import com.ruoyi.system.domain.vo.RouterVo;
import com.ruoyi.system.mapper.SysMenuMapper;
import com.ruoyi.system.mapper.SysRoleMapper;
import com.ruoyi.system.mapper.SysRoleMenuMapper;
import com.ruoyi.system.service.ISysMenuService;
import com.ruoyi.system.service.ISysTenantService;
import com.ruoyi.system.service.ISysTenantPackageService;
/**
*
@ -33,6 +39,8 @@ import com.ruoyi.system.service.ISysMenuService;
@Service
public class SysMenuServiceImpl implements ISysMenuService
{
private static final Logger log = LoggerFactory.getLogger(SysMenuServiceImpl.class);
public static final String PREMISSION_STRING = "perms[\"{0}\"]";
@Autowired
@ -44,6 +52,12 @@ public class SysMenuServiceImpl implements ISysMenuService
@Autowired
private SysRoleMenuMapper roleMenuMapper;
@Autowired
private ISysTenantService tenantService;
@Autowired
private ISysTenantPackageService packageService;
/**
*
*
@ -58,7 +72,7 @@ public class SysMenuServiceImpl implements ISysMenuService
/**
*
*
*
* @param menu
* @return
*/
@ -75,6 +89,8 @@ public class SysMenuServiceImpl implements ISysMenuService
{
menu.getParams().put("userId", userId);
menuList = menuMapper.selectMenuListByUserId(menu);
// 应用租户套餐过滤
menuList = applyPackageFilter(menuList);
}
return menuList;
}
@ -123,7 +139,7 @@ public class SysMenuServiceImpl implements ISysMenuService
/**
* ID
*
*
* @param userId
* @return
*/
@ -138,6 +154,8 @@ public class SysMenuServiceImpl implements ISysMenuService
else
{
menus = menuMapper.selectMenuTreeByUserId(userId);
// 应用租户套餐过滤
menus = applyPackageFilter(menus);
}
return getChildPerms(menus, 0);
}
@ -532,7 +550,7 @@ public class SysMenuServiceImpl implements ISysMenuService
/**
*
*
*
* @return
*/
public String innerLinkReplaceEach(String path)
@ -540,4 +558,61 @@ public class SysMenuServiceImpl implements ISysMenuService
return StringUtils.replaceEach(path, new String[] { Constants.HTTP, Constants.HTTPS, Constants.WWW, ".", ":" },
new String[] { "", "", "", "/", "/" });
}
/**
*
*
* Reason:
*
*
* @param menus
* @return
*/
private List<SysMenu> applyPackageFilter(List<SysMenu> menus)
{
try
{
// 1. 获取当前租户
LoginUser loginUser = SecurityUtils.getLoginUser();
if (loginUser == null || loginUser.getTenantId() == null)
{
return menus; // 无租户不过滤
}
// 2. 获取租户信息
SysTenant tenant = tenantService.selectSysTenantByTenantId(loginUser.getTenantId());
if (tenant == null || tenant.getPackageId() == null)
{
log.warn("租户 {} 未配置套餐,不限制菜单", loginUser.getTenantId());
return menus;
}
// 3. 获取套餐菜单ID集合
List<Long> packageMenuIds = packageService.selectMenuIdsByPackageId(tenant.getPackageId());
// 4. 空列表表示高级套餐(不限制)
if (packageMenuIds == null || packageMenuIds.isEmpty())
{
log.debug("租户 {} 使用高级套餐,不限制菜单", loginUser.getTenantId());
return menus;
}
// 5. 过滤:只保留套餐内菜单
Set<Long> packageMenuIdSet = new HashSet<>(packageMenuIds);
List<SysMenu> filteredMenus = menus.stream()
.filter(menu -> packageMenuIdSet.contains(menu.getMenuId()))
.collect(Collectors.toList());
log.debug("租户 {} 套餐过滤: {} → {} 个菜单",
loginUser.getTenantId(), menus.size(), filteredMenus.size());
return filteredMenus;
}
catch (Exception e)
{
log.error("应用租户套餐过滤失败,降级为不过滤", e);
return menus; // 容错:失败不影响正常功能
}
}
}

View File

@ -0,0 +1,124 @@
package com.ruoyi.system.service.impl;
import java.util.List;
import com.ruoyi.common.utils.DateUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.system.mapper.SysTenantPackageMapper;
import com.ruoyi.system.domain.SysTenantPackage;
import com.ruoyi.system.service.ISysTenantPackageService;
/**
* Service
*
* @author ruoyi
* @date 2025-12-19
*/
@Service
public class SysTenantPackageServiceImpl implements ISysTenantPackageService
{
@Autowired
private SysTenantPackageMapper tenantPackageMapper;
/**
*
*
* @param packageId
* @return
*/
@Override
public SysTenantPackage selectPackageById(Long packageId)
{
return tenantPackageMapper.selectPackageById(packageId);
}
/**
*
*
* @param sysTenantPackage
* @return
*/
@Override
public List<SysTenantPackage> selectPackageList(SysTenantPackage sysTenantPackage)
{
return tenantPackageMapper.selectPackageList(sysTenantPackage);
}
/**
*
*
* @param sysTenantPackage
* @return
*/
@Override
@Transactional
public int insertPackage(SysTenantPackage sysTenantPackage)
{
sysTenantPackage.setCreateTime(DateUtils.getNowDate());
int rows = tenantPackageMapper.insertPackage(sysTenantPackage);
// 保存菜单关联
if (sysTenantPackage.getMenuIds() != null && sysTenantPackage.getMenuIds().length > 0)
{
tenantPackageMapper.batchInsertPackageMenu(sysTenantPackage.getPackageId(), sysTenantPackage.getMenuIds());
}
return rows;
}
/**
*
*
* @param sysTenantPackage
* @return
*/
@Override
@Transactional
public int updatePackage(SysTenantPackage sysTenantPackage)
{
sysTenantPackage.setUpdateTime(DateUtils.getNowDate());
// 先删除旧的菜单关联
tenantPackageMapper.deletePackageMenuByPackageId(sysTenantPackage.getPackageId());
// 再插入新的菜单关联
if (sysTenantPackage.getMenuIds() != null && sysTenantPackage.getMenuIds().length > 0)
{
tenantPackageMapper.batchInsertPackageMenu(sysTenantPackage.getPackageId(), sysTenantPackage.getMenuIds());
}
return tenantPackageMapper.updatePackage(sysTenantPackage);
}
/**
*
*
* @param packageIds
* @return
*/
@Override
public int deletePackageByIds(Long[] packageIds)
{
return tenantPackageMapper.deletePackageByIds(packageIds);
}
/**
*
*
* @param packageId
* @return
*/
@Override
public int deletePackageById(Long packageId)
{
return tenantPackageMapper.deletePackageById(packageId);
}
/**
* ID
*
* @param packageId ID
* @return ID
*/
@Override
public List<Long> selectMenuIdsByPackageId(Long packageId)
{
return tenantPackageMapper.selectMenuIdsByPackageId(packageId);
}
}

View File

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.SysTenantPackageMapper">
<resultMap type="SysTenantPackage" id="SysTenantPackageResult">
<result property="packageId" column="package_id" />
<result property="packageName" column="package_name" />
<result property="packageCode" column="package_code" />
<result property="status" column="status" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectPackageVo">
select package_id, package_name, package_code, status, del_flag, create_by, create_time, update_by, update_time, remark from sys_tenant_package
</sql>
<select id="selectPackageList" parameterType="SysTenantPackage" resultMap="SysTenantPackageResult">
<include refid="selectPackageVo"/>
<where>
del_flag = '0'
<if test="packageName != null and packageName != ''"> and package_name like concat('%', #{packageName}, '%')</if>
<if test="packageCode != null and packageCode != ''"> and package_code = #{packageCode}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
</select>
<select id="selectPackageById" parameterType="Long" resultMap="SysTenantPackageResult">
<include refid="selectPackageVo"/>
where package_id = #{packageId} and del_flag = '0'
</select>
<insert id="insertPackage" parameterType="SysTenantPackage" useGeneratedKeys="true" keyProperty="packageId">
insert into sys_tenant_package
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="packageName != null and packageName != ''">package_name,</if>
<if test="packageCode != null and packageCode != ''">package_code,</if>
<if test="status != null">status,</if>
<if test="delFlag != null">del_flag,</if>
<if test="createBy != null">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="packageName != null and packageName != ''">#{packageName},</if>
<if test="packageCode != null and packageCode != ''">#{packageCode},</if>
<if test="status != null">#{status},</if>
<if test="delFlag != null">#{delFlag},</if>
<if test="createBy != null">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
</insert>
<update id="updatePackage" parameterType="SysTenantPackage">
update sys_tenant_package
<trim prefix="SET" suffixOverrides=",">
<if test="packageName != null and packageName != ''">package_name = #{packageName},</if>
<if test="packageCode != null and packageCode != ''">package_code = #{packageCode},</if>
<if test="status != null">status = #{status},</if>
<if test="delFlag != null">del_flag = #{delFlag},</if>
<if test="createBy != null">create_by = #{createBy},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where package_id = #{packageId}
</update>
<update id="deletePackageById" parameterType="Long">
update sys_tenant_package set del_flag = '2' where package_id = #{packageId}
</update>
<update id="deletePackageByIds" parameterType="Long">
update sys_tenant_package set del_flag = '2' where package_id in
<foreach item="packageId" collection="array" open="(" separator="," close=")">
#{packageId}
</foreach>
</update>
<!-- 查询套餐包含的菜单ID列表 -->
<select id="selectMenuIdsByPackageId" parameterType="Long" resultType="Long">
select menu_id from sys_package_menu where package_id = #{packageId}
</select>
<!-- 批量新增套餐菜单关联 -->
<insert id="batchInsertPackageMenu">
insert into sys_package_menu(package_id, menu_id) values
<foreach item="menuId" collection="menuIds" separator=",">
(#{packageId}, #{menuId})
</foreach>
</insert>
<!-- 删除套餐菜单关联 -->
<delete id="deletePackageMenuByPackageId" parameterType="Long">
delete from sys_package_menu where package_id = #{packageId}
</delete>
</mapper>

View File

@ -0,0 +1,52 @@
import request from '@/utils/request'
// 查询套餐列表
export function listPackage(query) {
return request({
url: '/system/package/list',
method: 'get',
params: query
})
}
// 查询套餐详细
export function getPackage(packageId) {
return request({
url: '/system/package/' + packageId,
method: 'get'
})
}
// 新增套餐
export function addPackage(data) {
return request({
url: '/system/package',
method: 'post',
data: data
})
}
// 修改套餐
export function updatePackage(data) {
return request({
url: '/system/package',
method: 'put',
data: data
})
}
// 删除套餐
export function delPackage(packageId) {
return request({
url: '/system/package/' + packageId,
method: 'delete'
})
}
// 查询套餐关联的菜单ID列表
export function getPackageMenus(packageId) {
return request({
url: '/system/package/' + packageId + '/menus',
method: 'get'
})
}

View File

@ -0,0 +1,376 @@
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="套餐名称" prop="packageName">
<el-input
v-model="queryParams.packageName"
placeholder="请输入套餐名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="套餐编码" prop="packageCode">
<el-input
v-model="queryParams.packageCode"
placeholder="请输入套餐编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
<el-option label="正常" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery"></el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery"></el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['system:package:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['system:package:edit']"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['system:package:remove']"
>删除</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="packageList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="套餐ID" align="center" prop="packageId" width="80" />
<el-table-column label="套餐名称" align="center" prop="packageName" :show-overflow-tooltip="true" />
<el-table-column label="套餐编码" align="center" prop="packageCode" :show-overflow-tooltip="true" />
<el-table-column label="状态" align="center" prop="status" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
{{ scope.row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['system:package:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['system:package:remove']"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改套餐对话框 -->
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="套餐名称" prop="packageName">
<el-input v-model="form.packageName" placeholder="请输入套餐名称" />
</el-form-item>
<el-form-item label="套餐编码" prop="packageCode">
<el-input v-model="form.packageCode" placeholder="请输入套餐编码" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio label="0">正常</el-radio>
<el-radio label="1">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="菜单权限">
<el-checkbox v-model="menuExpand" @change="handleCheckedTreeExpand($event, 'menu')">/</el-checkbox>
<el-checkbox v-model="menuNodeAll" @change="handleCheckedTreeNodeAll($event, 'menu')">/</el-checkbox>
<el-checkbox v-model="form.menuCheckStrictly" @change="handleCheckedTreeConnect($event, 'menu')"></el-checkbox>
<el-tree
class="tree-border"
:data="menuOptions"
show-checkbox
ref="menu"
node-key="id"
:check-strictly="!form.menuCheckStrictly"
empty-text="加载中,请稍候"
:props="defaultProps"
></el-tree>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listPackage, getPackage, addPackage, updatePackage, delPackage, getPackageMenus } from "@/api/system/package"
import { treeselect as menuTreeselect } from "@/api/system/menu"
export default {
name: "Package",
data() {
return {
//
loading: true,
//
ids: [],
//
single: true,
//
multiple: true,
//
showSearch: true,
//
total: 0,
//
packageList: [],
//
title: "",
//
open: false,
//
menuOptions: [],
//
menuExpand: false,
//
menuNodeAll: false,
//
queryParams: {
pageNum: 1,
pageSize: 10,
packageName: null,
packageCode: null,
status: null
},
//
form: {},
//
defaultProps: {
children: "children",
label: "label"
},
//
rules: {
packageName: [
{ required: true, message: "套餐名称不能为空", trigger: "blur" }
],
packageCode: [
{ required: true, message: "套餐编码不能为空", trigger: "blur" }
]
}
}
},
created() {
this.getList()
},
methods: {
/** 查询套餐列表 */
getList() {
this.loading = true
listPackage(this.queryParams).then(response => {
this.packageList = response.rows
this.total = response.total
this.loading = false
})
},
/** 查询菜单树结构 */
getMenuTreeselect() {
menuTreeselect().then(response => {
this.menuOptions = response.data
})
},
//
cancel() {
this.open = false
this.reset()
},
//
reset() {
if (this.$refs.menu != undefined) {
this.$refs.menu.setCheckedKeys([])
}
this.menuExpand = false
this.menuNodeAll = false
this.form = {
packageId: null,
packageName: null,
packageCode: null,
status: "0",
menuCheckStrictly: true,
remark: null
}
this.resetForm("form")
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm")
this.handleQuery()
},
//
handleSelectionChange(selection) {
this.ids = selection.map(item => item.packageId)
this.single = selection.length !== 1
this.multiple = !selection.length
},
// /
handleCheckedTreeExpand(value, type) {
if (type == 'menu') {
let treeList = this.menuOptions
for (let i = 0; i < treeList.length; i++) {
this.$refs.menu.store.nodesMap[treeList[i].id].expanded = value
}
}
},
// /
handleCheckedTreeNodeAll(value, type) {
if (type == 'menu') {
this.$refs.menu.setCheckedNodes(value ? this.menuOptions : [])
}
},
//
handleCheckedTreeConnect(value, type) {
if (type == 'menu') {
this.form.menuCheckStrictly = value ? true : false
}
},
/** 新增按钮操作 */
handleAdd() {
this.reset()
this.getMenuTreeselect()
this.open = true
this.title = "添加套餐"
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset()
this.getMenuTreeselect()
const packageId = row.packageId || this.ids
getPackage(packageId).then(response => {
this.form = response.data
this.form.menuCheckStrictly = true
this.open = true
this.title = "修改套餐"
//
this.$nextTick(() => {
getPackageMenus(packageId).then(res => {
let checkedKeys = res.data
checkedKeys.forEach((v) => {
this.$nextTick(() => {
this.$refs.menu.setChecked(v, true, false)
})
})
})
})
})
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
// ID
this.form.menuIds = this.getMenuAllCheckedKeys()
if (this.form.packageId != null) {
updatePackage(this.form).then(response => {
this.$modal.msgSuccess("修改成功")
this.open = false
this.getList()
})
} else {
addPackage(this.form).then(response => {
this.$modal.msgSuccess("新增成功")
this.open = false
this.getList()
})
}
}
})
},
/** 删除按钮操作 */
handleDelete(row) {
const packageIds = row.packageId || this.ids
this.$modal.confirm('是否确认删除套餐编号为"' + packageIds + '"的数据项?').then(function() {
return delPackage(packageIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess("删除成功")
}).catch(() => {})
},
//
getMenuAllCheckedKeys() {
//
let checkedKeys = this.$refs.menu.getCheckedKeys()
//
let halfCheckedKeys = this.$refs.menu.getHalfCheckedKeys()
checkedKeys.unshift.apply(checkedKeys, halfCheckedKeys)
return checkedKeys
}
}
}
</script>
<style scoped>
.tree-border {
margin-top: 5px;
border: 1px solid #e5e6e7;
background: #FFFFFF none;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@ -41,13 +41,15 @@
placeholder="请选择过期时间">
</el-date-picker>
</el-form-item>
<el-form-item label="套餐ID" prop="packageId">
<el-input
v-model="queryParams.packageId"
placeholder="请输入套餐ID"
clearable
@keyup.enter.native="handleQuery"
/>
<el-form-item label="租户套餐" prop="packageId">
<el-select v-model="queryParams.packageId" placeholder="请选择套餐" clearable>
<el-option
v-for="pkg in packageList"
:key="pkg.packageId"
:label="pkg.packageName"
:value="pkg.packageId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery"></el-button>
@ -113,7 +115,11 @@
<span>{{ parseTime(scope.row.expireTime, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="套餐ID" align="center" prop="packageId" />
<el-table-column label="租户套餐" align="center" prop="packageId">
<template slot-scope="scope">
<span>{{ getPackageName(scope.row.packageId) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
@ -167,11 +173,18 @@
placeholder="请选择过期时间">
</el-date-picker>
</el-form-item>
<el-form-item label="套餐ID" prop="packageId">
<el-input v-model="form.packageId" placeholder="请输入套餐ID" />
</el-form-item>
<el-form-item label="删除标志" prop="delFlag">
<el-input v-model="form.delFlag" placeholder="请输入删除标志" />
<el-form-item label="租户套餐" prop="packageId">
<el-select v-model="form.packageId" placeholder="请选择套餐" clearable style="width: 100%">
<el-option
v-for="pkg in packageList"
:key="pkg.packageId"
:label="pkg.packageName"
:value="pkg.packageId"
>
<span>{{ pkg.packageName }}</span>
<span style="color: #8492a6; font-size: 12px; margin-left: 10px">{{ pkg.remark }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
@ -187,6 +200,7 @@
<script>
import { listTenant, getTenant, delTenant, addTenant, updateTenant } from "@/api/system/tenant"
import { listPackage } from "@/api/system/package"
export default {
name: "Tenant",
@ -206,6 +220,8 @@ export default {
total: 0,
//
tenantList: [],
//
packageList: [],
//
title: "",
//
@ -237,6 +253,7 @@ export default {
},
created() {
this.getList()
this.getPackageList()
},
methods: {
/** 查询租户信息列表 */
@ -248,6 +265,18 @@ export default {
this.loading = false
})
},
/** 查询套餐列表 */
getPackageList() {
listPackage({ status: '0' }).then(response => {
this.packageList = response.rows
})
},
/** 根据套餐ID获取套餐名称 */
getPackageName(packageId) {
if (!packageId) return '-'
const pkg = this.packageList.find(p => p.packageId === packageId)
return pkg ? pkg.packageName : '-'
},
//
cancel() {
this.open = false

View File

@ -0,0 +1,120 @@
-- ============================================
-- 若依多租户 - 唯一索引改造脚本
-- 版本: 1.0
-- 日期: 2025-12-19
-- 警告: 执行前务必备份数据库!
-- ============================================
-- ============================================
-- 第一步:检查是否存在重复数据(必须先执行)
-- ============================================
-- 检查 sys_user 表重复数据
SELECT tenant_id, user_name, COUNT(*)
FROM sys_user
WHERE del_flag = '0'
GROUP BY tenant_id, user_name
HAVING COUNT(*) > 1;
-- 如有输出,需先处理重复数据
-- 检查 sys_role 表重复数据
SELECT tenant_id, role_key, COUNT(*)
FROM sys_role
WHERE del_flag = '0'
GROUP BY tenant_id, role_key
HAVING COUNT(*) > 1;
-- 如有输出,需先处理重复数据
-- 检查 sys_post 表重复数据
-- 注意sys_post 表没有 del_flag 字段
SELECT tenant_id, post_code, COUNT(*)
FROM sys_post
GROUP BY tenant_id, post_code
HAVING COUNT(*) > 1;
-- 如有输出,需先处理重复数据
-- ============================================
-- 第二步:执行唯一索引改造
-- ============================================
-- 1. sys_user表用户名改为租户内唯一
-- 说明:不同租户可以有同名用户(如都有 admin
-- 安全删除旧索引兼容MySQL 5.7
SET @index_exists = (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'sys_user' AND index_name = 'uk_user_name');
SET @sql = IF(@index_exists > 0, 'ALTER TABLE sys_user DROP INDEX uk_user_name', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 创建新的联合唯一索引
ALTER TABLE sys_user ADD UNIQUE KEY uk_tenant_user_name (tenant_id, user_name);
-- 2. sys_role表角色标识改为租户内唯一
-- 说明:不同租户可以有同标识角色(如都有 role_admin
SET @index_exists = (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'sys_role' AND index_name = 'uk_role_key');
SET @sql = IF(@index_exists > 0, 'ALTER TABLE sys_role DROP INDEX uk_role_key', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE sys_role ADD UNIQUE KEY uk_tenant_role_key (tenant_id, role_key);
-- 3. sys_post表岗位编码改为租户内唯一
-- 说明:不同租户可以有同编码岗位(如都有 ceo
SET @index_exists = (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'sys_post' AND index_name = 'uk_post_code');
SET @sql = IF(@index_exists > 0, 'ALTER TABLE sys_post DROP INDEX uk_post_code', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE sys_post ADD UNIQUE KEY uk_tenant_post_code (tenant_id, post_code);
-- ============================================
-- 第三步:验证索引创建
-- ============================================
-- 验证 sys_user 索引
SHOW INDEX FROM sys_user WHERE Key_name = 'uk_tenant_user_name';
-- 验证 sys_role 索引
SHOW INDEX FROM sys_role WHERE Key_name = 'uk_tenant_role_key';
-- 验证 sys_post 索引
SHOW INDEX FROM sys_post WHERE Key_name = 'uk_tenant_post_code';
-- ============================================
-- 执行结果说明
-- ============================================
-- 每个表应该显示索引的两条记录tenant_id 和对应的业务字段)
-- Seq_in_index = 1: tenant_id
-- Seq_in_index = 2: user_name / role_key / post_code
-- ============================================
-- 回滚脚本(仅供紧急情况使用)
-- ============================================
/*
-- 警告:回滚将恢复到单租户模式,可能导致数据冲突!
-- 回滚 sys_user 索引
ALTER TABLE sys_user DROP INDEX IF EXISTS uk_tenant_user_name;
ALTER TABLE sys_user ADD UNIQUE KEY uk_user_name (user_name);
-- 回滚 sys_role 索引
ALTER TABLE sys_role DROP INDEX IF EXISTS uk_tenant_role_key;
ALTER TABLE sys_role ADD UNIQUE KEY uk_role_key (role_key);
-- 回滚 sys_post 索引
ALTER TABLE sys_post DROP INDEX IF EXISTS uk_tenant_post_code;
ALTER TABLE sys_post ADD UNIQUE KEY uk_post_code (post_code);
*/
-- ============================================
-- 执行注意事项
-- ============================================
-- 1. 选择业务低峰期执行预计停机10-30分钟
-- 2. 执行前完整备份数据库mysqldump -u root -p database_name > backup.sql
-- 3. 务必先执行第一步的重复数据检查
-- 4. 如有重复数据,需先清理或合并
-- 5. 大表索引创建较慢,耐心等待不要中断
-- 6. 准备回滚脚本以备不时之需
-- 7. 执行完毕后验证应用功能正常

View File

@ -0,0 +1,204 @@
-- ============================================
-- 租户套餐功能 - 数据库脚本
-- 版本: 1.0
-- 日期: 2025-12-19
-- 说明: 实现租户套餐功能,通过套餐控制租户的菜单权限
-- ============================================
-- ============================================
-- 第一步:创建租户套餐表
-- ============================================
CREATE TABLE IF NOT EXISTS sys_tenant_package (
package_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '套餐ID',
package_name VARCHAR(50) NOT NULL COMMENT '套餐名称',
package_code VARCHAR(50) NOT NULL COMMENT '套餐编码',
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)',
create_by VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (package_id),
UNIQUE KEY uk_package_code (package_code)
) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='租户套餐表';
-- ============================================
-- 第二步:创建套餐菜单关联表
-- ============================================
CREATE TABLE IF NOT EXISTS sys_package_menu (
package_id BIGINT(20) NOT NULL COMMENT '套餐ID',
menu_id BIGINT(20) NOT NULL COMMENT '菜单ID',
PRIMARY KEY (package_id, menu_id),
INDEX idx_package_id (package_id) -- 为package_id添加索引用户要求
) ENGINE=InnoDB COMMENT='套餐菜单关联表';
-- ============================================
-- 第三步修改sys_tenant表增加套餐字段
-- ============================================
-- 检查列是否存在,避免重复执行报错
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'sys_tenant'
AND column_name = 'package_id';
-- 只有列不存在时才添加
SET @sql = IF(@col_exists = 0,
'ALTER TABLE sys_tenant ADD COLUMN package_id BIGINT(20) DEFAULT NULL COMMENT ''套餐ID'' AFTER tenant_code',
'SELECT ''Column package_id already exists'' AS message');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 添加索引(忽略已存在的情况)
-- 注意:如果索引已存在会报错,可忽略该错误继续执行
SET @index_exists = (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'sys_tenant' AND index_name = 'idx_package_id');
SET @sql = IF(@index_exists = 0, 'ALTER TABLE sys_tenant ADD INDEX idx_package_id (package_id)', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ============================================
-- 第四步:初始化套餐数据
-- ============================================
INSERT INTO sys_tenant_package (package_name, package_code, status, create_by, create_time, remark) VALUES
('基础套餐', 'BASIC', '0', 'admin', NOW(), '用户、部门管理'),
('标准套餐', 'STANDARD', '0', 'admin', NOW(), '增加角色、菜单、字典管理'),
('高级套餐', 'PREMIUM', '0', 'admin', NOW(), '全部功能,不限制菜单');
-- ============================================
-- 第五步:初始化套餐菜单关联(基础套餐)
-- ============================================
-- 基础套餐:只包含用户、部门、岗位、字典管理
INSERT INTO sys_package_menu (package_id, menu_id) VALUES
-- 系统管理模块
(1, 1), -- 系统管理
-- 菜单项
(1, 100), -- 用户管理
(1, 101), -- 部门管理
(1, 103), -- 岗位管理
(1, 104), -- 字典管理
-- 用户管理按钮
(1, 1000), (1, 1001), (1, 1002), (1, 1003), (1, 1004), (1, 1005), (1, 1006),
-- 部门管理按钮
(1, 1016), (1, 1017), (1, 1018), (1, 1019),
-- 岗位管理按钮
(1, 1020), (1, 1021), (1, 1022), (1, 1023), (1, 1024),
-- 字典管理按钮
(1, 1025), (1, 1026), (1, 1027), (1, 1028), (1, 1029);
-- ============================================
-- 第六步:初始化套餐菜单关联(标准套餐)
-- ============================================
-- 标准套餐:增加角色、菜单管理
INSERT INTO sys_package_menu (package_id, menu_id) VALUES
-- 系统管理模块
(2, 1), -- 系统管理
-- 菜单项
(2, 100), (2, 101), (2, 102), (2, 103), (2, 104), (2, 105), -- 用户/部门/角色/岗位/字典/菜单
-- 用户管理按钮
(2, 1000), (2, 1001), (2, 1002), (2, 1003), (2, 1004), (2, 1005), (2, 1006),
-- 角色管理按钮
(2, 1007), (2, 1008), (2, 1009), (2, 1010), (2, 1011),
-- 菜单管理按钮
(2, 1012), (2, 1013), (2, 1014), (2, 1015),
-- 部门管理按钮
(2, 1016), (2, 1017), (2, 1018), (2, 1019),
-- 岗位管理按钮
(2, 1020), (2, 1021), (2, 1022), (2, 1023), (2, 1024),
-- 字典管理按钮
(2, 1025), (2, 1026), (2, 1027), (2, 1028), (2, 1029);
-- ============================================
-- 第七步:高级套餐不插入关联数据
-- ============================================
-- 说明:高级套餐 (package_id=3) 不插入 sys_package_menu 数据
-- 代码逻辑:查询结果为空列表时,表示"不限制菜单",用户可以看到所有菜单
-- ============================================
-- 第八步:关联现有租户到套餐
-- ============================================
-- 根据实际租户情况调整(使用安全的更新方式)
UPDATE sys_tenant SET package_id = 1 WHERE tenant_code = 'DEFAULT' AND package_id IS NULL;
UPDATE sys_tenant SET package_id = 2 WHERE tenant_code = 'TENANT_A' AND package_id IS NULL;
UPDATE sys_tenant SET package_id = 3 WHERE tenant_code = 'TENANT_B' AND package_id IS NULL;
-- ============================================
-- 验证数据
-- ============================================
-- 验证套餐表
SELECT * FROM sys_tenant_package;
-- 验证套餐菜单关联(基础套餐)
SELECT COUNT(*) AS '基础套餐菜单数' FROM sys_package_menu WHERE package_id = 1;
-- 验证套餐菜单关联(标准套餐)
SELECT COUNT(*) AS '标准套餐菜单数' FROM sys_package_menu WHERE package_id = 2;
-- 验证套餐菜单关联(高级套餐)
SELECT COUNT(*) AS '高级套餐菜单数应为0' FROM sys_package_menu WHERE package_id = 3;
-- 验证租户套餐关联
SELECT tenant_id, tenant_name, tenant_code, package_id
FROM sys_tenant
WHERE package_id IS NOT NULL;
-- ============================================
-- 回滚脚本(仅供紧急情况使用)
-- ============================================
/*
-- 警告:回滚将删除所有套餐数据和配置!
-- 1. 清除租户的套餐关联
UPDATE sys_tenant SET package_id = NULL;
-- 2. 删除套餐菜单关联表
DROP TABLE IF EXISTS sys_package_menu;
-- 3. 删除套餐表
DROP TABLE IF EXISTS sys_tenant_package;
-- 4. 删除租户表的套餐字段
ALTER TABLE sys_tenant DROP COLUMN IF EXISTS package_id;
ALTER TABLE sys_tenant DROP INDEX IF EXISTS idx_package_id;
*/
-- ============================================
-- 使用说明
-- ============================================
/*
1. ** (BASIC)**
-
-
-
2. ** (STANDARD)**
-
- + +
-
3. ** (PREMIUM)**
-
-
- sys_package_menu "全部菜单可见"
- =
-
-
*/

View File

@ -0,0 +1,32 @@
-- ============================================
-- 套餐管理菜单 - 插入脚本
-- 版本: 1.0
-- 日期: 2025-12-20
-- ============================================
-- 1. 套餐管理菜单放在系统管理下parent_id = 1
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (2006, '套餐管理', 1, 10, 'package', 'system/package/index', NULL, 'Package', 1, 0, 'C', '0', '0', 'system:package:list', 'component', 'admin', NOW(), '', NULL, '租户套餐管理菜单');
-- 2. 套餐管理按钮权限
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (2007, '套餐查询', 2006, 1, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:package:query', '#', 'admin', NOW(), '', NULL, '');
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (2008, '套餐新增', 2006, 2, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:package:add', '#', 'admin', NOW(), '', NULL, '');
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (2009, '套餐修改', 2006, 3, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:package:edit', '#', 'admin', NOW(), '', NULL, '');
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
VALUES (2010, '套餐删除', 2006, 4, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:package:remove', '#', 'admin', NOW(), '', NULL, '');
-- 3. 给超级管理员角色分配权限role_id = 1
INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 2006);
INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 2007);
INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 2008);
INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 2009);
INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 2010);
-- 验证
SELECT * FROM sys_menu WHERE menu_id >= 2006;