saas 多租户使用文档
saas 多租户使用文档
💎 VIP 付费内容
本文档涉及 SaaS 多租户 相关功能,属于 VIP 付费内容。
- 后端:需在服务端开通对应授权后方可启用相关接口。
- 前端:需开通 钻石会员 才能完整使用本模块的全部功能。
未开通会员时,部分功能将受限或不可用,详情可参考会员说明。
本项目 采用 DB-per-tenant(独立数据库)多租户架构。功能开关由「套餐菜单配置」驱动(用户可见菜单/权限 = 角色菜单 ∩ 套餐菜单)。
一、架构概述
每个租户拥有独立的数据库实例,租户之间业务数据完全隔离;租户信息、套餐、菜单等系统级共享数据集中存放在系统库(ConfigId = "0")。可选的业务主库(BizMainDb)用于存放跨租户共享的业务数据(标记 [Tenant("MainDb")] 的实体),未配置时与系统库同库。
┌──────────────────────────────────────────────────────────┐
│ 系统库 (固定 ConfigId = "0") │
│ sys_tenant* · sys_menu · sys_dict_* · sys_config │
│ sys_file(_group) · sys_tasks(_log) · sys_user_msg │
│ ai_access_policy · ai_call_log · ai_model_price │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 业务主库 (BizMainDb, 可选) │
│ 标记 [Tenant("MainDb")] 的跨租户共享业务表 │
└──────────────────────────────────────────────────────────┘
│ │
┌────▼────┐ ┌──────▼──────┐
│ 租户库 A │ │ 租户库 B │
│ Tenant=X │ │ Tenant=Y │
│ · 用户 │ │ · 用户 │
│ · 角色 │ │ · 角色 │
│ · 部门 │ │ · 部门 │
│ · 日志 │ │ · 日志 │
│ · 商城 │ │ · 商城 │
└─────────┘ └─────────────┘
核心能力
| 能力 | 说明 |
|---|---|
| 多租户开关 | 通过 TenantSettings:UseTenant 一键启停 |
| 独立数据库 | 每个租户对应独立的数据库实例 |
| 域名绑定 | 租户可绑定子域名/自定义域名,按访问域名自动解析租户 |
| 租户生命周期 | 开通、初始化、停服(踢在线)、续费、注销全流程管理 |
| 套餐体系 | 预置免费版/专业版套餐,支持自定义套餐(CRUD) |
| 套餐菜单授权 | 为每个套餐勾选可用菜单,租户能力由套餐菜单决定 |
| 用户配额 | 按套餐限制租户用户数上限,支持单租户覆盖 |
| 试用开通 | 开通时指定试用期天数,自动分配 Pro 试用、期满回落默认套餐 |
| 到期管理 | 自动到期校验、30/15/7/3/1 阶梯提醒、到期自动停服、套餐到期降级通知 |
| 平台公告群发 | 向全部/指定租户管理员群发公告站内信(+短信) |
| 计费流水 | sys_tenant_order 记录开通/分配/续费/降级账目,微信在线续费闭环 |
| 短信触达 | 到期提醒/停服/注销/降级等场景短信旁路分发(模板 key 可配) |
| 数据隔离自检 | 一键扫描主库共享实体中被租户误写的数据,输出整改清单 |
| 平台菜单隔离 | 平台专属菜单(租户管理、菜单管理等)仅主租户可见 |
二、快速开始
2.1 启用多租户模式
在 ZR.Admin.WebApi/appsettings.json 中配置。库配置项全部可选——单库部署(系统库 = 业务库 = 商城库)不需要写任何库配置项,全部回退 ConfigId = "0":
{
"TenantSettings": {
"UseTenant": true, // 是否启用多租户 0/false=禁用, 1/true/on=启用
"RootDomains": ["zradmin.net"], // 租户根域名:按子域解析时据此刻离子域标签(a.zradmin.net → a)
"ReservedSubDomains": ["www", "admin", "api", "doc"], // 保留子域:不解析为租户
"PlatformMenuPermPrefixes": [
"system:menu:",
"system:tenant:",
"system:tenantplanmenu:",
"monitor:job:",
"tool",
"system:lang:",
"monitor:server",
"monitor:cache",
"system:datascreen:list"
]
},
"dbConfigs": [
{
"Conn": "Server=localhost;Database=ZrAdmin_Main;...",
"DbType": 1, // 0=MySql, 1=SqlServer, 3=Oracle, 4=PgSql
"ConfigId": "0", // 系统库,固定 0,同时是单库部署的默认业务库
"IsAutoCloseConnection": true
},
{
"Conn": "Server=localhost;Database=ZrAdmin_TenantA;...",
"DbType": 1,
"ConfigId": "tenant_a", // 租户A的数据库(需与 sys_tenant.TenantId 一致)
"IsAutoCloseConnection": true
}
]
}
关键点:
TenantSettings:UseTenant控制多租户总开关(已从根节点UseTenant迁移到TenantSettings节点内)SystemDb配置项已废弃:系统库ConfigId固定为"0"(--initdb建表、SqlSugarScope直查、[Tenant("0")]都硬编码0)。配置成其他值会被忽略,启动时输出黄色告警(详见 3.5)- 业务主库默认不需要配置:仅当存在
[Tenant("MainDb")]实体且要放到独立库时,才加"BizMainDb": "<ConfigId>"(旧键MainDb仍兼容);未配置回退"0"- 商城库默认不需要配置:仅非 SaaS 模式且商城独立建库时才加
"MallDb": "<ConfigId>";SaaS 下商城实体按租户库路由,该项不生效PlatformMenuPermPrefixes定义平台专属权限前缀,非主租户禁止访问(前缀匹配)- 想按域名进入不同租户,需配
RootDomains(根域名)与ReservedSubDomains(保留子域),规则见 3.2- 每新增一个租户,需在
dbConfigs中增加对应数据库连接dbConfigs[].ConfigId必须与租户表sys_tenant.TenantId严格一致- 历史版本中
MainDb承担系统库职责。升级时请把原系统库连接的ConfigId调整为"0"(详见 3.5 与第十一节第 7 条)
2.2 新增租户数据库
- 创建新数据库(如
ZrAdmin_TenantC) - 在
dbConfigs中添加连接串:
{
"Conn": "Server=localhost;Database=ZrAdmin_TenantC;...",
"DbType": 1,
"ConfigId": "tenant_c",
"IsAutoCloseConnection": true
}
- 重启应用生效
- 调用
POST /system/tenant/provision一键开通(建档 + 初始化 + 种子数据 + 默认套餐)
三、核心概念
3.1 数据库路由机制
BaseRepository<T>
├── UseTenant=1 (启用多租户)
│ ├── 实体实现 IMainDbEntity → 系统库 (ConfigId = "0")
│ ├── 实体有 [Tenant("MainDb")] → 业务主库 (BizMainDb, 缺省 "0")
│ └── 其他实体(无论有无 [Tenant("0")])→ 当前租户库 (TenantId)
│
└── UseTenant=0 (未启用)
├── 实体有 [Tenant("MainDb")] → 业务主库 (BizMainDb, 缺省 "0")
├── 实体有 [Tenant("X")] 特性 → App.Configuration["X"] ?? "X"(X 可为配置键,如 MallDb,未配置回退 "0")
└── 无特性 → 系统库 (ConfigId = "0")
⚠️ 易错点:SaaS 模式下
[Tenant("0")]不参与BaseRepository路由,只有IMainDbEntity与[Tenant("MainDb")]参与。[Tenant("0")]仅在非 SaaS 模式、或绕过BaseRepository的SqlSugarScope.Queryable<T>()/GetConnectionScopeWithAttr<T>()/CodeFirst时生效。
3.2 租户上下文解析
系统按以下优先级确定当前租户 ID:
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | TenantContext.CurrentTenantId | AsyncLocal,当前异步流显式设置 |
| 2 | HttpContext.Items["TenantId"] | 中间件注入 |
| 3 | HTTP Header tenantId | 请求头传递 |
| 4 | JWT Claims | 登录后 Token 中携带 |
| 5 | App.SystemDbConfigId | 默认兜底值,固定为 "0" |
按访问域名解析租户:
sys_tenant.Domain字段支持绑定子域名标签(如acme,对应acme.你的域名)或租户自定义完整域名(如acme.com)。租户数据经GetDomainTenantMap()构建"域名 → 租户ID"平台级缓存(域名小写归一),请求按访问域名自动定位租户;Domain为空表示未绑定,回退到上面的常规解析链。平台侧在租户新增 / 修改 / 停服 / 注销时会自动清除该缓存。域名全局唯一,由CheckDomainUnique校验。
域名解析细节(TenantResolveMiddleware)
匹配顺序固定为三步,任一命中即返回:
1) 保留子域名 → 不解析(TenantSettings:ReservedSubDomains,默认 www/admin/api/doc)
例:www.zradmin.net、admin.zradmin.net 永远不当作租户
2) 精确完整域名匹配(sys_tenant.Domain 存的是 acme.com 这类完整域名)
3) 子域名标签匹配(host 以 ".{RootDomains 中的某个根域名}" 结尾时取首段)
例:acme.zradmin.net → acme → 查 map 中的租户 "acme"
- 三步都没命中返回
null,回退到上面的 token / Header 链; - 匿名请求也会解析(商城游客、登录页品牌化同样需要知道是哪个租户),解析结果写入
TenantContext、HttpContext.Items["TenantId"]与请求头tenantId; - 已登录请求会做一致性校验:域名租户或请求头租户与 token 中的租户不一致时直接返回 403「租户信息不匹配」,避免用 A 租户的令牌读 B 租户的数据;
- 前端在每个请求头统一携带
tenantId(src/utils/request.js),BaseRepository在构造时读取该头完成库绑定; - 该域名数据同时被 OAuth 回调复用:
ResolveFrontendCallbackUrl会按租户域名推导前端回调页(详见第三方登录)。
3.3 系统库实体 (IMainDbEntity)
以下 19 个实体实现了 IMainDbEntity,数据始终存储在系统库(ConfigId = "0"):
| 实体类 | 对应表 | 说明 |
|---|---|---|
SysTenant | sys_tenant | 租户信息表 |
SysTenantPlan | sys_tenant_plan | 套餐定义 |
SysTenantPlanBinding | sys_tenant_plan_binding | 租户-套餐绑定 |
SysTenantPlanMenu | sys_tenant_plan_menu | 套餐-菜单授权 |
SysTenantOrder | sys_tenant_order | 租户套餐计费流水 |
SysMenu | sys_menu | 系统菜单(菜单集中在主库统一维护) |
SysDictType | sys_dict_type | 字典类型(主库基准) |
SysDictData | sys_dict_data | 字典数据(主库基准) |
SysConfig | sys_config | 系统参数 |
SysFile | sys_file | 文件存储(按 TenantId 隔离) |
SysFileGroup | sys_file_group | 文件分组(按 TenantId 隔离) |
SysUserMsg | sys_user_msg | 用户站内信(按 TenantId 隔离) |
SysTasks | sys_tasks | 计划任务 |
SysTasksLog | sys_tasks_log | 任务执行日志 |
CommonLang | sys_common_lang | 多语言配置 |
Area | sys_area | 行政区划参考数据 |
AiAccessPolicy | ai_access_policy | AI 访问与额度策略 |
AiCallLog | ai_call_log | AI 调用用量审计流水 |
AiModelPrice | ai_model_price | AI 模型单价 |
两类标记的分工(必读):
IMainDbEntity:决定 SaaS 下是否走系统库。想在 SaaS 下把表放系统库,必须实现该接口。[Tenant("0")]:非 SaaS 下把实体固定到ConfigId=0;SaaS 下对BaseRepository无效。目前 19 个IMainDbEntity实体已全部标注(AiCallLog使用等价的[Tenant(0)]写法)。- 新增系统库实体:
[SugarTable]+[Tenant("0")]+IMainDbEntity三项齐全;若表带TenantId字段,须同时登记到TenantFilter.FilteredEntityTypes,否则isolation/check会报可疑。业务主库实体: 跨租户共享的业务表(如会员
bus_member,若需集中存放)不要实现IMainDbEntity,改为标注[Tenant("MainDb")],SaaS 开启与关闭下都会路由到业务主库。两者同时存在时IMainDbEntity优先(会落到系统库)。
3.4 租户库实体
未实现 IMainDbEntity 的业务实体(如 SysUser、SysRole、SysDept、SysRoleMenu、SysLogininfor、SysOperLog、Article、AiChatSession 等)在 SaaS 下会自动路由到当前租户的数据库。
这些实体即便标注了
[Tenant("0")](用于非 SaaS 单库模式),SaaS 下也不会被固定到系统库 —— 想放系统库必须实现IMainDbEntity。商城实体用
[Tenant("MallDb")]:SaaS 下同样走当前租户库;非 SaaS 下按MallDb配置的 ConfigId 路由。
典型实例:2FA 与第三方账号表
SysUser2faDevice(sys_user_2fa_device)、SysUserWebAuthnCredential(sys_user_webauthn_credential)、SysThirdAccount(sys_third_account)都标了 [Tenant("0")],但没有实现 IMainDbEntity:
- 单库 / 非 SaaS:固定落
ConfigId="0"; - SaaS:跟随
SysUser落到各租户库(设备、凭据、绑定关系都是租户内数据,跨租户共享没有意义)。
因此这三张表同时登记在 SystemEntityTypes(单库建表)与 TenantBusinessEntityTypes(租户库建表)两份清单里,新建租户与启动补齐都会建表,见 数据库迁移。
3.5 系统库 ConfigId 固定为 "0"
系统库的 ConfigId 是常量 "0"(App.SystemDbConfigId),不读取配置:
--initdb/CodeFirst建表经SqlSugarScope执行,落默认连接0;- 系统实体上的
[Tenant("0")]硬编码0; BaseRepository的默认回退与App.GetCurrentTenantId()兜底都返回App.SystemDbConfigId。
因此配置项 SystemDb 已废弃:即使配了非 "0" 的值也会被忽略,并在启动时输出黄色告警(SqlsugarSetup.WarnLegacyDbConfig)。若确需把系统库独立到其他 ConfigId,需要统一改造建表入口(--initdb)与所有直查入口,逐个补 [Tenant] 解决不了。
对比主流的 ABP / Finbuckle / OrchardCore:顶层只保留「默认连接」一个概念,其余连接按名字解析(模块名 / 租户名),角色分层属于模块而非全局配置。本项目的对应关系是:系统库 = 默认连接
"0",租户库 = 租户ConfigId,业务主库 / 商城库 = 可选覆盖项。
3.6 权限模型:角色菜单 ∩ 套餐菜单
这是当前架构的核心变化。租户用户最终可见的菜单/权限由两层交集决定:
用户可见菜单 = 角色分配的菜单 (SysRoleMenu, 租户库)
∩ 套餐授权的菜单 (SysTenantPlanMenu, 主库)
- 主租户(系统库
ConfigId = "0"对应租户,即默认租户):不走套餐过滤,拥有全部系统库菜单 - 子租户:即便角色分配了某菜单,若套餐未授权该菜单,用户仍不可见/不可用
- 相关服务方法:
SelectMenuTreeByUserIdForTenant、SelectMenuPermsByUserIdForTenant
四、套餐体系
4.1 预置套餐
系统首次运行时(EnsureDefaultPlans)自动创建两个默认套餐写入数据库;数据库已有套餐后不再写入。之后可通过后台套餐管理页面自行新增/编辑/删除套餐,无需修改配置文件。
| 套餐 | PlanCode | 用户上限 | 是否默认 |
|---|---|---|---|
| 免费版 | free | 5 | 是 |
| 专业版 | pro | 100 | 否 |
套餐能力(可访问哪些菜单/功能)通过「套餐菜单配置」为每个套餐勾选菜单。
4.2 套餐菜单授权(能力控制核心)
在套餐管理页面点击某套餐的「分配菜单」,弹出菜单树勾选框,为该套餐授权可用菜单。保存后:
- 该套餐下的所有租户将只能访问被勾选菜单对应的功能
- 中间件
TenantFeatureMiddleware会以套餐菜单权限作为兜底拦截 - 目录节点(含子节点的 M 型菜单)勾选状态由子节点驱动,不单独持久化勾选
4.3 自定义套餐
通过前端「套餐管理」页面新增/编辑/删除套餐,或调用套餐 CRUD API(见 5.6)。
// 等价的实体结构
var plan = new SysTenantPlan
{
PlanCode = "enterprise", // 唯一,不可重复
PlanName = "企业版",
MaxUsers = -1, // -1 表示不限制
Status = 0, // 0=可用, 1=禁用
IsDefault = 0,
Sort = 30
};
新增套餐后,记得为其「分配菜单」,否则该套餐下的租户无任何可用功能。
五、租户与套餐管理 API
接口基础路径:
/system/tenant与/system/tenantPlanMenu
5.1 租户开通(一键式)
POST /system/tenant/provision 权限:system:tenant:add
自动完成:建档 → 数据库初始化 → 基础表创建 → 种子数据复制 → 默认套餐分配
{
"tenantId": "tenant_c",
"tenantName": "客户C",
"companyName": "客户C有限公司",
"contactName": "张三",
"contactPhone": "13800000000",
"domain": "tenant-c", // 可选:子域名标签或自定义域名,留空默认等于 tenantId
"logo": "/profile/upload/xxx.png", // 可选:企业Logo(登录页/顶栏品牌展示)
"expireTime": "2027-01-01",
"trialDays": 30, // 可选:试用期天数 1~365
"remark": "备注",
"initializeNow": true,
"seedFromMain": true,
"enableAfterInit": true
}
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| tenantId | string | 是 | 租户标识,需与 dbConfigs.ConfigId 一致 |
| tenantName | string | 否 | 租户名称 |
| companyName / contactName / contactPhone | string | 否 | 企业与联系人信息 |
| domain | string | 否 | 域名绑定(子域名标签/完整域名),留空默认等于 tenantId |
| logo | string | 否 | 企业Logo图片地址,留空不设置 |
| expireTime | datetime | 否 | 到期时间(租户本身的失效时间) |
| trialDays | int | 否 | 试用期天数(1~365):>0 时开通即分配 Pro 套餐、试用期满自动回落默认套餐,不影响租户本身有效期 |
| initializeNow | bool | 否 | 是否立即初始化数据库,默认 true |
| seedFromMain | bool | 否 | 是否从主库复制种子数据,默认 true |
| enableAfterInit | bool | 否 | 初始化后是否启用,默认 true |
开通即写入一条
plan-assign计费流水;开通时会同步创建数据库绑定与域名的唯一校验。
5.2 租户初始化
POST /system/tenant/initialize 权限:system:tenant:update
单独执行数据库初始化(创建表、复制种子数据)。
{
"tenantId": "tenant_c",
"createDatabaseIfNotExists": true,
"seedFromMain": true,
"ensureMainTenantRecord": false
}
5.3 租户停服
POST /system/tenant/suspend?tenantId=tenant_c&remark=xxx 权限:system:tenant:update
将租户状态设为停用,用户无法登录。默认租户不允许停服。
停服后系统立即执行两件事(失败只记日志、不影响停服本身):
- 踢出在线用户:通过 SignalR 在线列表过滤该租户连接并发送强退通知(提示语:您的租户已被停用,请联系平台管理员),避免已发放 Token 在自然过期前继续可用;
- 短信通知联系人(若
SmsOptions:Templates配置了tenantSuspended模板编号)。
5.4 租户续费
POST /system/tenant/renew 权限:system:tenant:update
{ "tenantId": "tenant_c", "extendDays": 365, "remark": "续费一年" }
或直接指定新到期时间:
{ "tenantId": "tenant_c", "newExpireTime": "2027-07-12", "remark": "续费到指定日期" }
amount(金额,选填)用于在sys_tenant_order写入renew计费流水记账。续费成功后:租户恢复启用(Status=0)、生效中套餐绑定的到期时间同步延长至新到期时间(避免绑定先于租户失效导致能力提前回落)、写入计费流水并站内信通知租户管理员。到期阶梯提醒按"到期日+阶段"打标去重,续费更新到期日后自动开启新一轮提醒。
5.5 租户注销
POST /system/tenant/decommission 权限:system:tenant:remove
{ "tenantId": "tenant_c", "deleteRecord": false, "remark": "客户主动注销" }
deleteRecord=false:停服但保留记录deleteRecord=true:停服并逻辑删除
注销同样会强制在线用户下线,并按需发送 tenantDecommissioned 短信;同时清除该租户的域名映射缓存。
5.6 套餐 CRUD
| 操作 | 方法 | 路径 | 权限 |
|---|---|---|---|
| 套餐列表 | GET | /system/tenant/plan/list | system:tenant:list |
| 套餐详情 | GET | /system/tenant/plan/{id} | system:tenant:list |
| 新增套餐 | POST | /system/tenant/plan | system:tenant:update |
| 编辑套餐 | PUT | /system/tenant/plan | system:tenant:update |
| 删除套餐 | DELETE | /system/tenant/plan/{id} | system:tenant:update |
新增/编辑请求体(SysTenantPlan):
{
"id": 0,
"planCode": "enterprise",
"planName": "企业版",
"maxUsers": -1,
"status": 0,
"isDefault": 0,
"sort": 30
}
删除保护: 套餐下仍有生效中的租户绑定时拒绝删除;删除时会同步清理该套餐的菜单授权关联。
5.7 套餐菜单授权 API
接口基础路径:
/system/tenantPlanMenu
| 操作 | 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|---|
| 套餐菜单树 | GET | /tree?planCode=xxx | system:tenant:list | 返回带勾选状态的菜单树 |
| 当前租户菜单树 | GET | /myTree | system:tenant:list | 子租户查看本租户套餐菜单 |
| 保存套餐菜单 | POST | /save | system:tenant:update | 全量替换 |
| 复制套餐菜单 | POST | /copy | system:tenant:update | 从源套餐复制到目标套餐 |
| 套餐权限列表 | GET | /perms?planCode=xxx | system:tenant:list | 返回套餐对应的权限标识 |
保存请求体:
{ "planCode": "pro", "menuIds": [1, 2, 3, 100, 101] }
复制请求体:
{ "sourcePlanCode": "free", "targetPlanCode": "pro" }
5.8 分配套餐
POST /system/tenant/plan/assign 权限:system:tenant:update
{
"tenantId": "tenant_c",
"planCode": "pro",
"startTime": "2026-07-01",
"endTime": "2027-07-01",
"maxUsersOverride": 200,
"remark": "客户升级专业版"
}
覆盖参数:
maxUsersOverride可覆盖套餐默认用户上限(留空则用套餐默认值);amount(选填)用于写plan-assign计费流水记账。 分配新套餐会自动将该租户旧的绑定置为失效(Status=1)。功能能力由套餐菜单配置决定,因此不再有featureFlagsOverride参数。
5.9 查询与用量
| 操作 | 方法 | 路径 | 权限 |
|---|---|---|---|
| 租户当前套餐 | GET | /system/tenant/plan/current?tenantId=xxx | system:tenant:list |
| 套餐用量面板 | GET | /system/tenant/plan/usage?tenantId=xxx | system:tenant:list |
| 到期提醒 | GET | /system/tenant/expire/reminders?withinDays=30 | system:tenant:list |
| 当前租户自助信息 | GET | /system/tenant/my | tenant:my |
5.10 租户 CRUD
| 操作 | 方法 | 路径 | 权限 |
|---|---|---|---|
| 列表 | GET | /system/tenant/list | system:tenant:list |
| 详情 | GET | /system/tenant/{id} | system:tenant:query |
| 新增 | POST | /system/tenant | system:tenant:add |
| 修改 | PUT | /system/tenant | system:tenant:update |
| 修改状态 | PUT | /system/tenant/changeStatus?id=&status= | system:tenant:update |
| 删除 | DELETE | /system/tenant/{ids} | system:tenant:remove |
注意: 默认租户(系统库
ConfigId = "0"对应租户)不允许停用或删除;编辑租户时TenantId不允许修改(与dbConfigs[].ConfigId绑定,改后即断联),domain全局唯一冲突会被拒绝。
5.11 到期自动化(定时任务)
系统内置三个租户到期相关定时任务(通过任务调度触发,执行时切到主库仅跑一次):
| 任务类(Run) | 行为 |
|---|---|
Job_TenantExpireRemind | ① 到期阶梯提醒(RemindExpiringTenants):扫描启用、未过期且剩余 ≤30 天的租户,剩余天数命中 30 / 15 / 7 / 3 / 1 天任一时,给租户管理员发站内信、给联系人发短信(模板 key tenantExpireRemind,参数 days / expiryDate)。同一"到期日+阶段"只提醒一次(在 sys_tenant.Remark 打标 [expire-remind:yyyyMMdd-Nd]),续费改期后自动开启新一轮。② 套餐到期降级通知( NotifyExpiredPlanBindings):扫描已过期(EndTime<now)且仍生效(Status=0)的套餐绑定,站内信 + 短信(模板 key tenantPlanDegraded,参数 planName / date)通知管理员"已自动降级为默认套餐",绑定置 Status=2(区别于被新绑定顶替的 Status=1,同时天然幂等),并写入一条 degrade 计费流水 |
Job_TenantExpireSuspend | 到期自动停服(SuspendExpiredTenants):扫描已过期(ExpireTime<now)且仍启用的租户,逐个执行停服(含踢在线用户) |
语义提醒:套餐绑定过期后租户能力判定即回落到默认套餐兜底;
Job_TenantExpireRemind负责把"已降级"的事实通知到位并留痕。
5.12 租户公告群发
POST /system/tenant/broadcast 权限:system:tenant:broadcast
向目标租户的管理员群发公告站内信:
{
"tenantIds": ["tenant_a", "tenant_b"], // 目标租户列表;为空表示全部启用租户
"content": "平台将于 2026-09-10 00:00 进行系统升级,预计持续 30 分钟"
}
返回 { "sent": 5 }(实际送达租户数)。适合平台发布升级通知、计费调整等运营公告。
5.13 租户数据隔离自检
GET /system/tenant/isolation/check 权限:system:tenant:list
平台巡检接口(报告式,不自动修复)。反射扫描 ZR.Model 中实现 IMainDbEntity 且带 TenantId 字段的实体,与 TenantFilter 已注册过滤的实体做差集,输出:
| 字段 | 含义 |
|---|---|
| CoveredList | 已注册租户过滤的实体清单(TenantFilter 静态过滤方法覆盖) |
| CandidateList | 全部候选主库共享实体(带 TenantId 字段) |
| ExemptList | 豁免清单(SysTenant / SysTenantPlan / SysTenantPlanBinding / SysTenantPlanMenu / SysTenantOrder 等平台管理表) |
| SuspiciousList | 可疑清单:带 TenantId 但既未注册过滤也未豁免的实体 → 需人工补过滤或加豁免 |
| RegistrationMismatchList | TenantFilter.FilteredEntityTypes(实际挂载)与过滤方法不一致的实体 → 说明"写了方法没注册"或"注册了没写方法" |
| IsSafe | SuspiciousList 与 RegistrationMismatchList 均为空时为 true |
当前覆盖清单(7 个):
SysUserMsg、SysFile、SysFileGroup、SysTasks、SysTasksLog、AiCallLog、AiAccessPolicy。
用途:防止新增共享表时漏注册租户过滤导致跨租户串数据;开发新增主库共享实体后跑一次即可自检。
5.14 在线续费(微信支付)与计费流水
计费流水 sys_tenant_order(主库):租户开通、套餐分配/变更、续费、到期降级都会写流水,只增不改(降级/退订以新记录表达,不物理删除),供平台对账与租户消费追溯。ActionType 取值:plan-assign(套餐分配/变更,含开通初始分配)/ renew(续费)/ degrade(到期降级,记录被降掉的套餐编码)。
在线续费闭环(租户侧自助),路由前缀 /system/tenant/pay:
| 操作 | 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|---|
| 下单(小程序 JSAPI) | POST | /pay/create-jsapi?durationDays=365&code=wx.login的code | tenant:my | 校验时长在 30~730 天(主库租户无需续费、微信支付未启用或配置不全时报错);金额 = 天数 × 1 元/天 由服务端计算;先落待支付流水(PayStatus=0),再用 code 换 OpenId 下单;返回 { orderNo, amount, payParams },payParams 直接交给 wx.requestPayment |
| 下单(H5,已停用) | POST | /pay/create?durationDays=365 | - | 直接抛错提示"请在系统小程序内续费,或联系平台线下续费"。保留路由只为给前端明确提示,避免语义不明的 404 |
| 状态轮询 | GET | /pay/status?orderNo=TOxxx | tenant:my | 返回 { orderNo, paid, amount, payTime };拉起支付后前端轮询,paid=true 即续费成功 |
| 模拟回调 | POST | /pay/mock-notify?orderNo=&totalFen= | tenant:my | 仅 Development 环境:无商户号时模拟微信支付成功回调——用与真实回调相同的密钥生成签名/加密体,HTTP 自调用商城 wechat/notify 入口,验签、解密、分发、金额比对、幂等、续费逻辑与线上完全一致 |
| 支付回调 | - | 复用商城 shopping/front/order/wechat/notify | - | WechatPayGateway 按单号前缀 TO 分发到租户续费处理器:金额比对 → MarkTenantOrderPaid(CAS,仅 PayStatus=0 置为已支付,天然幂等)→ 按流水 DurationDays 执行与后台一致的续费逻辑 |
H5 通道已停用:公网 C 端与后台拆分后,内网后台宿主在公网不可达,而微信 H5 支付需要用户手机能访问的回跳站点(
WechatPay:H5ReturnUrl已被公网商城 H5 占用,且为单值配置)。小程序 JSAPI 的调起参数由服务端直传,不需要回跳落地页,因此续费统一走小程序。关键约束:
- 前端不可传金额——金额/时长/租户全部以流水为准,支付回调只认单号;
payParams.signType固定RSA(服务端用商户私钥签名);- 重复回调 / 前端重复轮询不会重复续费(CAS + 幂等标记);
- 未启用微信支付时在线续费接口拒绝并提示走线下,
mock-notify可在开发期验证全链路。
六、业务集成点
6.1 登录流程
1. 前端带上 tenantId 调用登录接口
2. 后端 CheckTenant(tenantId)
├── 租户不存在 → 报错
├── 租户已停用 → 报错
└── 租户已过期 → 报错
3. TenantContext.Change(tenantId) 切换上下文
4. 在租户库中查询用户、验证密码
5. JWT Token 中写入 TenantId
6. Token 返回前端,后续请求自动识别租户
令牌有效期内的租户状态复核:JwtAuthMiddleware 在验签后会复核用户与租户状态(IsTenantActive,60 秒缓存,停服/到期时主动清除)。因此把租户停服或等到期后,存量 token 会立即失效,不必等会话自然过期,也不存在"续期把停服租户复活"的问题。
前端登录请求示例:
{ "username": "admin", "password": "admin123", "tenantId": "tenant_c", "uuid": "...", "code": "..." }
6.2 菜单路由过滤
GET /system/login/getRouters 获取路由时:
- 主租户用户 → 返回完整主库菜单
- 子租户用户 → 返回「角色菜单 ∩ 套餐菜单」的交集,并剔除平台专属菜单(
PlatformMenuPermPrefixes)
6.3 功能权限拦截(兜底)
权限核心过滤由 ActionPermissionFilter 完成。TenantFeatureMiddleware 中间件作为兜底,防止未经过滤器的新增接口直接暴露套餐外能力:
- 检查是否启用多租户
- 跳过匿名接口和 Admin 用户
- 读取接口
ActionPermissionFilter.Permission - 取当前租户套餐菜单权限集合
GetPermsByTenantId(tenantId) - 权限不在集合内 → 返回 403:
当前租户套餐未包含该权限
6.4 平台菜单隔离
TenantFeaturePolicy 仅负责平台专属菜单过滤:
- 权限前缀命中
TenantSettings:PlatformMenuPermPrefixes的菜单被视为平台菜单 - 非主租户请求时,这些平台菜单会被
FilterPlatformMenusForNonMainTenant从菜单树中剔除 - 主租户不受影响
6.5 用户配额控制
新增用户时自动调用 EnsureTenantUserQuotaForAdd(tenantId, addingCount),覆盖三个入口:
| 入口 | 说明 |
|---|---|
| 后台新增用户 | SysUserController 新增单用户 |
| 后台批量导入 | 按本次导入条数一次校验,不会"导入一半才失败" |
| 账号注册(C 端) | SysLoginController 注册接口同样校验 |
- 上限取值:
租户套餐绑定.MaxUsersOverride ?? 套餐.MaxUsers(租户级可单独调整上限,不改套餐本身); - 超过上限 → 返回错误(
ResultCode.DENY):当前租户套餐用户上限为N,现有M,新增X后将超限; MaxUsers = -1→ 不限制;未启用多租户时直接放行。
6.6 字典数据扩展
主库 sys_dict_data / sys_dict_type 提供基准字典数据(IMainDbEntity),普通租户可在自己的租户库中通过 SysTenantDictData 扩展表进行覆写(相同 DictType + DictValue 覆盖主库数据),读取时自动合并。
6.7 租户短信旁路分发
租户关键事件(到期阶梯提醒 / 停服 / 注销 / 套餐到期降级)在发送站内信的同时,会经 ISmsSender 旁路分发短信:
- 模板编码从
SmsOptions:Templates[场景key]读取,未配置对应场景 key 或联系人手机号为空时静默跳过,发送失败仅记日志、不影响主流程; - 场景 key:
tenantExpireRemind/tenantSuspended/tenantDecommissioned/tenantPlanDegraded(租户域),workflow(流程引擎行动类通知); - 真实发送需接入短信服务商(见对应文档),未接入时按模拟发送处理。
七、开发指南
7.1 注册新的主库实体
[SugarTable("my_shared_table", "共享数据表")]
[Tenant("0")]
public class MySharedEntity : SysBase, IMainDbEntity
{
// 字段定义
}
新增后还需三件事(缺一不可):
- 在
DbMigrationService.SystemEntityTypes登记实体类型,否则--initdb不建表; - 若表带
TenantId,在TenantFilter.FilteredEntityTypes+Apply()中注册过滤,并跑一次GET /system/tenant/isolation/check; - 首次上线执行
dotnet run -- --initdb建表(存量库)或由启动迁移补列。
业务主库实体(
[Tenant("MainDb")])不要实现IMainDbEntity,同时确认dbConfigs中存在BizMainDb指向的ConfigId(未配置时回退"0",即与系统库同库)。
7.2 使用 TenantContext 手动切换
// 在 using 作用域内切换租户,退出自动还原
using var scope = TenantContext.Change("tenant_c");
var users = _userService.Queryable().ToList(); // 路由到 tenant_c
7.3 常用工具方法
string tenantId = App.GetCurrentTenantId(); // 获取当前租户ID
bool enabled = App.IsTenantEnabled(); // 多租户是否启用
string systemDb = App.SystemDbConfigId; // 系统库 ConfigId(统一入口,勿直接读配置)
string mainDb = App.BizMainDbConfigId; // 业务主库 ConfigId([Tenant("MainDb")] 实体)
string mallDb = App.MallDbConfigId; // 商城库 ConfigId
7.4 在 Service 中显式切换数据源
// 操作租户库
var tenantDb = ResolveTenantDb(); // 当前请求租户
var tenantDb2 = ResolveTenantDb("T2"); // 指定租户
// 操作系统库
var sysDb = ResolveMainDb(); // 实际为系统库(ConfigId = "0")
// 查询示例
var dictList = sysDb.Queryable<SysDictData>().ToList(); // 系统库
var userList = tenantDb.Queryable<SysUser>().Where(...).ToList(); // 租户库
7.5 扩展套餐能力(新增功能)
- 在系统菜单中新增该功能对应的菜单/按钮(主库
sys_menu) - 在「套餐管理 → 分配菜单」中为目标套餐勾选该菜单
- 保存后,该套餐下租户即可访问
7.6 平台专属功能配置
若某功能应仅主租户可用,在 appsettings.json 的 TenantSettings:PlatformMenuPermPrefixes 中加入其权限前缀即可,无需改代码、无需重新编译。
7.7 多租户事务
// 单库事务
_baseRepo.UseTran(() =>
{
_repo1.Insert(data1);
_repo2.Update(data2);
});
// 跨库事务(多租户)
var client = DbScoped.SugarScope;
client.BeginTran();
try
{
client.GetConnectionScope("0").Insertable(...).ExecuteCommand(); // 主库
client.GetConnectionScope(tenantId).Insertable(...).ExecuteCommand(); // 租户库
client.CommitTran();
}
catch
{
client.RollbackTran();
}
7.8 模块化初始化扩展
实现 ITenantModuleInitializer 接口可在租户初始化流程中注入自定义的建表 / 种子逻辑:
public interface ITenantModuleInitializer
{
void InitializeNonSaaS();
}
各业务模块(如 ZR.Mall)可实现该接口,在非 SaaS 模式下执行本模块特有的初始化操作。InitTable.RunInitDb() 遍历所有 ITenantModuleInitializer 实现并自动调用。
7.9 存量库迁移
SqlsugarSetup.AddDb() 在每次启动时按顺序执行三步(幂等,失败只告警不阻断启动):
DbMigrationService.ApplyLegacyTableRenames(); // 1) 历史表名重命名(仅主库)
DbMigrationService.MigrateTenantColumns(); // 2) 系统库补 TenantId 列
DbMigrationService.EnsureTenantBusinessTables(); // 3) 租户库补业务表/列(跳过主库)
第 2 步:系统库补 TenantId
对注册表中实现 IMainDbEntity 且含 TenantId 属性的表补加 TenantId(varchar(64) NULL),列已存在或表不存在时跳过。当前涉及:
sys_tenant、sys_tenant_plan_binding、sys_tenant_ordersys_file、sys_file_group、sys_user_msgsys_tasks、sys_tasks_log
AiCallLog、AiAccessPolicy 属可选 AI 模块,由 --initdb / 手工同步维护,不在启动补列范围(StartupTenantColumnExcludedTypes)。
第 3 步:租户库补业务表
EnsureTenantBusinessTables() 遍历 dbConfigs(跳过系统库),按 TenantBusinessEntityTypes 清单对每个租户库做幂等建表 + 补列,单个租户失败不影响其他租户。清单覆盖 SysUser / SysRole / SysDept / SysRoleMenu / SysLogininfor / SysOperLog / SysUser2faDevice / SysUserWebAuthnCredential / sys_third_account / 文章相关表等;商城、工作流由各自 ITenantModuleInitializer 建表,不在此清单。
- 只在
UseTenant=1时执行; - 老租户升级、新租户创建共用同一份清单,避免"新建租户有表、老租户没表"。
新装库由 CodeFirst 自动建列,以上两步只兜底存量库;表/列不存在时仅告警不中断。全量迁移与差异预览见数据库迁移。
八、配置参考
appsettings.json 关键配置项
| 配置项 | 说明 | 可选值 |
|---|---|---|
TenantSettings:UseTenant | 多租户开关 | 0/false 禁用;1/true/on/yes/enabled 启用 |
TenantSettings:PlatformMenuPermPrefixes | 平台专属权限前缀(非主租户禁止访问) | 字符串数组,前缀匹配 |
TenantSettings:RootDomains | 租户根域名,按子域解析时据此剥离子域标签(a.zradmin.net → a) | 字符串数组,如 ["zradmin.net"];本地 localhost 不受影响 |
TenantSettings:ReservedSubDomains | 保留子域名,不解析为租户(维持主库/后台访问) | 缺省 ["www","admin","api","doc"] |
SystemDb | 已废弃,请勿配置 | 系统库 ConfigId 是常量 "0"(见 3.5),配了会被忽略并告警 |
BizMainDb | 业务主库 ConfigId(可选) | 仅 [Tenant("MainDb")] 实体使用,未配置回退 "0";旧键 MainDb 兼容 |
MallDb | 商城库 ConfigId(可选) | 仅非 SaaS 模式下商城实体使用,未配置回退 "0" |
dbConfigs[].ConfigId | 数据库标识 | 字符串,租户库需与 TenantId 一致 |
dbConfigs[].DbType | 数据库类型 | 0=MySql, 1=SqlServer, 2=Sqlite, 3=Oracle, 4=PgSql |
SmsOptions.Enabled | 短信服务总开关(租户短信旁路前置条件) | true/false |
SmsOptions.Templates | 短信模板:场景 key → 服务商模板编码 | key:tenantExpireRemind / tenantSuspended / tenantDecommissioned / tenantPlanDegraded(租户通知)、workflow(流程行动类通知);未配置对应 key 时该场景静默跳过 |
九、数据库表结构
sys_tenant(租户信息表,主库)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | bigint | 主键 |
| TenantId | varchar(64) | 租户标识,对应 dbConfigs.ConfigId |
| TenantName | varchar(100) | 租户名称 |
| Domain | varchar(200) | 域名绑定(子域名标签/完整域名),用于按访问域名解析租户,空=未绑定 |
| Logo | varchar(500) | 企业Logo图片地址(登录页/顶栏品牌展示) |
| CompanyName | varchar(200) | 企业名称 |
| ContactName | varchar(50) | 联系人 |
| ContactPhone | varchar(50) | 联系电话 |
| Status | int | 0=正常, 1=停用 |
| ExpireTime | datetime | 过期时间,null=永不过期 |
| Remark | varchar | 备注(另作到期提醒打标 [expire-remind:yyyyMMdd-Nd] 存储) |
| DelFlag | int | 0=存在, 2=删除 |
sys_tenant_plan(套餐定义表,主库)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | bigint | 主键 |
| PlanCode | varchar(64) | 套餐编码(唯一,如 free, pro) |
| PlanName | varchar(100) | 套餐名称 |
| MaxUsers | int | 最大用户数,-1=不限制 |
| Status | int | 0=可用, 1=禁用 |
| IsDefault | int | 0=否, 1=默认套餐 |
| Sort | int | 排序 |
| DelFlag | int | 0=存在, 1=删除 |
FeatureFlags字段已移除,套餐能力改由sys_tenant_plan_menu授权。
sys_tenant_plan_binding(套餐绑定表,主库)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | bigint | 主键 |
| TenantId | varchar(64) | 租户标识 |
| PlanCode | varchar(64) | 套餐编码 |
| Status | int | 0=生效, 1=被新绑定顶替失效, 2=到期降级已处理(幂等标记) |
| StartTime | datetime | 生效时间 |
| EndTime | datetime | 失效时间,null=永不过期 |
| MaxUsersOverride | int | 用户数覆盖(null 则用套餐默认) |
| DelFlag | int | 0=存在, 1=删除 |
sys_tenant_plan_menu(套餐菜单授权表,主库)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | bigint | 主键 |
| PlanCode | varchar(64) | 套餐编码 |
| MenuId | bigint | 菜单ID,关联主库 sys_menu.MenuId |
sys_tenant_order(租户套餐计费流水表,主库)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | bigint | 主键 |
| OrderNo | varchar(64) | 流水单号:TO+时间戳+随机片段,业务唯一(微信回调/状态查询以此定位) |
| TenantId | varchar(64) | 租户标识 |
| ActionType | varchar(32) | 动作类型:plan-assign=套餐分配/变更(含开通初始分配)、renew=续费、degrade=套餐到期降级 |
| PlanCode | varchar(64) | 涉及套餐编码(降级流水记录被降掉的套餐) |
| StartTime | datetime | 本次套餐/续费生效开始时间 |
| EndTime | datetime | 本次套餐/续费到期时间 |
| Amount | decimal | 计费金额;线下记账/分配选填,在线支付单为应付金额 |
| OperatorName | varchar(64) | 操作人(平台管理员或 system) |
| PayStatus | int | 支付状态:0=待支付 1=已支付(线下记账直接置已支付或留空) |
| PayChannel | varchar(32) | 支付渠道:wechat 等 |
| TransactionId | varchar(64) | 第三方交易号(微信 transaction_id) |
| DurationDays | int | 续费时长(天),在线续费下单写入,支付成功后据此执行续费 |
| PayTime | datetime | 支付完成时间 |
流水只增不改:降级、退订等以新记录表达,不做物理删除。
十、常见场景
场景1:新开一个租户
curl -X POST /system/tenant/provision \
-H "Content-Type: application/json" \
-d '{"tenantId":"new_company","tenantName":"新公司","expireTime":"2027-07-12"}'
自动完成建表、种子数据、默认套餐分配,一步到位。
场景2:租户到期续费
curl -X POST /system/tenant/renew \
-H "Content-Type: application/json" \
-d '{"tenantId":"tenant_c","extendDays":365,"remark":"续费一年"}'
系统自动恢复租户为启用状态。
场景3:租户升级套餐
curl -X POST /system/tenant/plan/assign \
-H "Content-Type: application/json" \
-d '{"tenantId":"tenant_c","planCode":"pro","remark":"客户升级专业版"}'
系统自动将旧套餐置为失效,新套餐立即生效;可访问功能由 pro 套餐已授权菜单决定。
场景4:新增自定义套餐并授权菜单
# 1) 新增套餐
curl -X POST /system/tenant/plan \
-H "Content-Type: application/json" \
-d '{"planCode":"enterprise","planName":"企业版","maxUsers":-1,"status":0,"sort":30}'
# 2) 为套餐授权菜单
curl -X POST /system/tenantPlanMenu/save \
-H "Content-Type: application/json" \
-d '{"planCode":"enterprise","menuIds":[1,2,3,100,101,102]}'
也可直接从已有套餐复制菜单:
curl -X POST /system/tenantPlanMenu/copy \
-H "Content-Type: application/json" \
-d '{"sourcePlanCode":"pro","targetPlanCode":"enterprise"}'
场景5:为单个租户调整用户上限
curl -X POST /system/tenant/plan/assign \
-H "Content-Type: application/json" \
-d '{"tenantId":"tenant_c","planCode":"free","maxUsersOverride":10,"remark":"临时提升免费版用户上限"}'
场景6:批量查询即将到期的租户
curl GET "/system/tenant/expire/reminders?withinDays=30"
运维人员可根据结果主动联系客户续费。另有 Job_TenantExpireRemind 定时任务按 30/15/7/3/1 天自动站内信+短信提醒,无需人工盯。
场景7:试用期开通(自动回落)
curl -X POST /system/tenant/provision \
-H "Content-Type: application/json" \
-d '{"tenantId":"trial_c","tenantName":"试用客户","domain":"trial-c","trialDays":30}'
开通即分配 Pro 套餐试用 30 天(绑定到期时间 = 现在+30 天,写入 plan-assign 流水);试用期满后由到期自动化任务把过期绑定置为已处理并通知管理员,租户能力回落默认套餐,不影响租户本身有效期。
场景8:平台向全体租户发升级公告
curl -X POST /system/tenant/broadcast \
-H "Content-Type: application/json" \
-d '{"content":"平台将于本周六 00:00-02:00 升级,期间可能短暂无法访问"}'
tenantIds 留空表示全部启用租户;目标租户管理员将收到站内信公告。
场景9:租户自助在线续费
租户管理员在「我的租户」发起续费(30~730 天)→ 服务端算金额(1 元/天)并落待支付流水 → wx.login 拿 code 调 /system/tenant/pay/create-jsapi,用返回的 payParams 拉起 wx.requestPayment → 前端轮询 /system/tenant/pay/status 直到 paid=true。微信异步回调按单号 TO 前缀分发并 CAS 置付,重复回调不会重复续费。平台也可用后台 /system/tenant/renew 人工续费(可填 amount 记账)。
H5 续费通道已停用(内网后台宿主在公网不可达,且 H5 回跳站点已被公网商城占用),续费统一在小程序内完成;如需恢复 H5,须先提供公网可达的回跳落地页。
十一、注意事项
- ConfigId 一致性:
dbConfigs[].ConfigId必须与sys_tenant.TenantId严格一致 - 默认租户保护: 系统库(
ConfigId = "0")对应的默认租户不允许停用或删除,且默认拥有全部菜单 - 权限双层交集: 子租户用户最终权限 = 角色菜单 ∩ 套餐菜单,两者缺一不可
- 套餐必授权: 新建套餐后务必「分配菜单」,否则该套餐租户无任何可用功能
- 退出切换: 使用
TenantContext.Change()时务必配合using语句 - 异步安全:
TenantContext使用AsyncLocal,在异步流中自动传播 - 升级迁移: 旧版本的
MainDb曾承担系统库职责。系统库ConfigId现固定为"0":若原部署的系统库连接在dbConfigs中不是"0",请把它的ConfigId调整为"0";若确实需要独立业务主库,再配置"BizMainDb": "<ConfigId>"(旧键MainDb仍兼容,迁移后建议改名)。配置项SystemDb已废弃,配了会被告警忽略 - 菜单在主库:
SysMenu为主库实体,所有租户共享同一套菜单定义;租户差异仅体现在套餐授权 - 平台菜单隔离: 平台专属权限前缀在
PlatformMenuPermPrefixes中维护,非主租户不可见 - WRITE 不过滤器: INSERT/UPDATE 不经过 QueryFilter,写入时需手动设置
TenantId;漏填时DataExecuting只打警告(主库共享表 INSERT 未设置 TenantId),不会自动补齐 —— 自动填会掩盖调用方漏赋值 - MallDb 配置: SaaS 模式下商城实体按租户自动路由;非 SaaS 模式下按
MallDb配置的 ConfigId 路由 - TenantId 只读: 租户创建后
TenantId禁止修改(对应dbConfigs[].ConfigId绑定);Domain全局唯一且可空 - 试用语义:
trialDays只影响套餐绑定有效期(试用 Pro → 期满回落默认套餐),不影响租户本身 ExpireTime - 流水只增不改:
sys_tenant_order不物理删除;在线支付置付用 CAS(仅待支付可置已支付),回调重复到达不会重复续费 - 到期自动化依赖任务调度: 阶梯提醒/到期停服/降级通知均由定时任务驱动(
Job_TenantExpireRemind/Job_TenantExpireSuspend),请确保任务调度启用;短信提醒需先配置SmsOptions:Templates对应场景 key - 标记职责不要混用: SaaS 下决定"是否系统库"的只有
IMainDbEntity;[Tenant("0")]只影响非 SaaS 模式与 SqlSugarScope 直查。系统库实体须三标记齐全([SugarTable]+[Tenant("0")]+IMainDbEntity),业务主库实体只加[Tenant("MainDb")] - 系统库 ConfigId 是常量
"0":--initdb建表与SqlSugarScope直查落默认连接0,配成其他值会导致建表库与运行时读库不一致。SystemDb配置项已废弃;SystemDb/BizMainDb/MallDb三项在单库部署下全部可省略(详见 3.5) - 库配置项的默认值: 系统库固定
"0"(不可配置)、BizMainDb未配置回退"0"、MallDb未配置回退"0";只有「业务主库/商城库独立建库」时才需要写配置项(旧键MainDb仍兼容BizMainDb),且对应ConfigId必须存在于dbConfigs
