多租户说明

概述

系统基于字段隔离的多租户架构设计,使用 tenant_id 作为租户标识符,实现在单一数据库实例中为多个租户提供隔离的数据存储和服务。

基于字段隔离的多租户架构是一种成本效益高的解决方案,适用于中小型应用。通过合理的设计和实现,可以在保证数据隔离的同时,实现资源的共享和高效利用。关键是要在应用层的各个层面都严格实施租户隔离策略,确保数据安全和系统稳定性。

与老版本(v3.3 tenant)的差异:老版在每个 logic 中内嵌 filter.TenantFilter,通过 s.Model(ctx, dao.Xxx.Ctx(ctx), s.WithWhere()) 手动包装模型;本项目已升级为 dao 外层壳覆写 Ctx() 统一自动过滤(查询/修改/删除自动注入 tenant_id 条件 + 新增 Hook 自动填充),logic 层直接使用 dao.Xxx.Ctx(ctx) 即获得租户隔离。

架构模式

字段隔离(共享数据库,共享表结构)

字段隔离是一种多租户架构模式,所有租户共享同一个数据库实例和表结构,通过在每个表中添加 tenant_id 字段来区分不同租户的数据。

+-----------------+     +-----------------+     +-----------------+
|   租户 A 数据    |     |   租户 B 数据    |     |   租户 C 数据    |
|   tenant_id=1   |     |   tenant_id=2   |     |   tenant_id=3   |
+-----------------+     +-----------------+     +-----------------+
\                 /     \                 /     \                 /
 \               /       \               /       \               /
  +-------------------------------------------------------------+
  |                    共享数据库表结构                          |
  |          (所有数据存储在同一表中,通过tenant_id区分)          |
  +-------------------------------------------------------------+

安装部署方式

采用数据表字段隔离方案,安装部署方式与相关配置和标准版操作完全一致,区别仅在于数据初始化:

  • 核心业务表已包含 tenant_id 字段;
  • 系统额外提供租户相关表:sys_tenant(租户)、sys_user_tenant(用户-租户成员关系)、sys_tenant_menu(租户菜单分配);
  • casbin 采用域感知模型,策略第 4 段为租户 ID。

数据库设计

基础表结构

所有租户隔离的业务表都需要包含 tenant_id 字段作为租户标识,并为常用查询建立索引:

-- 业务表示例
CREATE TABLE biz_demo (
    id         BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    tenant_id  BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
    name       VARCHAR(50) NOT NULL,
    created_at DATETIME DEFAULT NULL COMMENT '创建时间',
    updated_at DATETIME DEFAULT NULL COMMENT '修改时间',
    INDEX idx_tenant_id (tenant_id),
    UNIQUE KEY uk_tenant_name (tenant_id, name)  -- 唯一约束按租户维度建立
);

当前已接入租户隔离的核心表:

表名 说明 隔离方式
sys_user / sys_role / sys_post 用户、角色、岗位 严格隔离(tenant_id = 当前租户)
sys_user_tenant 用户-租户成员关系 严格隔离
sys_tenant_menu 租户菜单分配 严格隔离
sys_dict_type / sys_dict_data / sys_config 字典与参数 两级共享(tenant_id = 当前租户 OR 0,0 表示全局)

租户表

