Multi-tenancy and product editions
xly is a multi-tenant SaaS. The same codebase, the same database schema, the same metadata serve many customers. The framework enforces tenancy through three independent axes, applied at different layers.
For the worked example of how this plays out for a real module, see Slice 2. This page is the canonical two-paragraph summary you can link from anywhere.
The three axes
| Axis | Carried in | Granularity | Source of truth |
|---|---|---|---|
sBrandsId (加工商ID) |
almost every business row | per-row | the user's session (UserInfo.getsBrandsId()) |
sSubsidiaryId (子公司ID) |
almost every business row | per-row | the user's session |
sVersionFlowId (版本流程ID) |
gdsmodule only |
per-module | the user's edition (against sisversionflow) |
Per-row scoping is universal across business-data tables: both
sBrandsId and sSubsidiaryId appear on essentially every one. Most
framework-metadata tables also carry the columns, but four of them
(gdsformconst, gdsmodule, gdsconfigformmaster, gdsconfigformslave)
are an explicit exception — BusinessBaseServiceImpl.sTableNameList
(lines 162-169) lists them as "不需要公司子公司的表" and lines 1078-1084
strip sBrandsId/sSubsidiaryId from the write payload for those
tables. In practice they hold a single sentinel tenant value shared
across all customers. Convention: "if a row represents tenant-owned
state, both columns are present and populated from the session."
Per-module gating (sVersionFlowId) is the opposite — it lives on
gdsmodule only. So edition gating is a one-time filter at module-
discovery time, not a per-row check.
How it's enforced
Every authenticated REST endpoint runs the same line right after the request arrives:
RequestAddParamUtil.me().addParams(params, userInfo);
— see xlyPersist/.../RequestAddParamUtil.java. This 56-line utility
pulls the user's identity out of UserInfo and writes 16 keys into the
request params map (sBrandsId, sSubsidiaryId, sBrId, sSuId,
sLoginId, sIpAddress, sComputeName, sUserId, userId,
sLanguage, sUserType, sUserName, sMakePerson, sTeamId,
sMachineId, plus CURRENT_USER_LOGIN_TYPE). xlyApi ships its own
near-identical 57-line copy at xlyApi/.../api/util/RequestAddParamUtil.java.
Every downstream MyBatis query that references
#{sBrandsId} and #{sSubsidiaryId} is automatically scoped. The
frontend cannot influence these — they come from the server-side session
via the @CurrentUser argument resolver.
Failure modes
A query that forgets to filter by sBrandsId returns rows from every
tenant. That's the catastrophic data-leak case. Three places to watch:
-
Stored procedures that compose dynamic SQL at runtime — the
developer must remember to inject
sBrandsId. The codebase does this consistently but not enforceably. -
Database views. Almost all
viw_*views select tenant columns from their primary base table, but a hand-rolled view that omits them is a per-row leak. A maintainer audit script that flags such views is a useful tool. -
The save endpoint (
addUpdateDelBusinessData), which lets the frontend supplysTabledirectly in the payload. If the runtime doesn't validate the supplied table against the form's authorised tables, this is a privilege-escalation surface. See Slice 1.
How the design scales
The framework's multi-tenancy design scales by row count, not by
code. A small SaaS deployment with one brand and one subsidiary uses
exactly the same Java, MyBatis mappers, and stored procedures as a
deployment with dozens of brands × dozens of subsidiaries × several
editions; only the row distributions in gdsmodule, sisversionflow,
and the business-data tables differ.