切片 1 — CRUD 模块(Hello World)
xly 中最简单的端到端动作:打开一个系统模块、查看数据、编辑一行、保存。我们从 URL 栏一直追踪到运行时渲染到表格中的行,中间经过每一层。
本章基于观察到的网络流量和匹配源码。仍为假设的内容(例如尚未实际执行的保存路径)会明确标注。
记录对象
| 模块 |
系统常量配置(System Constants Config) |
| URL 片段 | /xtclpz |
模块 sId |
13 |
| 支撑表 |
gdsformconst(dev 中 2,986 行) |
表单 sId |
19211681019715574676360040 |
| 字段数 | 10 |
| 保存 / 删除过程 | 无,使用框架默认路径 |
| 审批工作流 | 无(bCheck = 0) |
这个模块本身就是框架的一部分:它是 PM 编辑系统常量的 后台 页面。记录它具有“元”意义:我们会看到框架如何配置框架。
为什么选这个模块
第一,sId = 13 说明它是靠近根部的基础模块。第二,它没有自定义过程:保存 / 删除使用框架默认 CRUD 路径,这是读者应先学会的规范代码路径。自定义过程属于后续切片。
四个元数据层
运行时读取四张元数据表来渲染页面:
| 层 | 表 | 含义 |
|---|---|---|
| 1. 模块目录 | gdsmodule |
模块树。sId='13' 是本切片对象。 |
| 2. URL 白名单(客户端侧) | gdsroute |
URL /xtclpz 在这里注册。SPA 读取该列表决定渲染哪些侧边栏 / deep-link;服务端不会对未注册路径返回 404。 |
| 3. 表单布局 | gdsconfigformmaster |
一行 sParentId='13' 声明表单:支撑表、类型、默认 SQL / where / order、分页和权限。 |
| 4. 字段布局 | gdsconfigformslave |
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)
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 拉取元数据
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 → 服务端(初始数据加载)
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}
保存、删除、新增都进入同一通用端点:
POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId={moduleId}
请求体形状:
{
"addData": [{"sTable": "<table>", "column": {"sId": "", "...": "..."}}],
"updateData": [{"sTable": "<table>", "column": {"sId": "", "...": "..."}}],
"delData": [{"sTable": "<table>", "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 路径用于基础数据合并,不负责清缓存。见元数据变更后的缓存失效。
6. 浏览器确认
保存返回成功;前端要么就地 patch 该行,要么用同一 getBusinessDataByFormcustomId 端点重新拉取表格。追踪结束。
保存流程时序图
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 行点击修改,<br/>编辑 sChinese,点击保存
SPA->>CTRL: POST /business/addSysLocking?sModelsId=13<br/>(乐观锁占用)
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 → 从 Redis 取得 UserInfo<br/>RequestAddParamUtil.addParams(16 个 key,含 sBrandsId/sSubsidiaryId)
CTRL->>SVC: addUpdateDelBusinessData(param)
Note over SVC: 按行分发:<br/>add → addBusinessData → businessBaseDao.add<br/>update → updateBusinessData → businessBaseDao.update<br/>del → deleteBusinessData → businessBaseDao.del<br/>(sTable 来自前端;没有白名单检查)
SVC->>DB: 对 sTable 命名的表执行 INSERT/UPDATE/DELETE
DB-->>SVC: rows affected
Note over SVC: 如果 sTable 在 sTableNameList 中<br/>(gdsformconst/gdsmodule/gdsconfigformmaster/<br/>gdsconfigformslave)→ 写入前移除 sBrandsId/sSubsidiaryId<br/>(4 张表的租户绕过)
SVC->>CLEAN: delCleanRedisData(sTable, sIds, sBrandsId, sSubsidiaryId, "update")
CLEAN->>REDIS: 对受影响 cache region 执行 @CacheEvict<br/>(同步,同一事务路径)
REDIS-->>CLEAN: evicted
SVC-->>CTRL: Feedback{code:1,msg:"操作成功"}
CTRL-->>SPA: AjaxResult{code:1,...}
SPA->>CTRL: POST /business/getBusinessDataByFormcustomId/...<br/>(重新拉取表格;cache miss → 读取新 DB 数据)
CTRL->>DB: SELECT ...
DB-->>CTRL: rows
CTRL-->>SPA: dataset
本切片引入的概念
- 数据驱动的基本论点:为什么 xly 把布局存为数据。
- 模块、表单、虚拟表:三个核心名词。
- 元数据驱动的请求生命周期:元数据读取 + 五键结果 map。
-
主从单据模式:
gdsconfigformmaster/slave本身就是该模式实例。 -
无物理外键、语义外键的现实:
gdsconfigformmaster.sParentId = gdsmodule.sId是语义 FK。
本切片使用的参考
配置人员:
维护人员:
- 运行时:BusinessBaseController 及相关组件:执行元数据读取的 controller 和 service 层。
-
元数据变更后的缓存失效:同步
@CacheEvict(JMS 路径服务于另一个目的)。 -
多服务部署:
xlyEntryvsxlyApivsxlyInterface;本切片完全运行在xlyEntry上。
待验证项 {#open-verification-items}
读路径已通过实时环境佐证;保存路径仍待继续捕获。
-
实时捕获一次读取。已关闭:在 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=13query 参数。登录后 URL 停在/xtmkpz,不会导航到/xtclpz,这确认 URL fragment 是显示状态,不是路由驱动。 -
实时捕获一次保存(部分)。 endpoint + URL + method 已在实时环境佐证:在 BACK 系统常量配置中点击新增 → 保存,会触发
POST /xlyEntry/business/addUpdateDelBusinessData?sModelsId=13→ 200 OK。(测试未产生 DB 变更,因为 audit-tag 值没有通过 SPA Vue model 传入,保存运行在原始行状态上。)下一步要捕获 SPA 发送的精确请求 body;目前文档中的 shape 仍来自BusinessBaseController.java:161-163Javadoc。SPA 进入编辑模式时还会触发POST /business/addSysLocking(乐观锁);见内部 API。 -
保存 / 删除发出的精确 SQL,从
syslog4j或 MyBatis debug log 捕获。 -
已关闭:运行时不会把前端提供的addUpdateDelBusinessData中的sTable校验。sTable与表单授权支撑表交叉检查。已作为维护关注点记录。