01-hello-world.md 14.3 KB

切片 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:8597xlyEntry WAR 提供(观察:每个 /business/* 调用的 context-path 都是 /xlyEntry/)。早期猜测是 xlyApi,这是错误的;xlyApi 是兄弟 WAR,有自己的 application.yml 和 context-path /xlyApi,角色不同。部署章节会覆盖二者拆分。

1. 浏览器 → 服务端(页面 shell)

GET /xtclpz

/xtclpzgdsroute 中的一项,但运行时它只是显示状态,不是路由驱动。登录后直接访问该 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 sftlogininfojurisdictiongroupsisjurisdictionclassify)。ADMIN 用户跳过,管理员看到全部。注意: map key 叫 gdsjurisdiction 有误导性;gdsjurisdiction 是配置侧动作目录,实际每用户授权来自 sysjurisdiction 按钮和数据权限
billnosetting 该模块的 sysbillnosettings 行(每租户) 单据编号规则;对 gdsformconst 无关但总会加载
report 关联到该表单的 sysreport 行(每租户) 打印报表定义

多租户在需要的地方执行:租户作用域读取(gdsconfigformpersonalizegdsconfigformcustomslavesysbillnosettingssysreport)都会按认证 session 注入的 sBrandsIdsSubsidiaryId 过滤。框架基础元数据表(gdsconfigformmastergdsconfigformslavegdsformconst)是全局的,只按 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.sTbNamegdsconfigformslave 字段列表生成 payload。

  • sSaveProName 的作用:基础新增 / 更新路径总是通过 BusinessBaseServiceImpl.addBusinessData / updateBusinessDataxlyBusinessService/.../BusinessBaseServiceImpl.java:1014:1250),再委托 businessBaseDao.add(map) / businessBaseDao.update(map),对 sTable 命名的表执行操作。sSaveProName(及其兄弟列 sSaveProNameBefore不是替换基础路径的二选一分支;它命名的是叠加在基础路径之上的额外存储过程钩子:保存后 / 保存前由 BusinessBaseServiceImpl.java:1824checkUpdate(...,"sSaveProName") 分发。切片 1 的 gdsformconstsSaveProName 为空,因此只运行基础路径。xlyEntry/src/main/resources/templates/templesql/sSaveProName.sql 是工程师编写这类钩子时使用的脚手架。非空 sSaveProName 的工作流闸门路径由切片 2 涉及。

开放验证(需要真实保存): 仍需捕获实时请求 body、响应 body,以及 syslog4j 中的实际 SQL,才能完全闭环。

安全相关架构备注: 前端在 payload 中直接提供 sTableBusinessBaseServiceImpl.addUpdateDelBusinessData 会读取该值并分发删除 / 更新 / 新增。类级 sTableNameListBusinessBaseServiceImpl.java:162-169,只含 gdsformconstgdsmodulegdsconfigformmastergdsconfigformslave)在部分分支被使用,但只是多租户作用域绕过闸门(这四张表是全局框架元数据,写入时会剥掉 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

本切片引入的概念

本切片使用的参考

配置人员:

维护人员:

待验证项 {#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
  3. 保存 / 删除发出的精确 SQL,从 syslog4j 或 MyBatis debug log 捕获。
  4. addUpdateDelBusinessData 中的 sTable 校验。 已关闭:运行时不会把前端提供的 sTable 与表单授权支撑表交叉检查。已作为维护关注点记录。