---
name: v8-saas-multi-tenant
description: Microi V8 SaaS 多租户指南。用于处理 OsClient、OsClientType、OsClientNetwork、租户配置、V8.OsClientModel、隔离和租户感知代码。
---

> **Codex 非阻塞自动更新：** 当前宿主为 Codex 时，吾码 CLI、Codex 插件与工作区 AI/MCP 由后台自动更新；需要诊断时读取 `../microi-codex-installer/SKILL.md`。更新失败、等待空闲或尚未重载均不得阻断当前、正在进行或新建任务。非 Codex 宿主跳过此项。

# Microi V8 SaaS 多租户引擎

你正在为 Microi 吾码平台编写多租户（SaaS）相关代码。每个租户使用独立数据库账号；Redis、对象存储、RabbitMQ、MQTT 和搜索集群可以共享基础设施，但必须使用租户命名空间与独立服务凭据，**所有 V8 代码都在租户上下文中运行**。

## 核心概念

Microi 多租户 = **`OsClient` + `OsClientType` + `OsClientNetwork`** 三参数模型：

| 参数 | 说明 | 示例 |
|------|------|------|
| `OsClient` | 租户标识（系统Key） | `tenant_a`, `tenant_demo` |
| `OsClientType` | 租户类型 | `Normal` / `App` / `Wechat` |
| `OsClientNetwork` | 网络环境 | `Intranet`（内网）/`Outernet`（外网） |

主租户不是固定字符串 `master`，而是由当前部署的环境变量 `OsClient` 或 `AppSettings:OsClient` 决定。租户记录存放在受保护的 `sys_osclients` 表中；普通业务角色和普通 V8 不得直接查询、复制或修改该表。

## 上下文变量

```javascript
V8.OsClient            // 当前租户标识，如 'tenant_demo'
V8.OsClientType        // 'Normal' / 'App' / 'Wechat'
V8.OsClientNetwork     // 'Intranet' / 'Outernet'
V8.OsClientModel       // 当前租户 SaaS 配置的脱敏副本
V8.ClientModel         // OsClientModel 的兼容别名，同样是脱敏副本
V8.SysConfig           // 当前租户系统配置根对象；不存在 PublicSettings 属性
```

前端 V8 的 `V8.SysConfig` 是匿名 `GetSysConfig` 的独立脱敏 `sys_config` 副本；`mci_system_setting` 的任何记录都不会进入浏览器。后端接口引擎与后端 V8 事件的 `V8.SysConfig` 包含当前租户完整、独立的 `sys_config`，后端私密设置统一位于 `V8.SysConfig.ServerPrivateSettings`，其中 Secret 由可信后端解密。两端都不存在 `PublicSettings` 包装层。后端可使用 Secret，但严禁返回前端、写日志或写审计。子租户显式传其它 `OsClient` 仍会被强制改回当前租户。

### V8.OsClientModel 常用字段

```javascript
V8.OsClientModel.SysTitle              // 租户系统标题
V8.OsClientModel.DbType                // 非敏感数据库类型
V8.OsClientModel.HDFS                  // 'Aliyun' / 'MinIO' / 'S3'
V8.OsClientModel.AliOssPublicDomain    // 可公开的文件域名
// 当前租户自行扩展的业务字段只作存量兼容；新增公开配置使用 sys_config，后端私密参数使用 mci_system_setting

// 以下基础设施字段不会注入 V8：
// DbConn / DbReadConn / AuthSecret
// RedisHost / RedisPwd / MinIOSecretKey / AliOss*AccessKey*
// MQHost / MQUserName / MQPassword / MqttPwd / SearchEngineApiKey
```

## 平台级配置与主租户规则

主库 `sys_osclients` 保存租户数据库/Redis/存储路由及影响整个 API 进程的平台级配置；目标租户建立上下文后，`sys_config` 和 `mci_system_setting` 从该租户自己的数据库读取。平台级配置必须遵守“主租户为准、子租户只能降额隔离”的规则：

