# 切片 2 — 多租户与产品版本

xly 是多租户 SaaS。同一套代码库、同一套数据库 schema、同一组元数据表服务多个客户；一个客户内部又可能有多个子公司；客户之间还有多个产品*版本*（基础版、EBC-MDM、EBC-SD、EBC-RD 等）。本切片追踪这些作用域如何被强制执行。

多租户是基础能力：做错了，一个客户就会读到另一个客户的订单。因此本切片也是 Wiki 中最与安全相关的章节之一。

## 三条作用域轴

| 轴 | 携带位置 | 粒度 | 用途 |
|---|---|---|---|
| **`sBrandsId`**（加工商ID） | 几乎每条业务行 | 每行 | “这行属于哪个加工商 / 公司？” |
| **`sSubsidiaryId`**（子公司ID） | 几乎每条业务行 | 每行 | “公司内哪个子公司？” |
| **`sVersionFlowId` / `sVersionFlowCode`**（版本流程ID / code） | 仅 `gdsmodule` | 每模块标签 | “这个模块标记为属于哪个产品版本？”运行时可见性由许可证产出的模块列表控制。 |

前两者是**每行**作用域。第三者是模块列表加载时的**每模块**过滤。机制不同，层级不同。

## 每行作用域（`sBrandsId` + `sSubsidiaryId`）

### 覆盖多广

几乎每张业务数据表和视图都带有 `sBrandsId` 与 `sSubsidiaryId`。**大多数框架元数据表也带有这两个列**，但四张表（`gdsformconst`、`gdsmodule`、`gdsconfigformmaster`、`gdsconfigformslave`）是明确例外：`BusinessBaseServiceImpl.sTableNameList`（162-169 行）把它们列为“不需要公司子公司的表”，1078-1084 行会从这些表的写入载荷中剥掉 `sBrandsId` / `sSubsidiaryId`。实际使用中它们保存的是所有客户共享的一组哨兵租户值。其他缺少其中一个或两个列的表，多为单租户共享字典或第三方 schema（`act_*`、`qrtz_*`）。

### 如何注入

每个已认证 REST 端点在请求到达后都会调用：

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

`RequestAddParamUtil` 位于 `xlyPersist/src/main/java/com/xly/utils/RequestAddParamUtil.java`（56 行，短且值得完整阅读）。它从 `UserInfo` 中取出认证用户身份，并向请求 `params` map 写入 16 个 key：`sBrandsId`、`sSubsidiaryId`、`sBrId` / `sSuId`、`sLoginId`、`sUserId`、`userId`、`sUserType`、`sUserName`、`sMakePerson`、`sLanguage`、`sIpAddress`、`sComputeName`、`sTeamId`、`sMachineId`、`CURRENT_USER_LOGIN_TYPE`。

xlyApi 在 `xlyApi/src/main/java/com/xly/api/util/RequestAddParamUtil.java` 中有几乎相同的 57 行副本；可视为跨两个服务复制的同一个工具。

任何下游 MyBatis 查询和存储过程调用，只要引用 `#{sBrandsId}` / `#{sSubsidiaryId}`，就会自动受作用域约束。前端不能影响这些值；它们来自服务端 session，经 `@CurrentUser` 参数解析器传入。

### 查询中如何体现

切片 1 的 `getModelBysId` 调用中，每租户谓词：

```sql
WHERE sBrandsId = #{sBrandsId} AND sSubsidiaryId = #{sSubsidiaryId}
```

会加在**每租户覆盖读取**（`gdsconfigformpersonalize`、`gdsconfigformcustomslave`）以及**业务状态读取**（`sysbillnosettings`、`sysreport`，以及 JOIN `sftlogininfojurisdictiongroup` 的 `sysjurisdiction` 用户授权；注意返回 map key 叫 `gdsjurisdiction`，但实际读取的是 `sysjurisdiction`）上。它不会加在框架基础元数据读取（`gdsmodule`、`gdsconfigformmaster`、`gdsconfigformslave`、`gdsformconst`）上；这些表是全局的，只按 form-id 过滤。同样谓词出现在代码库中几乎每个业务数据查询里。这是租户拥有状态的运行时边界；框架元数据有意全局共享。

### 失效模式

忘记按 `sBrandsId` 过滤的查询会返回所有租户的行，这是灾难性数据泄漏。切片 1 已经在保存端点上下文中提出该安全关注（前端直接提供 `sTable`）；同样问题会在任何漏掉租户谓词的存储过程或 MyBatis mapper 中重现。Wiki 的[维护人员权限参考](../reference/maintainer/runtime.md)应持续强调这一点。

## 每版本过滤（`sVersionFlowId`）

### “版本”是什么意思

xly 以多个版本销售：**基础版**、**EBC-MDM**、**EBC-SD**、**EBC-RD** 等。每个版本暴露不同模块集合。基础版客户看不到高级模块；高级客户看到基础版内容以及额外模块。

### 版本在哪里定义

版本定义在 `sisversionflow` 字典表中，每个版本一行。关键列：

| 列 | 值 | 含义 |
|---|---|---|
| `sId` | `17551378250008601172639655149000` | 版本主键 |
| `sCode` | `8S_001` | `gdsmodule.sVersionFlowCode` 引用的短码 |
| `sFlowName` | `基础版` | 显示名 |
| `bEbcErpPremium`, `bEbcMes`, `bEbcMesStandard`, `bSass` | flags | 该版本属于哪些产品变体 |

