# 多租户与产品版本

xly 是多租户 SaaS。同一套代码库、同一套数据库 schema、同一套元数据服务多个客户。框架通过三条相互独立的轴来强制租户边界，它们作用在不同层级。

真实模块中的贯穿示例见[切片 2](../slices/02-multi-tenancy.md)。本页是可从任何地方链接过来的规范摘要。

## 三条轴

| 轴 | 携带位置 | 粒度 | 事实来源 |
|---|---|---|---|
| **`sBrandsId`**（加工商ID） | 几乎每条业务行 | 每行 | 用户 session（`UserInfo.getsBrandsId()`） |
| **`sSubsidiaryId`**（子公司ID） | 几乎每条业务行 | 每行 | 用户 session |
| **`sVersionFlowId` / `sVersionFlowCode`**（版本流程ID / code） | 仅 `gdsmodule` | 每模块标签 | 版本目录元数据；运行时菜单可见性使用许可证产出的 `sVerifyLicense` 模块列表 |

逐行作用域在业务数据表中非常普遍：几乎每张业务数据表和视图都带有 `sBrandsId` 与 `sSubsidiaryId`。大多数框架元数据表也带有这两个列，但四张表（`gdsformconst`、`gdsmodule`、`gdsconfigformmaster`、`gdsconfigformslave`）是明确例外：`BusinessBaseServiceImpl.sTableNameList`（162-169 行）把它们列为“不需要公司子公司的表”，1078-1084 行会从这些表的写入载荷中剥掉 `sBrandsId` / `sSubsidiaryId`。实际使用中它们保存的是所有客户共享的一组哨兵租户值。惯例应理解为：如果一行代表租户拥有的状态，就有这两个列，且它们由 session 填充。

每模块版本元数据则相反：它只存在于 `gdsmodule` 上。实时运行时并不直接按 `sVersionFlowId` 过滤；模块发现由许可证产出的 `sVerifyLicense` 允许模块列表控制。因此版本控制发生在模块发现阶段，是一次性过滤，不是每行检查。

## 如何强制执行

每个已认证 REST 端点在请求到达后都会运行同一行：

```java
RequestAddParamUtil.me().addParams(params, userInfo);
```

见 `xlyPersist/.../RequestAddParamUtil.java`。这个 56 行工具类从 `UserInfo` 中取出用户身份，并向请求 `params` map 写入 16 个 key（`sBrandsId`、`sSubsidiaryId`、`sBrId`、`sSuId`、`sLoginId`、`sIpAddress`、`sComputeName`、`sUserId`、`userId`、`sLanguage`、`sUserType`、`sUserName`、`sMakePerson`、`sTeamId`、`sMachineId`，以及 `CURRENT_USER_LOGIN_TYPE`）。xlyApi 在 `xlyApi/.../api/util/RequestAddParamUtil.java` 中有几乎相同的 57 行副本。任何下游 MyBatis 查询只要引用 `#{sBrandsId}` 和 `#{sSubsidiaryId}`，就会自动受租户作用域约束。前端不能影响这些值；它们来自服务端 session，并通过 `@CurrentUser` 参数解析器进入。

## 失效模式

忘记按 `sBrandsId` 过滤的查询会返回所有租户的行，这是灾难性数据泄漏。需要关注三处：

1. **运行时拼动态 SQL 的存储过程**，开发者必须记得注入 `sBrandsId`。代码库中基本一致，但数据库不会强制。
2. **数据库视图。** 几乎所有 `viw_*` 视图都会从主基础表带出租户列，但手写视图如果漏掉这些列，就可能造成按行泄漏。维护审计脚本应标记此类视图。
3. **保存端点**（`addUpdateDelBusinessData`），它允许前端在 payload 中直接提供 `sTable`。如果运行时不校验该表是否属于表单授权范围，这就是权限提升面。见[切片 1](../slices/01-hello-world.md#4-user-edits-a-row-clicks-save)。

## 这个设计如何扩展，以及哪里扩展不了

框架的多租户设计靠**行数**扩展，而不是靠代码分支扩展。只有一个品牌、一个子公司、一种版本的小型 SaaS 部署，和拥有几十个品牌、几十个子公司、多个版本的部署，使用的是同一套 Java、MyBatis mapper 和存储过程；差异只体现在 `gdsmodule`、`sisversionflow` 和业务数据表中的行分布。

按行数扩展运维上简单，但限制也很明确：

- **共享物理 schema 意味着共享资源竞争。** 所有租户的查询都打到同一个 MySQL 实例、同一批表和同一批索引上。租户 A 的重报表会和租户 B 的录单竞争 buffer pool 和 CPU。这里没有按租户隔离资源。
- **每个 WHERE 都要带租户过滤。** 每条读查询都要携带 `sBrandsId = ? AND sSubsidiaryId = ?`。索引通常也要以这些列开头才有用；xly 大多数表按约定这样做，但维护人员新增索引时必须记得这一点。漏掉后，查询计划会扫过所有租户的行，并在表变大后悄悄变慢。
- **没有物理硬删除边界。** 租户下线不会 drop 一个数据库；行会留在原处，有的标记 `bInvalid`，有的被删除，有的完全不动。永久移除需要按租户写清理脚本。从 GDPR / 数据驻留角度看，“这个租户已经彻底消失”很难证明。
- **`sBrandsId` / `sSubsidiaryId` everywhere 固化了租户单位。** “租户”精确定义为 `(sBrandsId, sSubsidiaryId)` 这个二元组。其他切法（例如按区域授权、按部门授权但不拆子公司）不适合这个模型，需要并行的作用域列。这个模型假定该形状永远适合所有客户；目前实践上基本成立，但这是一个硬承诺。
