













在设计 多租户进销存系统(SaaS) 时,核心是 租户隔离 + 权限控制 + 组织结构。
一般推荐的设计是 “租户 → 机构 → 角色 → 用户” 的层级结构,同时所有业务数据都带 tenant_id。
sys_tenant
------
id bigint PK
tenant_code varchar(50) unique -- 租户编码
tenant_name varchar(200) -- 租户名称
contact_name varchar(100) -- 联系人姓名
contact_phone varchar(50) -- 联系人电话
contact_email varchar(100) -- 联系人邮箱
expire_time datetime -- 过期时间
status int -- 状态, 1启用, 0禁用
remark varchar(255) -- 备注
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
说明:
业务表统一规范:
status 字段用来标识当前数据是否有效。
系统表通常只需要表达 是否可用,状态很少变化。这些对象只有两件事:是否可用,是否禁用。用户有时需要 锁定状态,如密码输错次数过多。
因此,状态设计:
业务表的 status 设计,业务单据通常有 生命周期。
例如订单:
草稿 → 提交 → 审核 → 完成 → 作废
如果用一个简单 status:
别人几个月后根本看不懂。
所以业务表推荐:用 业务状态枚举。
例如订单:
DRAFT
SUBMITTED
APPROVED
FINISHED
CANCELLED
示例:
| status | 含义 |
|---|---|
| DRAFT | 草稿 |
| SUBMITTED | 已提交 |
| APPROVED | 已审核 |
| FINISHED | 完成 |
| CANCELLED | 作废 |
优点:
成熟系统一般这样设计:
status 业务状态
is_deleted 逻辑删除
因此:
系统表 vs 业务表总结:
| 类型 | status设计 |
|---|---|
| 系统表 | 0禁用 1启用 |
| 用户表 | 0禁用 1正常 2锁定 |
| 业务表 | 业务枚举字符串 |
sys_organization
-------------
id bigint PK
tenant_id bigint
org_code varchar(50) unique -- 机构编码
org_name varchar(200) -- 机构名称
pid bigint -- 父节点
path varchar(500) -- 层级路径
org_type varchar(50) -- 机构类型,如: company/department/store
sort varchar(50) -- 排序
status int -- 状态, 1启用, 0禁用
remark varchar(200) -- 备注
is_deleted int -- 逻辑删除, 1删除, 0未删除
created_at datetime
updated_at datetime
说明:
tenant
└── 总公司
├── 财务部
├── 销售部
└── 门店A
path 字段用来标识当前机构的层级路径。
| id | path |
|---|---|
| 1 | 1 |
| 2 | 1/2 |
| 3 | 1/3 |
| 4 | 1/2/4 |
| 5 | 1/2/5 |
| 6 | 1/3/6 |
如果用户机构:org_id = 2, 查询:
SELECT id
FROM sys_organization
WHERE path LIKE '1/2/%'
OR id = 2;
ERP 中 机构移动很少,所以这是一个很好的方案。
sys_user
-----
id bigint PK
tenant_id bigint
username varchar(100) -- 用户名
password varchar(255) -- 密码哈希
salt varchar(50) -- 密码盐
real_name varchar(100) -- 真实姓名
nickname varchar(100) -- 昵称
gender varchar(10) -- 性别
avatar varchar(200) -- 头像
mobile varchar(50) -- 手机号
email varchar(100) -- 邮箱
org_id bigint -- 机构ID
position_id bigint -- 岗位ID
login_count int -- 登录次数
last_login_time datetime -- 最后登录时间
last_login_ip varchar(50) -- 最后登录IP
is_super int -- 是否超级管理员, 1超级管理员, 0普通用户
is_deleted int -- 逻辑删除, 1删除, 0未删除
status int -- 状态, 1启用, 0禁用
remark varchar(200) -- 备注
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
说明:
is_super 表示:
系统超级管理员,不受任何权限控制
if user.is_super:
允许所有操作
else:
按 RBAC 权限判断
避免误操作:
is_super 的作用:
用户有时需要 锁定状态,如密码输错次数过多。
因此,状态设计:
角色是租户级的。
sys_role
-----
id bigint PK
tenant_id bigint
role_code varchar(50) unique -- 角色编码
role_name varchar(200) -- 角色名称
role_type varchar(50) -- 角色类型
data_scope varchar(50) -- 数据权限
sort varchar(50) -- 排序
status int -- 状态, 1启用, 0禁用
is_deleted int -- 逻辑删除, 1删除, 0未删除
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
常见角色:
角色通常需要:data_scope,例如:
SQL示例:
1、data_scope = ALL 时;
WHERE tenant_id = ?
2、data_scope = ORG 时;
WHERE tenant_id = ?
AND org_id = current_org
3、data_scope = ORG_CHILD 时;
WHERE tenant_id = ?
AND org_id IN (子机构列表)
4、data_scope = SELF 时;
WHERE tenant_id = ?
AND created_by = current_user
5、data_scope = CUSTOM 时;
WHERE tenant_id = ?
AND org_id IN (role_org)
ERP 实际 SQL 拼接
SELECT *
FROM sales_order
WHERE tenant_id = ?
AND (
created_by = :user_id
OR org_id IN (:org_ids)
)
sys_position
----------
id bigint PK
tenant_id bigint
position_code varchar(50) -- 岗位编码
position_name varchar(200) -- 岗位名称
org_id bigint -- 所属机构
status int -- 状态, 1启用, 0禁用
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
角色主要解决 权限问题, 岗位主要解决 组织职责问题。岗位通常是 组织结构的一部分。
岗位通常是“一人一岗”(主岗位):
优点:
有些企业确实存在 兼职岗位:
权限通常是菜单 + 按钮。
sys_permission
-----------
id bigint PK
system_code varchar(50) -- 系统类型
perm_code varchar(50) PK -- 权限编码
perm_name varchar(200) -- 权限名称
perm_type varchar(50) -- 权限类型,如: menu/button/api
pid bigint -- 父节点
path varchar(500) -- 层级路径
api_path varchar(200) -- API路径
scope varchar(50) -- 权限范围,如:SYSTEM/TENANT
module_code varchar(50) -- 模块编码
resource_code varchar(50) -- 资源编码
action_code varchar(50) -- 操作编码
sort varchar(50) -- 排序
status int -- 状态, 1启用, 0禁用
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
说明:
perm_type 枚举:
例如:
| perm_code | perm_type |
|---|---|
| user:add | 按钮 |
| user:delete | 按钮 |
| /api/user/list | API |
这样可以:
scope 权限作用范围:
例如:
| perm_code | scope |
|---|---|
| tenant:create | SYSTEM |
| user:add | TENANT |
权限表通常 不带 tenant_id:
sys_permission 全局
sys_menu 全局
权限只负责“动作”, 如下面示例:
module_code + resource_code + action_code
这是 工业级权限编码拆分设计,后期非常好用。
例如:
| module_code | resource_code | action_code | perm_code |
|---|---|---|---|
| sys | user | view | sys:user:view |
| sys | user | add | sys:user:add |
| order | sales_order | approve | order:sales_order:approve |
比单纯 perm_code 更利于:
权限编码必须 统一规范:
推荐:模块:资源:操作
例如:
user:list
user:add
user:update
user:delete
order:create
order:approve
order:cancel
最终模型:
Menu (导航)
Permission (功能)
User
└─ Role
└─ Permission
├─ Menu
├─ Button
└─ API
核心思想:
前端菜单生成逻辑:
流程:
用户登录
↓
获取角色
↓
获取权限和菜单集合
↓
根据权限和菜单集合生成菜单树
↓
前端展示菜单树
sys_menu
------
id bigint PK
pid bigint -- 父节点
system_code varchar(50) -- 系统类型
perm_code varchar(50) -- 访问权限编码(可选)
menu_code varchar(50) PK -- 菜单编码
menu_name varchar(200) -- 菜单名称
tag varchar(50) -- 标签
path varchar(200) -- 路由路径
redirect varchar(200) -- 重定向路径
is_iframe int -- 是否内嵌窗口,1内嵌窗口, 0不内嵌窗口
out_link varchar(200) -- 外链地址
is_keep_alive int -- 是否缓存,1缓存, 0不缓存
is_affix int -- 是否固定,1固定, 0不固定
is_expand int -- 是否展开
url varchar(200) -- 界面Url地址
is_eav_menu int -- 是否EAV菜单
entity_type_id bigint -- 实体类型ID
component varchar(200) -- 组件路径
icon varchar(50) -- 图标
sort varchar(50) -- 排序
status int -- 状态, 1启用, 0禁用
is_visible int -- 是否可见,1可见,0不可见
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
菜单和权限分离,但菜单支持可选绑定访问权限。
菜单有一个可选的“访问权限编码”
不是所有菜单页面都必须有 VIEW 权限。只给重要页面加,例如:
sys_system_type
------------
id bigint PK
system_code varchar(50) -- 系统类型编码
system_name varchar(200) -- 系统类型名称
remark varchar(200) -- 备注
status int -- 状态, 1启用, 0禁用
created_at datetime -- 创建时间
系统类型表,用于区分不同系统之间的资源,如系统菜单、权限功能点等。
sys_user_role
----------
id bigint PK
tenant_id bigint
user_id bigint
role_id bigint
唯一约束:
UNIQUE (tenant_id, user_id, role_id)
企业级系统,中间表建议带 tenant_id
tenant_id 可以冗余,但利大于弊。
当使用 CUSTOM 时,需要指定机构:
sys_role_org
---------
id bigint PK
tenant_id bigint
role_id bigint
org_id bigint
唯一性约束:
UNIQUE (tenant_id, role_id, org_id)
sys_role_permission
----------------
id bigint PK
tenant_id bigint
role_id bigint
perm_id bigint
唯一性约束:
UNIQUE (tenant_id, role_id, perm_id)
表示:某个租户可使用哪些菜单资源(一级分配)。然后该租户下的角色,可以根据当前租户拥有的菜单权限来控制可见菜单(二级分配)。
sys_tenant_menu
-
id bigint PK
tenant_id bigint -- 租户ID
menu_id bigint -- 菜单ID
status int -- 状态, 1启用, 0禁用
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
作用, 比如:
这样你就实现了“不同租户看到不同菜单体系”。
唯一性约束:
UNIQUE (tenant_id, menu_id)
在实际项目里,尤其是 ERP / 后台管理系统,如果把“菜单”和“权限点”完全绑定死,往往会出现:
因此,菜单和权限分离建模,但允许菜单可选绑定权限。
菜单直接给角色分配,简单直观。
特点
优点
缺点
为什么这是最适合 ERP 的?
ERP 里通常有三层:
如果你把它们全都统一成权限,会出现:
而 ERP 实际上最关键的是:
这样非常清晰。
sys_role_menu
-----------
id bigint PK
tenant_id bigint
role_id bigint
menu_id bigint
唯一性约束:
UNIQUE (tenant_id, role_id, menu_id)
sys_operation_log
--------------
id bigint PK
tenant_id bigint
user_id bigint
module varchar(250) -- 模块
action varchar(50) -- 操作
content varchar(2000) -- 内容
ip varchar(50) -- IP地址
created_at datetime -- 创建时间
sys_login_log
----------
id bigint PK
tenant_id bigint
user_id bigint
content varchar(2000) -- 内容
ip varchar(50) -- IP地址
created_at
在多租户系统里,字典类型(Dictionary Type) 和 字典项目(Dictionary Item) 是否租户隔离,通常不能一刀切。
字典类型大概率是“全局定义为主”,而字典项目既可能全局共享,也可能租户自定义。
建议把字典分成 3类:
1 系统级字典(全局共享)
2 租户级字典(租户私有)
3 混合型字典(系统默认 + 租户可扩展)
如果你做的是 ERP 场景,下面这些字典项几乎一定会不同:
哪些字典通常不会不同(全局)
例如:
sys_dict_type
---------------
id bigint PK
pid bigint
type_code varchar(50) -- 类型编码
type_name varchar(200) -- 类型名称
dict_category varchar(20) -- SYSTEM / BUSINESS / CUSTOM -- 字典类别
scope_mode varchar(20) -- GLOBAL / TENANT / MIXED -- 作用范围
sort varchar(50) -- 排序
remark varchar(200) -- 备注
is_deleted int -- 逻辑删除, 1删除, 0未删除
is_system int -- 是否系统字典
status int -- 状态, 1启用, 0禁用
created_at datetime -- 创建时间
scope_mode 这个字段非常重要,可取值:
sys_dict_data
---------------
id bigint PK
type_id bigint -- 类型ID
tenant_id bigint null -- null = 全局项;有值 = 租户项
item_code varchar(100) -- 项目编码
item_name varchar(200) -- 项目名称
item_value varchar(200) -- 项目值
remark varchar(200) -- 备注
is_default smallint default 0 -- 是否默认项
is_builtin smallint default 0 -- 系统内置项
ext_json text -- 扩展属性(颜色、标签、额外配置)
sort varchar(50) -- 排序
status int -- 状态, 1启用, 0禁用
is_deleted int -- 逻辑删除, 1删除, 0未删除
created_at datetime -- 创建时间
tenant_id = null 表示全局字典项,例如:
GENDER
这些对所有租户都一样。
tenant_id = 某租户ID 表示租户私有字典项
例如:
租户 A 的 CUSTOMER_LEVEL
租户 B 的 CUSTOMER_LEVEL
1 GLOBAL 字典,只查全局项:
select *
from sys_dict_item
where dict_type_id = :dict_type_id
and tenant_id is null
and status = 1
order by sort_no, id;
2 TENANT 字典,只查当前租户项:
select *
from sys_dict_item
where dict_type_id = :dict_type_id
and tenant_id = :tenant_id
and status = 1
order by sort_no, id;
3 MIXED 字典,查全局和租户项:
查询规则:
例如:
PAYMENT_METHOD(付款方式), 系统默认:
租户 A 追加:
租户 B 追加:
查询逻辑(追加模式):
select *
from sys_dict_item
where dict_type_id = :dict_type_id
and status = 1
and (tenant_id is null or tenant_id = :tenant_id)
order by sort_no, id;
查询逻辑(覆盖模式:按 item_code 覆盖):
如果你希望租户能覆盖系统默认项,比如:
系统有 BANK_TRANSFER
租户也定义一个 BANK_TRANSFER,名字改成“对公转账”
那么可以按 item_code 做唯一语义。
规则:
参数表必须天然支持多租户。
参数表在多租户下,强烈建议按“作用域(Scope)”设计。
也就是同一套参数定义,参数值可以有不同层级:
参数“定义”和“值”分离,拆成两张表
参数定义表 sys_param_def, 定义“这个参数是什么、类型是什么、支持什么作用域、默认值是什么”
sys_parameter_def
----------
id bigint PK
param_code varchar(50) -- 参数编码
param_name varchar(50) -- 参数名称
group_name varchar(50) -- 分组名称, SYSTEM / INVENTORY / SALES / FINANCE ...
param_type varchar(20) -- 参数类型: STRING / INT / DECIMAL / BOOL / JSON / DATE
scope_mode varchar(20) -- GLOBAL / TENANT / MIXED -- 作用范围
is_required int -- 是否必填 1必填 0非必填
is_encrypted int -- 是否加密 1加密 0不加密
is_builtin int -- 是否系统内置 1系统内置 0非系统内置
remark varchar(200) -- 备注
is_deleted int -- 逻辑删除, 1删除, 0未删除
sort int -- 排序
status int -- 状态, 1启用, 0禁用
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
scope_mode 这个字段非常重要,可取值:
参数值表是指“在某个作用域下,这个参数的实际值是什么”,参数值表设计:
sys_parameter
----------
id
tenant_id bigint -- 租户ID
param_def_id bigint -- 参数定义ID
scope_level varchar(20) -- GLOBAL / TENANT / ORG / USER-- 作用范围
org_id bigint -- 组织ID
user_id bigint -- 用户ID
param_value varchar(4000) -- 参数值
value_source varchar(200) -- 参数值来源:MANUAL / DEFAULT / IMPORT / SYSTEM
remark varchar(200) -- 备注
status int -- 状态, 1启用, 0禁用
created_at datetime -- 创建时间
updated_at datetime -- 更新时间
优先级建议
USER > ORG > TENANT > GLOBAL > sys_param_def.default_value
也就是说:
读取规则(V1)
核心业务表:
product
category
warehouse
inventory
supplier
customer
purchase_order
purchase_order_item
sales_order
sales_order_item
stock_in
stock_out
所有表都带 tenant_id 字段,用来标识当前数据所属的租户。
对于中间关联表,如UserRole设计,需要增加tenant_id字段。
user_role
----------
id
tenant_id
user_id
role_id
查询,需要根据租户过滤,如:
SELECT *
FROM user_role
WHERE tenant_id = ?
避免跨租户脏数据,可以加唯一索引,逻辑更安全:
CREATE UNIQUE INDEX idx_user_role_tenant_id_user_id_role_id ON user_role (tenant_id, user_id, role_id);
SaaS ORM自动过滤更容易:
query.filter(Model.tenant_id == current_tenant)
删除租户数据更容易:
DELETE FROM user_role WHERE tenant_id = ?
大多数企业系统 全部中间表都会带 tenant_id。例如:
多租户系统设计原则:
只要是业务表,一律带 tenant_id 字段,并且查询时需要根据租户过滤。
什么时候可以不加 tenant_id? 只有一种情况:全局表,这种是 平台共享数据,不属于某个租户吗,如:租户表,字典表、参数表、系统配置表、菜单表等。。
除了租户基础表外,租户还需要包括:租户套餐, 租户套餐关联。
租户套餐:
tenant_package
--------------
id
package_name
user_limit
storage_limit
price
租户套餐关联:
tenant_package_rel
-------------------
tenant_id
package_id
start_time
end_time
大多数 ERP 系统推荐:
主键ID:BIGINT
业务编码:VARCHAR
主键ID使用分布式ID:
Snowflake
Leaf
Sonyflake
生成 64bit BIGINT:
178923741239123
特点:
因此使用BIGINT + Snowflake 方案。
结构:
id BIGINT PRIMARY KEY
生成:
优点:
ERP数据库标准结构:
典型表:
id BIGINT PRIMARY KEY
tenant_id BIGINT
created_at DATETIME
updated_at DATETIME
不要在id中,把 GUID 存 VARCHAR。
例如:
550e8400-e29b-41d4-a716-446655440000
什么时候用 GUID ?
1 微服务跨系统ID
例如:
订单服务
支付服务
物流服务
2 离线客户端
例如:
移动端
离线同步
3 数据合并
例如:
多数据库合并
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。