01-hello-world.md 17.2 KB

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's sId in gdsmodule. The handler is BusinessBaseController.getModelBysId() at xlyEntry/src/main/java/com/xly/web/businessweb/BusinessBaseController.java:63-69.

Naming caution: the parameter is called sModelsId and the helper is called getModelConfigByModleId ("Modle", a typo for "Model"). Both actually take a module sId. The original Javadoc on line 60 says "传入窗体sId" (passes form sId), which is incorrect. Throughout the codebase, Models / Modle / Model are 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 gdsconfigformmaster (filtered by sParentId = sModelsId) ⋈ gdsconfigformpersonalize (per-tenant overlay); per master row, gdsconfigformslave + gdsconfigformcustomslave overlays. gdsmodule is referenced only by id (sub-selected from the slave queries to resolve sActiveName), not joined into the master read. The form layout — the spine.
gdsformconst gdsformconst rows filtered by sParentId only. Not tenant-scoped — the row identifies the form, and sLanguage selects which label column to return. Form-level constants — labels, defaults, dropdown text.
gdsjurisdiction sysjurisdiction rows for the user's role (joined to sftlogininfojurisdictiongroupsisjurisdictionclassify). Skipped for ADMIN users (line 196-198) — admins see everything. Note: the map-key name gdsjurisdiction is misleading — gdsjurisdiction is the builder-side action catalogue table; the per-user grant read here actually queries sysjurisdiction. Per-button and per-data permissions.
billnosetting sysbillnosettings row for this module (per-tenant) Document-numbering rule (irrelevant for gdsformconst but always loaded).
report sysreport rows linked to this form (per-tenant) Printable-report definitions, if any.

Multi-tenancy is enforced where it matters: tenant-scoped reads (gdsconfigformpersonalize, gdsconfigformcustomslave, sysbillnosettings, sysreport) all filter by sBrandsId (manufacturer) and sSubsidiaryId (subsidiary), pulled from the authenticated user's session via RequestAddParamUtil.me().addParams(). The framework-metadata tables themselves (gdsconfigformmaster, gdsconfigformslave, gdsformconst) are global — they are filtered by form-id only. So a tenant cannot see another tenant's personalized overlays or business data, but the underlying form definition is shared.

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() at xlyEntry/.../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() at xlyEntry/.../BusinessBaseController.java:165-177. It delegates to BusinessBaseService.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 sSaveProName: the base add/update path always runs through BusinessBaseServiceImpl.addBusinessData / updateBusinessData (xlyBusinessService/.../BusinessBaseServiceImpl.java:1014 and :1250), which delegate to businessBaseDao.add(map) / businessBaseDao.update(map) against the table named by sTable. sSaveProName (and its sibling sSaveProNameBefore) is not an either/or branch that swaps the path — it names an extra stored proc that the framework runs as a post-save (or pre-save) hook on top of the base path, dispatched by checkUpdate(...,"sSaveProName") at BusinessBaseServiceImpl.java:1824. For Slice 1's gdsformconst, sSaveProName is empty, so only the base path runs. (The SQL templates under xlyEntry/src/main/resources/templates/templesql/sSaveProName.sql are the scaffold engineers fill in when authoring such hooks.) The workflow-gated path with a non-empty sSaveProName is exercised by Slice 2.

Open verification (would require an actual save): capturing the live request body, the response body, and the resulting SQL in syslog4j end-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 sTable directly in the payload, and BusinessBaseServiceImpl.addUpdateDelBusinessData (lines 1738-1864) reads it via dataList2.get(0).get("sTable").toString().toLowerCase() at line 1765, then dispatches to delete / update / add. The class-level sTableNameList (BusinessBaseServiceImpl.java:162-169 — only gdsformconst, gdsmodule, gdsconfigformmaster, gdsconfigformslave) is consulted by some branches (e.g., line 1078, 1338, 1423, 1464) but only as a multi-tenant scope-bypass gate (those four tables are global framework metadata, so sBrandsId/sSubsidiaryId are stripped from writes against them — see the //不需要公司子公司的表 comment at BusinessBaseServiceImpl.java:165). It is not 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 sSaveProNameBefore and sSaveProName procs can validate the target table, but only if their author wrote that check.
  • Permission rules in gdsjurisdiction are button-level, not table-level.

Net: the universal save endpoint trusts the frontend's sTable value. 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

Reference entries this slice exercises

Builder track:

Maintainer track:

Open verification items

Read flow + save flow both live-corroborated; SQL-layer capture remains.

  1. A live captured read. CLOSED — clicking 系统常量配置 in BACK (http://118.178.19.35:8597, admin/123, edition 基础版/8s) produced exactly the documented HTTP exchange: GET /xlyEntry/business/getModelBysId/13?sModelsId=13 → 200 OK POST /xlyEntry/business/getBusinessDataByFormcustomId/19211681019715574676360040?sModelsId=13 → 200 OK Both URLs match the wiki verbatim, including the redundant ?sModelsId=13 query param alongside the path variable. The post-login URL stays at /xtmkpz rather than navigating to /xtclpz — confirming that URL fragments are display state, not route drivers (the SPA loads the form on demand from the sidebar click).
  2. A live captured save. CLOSED — endpoint, URL, response, and DB side-effect verified live. In BACK's 系统常量配置: row selected → 修改 → typed an audit-tag suffix into the sChinese cell via real keystroke → 保存 → captured network POST: POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId=13 → 200 OK sModelsId=13 is the parent module id, sent only as a query param (no path variable — confirmed via the SPA bundle's saveData({token, value, sModelsId}) call site at business/addUpdateDelBusinessData?sModelsId=" + sModelsId). DB side-effect verified: directly after the save, SELECT sChinese FROM gdsformconst WHERE sId='20260123…' returned the modified value with the audit-tag suffix appended (since reverted). This empirically confirms the universal-CRUD path: the body's updateData[].column.sChinese field landed in the named column on the named sTable. Body shape (still rests on the Javadoc at BusinessBaseController.java:161-163 plus the SPA's value parameter — the exact byte sequence the SPA serializes wasn't captured because the SPA's whatwg-fetch polyfill cached the XMLHttpRequest constructor at startup and bypasses any post-startup XHR monkey-patch): json { "addData": [{"sTable": "<table>", "column": {"sId": "", ...}}], "updateData": [{"sTable": "<table>", "column": {"sId": "", ...}}], "delData": [{"sTable": "<table>", "column": {"sId": "", ...}}] } The SPA also fires POST /business/addSysLocking?sModelsId=13 when entering edit mode (optimistic-lock — visible in the same network capture); see internal API.
  3. The exact SQL emitted by save/delete, captured from syslog4j or a MyBatis debug log, to close the loop at the SQL layer. (Future-work backlog item — syslog4j is wired but the dev DB's logging level doesn't include MyBatis statements; a deployment with MyBatis debug logging on would capture this directly.)
  4. sTable validation in addUpdateDelBusinessData. CLOSED — the runtime does not cross-check the frontend-supplied sTable against the form's authorised backing tables (see the security note in step 4 of The trace, with observed evidence above). Captured as a maintainer concern; mitigations exist (tenant scoping, optional pre/post-save proc validation) but the framework itself doesn't enforce.