> **当前 dev DB 状态：** `sisversionflow` 目前只定义了一行：`8S_001 / 基础版`。`gdsmodule.sVersionFlowCode` 中出现的其他版本码（`EBC-SD-002`、`EBC-RD-007`、`EBC-MDM-002` 等）作为模块行标签存在，但在这里没有匹配的 `sisversionflow` 行。SaaS 生产租户很可能会填充完整版本目录；dev DB 没有。

### 模块如何按版本过滤（实际机制）

`sVersionFlowId` / `sVersionFlowCode` 是 `gdsmodule` 行上的标签，用来标记每个模块属于哪个版本；**但这两个列没有出现在任何 Java 源码或 MyBatis mapper 中**（已验证：`grep -r sVersionFlowId xly-src --include='*.java' --include='*.xml'` 在 mapper SQL 中没有命中）。运行时并不直接按这两个列过滤。

真正的控制点由许可证驱动：`xly-src/xlyBusinessService/.../license/`（TrueLicense + xly 的 `VerifyLicense.getModelAllList()`）返回租户许可证允许的模块 `sId` 列表。该列表会以 `sVerifyLicense` 形式逗号替换进菜单 SQL：

```java
// MenuChildServiceImpl.java:38-65 — getBuMenuSql
sql.append(" AND m.sId in ("+sVerifyLicense+")");
```

`sVerifyLicense` 要么由 xlyApi 的 `RequestAddParamUtil` 注入（50-52 行：`params.put("sVerifyLicense","'"+String.join("','",listModel)+"'")`），要么由 xlyEntry 中的 controller 手工组装参数（例如 `MobliePhoneController.java:57`）。因此每版本过滤真正发生在**模块发现阶段，由许可证层完成**，而不是通过 `sVersionFlowId`。`sVersionFlowId` / `sVersionFlowCode` 是给运维和 BACK 侧报表看的目录元数据；运行时控制链是 `sVerifyLicense` → 许可证产出的模块列表 → `IN (...)`。

`gdsmodule`（dev DB 中 1358 行）里有三种标签模式：

- **未标记行**（`sVersionFlowCode` 为空）：1002 行。框架内部模块和不受版本可见性控制影响的页面。
- **基础版标记行**（`sVersionFlowCode = '8S_001'`）：322 行。所有版本都会获得的通用核心。
- **版本特定行**：`EBC-SD-002`（15）、`EBC-RD-007`（6）、`EBC-MDM-002`（5）、`EBC_001`（4）、`EBC-SD-003`（2）、`EBC-SD-001`（1）、`EBC-COM-001`（1）。这些是由客户许可证控制可见性的增购模块。

补充：本 Wiki 使用的 `xlyweberp_saas_ai` 只有一个品牌（`sBrandsId = '1111111111'`）、一个子公司（同值）和一个填充版本（`8S_001`）。多租户机制已接好，但几乎没有压力测试。生产环境应预期几十个品牌 × 每品牌几十个子公司 × 多个版本，全部通过同样的每行过滤模式隔离。

## 本切片引入的概念

- *多租户作用域*：`sBrandsId` / `sSubsidiaryId` 是每行租户边界；框架的通用注入器是 `RequestAddParamUtil`。
- *产品版本*：`sVersionFlowId` / `sVersionFlowCode` 标记模块所属版本，但运行时模块可见性由许可证产出的 `sVerifyLicense` 模块列表控制；区分行级作用域和模块级可见性控制。

## 本切片使用的参考

- [运行时](../reference/maintainer/runtime.md)：`RequestAddParamUtil` 属于运行时章节，是通用租户上下文注入器。

## 待验证项

1. ~~**按版本过滤模块发现：定位代码路径。**~~ **已关闭。** 许可证驱动过滤位于 `xlyBusinessService/.../service/impl/MenuChildServiceImpl.java:38-65`（`getBuMenuSql`），SQL 以 `AND m.sId in (#{sVerifyLicense})` 结束。`sVerifyLicense` 来自 `VerifyLicense.getModelAllList()`（TrueLicense 绑定），并通过 `RequestAddParamUtil`（xlyApi）或 controller 级参数组装（xlyEntry）注入。见上方已修正的“模块如何按版本过滤”章节；之前把它说成 `sVersionFlowId` 过滤是错误的。
2. ~~**Activiti 工作流 / `sVersionFlowId` 不是工作流 id。**~~ **已关闭。** 已记录在 [Activiti 集成](../reference/maintainer/activiti.md)：Activiti 已接线但 idle；未部署 BPMN；框架实际工作流使用三条非 Activiti 路径（一步 proc + bCheck、单据链、已接线但当前硬禁用的 Activiti 分发）。
3. ~~**session 级租户解析：JWT / session 查找链路。**~~ **已关闭。** 链路（均在 `xlyBusinessService/.../web/token/` 下）：`AuthorizationInterceptor.preHandle` 用 `Authorization` header 调 `RedisTokenManager.getToken`（AES 解密 bearer，恢复 `(userId, sBrandsId, sSubsidiaryId, …)`），再由 `checkToken` 校验 Redis key `<sLoginType><userId>` 下缓存 token 并刷新 TTL。解析出的 `UserInfo` 通过 `@CurrentUser`（`CurrentUserMethodArgumentResolver`）传入 controller，随后 `RequestAddParamUtil.me().addParams(params, userInfo)` 在每个认证方法调用中注入 16 个 key：`sBrandsId`、`sSubsidiaryId`、`sBrId`、`sSuId`、`sLoginId`、`sIpAddress`、`sComputeName`、`sUserId`、`userId`、`sLanguage`、`sUserType`、`sUserName`、`sMakePerson`、`sTeamId`、`sMachineId`、`CURRENT_USER_LOGIN_TYPE`。