- 所有可变业务逻辑默认必须由接口引擎编排，包括但不限于租户开通、开库、初始化、归属修复、官网个人中心、付费额度等 SaaS 业务流程。C# 后端只暴露原子 V8 能力，例如建库、导入空库模板、复制 `sys_config`、刷新 SaaS 缓存、补偿回滚、字段兜底等；不要把可变业务分支写死到 Controller 或 `TenantProvisioningService` 这类后端定制代码里。接口引擎缺少能力时，优先扩展 `V8.Method`/V8 引擎原子函数，再由接口引擎调用。
- 官方租户开通固定由 SaaS 包的 `platform-create-tenant` 编排：`platform-runtime-custom-hook` 的 Before 阶段可以阻断，`AuthorizeCurrentUserTenantProvisioning` / `ProvisionCurrentUserTenant` 只从可信主租户 DiyToken 派生所有者与密码材料；创建完成后的 Hook/审计失败只能返回 Warning，不能诱导重复创建。系统账号偏好/资料与租户系统设置分别由 `app.microi.sys_user`、`app.microi.sys-config` 单一拥有，SaaS/Store 不得复制这些 Managed ApiEngineKey。
- 租户开库失败时，先核对编排 `OsClientDbConn` 的真实账号与权限。业务库账号通常不能开库、创建用户或授权；应使用已有正确授权的管理账号更新编排，不通过 SQL/MCP 手工修复业务数据库。管理员页面必须同时显示中文原因、失败阶段、可执行的处理方法，以及可展开且已脱敏的原始异常链；区分密码/来源认证失败与数据库权限不足，不能只显示笼统失败或隐藏驱动原始错误。
- 旧版 UniApp 的 `microi-init` 是 SaaS 包继续托管的 Managed 兼容门面。匿名阶段只组合 `platform-os-client-by-domain` 与 `platform-sys-config` 的公开投影；原始 DiyToken 必须由 `GetCurrentToken` 和 `RefreshLoginUser(..., rawToken)` 在后端重新验证且绑定当前租户，菜单再由只允许该固定 Key 调用的 `GetLegacyInitMenuTree(rawToken, osClient?)` 复用 `SysMenuLogic` 权威角色过滤。禁止在匿名 V8 中直接通过 FormEngine 读取 `sys_menu`；域名解析到其它租户时返回目标 `OsClient` 并要求重试，禁止匿名跨租户读取配置或菜单。
- 主租户由运行环境决定：优先读取环境变量 `OsClient`，其次读取 `appsettings.json` 的 `AppSettings:OsClient`。只有这条主租户 `sys_osclients` 数据中的平台级字段会作为全局配置生效。
- 同一个主租户可以同时运行在 `Product/Internal`、`Product/Internet` 等多个分区；租户目录、后台任务领取、通知和批量平台应用维护都必须按当前节点的 `OsClientType + OsClientNetwork` 隔离。一次全量维护只证明当前分区，事故恢复必须先盘点所有活跃分区，再从各分区对应节点分别执行并等待 `SucceededCount=ExpectedCount、FailedCount=0`，随后各自以新幂等键做零安装／零更新复跑。禁止临时改租户网络字段、跨分区借用任务或并发维护可能指向同一物理库的重复租户记录。
- 大范围启动事故只允许受信后台编排器使用 `MaintenanceScope=StartupDependencies`，精确闭包固定为 `app.microi.store + app.microi.saas-engine`，并可显式启用 `StartupDependencyBootstrapOnly=true`。编排器必须先用权威当前分区目录校验 `TargetOsClients`，再在子任务仍为 `Pending` 时原子写入并强回读两应用范围与只自举标志；失败即取消，禁止退化为完整安装。工作器只从官方不可变包补齐 `platform-sys-menu` 和六项 SaaS 启动门面，七项物理契约强回读通过后终止，不写安装版本。该模式只恢复可登录性，不代表 API 进程的完整运行时已经就绪；新版后端接收流量前还必须从九个内置官方基础应用包补齐并回读全部接口引擎，后续完整应用更新仍走普通商城任务。未带标志的任务行为不得改变。
- API 启动配置只有十项白名单：`OsClient`、`OsClientType`、`OsClientNetwork`、`OsClientDbType`、`OsClientDbConn`、`OsClientRedisHost`、`OsClientRedisPort`、`OsClientRedisPwd`、`OsClientRedisDataBase`、`OsClientDbMongoConn`。除这十项外，部署/节点级运行参数与基础设施秘密从主控 `sys_osclients` 读取；允许子租户自行维护且需要浏览器判断的业务开关、入口显示和公开交互配置使用该租户 `sys_config` 实体字段，OAuth/第三方集成的凭据、RP/Origin/Issuer/Scope 与仅后端参数使用 `mci_system_setting`，未配置时使用代码安全默认值。官方 License 恢复次数/间隔与固定私钥挂载 `/app/microi_private.pem` 是信任链例外。禁止再增加 `MICROI_*`、`DOS_ORM_*`、自定义 `AppSettings` 节点或动态名称的环境变量读取。节点身份由平台自动生成。
- 存量畅捷通/微信 OAuth C# 协议网关有一组已发布到 SaaS 引擎的兼容字段：`OAuthReturnUrlOrigins`、`ChanjetOAuthState`、`ChanjetAesKey`、`ChanjetAppKey`、`WeChatTemplateAppId`、`WeChatTemplateAppSecret`、`WeChatTemplateId`、`WeChatMiniProgramAppId`。只能通过 Core 的租户绑定协议设置原子按请求权威 `OsClient` 读取，禁止使用主租户 `ConfigHelper` RuntimeConfigurationReader；整组字段不得复制给新租户，其中 OAuthState/AES Key/AppKey/AppSecret 不得进入 `V8.OsClientModel` 或前端投影且必须在审计中掩码。新集成不得继续扩展该兼容集合，仍使用当前租户 `mci_system_setting` + Managed ApiEngine。
- `ASPNETCORE_*`、`DOTNET_*` 仅用于 .NET 宿主；构建、安装、测试、MCP、发布脚本可使用自身进程变量，但 API 生产代码不得把它们当业务配置。新增 SaaS 运行字段必须配套独立或既有 Tab、幂等升级、缓存刷新、敏感字段脱敏、子租户不继承和源码扫描测试。
- 文件上传的租户业务开关与额度按“当前租户 `sys_osclients` → 代码默认值”解析；现行负向字段 `DisableFileUpload` 默认关闭/空值即允许上传，只有 `1/true` 才禁止。旧 `FileUploadEnabled` 只在新物理字段尚不存在的滚动升级节点回退读取，并须在新版 SaaS 表单隐藏。平台固定灾难保护、HTTP/Multipart/Form 和反向代理上限不可由租户覆盖，也不要求安装者维护额外上传环境变量。
- 类似 MQTT 端口、PressureGuard、V8Limits、OrmLimits、StartupLimits、SecurityGuard 这类影响整进程资源的配置，不能让每个子租户各自抬高全局上限。子租户同名隔离字段只能降低自己的并发、等待时间或资源额度，用于隔离弱租户、试用租户或异常租户。
- 修改 `sys_osclients` 的表、字段、数据源或配置值后，必须刷新 SaaS 引擎运行缓存，并回读验证字段 `Component`、`Data`、`Config`、实际数据值和前端真实消费结果。不要只看 MCP 写入成功。
- SaaS 配置只在启动、管理员保存 `sys_osclients` 或显式租户刷新时发布到共享 Redis。初始化数据库会话、创建 `V8.Dbs` 运行态对象、普通 FormEngine 请求和表单设计器保存不得冒充配置变更反复发布。
- JWT Key 由可信后端生成并持久化；SaaS 表单只读显示配置状态，不允许普通表单或 V8 写入 `AuthSecret`。手动接入旧库的 NULL/空白值必须支持原子写回，多节点竞争后回读持久胜者；重复刷新不能轮换有效 Key。只读状态查询不得初始化目标租户数据库。验收只记录是否已配置、是否稳定，不输出密钥原文。
- 扩展库缓存必须区分“尚未加载”和“已加载但为 0 条”；后者是有效结果。没有配置 `microi_database` 的租户不能在每次 V8 执行时重复查询、调用 `AddOrUptClient` 或打印“缓存 OsClient 配置到 Redis”。
- 多节点的缓存失效订阅只做本节点失效与数据库回源，禁止收到消息后再次发布形成回声。进程内初始化标记仅是可丢失优化，真正租户配置仍以共享数据库/Redis 为准。
- 新增平台级字段时，字段名建议保持英文稳定，例如 `PressureGlobalMaxConcurrentRequests`、`PressureV8MaxConcurrentExecutions`、`PressureOrmMaxConcurrentConnectionOpens`；字段标签和说明必须中文，说明中写清楚“主租户有效/子租户仅可降低”。
- 新租户记录不得复制主租户的数据库、鉴权、Redis/对象存储密钥或 MQ/MQTT/Search 凭据。共享基础设施地址与管理密钥只在服务端运行时解析，不持久化到子租户记录，也不进入 V8 投影。
- RabbitMQ 子租户必须使用独立 user/vhost/ACL，MQTT 必须使用独立账号密码，Search 必须使用限制到 `{osClient}_*` 的 API Key/用户。外部资源尚未真实创建时保持空凭据并失败关闭，禁止把主租户管理员凭据复制过去冒充完成。
- `V8.Cache`、`V8.HDFS`、RabbitMQ、MQTT、Search 分别强制 `Microi:{OsClient}:*`、`/{osClient}/...`、`microi.{osClient}.*`、`tenant/{osClient}/...`、`{osClient}_*` 命名空间。body/query 中的 `OsClient` 不能覆盖登录 Token 或 V8 上下文。

