# 多租户与产品版本 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)` 这个二元组。其他切法(例如按区域授权、按部门授权但不拆子公司)不适合这个模型,需要并行的作用域列。这个模型假定该形状永远适合所有客户;目前实践上基本成立,但这是一个硬承诺。