CREATE TABLE `sys_tenant` (
    `id`         bigint(20) unsigned NOT NULL COMMENT '主键(雪花ID)',
    `name`       varchar(50)  NOT NULL COMMENT '租户名称',
    `en_no`      varchar(255) NOT NULL COMMENT '租户编码(域名或租户代码)',
    `status`     tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '状态;0:禁用,1:正常',
    `created_by` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '创建人',
    `created_at` datetime DEFAULT NULL COMMENT '创建时间',
    `updated_at` datetime DEFAULT NULL COMMENT '修改时间',
    `deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `en_no` (`en_no`, `deleted_at`)  -- 编码唯一约束含软删除字段,删除后编码可复用
) ENGINE=InnoDB COMMENT='租户表';

应用层实现

1. 中间件处理租户ID

在路由文件中,分组路由加上 service.Middleware().Tenant 中间件处理(必须挂在 Ctx 之后,依赖 token 解析出的用户上下文):

group.Middleware(service.Middleware().Ctx, service.Middleware().Tenant)

中间件规则(internal/app/system/logic/middleware/middleware.go):

  • 已登录用户:一律以 token 中的激活租户(SysTenantId)为准,禁止 header/参数覆盖,防止伪造租户上下文绕过数据隔离;
  • 未登录(公开接口/登录页):才允许 header/请求参数 tenantId 兜底。

2. 数据访问层:dao 壳自动过滤

对租户隔离表,dao 外层壳覆写 Ctx(),自动应用租户过滤(以 sys_role 为例):

// 查询/修改/删除自动带上 tenant_id 条件;新增通过 Insert Hook 自动填充 tenant_id
func (d sysRoleDao) Ctx(ctx context.Context) *gdb.Model {
    return filter.Apply(ctx, d.SysRoleDao.Ctx(ctx))
}

// 主表 As(别名) 并 Join 的查询必须用 CtxAs:
// gdb 会把裸字段条件自动补成真实表名前缀(如 `sys_role`.`tenant_id`),
// 主表一旦有别名即报 1054 Unknown column
func (d sysRoleDao) CtxAs(ctx context.Context, alias string) *gdb.Model {
    return filter.Apply(ctx, d.SysRoleDao.Ctx(ctx), alias)
}

internal/app/common/filter/tenant.go 提供两种过滤语义:

方法 SQL 语义 适用场景
filter.Apply(ctx, m, alias...) tenant_id = 当前租户 严格租户隔离表;Insert Hook 自动填充 tenant_id(仅当数据未显式提供时)
filter.ApplyShared(ctx, m, alias...) (tenant_id = 当前租户 OR tenant_id = 0) 字典/参数等”全局 + 租户覆盖”表

3. 服务层(logic)

logic 层无需任何租户包装,直接使用 dao.Xxx.Ctx(ctx) 即获得租户隔离。

查询数据:

func (s *sDemo) List(ctx context.Context, req *model.DemoSearchReq) (listRes *model.DemoSearchRes, err error) {
    ...
    err = dao.Demo.Ctx(ctx).Page(req.PageNum, req.PageSize).Order(order).Scan(&res)
    ...
}

新增数据:

func (s *sDemo) Add(ctx context.Context, req *model.DemoAddReq) (err error) {
    ...
    _, err = dao.Demo.Ctx(ctx).Data(data).Insert()
    // 注意:data 中不需要(也不应)携带 tenant_id 字段,Insert Hook 中自动按当前租户填充
    ...
}

修改数据:

func (s *sDemo) Edit(ctx context.Context, req *model.DemoEditReq) (err error) {
    ...
    _, err = dao.Demo.Ctx(ctx).WherePri(req.Id).Update(data) // 更新范围自动限定在本租户内
    ...
}

删除数据:

func (s *sDemo) Delete(ctx context.Context, ids []uint) (err error) {
    ...
    _, err = dao.Demo.Ctx(ctx).Delete(dao.Demo.Columns().Id+" in (?)", ids)
    ...
}

若主表带别名并 Join 其它表,改用 dao.Demo.CtxAs(ctx, "别名") 创建模型即可,其余写法不变。

4. 特殊场景

  • 无请求上下文(定时任务、消息消费、C端应用):ctx = filter.WithTenant(ctx, tenantId) 显式注入租户;
  • 平台超管跨租户管理、全量级联更新:ctx = filter.WithGlobal(ctx) 跳过租户过滤;
  • tenant_id = 0 表示全局共享数据(如全局字典/参数);ctx 无租户(未登录、后台任务未注入)时自动跳过过滤。

代码生成支持

代码生成支持多租户功能:只要表中存在 tenant_id 字段,便会自动生成租户处理能力,具体包括:

  1. dao 外层壳覆写 Ctx() 并注入 filter.Apply(查询/更新/删除自动带 tenant_id、新增自动填充);
  2. AddReq/EditReq/SearchReq/ListRes 等请求/返回结构不含 tenant_id,客户端无法伪造租户归属;
  3. 新增/修改/删除 logic 自动补充租户条件;
  4. Excel 导入不映射 tenant_id(防止恶意构造跨租户注入或全量落入 tenant_id=0);
  5. 关联表含 tenant_id 时,关联查询自动补租户条件;
  6. 不含 tenant_id 的普通表,生成产物保持不变。

代码生成模板行为由 internal/app/system/logic/toolsGenTable/tenant_render_test.go 保障回归。

安全考虑

1. 数据隔离

  • 所有数据库查询、修改、删除由 dao 壳自动包含 tenant_id 条件;
  • 新增数据由 Insert Hook 自动写入 tenant_id;
  • 使用中间件自动注入租户上下文;
  • 隔离收敛在数据访问层,业务代码默认无法绕过。

2. 防伪造与越权

  • 已登录用户的租户上下文只信任 token,header/参数中的 tenantId 不生效;
  • 生成代码的请求结构不含 tenant_id 字段,租户归属由服务端决定;
  • 跨租户管理仅对平台超管开放(filter.WithGlobal),普通租户管理员只能在本租户域内操作。

3. 权限模型

casbin 采用域感知模型:p 规则第 4 段为租户 ID,g 规则格式为 g, u_用户ID, 角色ID, 租户ID,权限校验天然限定在租户域内。

性能优化

1. 数据库索引

-- 为所有租户隔离表的 tenant_id 字段创建索引
CREATE INDEX idx_users_tenant_id ON sys_user(tenant_id);
CREATE INDEX idx_roles_tenant_id ON sys_role(tenant_id);

-- 创建复合索引优化高频查询
CREATE INDEX idx_users_tenant_username ON sys_user(tenant_id, user_name);

2. 缓存策略

缓存键统一以租户 ID 作为前缀,保证各租户缓存互不干扰:

// 字典缓存:前缀 + 租户ID + 字典类型
cacheKey := consts.CacheSysDict + ":" + gconv.String(libUtils.GetTenantId(ctx)) + ":" + dictType

// 参数缓存:完整 key 形如 APP:sysConfig:<tenantId>:<key>
cacheKey := consts.CacheSysConfig + ":" + gconv.String(libUtils.GetTenantId(ctx)) + ":" + key

字典全量缓存同样按租户区分(...:<tenantId>_dict_type_all),字典/参数变更时按租户维度精准清理。

系统使用

不同租户下的用户只能访问自己归属的租户相关数据,不能跨租户访问数据(租户管理员除外),租户管理员可以选择登录不同租户来设置租户相关数据(比如设置不同租户的字典,参数等相关信息)。

字典和参数分为全局和租户,全局:所有租户可以使用;租户:不同租户的字典和参数相互隔离,示例如下:

如:租户A只能使用全局和租户A的字典:

20251017102005

租户B只能使用全局和租户B的字典:

20251017102102

租户管理员在角色授权时,选择是租户管理员则可以在不同租户中切换维护租户数据:

20251017102345

注意:普通管理人员不要给租户维护相关的权限(这个权限应该只是给系统维护人员或运维人员)。

作者:管理员  创建时间:2026-09-20 15:36
最后编辑:管理员  更新时间:2026-09-20 15:48