## 接口调用区分租户的三种方式

### 方式 1：Token 自动识别（最常用）

请求头携带 `Token`，平台自动识别用户所属租户。

```bash
GET /apiengine/get-products
Token: xxx-token-xxx
```

### 方式 2：URL 参数

```bash
GET /apiengine/get-products?OsClient=tenant_demo
```

### 方式 3：特殊 URL 格式（无 Token、无参数）

```bash
GET /apiengine/get-products--OsClient--tenant_demo--
GET /apiengine/get-products--OsClient--tenant_demo--OsClientType--App--
```

> 适用于第三方回调（无法添加 Header）、支付/微信回调等场景。

## 跨租户操作（仅可信控制面）

普通 V8 的 `FormEngine`、`DataSourceEngine`、`TranslateEngine`、`WFEngine`、`Cache`、`HDFS`、MQ/MQTT、Search 和 `Dbs` 都必须绑定当前 Token/V8 上下文中的 `OsClient`。请求 body/query 里传入其它租户不能切换连接，也不能读取其它租户凭据。

租户开通、迁移、备份或平台管理员代维只能走明确的控制面服务/接口引擎：

- 调用者必须是 `Level >= 9999`，并再次校验目标租户白名单和操作类型；
- 使用专用原子能力，不通过通用 FormEngine 读取完整 `sys_osclients` 记录；
- 不把数据库、认证、Redis、存储、MQ/MQTT、搜索等连接与密钥投影进 V8；
- 每次操作写安全审计、幂等键和补偿状态，并对目标租户回读验收；
- 多节点部署使用共享租约和业务幂等，不能依赖进程静态锁。

