多租户系统PostgreSQL

这是一个非常明智的选择。使用 PostgreSQL 的 RLS (Row Level Security) 可以将复杂的权限判断逻辑从应用层(Go 代码)剥离,下沉到数据库层。

这样做的好处是:

  1. 应用层代码极简 :业务代码只需写 db.Find(&users),不需要手动拼 WHERE 条件。
  2. 防止越权:即使开发者忘记在代码里加权限判断,数据库也会自动拦截。
  3. 性能优化:利用 PG 的索引和原生特性处理层级关系,比 Go 查出数组再 IN 查询要快。

以下是基于 PG RLS 重新设计的模型方案。


1. 核心设计思路

为了配合 RLS,我们需要在数据库结构上做一点微调,主要是引入 TreePath (路径字段)

  • 原理 :不再在代码里递归查询"子部门",而是在数据保存时维护路径(如 1.5.20)。
  • RLS 判定 :利用 PG 的 ltree 扩展或字符串匹配,一行 SQL 解决"本公司及下级"的判断。

2. 重新设计的模型

我们删除复杂的逻辑字段,保留核心结构字段,并增加 TreePath

go 复制代码
package models

import (
	"database/sql/driver"
	"encoding/json"
	"errors"
	"time"
)

// Ltree 类型适配 (用于处理树形路径)
type Ltree string

func (l *Ltree) Scan(value interface{}) error {
	if value == nil {
		*l = ""
		return nil
	}
	switch v := value.(type) {
	case []byte:
		*l = Ltree(v)
	case string:
		*l = Ltree(v)
	default:
		return errors.New("cannot scan Ltree")
	}
	return nil
}

func (l Ltree) Value() (driver.Value, error) {
	return string(l), nil
}

// 租户表
type Tenant struct {
	ID        int64  `gorm:"primaryKey"`
	Name      string `gorm:"size:100"`
	Status    int8   `gorm:"default:0"`
	CreatedAt time.Time
}

func (Tenant) TableName() string { return "sys_tenant" }

// 公司表 (核心变更:增加 TreePath)
type Company struct {
	ID        int64  `gorm:"primaryKey"`
	TenantID  int64  `gorm:"not null;index"`
	ParentID  int64  `gorm:"default:0"`
	// 使用 Ltree 类型存储路径,例如: "1.5.10" 表示根节点1下的5下的10
	TreePath  Ltree `gorm:"type:ltree;not null;index"` 
	Name      string `gorm:"size:100"`
	Status    int8   `gorm:"default:0"`
	CreatedAt time.Time
}

func (Company) TableName() string { return "sys_company" }

// 部门表 (核心变更:增加 TreePath)
type Department struct {
	ID        int64  `gorm:"primaryKey"`
	TenantID  int64  `gorm:"not null;index"`
	CompanyID int64  `gorm:"not null"`
	ParentID  int64  `gorm:"default:0"`
	TreePath  Ltree `gorm:"type:ltree;not null;index"` // 例如: "1.5.20.33"
	Name      string `gorm:"size:100"`
	Status    int8   `gorm:"default:0"`
	CreatedAt time.Time
}

func (Department) TableName() string { return "sys_department" }

// 用户表
type User struct {
	ID           int64  `gorm:"primaryKey"`
	TenantID     int64  `gorm:"not null;index"`
	CompanyID    int64  `gorm:"not null"`
	DeptID       int64  `gorm:"not null"`
	
	// 简化权限字段:仅保留标志位,具体的 DataScope 逻辑由 RLS Policy 内部处理
	// 或者保留 DataScope 传递给 RLS
	IsSuperAdmin bool   `gorm:"default:false"` // 平台超管
	IsTenantAdmin bool  `gorm:"default:false"` // 租户超管
	DataScope    int8   `gorm:"default:4"`     // 1:全部 2:公司 3:部门 4:本人

	Username     string `gorm:"size:50"`
	Status       int8   `gorm:"default:0"`
	CreatedAt    time.Time
}

func (User) TableName() string { return "sys_user" }

