# 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`](../auto-catalog/tables/gdsformconst.md) | | **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`](../auto-catalog/tables/gdsmodule.md) | Tree of modules. Row `sId='13'` is our subject. | | 2. URL whitelist (client-side) | [`gdsroute`](../auto-catalog/tables/gdsroute.md) | 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`](../auto-catalog/tables/gdsconfigformmaster.md) | One row, `sParentId='13'`, declares the form: backing table = `gdsformconst`, type = `table`, default SQL/where/order, paging, jurisdiction. | | 4. Field layout | [`gdsconfigformslave`](../auto-catalog/tables/gdsconfigformslave.md) | 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](../reference/maintainer/deployment.md) 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 `sftlogininfojurisdictiongroup` ⋈ `sisjurisdictionclassify`). **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):** ```json { "addData": [{"sTable": "", "column": {"sId": "", ...}}], "updateData": [{"sTable": "
", "column": {"sId": "", ...}}], "delData": [{"sTable": "
", "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](../reference/maintainer/cache-invalidation.md). ### 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](../concepts/thesis.md) — why xly stores layouts as data. - [Modules, forms, virtual tables](../concepts/modules-forms-vtables.md) — the three core nouns. - [The metadata-driven request lifecycle](../concepts/request-lifecycle.md) — the metadata read + the five-key result map. - [Master / slave document pattern](../concepts/master-slave.md) — `gdsconfigformmaster`/`slave` is itself an instance of the pattern. - [No-FK, semantic-FK reality](../concepts/semantic-fk.md) — `gdsconfigformmaster.sParentId = gdsmodule.sId` is a semantic FK, not enforced. ## Reference entries this slice exercises **Builder track:** - [How to define a form](../reference/builder/define-form.md) — what a PM does to *create* the metadata rows we just read. - [How to set permissions](../reference/builder/permissions.md) — `gdsjurisdiction` participation. **Maintainer track:** - [The runtime: BusinessBaseController & friends](../reference/maintainer/runtime.md) — the controller and service layer that did the four-table read. - [Cache invalidation on metadata change](../reference/maintainer/cache-invalidation.md) — synchronous `@CacheEvict` (the JMS path serves a different purpose). - [Multi-service deployment](../reference/maintainer/deployment.md) — `xlyEntry` vs `xlyApi` vs `xlyInterface`; this slice runs entirely on `xlyEntry`. ## Open verification items Read flow live-corroborated; save flow still pending. 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 (partial).** Endpoint + URL + method live-corroborated: clicking 新增 → 保存 in BACK's 系统常量配置 fired `POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId=13` → 200 OK. (No DB mutation in our test because the audit-tag value change didn't propagate through the SPA's Vue model — the save ran on the original row state.) The exact request **body** the SPA sends is the next thing to capture; for now the documented shape rests on `BusinessBaseController.java:161-163` Javadoc. The SPA also fires `POST /business/addSysLocking` when entering edit mode (optimistic-lock) — see [internal API](../api-reference/internal.md#the-universal-crud-surface-business). 3. **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.