### SaaS 数据库 ZIP 开库的进度与租约

通过 SaaS 引擎上传数据库 ZIP 创建租户时，必须把上传和还原建模为两个独立阶段：

- 上传使用分片会话并显示真实字节进度；暂停或网络中断后复用同一文件指纹续传，不能把上传百分比冒充数据库导入进度。
- 上传完成后提交平台持久后台任务。租户开通进度总数使用后端共享契约的固定 13 步，接口引擎、可信宿主原子和前端不得各自维护不同分母。
- 第 2 步是数据库 ZIP 校验与还原。该阶段应按真实 SQL 读取量推进总百分比，并显示已读取/总字节、SQL 百分比、已执行语句数、当前批次与语句类型、平均吞吐和动态预计剩余时间；不能长时间只显示静态的 `2 / 13`。
- 进度状态仅在距上次更新约 2 秒或新增约 16MB 数据时刷新；执行日志只记录阶段里程碑和 SQL 每跨过约 5% 的进度，前端只渲染最近 200 条。禁止按 SQL 行或每条语句推送日志，避免几十万条数据把共享任务表、SignalR 或浏览器 DOM 拖垮。
- 页面同时显示服务端心跳。单个百分比短时不变不等于卡死，应综合心跳、字节、语句数、批次和消息判断；失败、取消或中断必须保留最后真实进度和错误原因，只有成功终态才算开库完成。

