切片 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 端点在请求到达后都会调用:
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 调用中,每租户谓词:
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 的维护人员权限参考应持续强调这一点。
每版本过滤(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:
// 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模块列表控制;区分行级作用域和模块级可见性控制。
本切片使用的参考
-
运行时:
RequestAddParamUtil属于运行时章节,是通用租户上下文注入器。
待验证项
-
按版本过滤模块发现:定位代码路径。已关闭。 许可证驱动过滤位于xlyBusinessService/.../service/impl/MenuChildServiceImpl.java:38-65(getBuMenuSql),SQL 以AND m.sId in (#{sVerifyLicense})结束。sVerifyLicense来自VerifyLicense.getModelAllList()(TrueLicense 绑定),并通过RequestAddParamUtil(xlyApi)或 controller 级参数组装(xlyEntry)注入。见上方已修正的“模块如何按版本过滤”章节;之前把它说成sVersionFlowId过滤是错误的。 -
Activiti 工作流 /已关闭。 已记录在 Activiti 集成:Activiti 已接线但 idle;未部署 BPMN;框架实际工作流使用三条非 Activiti 路径(一步 proc + bCheck、单据链、已接线但当前硬禁用的 Activiti 分发)。sVersionFlowId不是工作流 id。 -
session 级租户解析:JWT / session 查找链路。已关闭。 链路(均在xlyBusinessService/.../web/token/下):AuthorizationInterceptor.preHandle用Authorizationheader 调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。