01-hello-world.md 12.6 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 (3,108 rows in dev)
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 gdsroute URL /xtclpz is registered here.
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 10 rows whose sParentId = the form's sId. Each row is one column: 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.

So gdsroute is the URL whitelist (a request to an unregistered path 404s), but the actual dispatch happens 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 gdsmodulegdsconfigformmastergdsconfigformslave (+ 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() 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 empty sSaveProName: the runtime takes the default Add/Update path implemented in xlyBusinessService/.../AddDelUpdCommonServiceImpl.java. That path generates parameterised INSERT/UPDATE/DELETE against the table named in the payload — no stored procedure invoked.

  • What happens when sSaveProName is non-empty: the runtime invokes the named stored procedure (the SQL templates under xlyEntry/src/main/resources/templates/templesql/sSaveProName.sql are 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 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-1827) reads it via dataList2.get(0).get("sTable").toString().toLowerCase() at line 1765, then dispatches to delete / update / add without cross-checking that the supplied table is one of the form's authorised backing tables. The hardcoded special case at line 1768 (mftproductionplanslave) is the only table-name 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

Two items still open; one is now closed.

  1. 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.
  2. The exact SQL emitted by save/delete, captured from syslog4j or a MyBatis debug log, to close the loop end-to-end.
  3. 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 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.