# 切片 1 — CRUD 模块(Hello World)
xly 中最简单的端到端动作:打开一个系统模块、查看数据、编辑一行、保存。我们从 URL 栏一直追踪到运行时渲染到表格中的行,中间经过每一层。
本章基于观察到的网络流量和匹配源码。仍为假设的内容(例如尚未实际执行的保存路径)会明确标注。
## 记录对象
| | |
|---|---|
| **模块** | `系统常量配置`(System Constants Config) |
| **URL 片段** | `/xtclpz` |
| **模块 `sId`** | `13` |
| **支撑表** | [`gdsformconst`](../auto-catalog/tables/gdsformconst.md)(dev 中 2,986 行) |
| **表单 `sId`** | `19211681019715574676360040` |
| **字段数** | 10 |
| **保存 / 删除过程** | 无,使用框架默认路径 |
| **审批工作流** | 无(`bCheck = 0`) |
这个模块本身就是框架的一部分:它是 PM 编辑系统常量的 **后台** 页面。记录它具有“元”意义:我们会看到框架如何配置框架。
## 为什么选这个模块
第一,`sId = 13` 说明它是靠近根部的基础模块。第二,它**没有自定义过程**:保存 / 删除使用框架默认 CRUD 路径,这是读者应先学会的规范代码路径。自定义过程属于后续切片。
## 四个元数据层
运行时读取四张元数据表来渲染页面:
| 层 | 表 | 含义 |
|---|---|---|
| 1. 模块目录 | [`gdsmodule`](../auto-catalog/tables/gdsmodule.md) | 模块树。`sId='13'` 是本切片对象。 |
| 2. URL 白名单(客户端侧) | [`gdsroute`](../auto-catalog/tables/gdsroute.md) | URL `/xtclpz` 在这里注册。SPA 读取该列表决定渲染哪些侧边栏 / deep-link;服务端不会对未注册路径返回 404。 |
| 3. 表单布局 | [`gdsconfigformmaster`](../auto-catalog/tables/gdsconfigformmaster.md) | 一行 `sParentId='13'` 声明表单:支撑表、类型、默认 SQL / where / order、分页和权限。 |
| 4. 字段布局 | [`gdsconfigformslave`](../auto-catalog/tables/gdsconfigformslave.md) | 10 行,`sParentId` 等于表单 `sId`。每行是一个字段:控件类型、多语言标签、校验、默认值、下拉 SQL 等。 |
这次四表读取就是 **xly 每个元数据驱动页面的生命周期**。理解它之后,框架其余部分只是变体。
## 追踪与证据
### 0. 服务形状
**后台** URL `http://118.178.19.35:8597` 由 **`xlyEntry`** WAR 提供(观察:每个 `/business/*` 调用的 context-path 都是 `/xlyEntry/`)。早期猜测是 `xlyApi`,这是错误的;`xlyApi` 是兄弟 WAR,有自己的 `application.yml` 和 context-path `/xlyApi`,角色不同。部署章节会覆盖二者拆分。
### 1. 浏览器 → 服务端(页面 shell)
```text
GET /xtclpz
```
`/xtclpz` 是 `gdsroute` 中的一项,但运行时它只是**显示状态**,不是路由驱动。登录后直接访问该 URL,不会深链到“系统常量配置”模块,而是加载与其他 URL 相同的 SPA shell,并显示默认落地模块。用户仍需点击侧边栏让 SPA 切换模块。
需要修正的早期假设:`gdsroute` **不在服务端强制**。探测未注册路径 `GET /xtclpz_NOT_A_REAL_ROUTE` 也返回同一 200 + SPA shell。白名单是客户端构造:SPA 读取 `gdsroute` 判断愿意挂载哪些侧边栏 / deep-link;真正分发发生在 SPA 加载后。
### 2. 用户点击侧边栏,SPA 拉取元数据
```text
GET /xlyEntry/business/getModelBysId/13?sModelsId=13 → 200 OK
```
路径变量和 query 参数都是 `13`,即 `gdsmodule` 中的**模块 `sId`**。handler 是 `BusinessBaseController.getModelBysId()`。
> **命名提醒:** 参数叫 `sModelsId`,helper 叫 `getModelConfigByModleId`(Modle 是 Model 拼写错误),但它们实际都接收模块 `sId`。原 Javadoc 写“传入窗体sId”,也是错的。阅读代码时可把 `Models / Modle / Model` 统一理解为“module”。
`BusinessBaseServiceImpl.getModelBysId()` 返回一个包含五个 key 的复合 map:
| Key | 来源 | 内容 |
|---|---|---|
| `formData` | `gdsconfigformmaster`(按 `sParentId = sModelsId` 过滤)⋈ `gdsconfigformpersonalize`(每租户覆盖);每个 master 行再加载 `gdsconfigformslave` + `gdsconfigformcustomslave` 覆盖。`gdsmodule` 只通过 id 引用(slave 查询会用它解析 `sActiveName`),不参与 master 读取。 | 表单布局主干 |
| `gdsformconst` | 仅按 `sParentId` 过滤的 `gdsformconst` 行。**不按租户过滤**;该行识别表单,`sLanguage` 决定返回哪列标签。 | 表单级常量、标签、默认值、下拉文本 |
| `gdsjurisdiction` | 用户角色的 `sysjurisdiction` 行(JOIN `sftlogininfojurisdictiongroup` ⋈ `sisjurisdictionclassify`)。`ADMIN` 用户跳过,管理员看到全部。**注意:** map key 叫 `gdsjurisdiction` 有误导性;`gdsjurisdiction` 是配置侧动作目录,实际每用户授权来自 `sysjurisdiction`。 | 按钮和数据权限 |
| `billnosetting` | 该模块的 `sysbillnosettings` 行(每租户) | 单据编号规则;对 `gdsformconst` 无关但总会加载 |
| `report` | 关联到该表单的 `sysreport` 行(每租户) | 打印报表定义 |
**多租户**在需要的地方执行:租户作用域读取(`gdsconfigformpersonalize`、`gdsconfigformcustomslave`、`sysbillnosettings`、`sysreport`)都会按认证 session 注入的 `sBrandsId` 和 `sSubsidiaryId` 过滤。框架基础元数据表(`gdsconfigformmaster`、`gdsconfigformslave`、`gdsformconst`)是全局的,只按 form-id 过滤。因此租户看不到其他租户的*个性化覆盖*或*业务数据*,但底层表单定义是共享的。
### 3. SPA → 服务端(初始数据加载)
```text
POST /xlyEntry/business/getBusinessDataByFormcustomId/19211681019715574676360040?sModelsId=13 → 200 OK
```
路径变量是**表单 `sId`**,query 参数是**模块 `sId`**。handler 是 `BusinessBaseController.getBusinessDataByFormcustomId()`。
handler 根据请求 body 是否包含 `sGroupList` 分支:没有 group-by 就走 `getBusinessDataByFormcustomId`,有则走 `getBusinessDataByGroup`。本模块走简单路径。
SQL 由 `gdsconfigformmaster.sSqlStr` + `sWhere` + `sOrder` 组合,并带入用户租户和语言参数后通过 MyBatis 执行。同一路径也处理编辑时按明细 ID 拉取一行。
### 4. 用户编辑一行并点击保存 {#4-user-edits-a-row-clicks-save}
保存、删除、新增都进入同一通用端点:
```text
POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId={moduleId}
```
请求体形状:
```json
{
"addData": [{"sTable": "
", "column": {"sId": "", "...": "..."}}],
"updateData": [{"sTable": "", "column": {"sId": "", "...": "..."}}],
"delData": [{"sTable": "", "column": {"sId": "", "...": "..."}}]
}
```
三种操作打包成一个原子请求。前端告诉后端要写**哪张表**和**哪些列**;没有每模块专用写 API,元数据驱动 UI 根据 `gdsconfigformmaster.sTbName` 和 `gdsconfigformslave` 字段列表生成 payload。
- `sSaveProName` 的作用:基础新增 / 更新路径总是通过 `BusinessBaseServiceImpl.addBusinessData` / `updateBusinessData`(`xlyBusinessService/.../BusinessBaseServiceImpl.java:1014` 和 `:1250`),再委托 `businessBaseDao.add(map)` / `businessBaseDao.update(map)`,对 `sTable` 命名的表执行操作。`sSaveProName`(及其兄弟列 `sSaveProNameBefore`)**不是**替换基础路径的二选一分支;它命名的是叠加在基础路径之上的额外存储过程钩子:保存后 / 保存前由 `BusinessBaseServiceImpl.java:1824` 的 `checkUpdate(...,"sSaveProName")` 分发。切片 1 的 `gdsformconst` 中 `sSaveProName` 为空,因此只运行基础路径。`xlyEntry/src/main/resources/templates/templesql/sSaveProName.sql` 是工程师编写这类钩子时使用的脚手架。非空 `sSaveProName` 的工作流闸门路径由切片 2 涉及。
> **开放验证(需要真实保存):** 仍需捕获实时请求 body、响应 body,以及 `syslog4j` 中的实际 SQL,才能完全闭环。
> **安全相关架构备注:** 前端在 payload 中直接提供 `sTable`。`BusinessBaseServiceImpl.addUpdateDelBusinessData` 会读取该值并分发删除 / 更新 / 新增。类级 `sTableNameList`(`BusinessBaseServiceImpl.java:162-169`,只含 `gdsformconst`、`gdsmodule`、`gdsconfigformmaster`、`gdsconfigformslave`)在部分分支被使用,但只是**多租户作用域绕过**闸门(这四张表是全局框架元数据,写入时会剥掉 `sBrandsId` / `sSubsidiaryId`;见 `BusinessBaseServiceImpl.java:165` 的 `//不需要公司子公司的表` 注释),不是“该表是否被此表单授权”的闸门。整个流程里唯一的模块 / 表交叉检查是 1768 行的 `mftproductionplanslave` 硬编码特例。缓解措施包括租户作用域自动注入、模块可通过 `sSaveProNameBefore` / `sSaveProName` 做验证;但权限规则是按钮级,不是表级。净结论:通用保存端点信任前端的 `sTable` 值,值得维护工单。
### 5. 缓存失效
修改元数据行后,保存 service 会同步调用 `BusinessCleanRedisData.delCleanRedisData*`,进而触发 `CleanRedisServiceImpl` 上的 `@CacheEvict`。名字相近的 JMS 路径用于基础数据合并,不负责清缓存。见[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)。
### 6. 浏览器确认
保存返回成功;前端要么就地 patch 该行,要么用同一 `getBusinessDataByFormcustomId` 端点重新拉取表格。追踪结束。
## 保存流程时序图
```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: 用户在 sReopen 行点击修改,
编辑 sChinese,点击保存
SPA->>CTRL: POST /business/addSysLocking?sModelsId=13
(乐观锁占用)
CTRL-->>SPA: 200 OK
SPA->>CTRL: POST /business/addUpdateDelBusinessData?sModelsId=13
{addData:[],updateData:[{sTable:"gdsformconst",column:{sId,sChinese,...}}],delData:[]}
Authorization:
Note over CTRL: AuthorizationInterceptor → 从 Redis 取得 UserInfo
RequestAddParamUtil.addParams(16 个 key,含 sBrandsId/sSubsidiaryId)
CTRL->>SVC: addUpdateDelBusinessData(param)
Note over SVC: 按行分发:
add → addBusinessData → businessBaseDao.add
update → updateBusinessData → businessBaseDao.update
del → deleteBusinessData → businessBaseDao.del
(sTable 来自前端;没有白名单检查)
SVC->>DB: 对 sTable 命名的表执行 INSERT/UPDATE/DELETE
DB-->>SVC: rows affected
Note over SVC: 如果 sTable 在 sTableNameList 中
(gdsformconst/gdsmodule/gdsconfigformmaster/
gdsconfigformslave)→ 写入前移除 sBrandsId/sSubsidiaryId
(4 张表的租户绕过)
SVC->>CLEAN: delCleanRedisData(sTable, sIds, sBrandsId, sSubsidiaryId, "update")
CLEAN->>REDIS: 对受影响 cache region 执行 @CacheEvict
(同步,同一事务路径)
REDIS-->>CLEAN: evicted
SVC-->>CTRL: Feedback{code:1,msg:"操作成功"}
CTRL-->>SPA: AjaxResult{code:1,...}
SPA->>CTRL: POST /business/getBusinessDataByFormcustomId/...
(重新拉取表格;cache miss → 读取新 DB 数据)
CTRL->>DB: SELECT ...
DB-->>CTRL: rows
CTRL-->>SPA: dataset
```
## 本切片引入的概念
- [数据驱动的基本论点](../concepts/thesis.md):为什么 xly 把布局存为数据。
- [模块、表单、虚拟表](../concepts/modules-forms-vtables.md):三个核心名词。
- [元数据驱动的请求生命周期](../concepts/request-lifecycle.md):元数据读取 + 五键结果 map。
- [主从单据模式](../concepts/master-slave.md):`gdsconfigformmaster` / `slave` 本身就是该模式实例。
- [无物理外键、语义外键的现实](../concepts/semantic-fk.md):`gdsconfigformmaster.sParentId = gdsmodule.sId` 是语义 FK。
## 本切片使用的参考
配置人员:
- [如何定义表单](../reference/builder/define-form.md):PM 如何创建我们刚读取的元数据行。
- [如何设置权限](../reference/builder/permissions.md):`gdsjurisdiction` 如何参与。
维护人员:
- [运行时:BusinessBaseController 及相关组件](../reference/maintainer/runtime.md):执行元数据读取的 controller 和 service 层。
- [元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md):同步 `@CacheEvict`(JMS 路径服务于另一个目的)。
- [多服务部署](../reference/maintainer/deployment.md):`xlyEntry` vs `xlyApi` vs `xlyInterface`;本切片完全运行在 `xlyEntry` 上。
## 待验证项 {#open-verification-items}
读路径已通过实时环境佐证;保存路径仍待继续捕获。
1. ~~**实时捕获一次读取。**~~ **已关闭**:在 BACK(`http://118.178.19.35:8597`,admin/123,版本 `基础版/8s`)点击系统常量配置,产生了与文档一致的 HTTP 交换:
```text
GET /xlyEntry/business/getModelBysId/13?sModelsId=13 → 200 OK
POST /xlyEntry/business/getBusinessDataByFormcustomId/19211681019715574676360040?sModelsId=13 → 200 OK
```
两个 URL 都与 Wiki 完全匹配,包括路径变量旁边冗余的 `?sModelsId=13` query 参数。登录后 URL 停在 `/xtmkpz`,不会导航到 `/xtclpz`,这确认 URL fragment 是显示状态,不是路由驱动。
2. **实时捕获一次保存(部分)。** endpoint + URL + method 已在实时环境佐证:在 BACK 系统常量配置中点击新增 → 保存,会触发 `POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId=13` → 200 OK。(测试未产生 DB 变更,因为 audit-tag 值没有通过 SPA Vue model 传入,保存运行在原始行状态上。)下一步要捕获 SPA 发送的精确请求 **body**;目前文档中的 shape 仍来自 `BusinessBaseController.java:161-163` Javadoc。SPA 进入编辑模式时还会触发 `POST /business/addSysLocking`(乐观锁);见[内部 API](../api-reference/internal.md)。
3. **保存 / 删除发出的精确 SQL**,从 `syslog4j` 或 MyBatis debug log 捕获。
4. ~~**`addUpdateDelBusinessData` 中的 `sTable` 校验。**~~ **已关闭**:运行时不会把前端提供的 `sTable` 与表单授权支撑表交叉检查。已作为维护关注点记录。