02-multi-tenancy.md 9.57 KB

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

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

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

三条作用域轴

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

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

每行作用域(sBrandsId + sSubsidiaryId

覆盖多广

几乎每张业务数据表和视图都带有 sBrandsIdsSubsidiaryId大多数框架元数据表也带有这两个列,但四张表(gdsformconstgdsmodulegdsconfigformmastergdsconfigformslave)是明确例外: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:sBrandsIdsSubsidiaryIdsBrId / sSuIdsLoginIdsUserIduserIdsUserTypesUserNamesMakePersonsLanguagesIpAddresssComputeNamesTeamIdsMachineIdCURRENT_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}

会加在每租户覆盖读取gdsconfigformpersonalizegdsconfigformcustomslave)以及业务状态读取sysbillnosettingssysreport,以及 JOIN sftlogininfojurisdictiongroupsysjurisdiction 用户授权;注意返回 map key 叫 gdsjurisdiction,但实际读取的是 sysjurisdiction)上。它不会加在框架基础元数据读取(gdsmodulegdsconfigformmastergdsconfigformslavegdsformconst)上;这些表是全局的,只按 form-id 过滤。同样谓词出现在代码库中几乎每个业务数据查询里。这是租户拥有状态的运行时边界;框架元数据有意全局共享。

失效模式

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

每版本过滤(sVersionFlowId

“版本”是什么意思

xly 以多个版本销售:基础版EBC-MDMEBC-SDEBC-RD 等。每个版本暴露不同模块集合。基础版客户看不到高级模块;高级客户看到基础版内容以及额外模块。

版本在哪里定义

版本定义在 sisversionflow 字典表中,每个版本一行。关键列:

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

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

模块如何按版本过滤(实际机制)

sVersionFlowId / sVersionFlowCodegdsmodule 行上的标签,用来标记每个模块属于哪个版本;但这两个列没有出现在任何 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)。因此每版本过滤真正发生在模块发现阶段,由许可证层完成,而不是通过 sVersionFlowIdsVersionFlowId / 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 属于运行时章节,是通用租户上下文注入器。

待验证项

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