// 角色表 (不再承担复杂的 SQL 拼接逻辑,仅做配置存储)
type Role struct {
	ID          int64  `gorm:"primaryKey"`
	TenantID    int64  `gorm:"index"`
	Name        string `gorm:"size:50"`
	DataScope   int8   `gorm:"default:4"` // 1:全部 2:公司 3:部门 4:本人
	Status      int8   `gorm:"default:0"`
}

// 用户角色关联
type UserRole struct {
	UserID int64 `gorm:"primaryKey"`
	RoleID int64 `gorm:"primaryKey"`
}

3. RLS 核心逻辑

这是方案的灵魂。我们需要在数据库层面定义策略。

第一步:创建扩展并设置会话变量

在每次请求开始时(中间件),将当前用户的上下文注入到 PG 会话中:

sql 复制代码
-- 开启 ltree 扩展 (只需执行一次)
CREATE EXTENSION IF NOT EXISTS ltree;

-- 中间件执行的 SQL (Go 伪代码)
-- SET LOCAL app.current_tenant_id = '1001';
-- SET LOCAL app.current_user_id = '2001';
-- SET LOCAL app.current_company_id = '501';
-- SET LOCAL app.current_dept_path = '1.5.20'; -- 用户所在部门的路径
-- SET LOCAL app.is_tenant_admin = 'false';
-- SET LOCAL app.data_scope = '3'; -- 用户最大的数据范围权限
第二步:编写通用的 RLS 策略函数

我们创建一个通用的函数来处理所有需要隔离的表(如订单表、客户表)。

sql 复制代码
CREATE OR REPLACE FUNCTION check_data_permission()
RETURNS boolean AS $$
DECLARE
    v_tid bigint;
    v_is_tenant_admin boolean;
    v_data_scope int;
    v_user_company_id bigint;
    v_user_dept_path ltree;
    v_table_company_id bigint;
    v_table_dept_id bigint;
BEGIN
    -- 1. 获取当前会话变量
    v_tid := NULLIF(current_setting('app.current_tenant_id', true), '')::bigint;
    
    -- 超级管理员直接放行 (如果是超级管理员,通常不设置 tenant_id,或者在应用层直接拦截)
    IF v_tid IS NULL THEN
        RETURN true; 
    END IF;

    -- 2. 获取表记录的租户ID (假设表都有 tenant_id 字段)
    -- 这是一个动态写法,实际 PG 需要针对特定表写 Policy,这里演示逻辑
    -- 在具体 Policy 中为: NEW.tenant_id 或 OLD.tenant_id
    -- 这里假设表名为 target_table,字段为 tenant_id
    -- v_table_tid := NEW.tenant_id; 

    -- 3. 租户级隔离 (基础红线)
    -- 在具体 Policy 中写: WHERE tenant_id = v_tid
    
    -- 下面是具体的行级过滤逻辑 (DataScope)
    
    -- 获取用户权限上下文
    v_is_tenant_admin := NULLIF(current_setting('app.is_tenant_admin', true), '')::boolean;
    v_data_scope := NULLIF(current_setting('app.data_scope', true), '')::int;
    v_user_company_id := NULLIF(current_setting('app.current_company_id', true), '')::bigint;
    v_user_dept_path := NULLIF(current_setting('app.current_dept_path', true), '')::ltree;

    -- 4. 租户管理员 (看租户下所有)
    IF v_is_tenant_admin THEN
        RETURN true;
    END IF;

    -- 5. 根据 DataScope 判断
    IF v_data_scope = 1 THEN
        -- 全部数据 (在当前租户下)
        RETURN true;
        
    ELSIF v_data_scope = 2 THEN
        -- 本公司及下级
        -- 假设业务表有 company_id 字段
        -- RETURN (NEW.company_id = v_user_company_id); 
        -- 如果需要严格的公司层级,可以用 ltree 查 sys_company 表,但通常公司隔离 ID 相等即可
        RETURN true; -- 简化示意
        
    ELSIF v_data_scope = 3 THEN
        -- 本部门及下级 (利用 ltree 的 @> 操作符)
        -- 假设业务表有 dept_id,我们需要根据 dept_id 去查 sys_department.tree_path
        -- 这在 WHERE 条件里写比较复杂,建议在 Policy JOIN 查询
        RETURN true; -- 简化示意,见下方具体 Policy 写法
        
    ELSIF v_data_scope = 4 THEN
        -- 仅本人
        -- RETURN (NEW.creator_id = current_user_id);
        RETURN true;
    END IF;

    RETURN false;
