# 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": "<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 through the shared Redis store. The save path
calls `BusinessCleanRedisData.delCleanRedisData*`, which fires Spring
`@CacheEvict` synchronously in the BACK process. The similarly named JMS
`CHANGE_GDS_MODULE` path runs base-data merge procedures; it does not clear
Redis. 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.

## The save flow as a sequence

The full live-verified save round-trip, including the optimistic-lock
preamble and the synchronous cache-bust that follows the DB write:

```mermaid
sequenceDiagram
    autonumber
    participant SPA as Browser SPA
    participant CTRL as BusinessBaseController
    participant SVC as BusinessBaseServiceImpl
    participant CLEAN as BusinessCleanRedisData
    participant DB as MySQL (xlyweberp_*)
    participant REDIS as Redis (RedisCacheManager)

    Note over SPA: User clicks 修改 on the sReopen row,<br/>edits sChinese, clicks 保存
    SPA->>CTRL: POST /business/addSysLocking?sModelsId=13<br/>(optimistic-lock claim)
    CTRL-->>SPA: 200 OK
    SPA->>CTRL: POST /business/addUpdateDelBusinessData?sModelsId=13<br/>{addData:[],updateData:[{sTable:"gdsformconst",column:{sId,sChinese,...}}],delData:[]}<br/>Authorization: <bearer>
    Note over CTRL: AuthorizationInterceptor → UserInfo from Redis<br/>RequestAddParamUtil.addParams (16 keys incl. sBrandsId/sSubsidiaryId)
    CTRL->>SVC: addUpdateDelBusinessData(param)
    Note over SVC: per-row dispatch:<br/>add → addBusinessData → businessBaseDao.add<br/>update → updateBusinessData → businessBaseDao.update<br/>del → deleteBusinessData → businessBaseDao.del<br/>(sTable from frontend; NO whitelist check)
    SVC->>DB: INSERT/UPDATE/DELETE on the named sTable
    DB-->>SVC: rows affected
    Note over SVC: if sTable in sTableNameList<br/>(gdsformconst/gdsmodule/gdsconfigformmaster/<br/>gdsconfigformslave) → strip sBrandsId/sSubsidiaryId<br/>before write (4-table tenant-bypass)
    SVC->>CLEAN: delCleanRedisData(sTable, sIds, sBrandsId, sSubsidiaryId, "update")
    CLEAN->>REDIS: @CacheEvict over the affected cache regions<br/>(synchronous, same transaction)
    REDIS-->>CLEAN: evicted
    SVC-->>CTRL: Feedback{code:1,msg:"操作成功"}
    CTRL-->>SPA: AjaxResult{code:1,...}
    SPA->>CTRL: POST /business/getBusinessDataByFormcustomId/...<br/>(re-pull the grid; cache miss → fresh DB read)
    CTRL->>DB: SELECT ...
    DB-->>CTRL: rows
    CTRL-->>SPA: dataset
```

End-to-end live trace confirms: the SPA fires `addSysLocking` on
edit-mode entry, then `addUpdateDelBusinessData` on save, then a
follow-up grid re-fetch — three round-trips per save. The
`@CacheEvict` runs synchronously in the same transaction as the DB
write, so any node hitting Redis next sees the evicted region (see
[cache invalidation](../reference/maintainer/cache-invalidation.md)
for why this is Redis-backed and cross-node-coherent).

## 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 + 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](../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 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](#4-user-edits-a-row-clicks-save)
   above). Captured as a maintainer concern; mitigations exist (tenant
   scoping, optional pre/post-save proc validation) but the framework
   itself doesn't enforce.
