# 切片 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` 与表单授权支撑表交叉检查。已作为维护关注点记录。