租户开库使用平台可信后台上下文中的可续租共享锁：`Timeout` 是单次 Redis 租期，当前开库最长保护边界为 12 小时。普通 HTTP/V8 请求传入 `_BackgroundTaskId`、`_TrustedServerInvocation` 或伪造用户对象不能开启续租。持有者令牌不匹配、锁过期、Redis 所有权/续租确认失败或达到最长租约都必须失败关闭并停止推进；不能为了避免报错而把真实锁丢失降级成成功或 Warning。

自动续租只解决“正常长任务不因短 TTL 自然过期”，不能代替业务幂等、检查点、补偿状态和 fencing token。大库导入完成但域名绑定未完成时，应保留已经成功创建的租户和数据库，进入明确的域名补偿状态；不能因数据面域名失败重新导入整个数据库。接口引擎的通用锁配置读取 `../v8-api-config/SKILL.md`，长任务按钮和检查点读取 `../v8-menu-buttons/references/progressive-02-8-模式-f-后台任务按钮-长任务.md`。

## 缓存按租户隔离

```javascript
// 推荐传逻辑 Key；服务端自动添加当前租户前缀
var key = 'Product:' + V8.Param.id;
V8.Cache.Set(key, value, 600);

// 完整当前租户 Key 继续兼容
var fullKey = 'Microi:' + V8.OsClient + ':Product:' + V8.Param.id;

// 其它租户的 Microi: 前缀会被服务端拒绝
```

## 接口引擎中针对不同租户走不同逻辑

```javascript
// 租户差异应来自当前租户的非敏感业务配置，不要硬编码某个“主租户”字符串
if (V8.OsClientModel.OrderApprovalMode === 'Direct') {
  V8.FormEngine.UptFormData('Order', { Id: id, Status: 'Approved' });
} else {
  await V8.WFEngine.StartWork({ FlowDesignId: 'order-flow', TableRowId: id });
}

// App 端 vs PC 端不同返回
if (V8.OsClientType === 'App') {
  return { Code: 1, Data: simplifiedList };
}
return { Code: 1, Data: fullList };

// 内网外网走不同 ERP 网关
var erpUrl = (V8.OsClientNetwork === 'Intranet')
  ? 'http://192.168.1.10/erp/api'
  : 'https://erp.public.com/api';
```

## 第三方密钥放租户动态设置（不要硬编码）

```javascript
// ❌ 危险：密钥写在代码里，所有租户共用，无法独立轮换
var ak = 'AKIDxxxxxxxx';

// ✅ 浏览器需要判断的开关：使用 sys_config 实体字段
var giteeEnabled = V8.SysConfig.GiteeLoginEnabled === 1;

// ✅ 后端 V8 可读取当前租户 Secret 并直接调用供应商
var privateSettings = V8.SysConfig.ServerPrivateSettings || {};
var secret = privateSettings['Login.Gitee.ClientSecret'];
// 禁止 return secret、console.log(secret) 或写入前端可读字段。
```

> `mci_system_setting` 位于每个租户自己的数据库，只保存不能公开或仅供后端执行的配置；普通值与 Secret 都不会下发浏览器。Secret 保存认证密文，只在后端 V8 的当前租户 `ServerPrivateSettings` 中解密使用。能力开关和入口显示必须建成 `sys_config` 实体字段，禁止通过 `IsPublic` 或其它运行时勾选把私密记录公开。前端 V8、普通 FormEngine HTTP、匿名/访问密钥会话不能读取私密设置，后端 V8 也不能获得通用解密器。`sys_osclients` 自定义业务字段只保留存量兼容；共享基础设施字段由服务端强制移除，不能用自定义同义字段绕过安全代理。

