# 切片 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 `` 下缓存 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`。