# 切片 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` | `gdsmodule` ⋈ `gdsconfigformmaster` ⋈ `gdsconfigformslave`(+ personalize) | 表单布局主干 | | `gdsformconst` | 按 `sBrandsId` / `sSubsidiaryId` / language 过滤的 `gdsformconst` 行 | 表单级常量、标签、默认值、下拉文本 | | `gdsjurisdiction` | 用户角色的 `gdsjurisdiction` 行 | 按钮和数据权限;`ADMIN` 用户跳过,管理员看到全部 | | `billnosetting` | 该模块的 `sysbillnosettings` 行 | 单据编号规则;对 `gdsformconst` 无关但总会加载 | | `report` | 关联到该表单的打印模板 | 打印报表定义 | **多租户**在这次读取中执行:每个子查询都带有从认证 session 注入的 `sBrandsId` 和 `sSubsidiaryId`。租户之间看不到对方元数据。 ### 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` 为空时,运行时走 `AddDelUpdCommonServiceImpl.java` 的默认 Add/Update 路径,生成参数化 `INSERT` / `UPDATE` / `DELETE`。 - `sSaveProName` 非空时,运行时调用指定存储过程。`xlyEntry/src/main/resources/templates/templesql/sSaveProName.sql` 是工程师编写这类过程时使用的脚手架。 > **待验证:** 真实保存请求体、响应体以及 `syslog4j` 中的实际 SQL 还未捕获。为了避免修改共享 dev DB 中的框架常量表,本轮没有执行保存。端点、handler 和 payload 形状已从源码确认。 > **安全相关架构备注:** 前端在 payload 中直接提供 `sTable`。`BusinessBaseServiceImpl.addUpdateDelBusinessData` 会读取该值并分发删除 / 更新 / 新增。类级 `sTableNameList` 主要作为缓存失效 gate,而不是“该表是否被此表单授权”的 gate。已确认的缓解包括租户作用域自动注入,以及模块可通过 `sSaveProNameBefore` / `sSaveProName` 做验证;但权限规则是按钮级,不是表级。净结论:通用保存端点信任前端的 `sTable` 值,值得维护 ticket。 ### 5. 缓存失效 修改任何四张元数据表中的 `gds*` 行,都会让每个运行节点失效缓存副本。xly 通过 JMS 消息做到这一点:`xlyErpJmsConsumer/.../ConsumerChangeGdsModuleThread.java` 监听“module changed”事件并清相关 Redis key。见[元数据变更后的缓存失效](../reference/maintainer/cache-invalidation.md)。 ### 6. 浏览器确认 保存返回成功;前端要么就地 patch 该行,要么用同一 `getBusinessDataByFormcustomId` 端点重新拉取表格。追踪结束。 ## 本切片引入的概念 - [数据驱动的基本论点](../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):JMS 驱动 Redis flush。 - [多服务部署](../reference/maintainer/deployment.md):`xlyEntry` vs `xlyApi` vs `xlyInterface`;本切片完全运行在 `xlyEntry` 上。 ## 待验证项 {#open-verification-items} 1. **真实捕获一次保存。** 端点、handler 和 payload 形状已从源码确认,但实际保存请求体尚未捕获。需要打开模块、点击新增、填写、保存,并捕获 JSON body 和响应。 2. **保存 / 删除发出的精确 SQL**,从 `syslog4j` 或 MyBatis debug log 捕获。 3. ~~**`addUpdateDelBusinessData` 中的 `sTable` 校验。**~~ **已关闭**:运行时不会把前端提供的 `sTable` 与表单授权支撑表交叉检查。已作为维护关注点记录。 前两项属于切片 1 v2,需要对 dev DB 做实际写入;为避免修改共享状态而暂缓。