## 用户扩展字段访问（同理）

平台 `sys_user` 也由表单引擎驱动。如给 `sys_user` 添加 `Wife` 字段：

```javascript
// V8 代码中可访问
V8.CurrentUser.Wife;

// SQL 数据源中可访问
SELECT * FROM Contact WHERE OwnerId = $CurrentUser.Id$ AND Spouse = $CurrentUser.Wife$
```

## 常见错误

- 旧库升级的内置应用包包含定时任务时，升级内核按“资源独立提交 → 幂等保存并回读 Quartz/任务元数据 → 确认安装版本”执行。仅程序集白名单包及不可伪造的宿主授权可进入该兼容流程；普通应用安装仍必须使用持久后台任务分片。任一阶段失败不推进 ServerVersion，修复后从原升级入口重试，不手动抬高版本或伪造任务信封。

❌ 绕开 `V8.Cache` 使用底层 Redis → 租户数据串号（V8 已不再暴露底层句柄）
❌ MongoDB DbName 不带 OsClient → 数据混淆  
❌ 把 OsClientModel 字段直接返回给前端 → 密钥泄漏  
❌ 查询 `mci_system_setting.SecretCipher` 或给 V8 暴露通用解密器 → 密钥边界失效  
❌ 在前端硬编码 OsClient → 一改全改，应通过 token/URL 自动识别  
❌ 跨租户操作不验证当前用户权限 → 越权风险  
❌ 子租户缺少 MQ/MQTT/Search 独立凭据时回退主账号 → 全平台越权

## 检查清单

- [ ] 缓存只通过 `V8.Cache` 使用逻辑 Key 或当前租户完整 Key
- [ ] 所有 MongoDB DbName 都包含 `V8.OsClient`
- [ ] 当前租户自有的业务集成密钥可放 `V8.OsClientModel`，共享 Redis/存储/MQ/MQTT/Search 凭据只能由服务端托管
- [ ] 跨租户操作前校验权限
- [ ] 不向前端返回 `V8.OsClientModel`
- [ ] 子租户数据库账号只授权本租户库，MQ/MQTT/Search 独立凭据已真实创建
- [ ] 文件、队列、Topic、索引均由服务端规范为当前租户命名空间
- [ ] 无扩展库租户重复执行 V8 时不会重复刷新 SaaS 配置；真实 `sys_osclients` 保存后各节点能按租户失效并回源
- [ ] ZIP 开库区分上传与还原进度，使用固定 13 步、低频明细、服务端心跳和有界日志
- [ ] 开库锁只由可信持久后台任务自动续租；真实租约丢失失败关闭，所有副作用仍具备幂等、检查点和 fencing token

## Microi.AI 中转站租户凭据

- `mic_ai` 的 `Microi.AI中转站.ApiKey` 是租户调用吾码官方中转站的用户凭据。运行时不得因为该字段为空而静默创建、回退到当前用户字段或绕过校验，否则管理员无法判断真实配置来源。
- 老租户由用户在吾码官网个人中心复制 ApiKey 后填写到本租户的 `mic_ai` 中转站记录；空值应返回明确配置错误。
- 官网创建新 SaaS 租户时，必须先取得当前官网账号的 ApiKey，再由租户初始化服务写入新库 `mic_ai` 的 `Microi.AI中转站` 记录。该自动写入只发生在受控开库流程，不得散落到普通 AI 对话请求中。
- 开库入口把 ApiKey 传给后台 worker 后，worker 还必须继续显式传入最终的原子初始化方法；不能只在父接口或中间参数中“带过”。每个异步/后台边界都应对空值立即失败，避免租户创建成功但 `mic_ai.ApiKey` 静默为空。
- 自动化验收必须通过新租户 `admin` 登录回读 `mic_ai`，只输出“非空、长度、与官网账号 ApiKey 是否一致”等布尔结果，不得把真实 ApiKey 写入日志、截图或测试报告。
- 中转模型公开目录只返回模型 Id、显示名、厂商等非敏感字段，可匿名读取；严禁把官方上游模型的 ApiKey、Endpoint 私密配置随模型列表返回前端。