END;
$$ LANGUAGE plpgsql;

4. 实际应用示例:给业务表加 RLS

假设我们有一张业务表 bus_order

sql 复制代码
-- 1. 启用 RLS
ALTER TABLE bus_order ENABLE ROW LEVEL SECURITY;

-- 2. 创建策略:只能操作本租户的,且符合数据范围的
CREATE POLICY tenant_isolation_policy ON bus_order
    FOR ALL
    TO public
    USING (
        tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
        AND
        (
            -- A. 租户管理员
            NULLIF(current_setting('app.is_tenant_admin', true), '')::boolean = true
            OR
            -- B. 全部数据权限
            NULLIF(current_setting('app.data_scope', true), '')::int = 1
            OR
            -- C. 本公司 (直接比对 ID)
            (
                NULLIF(current_setting('app.data_scope', true), '')::int = 2 
                AND company_id = NULLIF(current_setting('app.current_company_id', true), '')::bigint
            )
            OR
            -- D. 本部门及下级 (利用子查询和 ltree)
            (
                NULLIF(current_setting('app.data_scope', true), '')::int = 3 
                AND EXISTS (
                    SELECT 1 FROM sys_department 
                    WHERE id = bus_order.dept_id 
                    AND tree_path <@ NULLIF(current_setting('app.current_dept_path', true), '')::ltree
                )
            )
            OR
            -- E. 仅本人
            (
                NULLIF(current_setting('app.data_scope', true), '')::int = 4 
                AND creator_id = NULLIF(current_setting('app.current_user_id', true), '')::bigint
            )
        )
    );

5. 应用层代码(Go)变得极其简单

以前你需要写大量的 Where 拼接:

go 复制代码
// 旧代码:非常痛苦
query := db.Where("tenant_id = ?", user.TenantID)
if user.IsTenantAdmin {
    // pass
} else {
    scope := user.GetMaxDataScope()
    if scope == 2 {
        query = query.Where("company_id = ?", user.CompanyID)
    } else if scope == 3 {
        // 递归查询子部门 ID...
        childIDs := GetChildDeptIDs(user.DeptID)
        query = query.Where("dept_id IN ?", childIDs)
    }
}
var orders []Order
query.Find(&orders)

现在(基于 RLS):

go 复制代码
// 新代码:中间件负责设置上下文,这里只管业务逻辑
// 中间件已经执行了: SET LOCAL app.current_tenant_id = '1001' ...

var orders []Order
// 这行 SQL 发送到 PG 后,PG 会自动应用上面的 POLICY,过滤掉没权限的数据
db.Find(&orders) 

总结

通过 PG RLS + Ltree ,我们将复杂度从 N*M(N个业务接口 * M种权限判断)降到了 O(1)

  1. 模型层 :只需加 TreePath 字段。
  2. 中间件层 :负责计算用户的 DeptPathDataScope,并 SET LOCAL
  3. 业务层:完全透明,像没有权限系统一样写代码。
  4. 数据库层:通过 Policy 统一守住安全底线。
相关推荐
lbb 小魔仙1 小时前
2026远程办公软件夏季深度横测:ToDesk、向日葵、网易UU远程全面对比,远控白皮书
android·服务器·网络协议·tcp/ip·postgresql
发现你走远了1 小时前
极简后端环境搭建:一行 Docker 命令部署四大核心数据库(避坑 PG 18+)
数据库·docker·容器
北重楼012 小时前
如何取消一个挂起的 PostgreSQL 查询
数据库·postgresql
与数据交流的路上2 小时前
mysql参数-优化器 range_optimizer_max_mem_size 相关
数据库·mysql
PaperData2 小时前
2012-2022年农业产业结构高级化
数据库·人工智能·数据分析·经管
喝可乐的希饭a2 小时前
MYSQL的mvcc
数据库·mysql
冷小鱼2 小时前
Valkey 深度剖析:Redis 最佳平替的技术全景
数据库·redis·缓存·valkey
Deryck_德瑞克2 小时前
Nacos适配Kingbase数据库
数据库·windows
hashiqimiya2 小时前
postgres数据库操作指南
数据库