Slice 1 — a CRUD module (Hello World)
The simplest end-to-end action in xly: open a system module, look at the data, edit a row, save. We trace it from the URL bar all the way to the rows the runtime renders in the grid, hitting every layer in between.
This chapter is built from observed network traffic + matching source. Where something is still a hypothesis (e.g., the save path, which we haven't exercised yet), it's marked as such.
What we're documenting
| Module |
系统常量配置 (System Constants Config) |
| URL fragment | /xtclpz |
Module sId |
13 |
| Backing table | gdsformconst |
Form sId |
19211681019715574676360040 |
| Field count | 10 |
| Save / delete proc | (none — uses framework default) |
| Approval workflow | none (bCheck = 0) |
This module is itself part of the framework: it's the BACK page where a PM edits system constants. Documenting it is meta-perfect — we'll watch the framework configure the framework.
Why this module
Two reasons. First, sId = 13 makes it a foundational, top-of-tree module —
about as old as the system gets. Second, it has no custom procs: the
framework uses its default CRUD path for save/delete, which is the canonical
code-path we want a reader to learn first. Custom procs are Slice 2's job.
The four metadata layers
The runtime reads four metadata tables to render this page:
| Layer | Table | Meaning |
|---|---|---|
| 1. Module catalog | gdsmodule |
Tree of modules. Row sId='13' is our subject. |
| 2. URL whitelist (client-side) | gdsroute |
URL /xtclpz is registered here. The SPA consults this list to decide which sidebar/deep-link entries to render — the server does not 404 unregistered paths (it always serves the SPA shell). |
| 3. Form layout | gdsconfigformmaster |
One row, sParentId='13', declares the form: backing table = gdsformconst, type = table, default SQL/where/order, paging, jurisdiction. |
| 4. Field layout | gdsconfigformslave |
One row per field, sParentId = the form's sId. Each row carries control type, i18n labels, validation, default, dropdown SQL, etc. |
That four-table read is the lifecycle of every metadata-driven page in xly. Once you've internalised it, the rest of the framework is variations on a theme.
The trace, with observed evidence
0. Service shape
The BACK URL http://118.178.19.35:8597 is served by the xlyEntry WAR
(observed: every /business/* call has context-path /xlyEntry/). Earlier
hypotheses guessed xlyApi — that turns out to be wrong; xlyApi is a
sibling WAR with its own application.yml and context-path /xlyApi, and
serves a different role. Maintainer chapter on
deployment will cover the split.
1. Browser → server (page shell)
GET /xtclpz
The URL /xtclpz is one entry in gdsroute, but at runtime it functions as
display state, not a route driver. Navigating directly to
http://118.178.19.35:8597/xtclpz after login does not deeplink to the
"System Constants Config" module — it loads the same SPA shell that any other
URL would load, and shows the default landing module (系统模块配置). The
sidebar then has to be clicked for the SPA to switch modules.
Worth correcting an earlier hypothesis here: gdsroute is not enforced
server-side. A direct probe of an unregistered path
(GET /xtclpz_NOT_A_REAL_ROUTE) returns the same 200 + SPA shell as the
registered /xtclpz. The whitelist is a client-side construct: the SPA
reads gdsroute to know which sidebar links / deep-links it is willing
to mount, but the static-file server serves the SPA shell for any path.
The actual dispatch happens entirely client-side once the SPA is
loaded.
2. User clicks the sidebar item; SPA fetches metadata
When the user clicks 系统常量配置 in the sidebar:
GET /xlyEntry/business/getModelBysId/13?sModelsId=13 → 200 OK
- Path variable and query param are both
13— the module'ssIdingdsmodule. The handler isBusinessBaseController.getModelBysId()atxlyEntry/src/main/java/com/xly/web/businessweb/BusinessBaseController.java:63-69.
Naming caution: the parameter is called
sModelsIdand the helper is calledgetModelConfigByModleId("Modle", a typo for "Model"). Both actually take a modulesId. The original Javadoc on line 60 says "传入窗体sId" (passes form sId), which is incorrect. Throughout the codebase,Models / Modle / Modelare used interchangeably to mean "module"; treat them as one concept while reading.
The implementation (BusinessBaseServiceImpl.getModelBysId() at
xlyBusinessService/src/main/java/com/xly/service/impl/BusinessBaseServiceImpl.java:181-211)
returns a single composite map with five keys:
| Key | Source | What it contains |
|---|---|---|
formData |
gdsmodule ⋈ gdsconfigformmaster ⋈ gdsconfigformslave (+ personalize) |
The form layout — the spine. |
gdsformconst |
gdsformconst rows scoped by sBrandsId/sSubsidiaryId/language |
Form-level constants — labels, defaults, dropdown text. |
gdsjurisdiction |
gdsjurisdiction rows for the user's role |
Per-button and per-data permissions. Skipped for ADMIN users (line 196-198) — admins see everything. |
billnosetting |
sysbillnosettings row for this module |
Document-numbering rule (irrelevant for gdsformconst but always loaded). |
report |
print templates linked to this form | Printable-report definitions, if any. |
Multi-tenancy is enforced inside this read: every sub-query is scoped by
sBrandsId (manufacturer) and sSubsidiaryId (subsidiary), pulled from the
authenticated user's session via RequestAddParamUtil.me().addParams().
Tenants do not see each other's metadata.
3. SPA → server (initial data load)
With the metadata in hand, the SPA renders the empty grid and asks for rows:
POST /xlyEntry/business/getBusinessDataByFormcustomId/19211681019715574676360040?sModelsId=13 → 200 OK
- Path variable: the form's
sId(19211681019715574676360040). - Query param: the module's
sId(13). - Handler:
BusinessBaseController.getBusinessDataByFormcustomId()atxlyEntry/.../BusinessBaseController.java:105-123.
The handler branches on whether sGroupList is in the request body (line 113):
no group-by → getBusinessDataByFormcustomId; group-by present →
getBusinessDataByGroup. Slice 1's module (gdsformconst) hits the simple path.
The SQL is composed from gdsconfigformmaster.sSqlStr + sWhere + sOrder,
parameterised with the user's tenant and language, then run through MyBatis.
The same path also handles edit-row "fetch one" by detail-id when sId is
in the body.
4. User edits a row, clicks save
The "save" button (and the "delete" button, and the "add" button) all funnel through one universal endpoint:
POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId={moduleId}
Handler:
BusinessBaseController.addUpdateDelBusinessData()atxlyEntry/.../BusinessBaseController.java:165-177. It delegates toBusinessBaseService.addUpdateDelBusinessData(params).Request body shape (per the Javadoc on lines 161-163):
{
"addData": [{"sTable": "<table>", "column": {"sId": "", ...}}],
"updateData": [{"sTable": "<table>", "column": {"sId": "", ...}}],
"delData": [{"sTable": "<table>", "column": {"sId": "", ...}}]
}
All three operations are bundled atomically. The frontend tells the
backend which table and which columns to write — there's no
per-module write-API; the metadata-driven UI generates the payload from
gdsconfigformmaster.sTbName and the gdsconfigformslave field list.
What the framework does with empty
sSaveProName: the runtime takes the default Add/Update path implemented inxlyBusinessService/.../AddDelUpdCommonServiceImpl.java. That path generates parameterisedINSERT/UPDATE/DELETEagainst the table named in the payload — no stored procedure invoked.What happens when
sSaveProNameis non-empty: the runtime invokes the named stored procedure (the SQL templates underxlyEntry/src/main/resources/templates/templesql/sSaveProName.sqlare the scaffold engineers fill in when authoring such procs). That path is exercised by Slice 2 (workflow), not this one.
Open verification (would require an actual save): capturing the live request body, the response body, and the resulting SQL in
syslog4jend-to-end. We chose not to exercise this against the shared dev DB to avoid mutating the framework's own constants table. The endpoint, handler, and payload shape are confirmed from source; only the live evidence trail is open.Architectural note (security-relevant — confirmed open at the time of writing): the frontend supplies
sTabledirectly in the payload, andBusinessBaseServiceImpl.addUpdateDelBusinessData(lines 1738-1864) reads it viadataList2.get(0).get("sTable").toString().toLowerCase()at line 1765, then dispatches to delete / update / add. The class-levelsTableNameList(BusinessBaseServiceImpl.java:162-169— onlygdsformconst,gdsmodule,gdsconfigformmaster,gdsconfigformslave) is consulted by some branches (e.g., line 1078, 1338, 1423, 1464) but only as a cache-invalidation gate, not as a "is this table authorised for this form?" gate. The hardcoded special case at line 1768 (mftproductionplanslave) is the only module/table cross-check in the whole flow. Mitigations that exist:
- Multi-tenant scope (
sBrandsId/sSubsidiaryId) is auto-injected, so cross-tenant writes are still blocked.- A module's
sSaveProNameBeforeandsSaveProNameprocs can validate the target table, but only if their author wrote that check.- Permission rules in
gdsjurisdictionare button-level, not table-level.Net: the universal save endpoint trusts the frontend's
sTablevalue. Whether this is acceptable depends on what the SPA can be coerced to send; a forged request from an authenticated low-privilege user could attempt writes against any table sharing the user's tenant. Worth a maintainer ticket.
5. Cache invalidation
A modified gds* row in any of the four metadata tables invalidates cached
copies on every running node. xly does this through a JMS message:
xlyErpJmsConsumer/.../ConsumerChangeGdsModuleThread.java listens for the
"module changed" event and clears the relevant Redis keys. See
Cache invalidation on metadata change.
6. Browser confirms
The save returns success; the front-end either patches the row in place or
re-pulls the grid via the same getBusinessDataByFormcustomId endpoint.
End of trace.
Concepts this slice introduces
- The data-driven thesis — why xly stores layouts as data.
- Modules, forms, virtual tables — the three core nouns.
- The metadata-driven request lifecycle — the four-table read + the three-key result map.
-
Master / slave document pattern —
gdsconfigformmaster/slaveis itself an instance of the pattern. -
No-FK, semantic-FK reality —
gdsconfigformmaster.sParentId = gdsmodule.sIdis a semantic FK, not enforced.
Reference entries this slice exercises
Builder track:
- How to define a form — what a PM does to create the metadata rows we just read.
-
How to set permissions —
gdsjurisdictionparticipation.
Maintainer track:
- The runtime: BusinessBaseController & friends — the controller and service layer that did the four-table read.
- Cache invalidation on metadata change — JMS-driven Redis flush.
-
Multi-service deployment —
xlyEntryvsxlyApivsxlyInterface; this slice runs entirely onxlyEntry.
Open verification items
Two items still open; one is now closed.
- A live captured save. The endpoint, handler, and payload shape are confirmed from source; the body of an actual save request hasn't been captured. This requires writing to the dev DB and we deferred it. Closing this means: open the module, click 新增, fill the form, click 保存, capture the JSON body and the response.
-
The exact SQL emitted by save/delete, captured from
syslog4jor a MyBatis debug log, to close the loop end-to-end. -
CLOSED — the runtime does not cross-check the frontend-suppliedsTablevalidation inaddUpdateDelBusinessData.sTableagainst the form's authorised backing tables (see the security note in step 4 above). Captured as a maintainer concern; mitigations exist (tenant scoping, optional pre/post-save proc validation) but the framework itself doesn't enforce.
Items 1 and 2 belong to a Slice-1 v2 pass and require an actual save against the dev DB — deferred to avoid mutating shared dev state.