Skip to content

方案 A:SubForm 附属子表(storage=relation)设计稿 ​

状态:设计稿,未落地。后端改动需 Maven 编译 + 重启验证(沙箱无 java/8030)。 目标场景:app_mall 发货单(shipment)+ 发货明细(shipment_item)在一个界面一次保存完成录入。


1. 目标 ​

给 SubForm 字段类型增加第二种存储形态:

形态含义数据落点子模型规则独立查询
embedded(现状,默认)内嵌子表单主表 json 列❌ 不触发❌
relation(新增)附属子表子模型独立表✅ 全量触发✅

命名对齐简道云「附属子表」:既是主表的一部分(跟随主表一屏录入、主表删除时级联删除),又保留独立实体表可被单独查询与处理。

核心价值:一屏录入与独立存储兼得,且不破坏任何已落地的业务规则。


2. 现状盘点(代码事实,非推测) ​

事实出处
SubForm 落库为 json 列,运行时无特殊逻辑MetaConst.java:136 fieldTypeMap.put(S_FIELD_TYPE_SUB_FORM, FieldExtendInfo.of("json", -1, List.class))
后端仅在导入导出里对 SubForm 做特殊处理ExportImportServiceImpl.java:382/611/780
create 私有方法事务边界为 transactionTemplate.executeModelDataServiceImpl.java:106
写入后级联钩子已有先例(LinkChildData)ModelDataServiceImpl.java:1168(create)、1240(update)、230(delete)
级联handler 模板ModelDataLinkFieldHandler.java:handleLinkChildDataFields / deleteLinkChildDataField
钩子顺序:级联 → businessRuleService.execute:1168→1171、:1240→1243、:230→234
dataSource 定义已存在于 schema(SelectData 复用)common.schema.json#/definitions/dataSource(appId + modelName + displayFields + sortFields)
前端 SubForm 是纯受控组件(modelValue: Array)FormSubForm.vue:134,支持增/删/排序/逐单元格编辑/逐行必填校验

结论:后端有现成的级联插入位与 handler 模板,前端组件已完整 —— 方案 A 的成本主要在「存储层第二种形态」,而不是从零造组件。


3. 字段定义(发货场景) ​

在 app_mall_shipment.json 的 fields(与 _allFields)新增:

json
{
  "id": "f_318",
  "name": "items",
  "label": "发货明细",
  "type": "SubForm",
  "storage": "relation",
  "dataSource": {
    "appId": "app_mall",
    "modelName": "app_mall_shipment_item"
  },
  "relationField": "shipmentId",
  "required": true,
  "children": [
    {
      "name": "orderItemId", "label": "订单商品", "type": "SelectData", "required": true,
      "dataSource": { "appId": "app_mall", "modelName": "app_mall_order_item", "displayFields": ["name"] }
    },
    { "name": "name", "label": "商品名称", "type": "Text" },
    { "name": "qty", "label": "本次发货件数", "type": "Number", "required": true, "min": 1 }
  ]
}

app_mall_shipment_item 模型不做任何改动 —— 它仍是独立模型,保留:

  • validationRules:明细归属校验(associateQuery)
  • businessRules:create/update 两条 callApi → shipment_recompute(joined + abort)

这是方案 A 的全部价值:性能体验升级,规则体系零改动。

父表需要额外提供「写子行时自动注入的父级字段」映射(思路同 customButton 的 currentField):

json
"childFieldMapping": [
  { "targetField": "shipmentId", "value": "$id$" },
  { "targetField": "orderId",    "value": "$orderId$" }
]

4. Schema 改动 ​

common.schema.json#/definitions/field/properties 新增 2 个属性(其余全部复用现有定义):

属性类型必填说明
storagestring enum embedded|relation否,默认 embedded存储形态。缺省即现状,向后兼容
relationFieldstringstorage=relation 时必填子模型中指向父表的外键名,如 shipmentId
childFieldMappingarray否写子行时从父记录注入的字段

同步更新 light-app/docs/metadata-schema.md 与 field-types.md 的 SubForm 章节。

校验约束(保存模型时 reject,不静默降级):

  • storage=relation 时必须同时提供 dataSource.modelName 与 relationField
  • relationField 指向的字段必须存在于子模型
  • 一期禁止嵌套 relation(子模型的 SubForm 也是 relation 时直接报错)

5. 后端改动 ​

5.1 新建 ModelSubFormRelationHandler.java ​

完全对标 ModelDataLinkFieldHandler,三个方法:

方法职责
syncChildRows(data, modelMeta, parentId)按父 id 全量 diff:无 id 的行 → 子模型 create;有 id → 子模型 updateById;提交列表中缺失的旧行 → 子模型 delete
deleteChildRows(id, modelMeta)删除父记录时级联删子行(对标 deleteLinkChildDataField)
loadChildRows(modelMeta, parentId)按 relationField = parentId 查子表,回填到 SubForm 字段值

5.2 挂钩点(ModelDataServiceImpl.java) ​

位置现有调用新增
:1168 后(private create)handleLinkChildDataFieldssubFormRelationHandler.syncChildRows(data, modelMeta, id)
:1240 后(private updateById)handleLinkChildDataFieldssubFormRelationHandler.syncChildRows(data, modelMeta, id)
:230 旁(deleteInternal)deleteLinkChildDataFieldsubFormRelationHandler.deleteChildRows(id, modelMeta)
getById 返回前—subFormRelationHandler.loadChildRows(...) 回填

5.3 三条硬约束(方案 A 的灵魂) ​

  1. 子行必须逐行调用 modelDataService.create(appId, childModelName, rowData, false),禁止批量 mapper insert。 只有走子模型 create 才能触发子模型的 validationRule(归属校验)与 businessRule(callApi → shipment_recompute)。批量 insert 会让防超发静默失效。

  2. 事务:create/updateById 已在 transactionTemplate.execute 内;嵌套 modelDataService.create 默认 PROPAGATION_REQUIRED 加入同一事务 → 任一行抛错,父记录与所有子行一起回滚。

  3. permCheck 传 false:权限已在父表校验过。这是显式选择,需在代码注释写明理由,避免被当成疏漏。

    已落地(2026-09-17):permCheck 由 ModelDataServiceImpl.childRowWriter(permCheck) 透传到 resolveChildFieldPerm,false 时返回 ChildFieldPerm.ALLOW_ALL。此前只传 false 却不透传, 子行字段级门禁仍会 fail-closed 拒绝 —— 详见 light-app/docs/sub-table-design.md §6.4。

5.4 写入顺序 ​

validationRule(父) → insert 父行 → syncChildRows(逐行 create 子行,触发子规则)→ businessRule(父, create)

子行放在父 businessRule 之前:父规则若基于子行汇总能读到最新;且子行被拦截时父规则不必跑。


6. 前端改动 ​

FormSubForm.vue 是纯受控组件(modelValue: Array),改动很小:

分支行为
storage 缺省 / embedded完全保持现状
storage === 'relation'加载:mode !== 'add' 时按 relationField = recordId 查子模型,结果填进 subFormData(保留 id,供后端 diff)
保存:不动,仍 emit('update:modelValue', rows),父表单把 items 一并提交,后端负责 diff 落库

关键:前端不做逐行保存,保持「一次提交」的体验。row 里带不带 id 就是后端 diff 的依据。

配套:

  • subform/Properties.vue:补 storage 下拉 + relationField 输入 + 子模型选择器(复用 SelectData 的 dataSource 选择器)
  • subform/TableEditComponent.vue(列表内编辑):沿用现状,relation 模式下只读展示行数即可
  • subform/config.js:默认值补 storage: 'embedded',保证既有配置不受影响

6.1 配置器交互设计(设计器侧) ​

现状(内嵌模式)实测路径 —— 子字段是「新建」的,不是「引用」的:

  1. 从左侧控件库拖「子表单」到画布 → 生成 type=SubForm, children=[]
  2. 选中它 → 右侧属性面板:标题 / 标识 / 必填 / 占位符 / 子字段配置 / 默认值 / 描述 / 宽度 / 可见 / 可编辑
  3. 点「添加子字段」→ 下拉选字段类型(availableFieldTypes 已过滤掉 SubForm 自身以杜绝无限嵌套)→ 生成默认子字段 {id,type,name,label,placeholder,required,visible,editable,width} → 自动选中 → 属性面板切换为该类型的 Properties.vue(选数字就出最小值/最大值/精度)
  4. 子字段列表可拖拽排序;点铅笔回到该子字段配置,点垃圾桶删除
  5. 画布上的子表单(CanvasComponent.vue)只能拖拽排序已有子字段,不能从控件库拖新控件进去

关键认知:内嵌子字段不存在于任何模型中,只是主表 json 列里的一个 key。

关联模式(relation)新增路径 —— 复用 selectdata/DataSourceConfig.vue 已有的「选表单 + 选字段」范式(ModelInfoApi.page({appId}) 列模型 → ModelInfoApi.get 取 meta.fields → 多选):

步骤控件落到字段定义
1属性面板顶部新增「存储方式」单选:内嵌(默认)/ 关联子表storage
2「数据来源配置」按钮 → 弹窗选子模型dataSource.{appId,modelName}
3「关联字段」下拉(可自动推断:子模型中 SelectData 指向本模型的那个字段)relationField
4「显示字段」多选 → 勾选子模型字段生成 children
5「父字段注入」映射表(可选)childFieldMapping

一个必须拍板的设计决策:勾选字段后是「快照」还是「引用」?

取向做法优点缺点
A 快照生成 children(推荐)勾选后把源模型字段展开复制成 children(继承 type/label/name/options/dataSource)FormSubForm.vue 零改动即可渲染;支持逐列覆盖(qty 必填、orderItemId 带过滤、name 只读)源模型改字段不自动跟随 → 需补「重新同步」按钮
B 动态引用只存字段名列表,渲染时实时拉子模型定义自动跟随源模型无法逐列覆盖配置;每次渲染多一次接口

推荐 A:实际业务中子表列几乎必然要定制(我们自己的场景就是:qty 必填、orderItemId 用 SelectData 带订单过滤、name 只读回显),简道云/明道云也是这个路子。快照的「不跟随」代价用一个「重新同步字段」按钮兜住即可。


7. 事务与防超发链路(验收主路径) ​

用户在发货单表单填:快递 SF123 + 明细[橘子 8 箱] → 保存
  ↓ 一次 POST /model/data/create (app_mall_shipment)
  ↓ [事务开始]
  ↓ validationRule(父) → insert t_app_mall_shipment
  ↓ syncChildRows → create(app_mall_shipment_item, {orderItemId, qty:8, shipmentId, orderId})
       → 子模型 validationRule「明细归属校验」通过
       → 子模型 businessRule callApi → shipment_recompute
            → 聚合已发 8 ≤ 应发 10 → order.shipStatus=PART_SHIPPED, status=SHIPPED
  ↓ businessRule(父, create)
  ↓ [事务提交]

超发时:shipment_recompute throw → joined + abort → 子行 create 回滚 → 异常冒泡至父 create 的 transactionTemplate → 整张发货单回滚,用户看到「超出应发数量」,一行都没落库。


7.1 编辑态的增删改:diff 策略与删除重算 ​

三路 diff,锚点唯一是 id

提交行形态判定动作
无 id新增子模型 create
有 id修改子模型 updateById
库里有、提交列表里没有删除子模型 delete

不能用行索引或内容匹配 —— 两行都是「橘子 8 箱」时无法区分。前端必须原样带回 id,这也是 loadChildRows 回填时必须保留 id 的原因。

执行顺序:先删 → 再改 → 后增

防超发校验的是「累计量 ≤ 应发量」,必须先释放额度再占用,否则会误拦合法编辑。 反例:现有 A=10(已满),用户改成 B=10。若先增后删 → 新增 B 时累计 20 被拒(误拦);先删后增 → 删 A(0)→ 增 B(10)✅。

坑 1:删除不会触发重算(现存缺口,已在手)

shipment_item 目前只有 create/update 两条 businessRule,没有 delete。删掉一行后累计已发量下降、shipStatus 应回退,但无人触发。

注意:这不是方案 A 引入的问题 —— 现在两个独立列表,直接在 shipment_item 列表删一行同样不会重算。这是现存 bug。

补齐方式:新增 triggerEvent: delete 的第三条规则,同样 joined + abort、callApi → shipment_recompute;fieldMappings 传 orderId/orderItemId/id,qty 传常量 0(本次变动量为 0,且从聚合中排除自身)。

好消息:已确认 ModelDataMapper.xml 的 selectPage 在 <where> 里硬编码 deleted = 0,查询自动排除软删行。所以用逻辑删除(logicalDelete=true)后该行立刻从聚合中消失,脚本无需改过滤条件。

坑 2:删空后 status 绝不能回退

订单状态机(app_mall_order.json 的 validationRules):

SHIPPED → [COMPLETED, CANCELED]

没有 PAID。因此:

  • shipStatus 可自由回退(无状态机约束)→ 删空时回 UN_SHIPPED ✅
  • status 绝不能回退到 PAID → 状态机 fail-closed 直接拒绝,整条更新报错

现有脚本那句「首单翻 SHIPPED」是单向的,本身安全;但必须在脚本注释里写明不要写它的逆操作。语义上也成立:「已发货」是既成事实,删明细只代表这批货没发,不代表订单回到待发货。

整体删除发货单时的收口

级联删所有子行时若逐行 delete,会触发 N 次全表重算(N = 子行数),且中间态全是垃圾状态。建议:deleteByFilter(shipmentId = parentId) 一次性清理,再显式调一次 shipment_recompute 收口(已确认 deleteByFilter(ModelDataServiceImpl:241)不逐行触发 businessRule,显式调用是必要的)。

逐行校验的固有局限(已知,一期接受)

同一次提交里做「此增彼减」的组合调整可能被误拦:现有 A=8、B=2(应发 10),改成 A=10、B=0(净变化 0,合法);逐行校验在 A 8→10 时聚合为 10+2=12 > 10 ❌,因为 B 尚未被改成 0。 务实处理:UI 提示「单件商品本次发货量不能超过应发量,如需调整请先删除再新增」。彻底解决需两阶段校验(先落库后统一校验、不通过整体回滚),复杂度显著上升,一期不做。


8. 边界清单(必须处理,不可跳过) ​

场景处理
编辑时改了件数提交行带 id → 子模型 updateById → 触发 update 那条 businessRule → 重算
编辑时删了一行diff 出缺失 id → 子模型 delete → 重算
父记录删除级联删子行,避免孤儿数据
孤儿行子模型 shipmentId 外键保留,可独立查询 / 报表
权限子行 permCheck=false,权限收敛在父表
流水线子行走 create 会触发 afterSourceChange → 子表变更也能被 pipeline 消费(能力外溢收益)
性能1 父行 = N 次子行 create(含规则执行)。relation 模式建议限 ≤50 行,超量提示转独立列表(对齐简道云边界)
递归一期禁止嵌套 relation,保存模型时 reject
数据迁移storage 缺省 embedded → 既有 SubForm 配置行为完全不变,无需迁移

9. 实施拆解 ​

阶段内容产出
M0schema + 属性面板(storage/relationField 可配、存得下)配置能保存,行为不变
M1后端 syncChildRows(create)+ loadChildRows 回填跑通「一屏录入 → 子行落独立表 → 防超发拦截」
M2后端 update diff(增/改/删)+ delete 级联编辑与删除闭环
M3前端 FormSubForm relation 分支编辑态能看到已有明细
M4发货场景接线 + 回归shipment 加 items 字段;回归 k6 场景 10/11

10. 风险与「不做」的边界 ​

风险

  • 后端改动无热更新,需 mvn 编译 + 重启;沙箱无法验证,必须本地起 8030 实跑。
  • 子行逐行 create 在 50 行量级有 N 次规则执行开销,需在文档标注上限。

明确不做

  • 不做「SubForm 内嵌转独立表」的一键迁移工具(一期无存量 SubForm 数据需要迁移)。
  • 不做嵌套 relation(子表再挂子表)。
  • 不改 LinkChildData 语义(它是「从已有子数据里挑」,与本方案「新建子行」不同)。

11. 方案 F:内嵌录入 + 脚本重投影(短期推荐) ​

重新审视方案 A 后提出的新思路。核心洞察:agent-paas 本身是低代码平台,能用资产(模型 JSON + 脚本 API)表达的业务逻辑,就不应该写进 Java 框架代码。

11.1 做法 ​

  1. app_mall_shipment 加一个普通内嵌 SubForm 字段(items,json 列)—— 用现有能力,前端零改动就有一屏录入
  2. 给 shipment 挂 create / update 的 businessRule → callApi → shipment_expand
  3. 脚本 shipment_expand 做的事:
读父记录的 json 明细列
  → deleteByFilter(shipmentId = 当前发货单)   // 全量清旧
  → 逐行 modelDataService.create(app_mall_shipment_item, ...)  // 重投影
  1. 每行仍走子模型 create → 归属校验 + 防超发 businessRule 照常生效(与方案 A 的灵魂约束一致)

11.2 「全量重投影」让编辑态自动闭环 ​

json 列 = 用户编辑的源;独立表 = 由源派生的投影。每次保存全量覆盖,天然幂等 —— 编辑态的增、删、改全自动支持,不需要写 diff 逻辑。

这直接消掉了方案 A 里最麻烦的部分(三路 diff、行 id 锚定、先删后增的顺序、逐行校验误拦)。

11.3 与方案 A 对比 ​

维度A(增强 SubForm)F(内嵌 + 重投影)
后端 Java 改动需要(4 个核心写路径挂钩点 + 新 handler)不需要
前端改动小(加载回填分支)零(用现有内嵌 SubForm)
录入体验一屏一屏
明细落独立表是是
子模型规则生效是是(脚本内逐行 create)
编辑态增/删/改完整,但需自写 diff完整,全量重投影天然支持
数据冗余无有(json 草稿 + 独立表)
子行 id 稳定性稳定每次保存重建(当前无外部引用)
通用平台能力是(所有主子表受益)否(只解决本场景)
风险面改核心写路径,影响所有模型的所有写入仅在资产层,出问题改 JSON 即可

11.4 代价与红线 ​

  • 双源风险:若有其它途径直接写 shipment_item(不经过 shipment),json 列与表会分叉。需约定「shipment_item 只能经 shipment 写入」。
  • 子行 id 每次重建:当前无任何外部引用子行 id,可接受;一旦有人引用就必须回退到 diff 方案。
  • 全删全插触发 N 次规则:N 为明细行数(发货场景一般 <10),可接受;清理用 deleteByFilter(不逐行触发 businessRule),再靠最后一次 create 的重算收口。

11.5 结论 ​

短期(解发货痛点)选 F —— 成本约为 A 的 1/10,风险约 1/100,且把复杂度从「平台框架层」下沉到「资产层」,符合低代码平台的正确方向。

长期(平台能力)保留 A —— 但建议在出现第 2~3 个主子表场景时再启动。现在只有发货一个场景,为它改造 ModelDataServiceImpl 的 create/update/delete 核心路径,风险收益比不划算。


12. 方案全景与推荐路径 ​

方案定位建议
F 内嵌录入 + 脚本重投影一屏录入 + 独立表,零框架改动短期首选,立刻落地
A 增强 SubForm storage=relation通用主子表能力,无冗余长期平台能力,等第 2~3 个场景再启动
B 表单下嵌 grid纯配置过渡已确认 ModelDataView 无默认值能力,自动带入 shipmentId 需额外开发 —— 性价比低于 F,不再推荐
C 明细存 JSON 列不落表—永不选:防超发脚本、归属校验、两条 businessRule 全部失效,且丧失 SQL 聚合能力

推荐路径:先做 F(发货场景当天可用)→ 观察是否出现更多主子表需求 → 若有,启动 A 并让 F 作为 A 未就绪时的兜底。


13. 已落地(2026-09-11):方案 F 实现记录 ​

按 §11/§12 的结论,发货场景已按方案 F 落地(资产层:模型 JSON + 脚本;后端仅改 1 行代理)。

文件改动
app_mall_shipment.json新增内嵌子表单字段 items(SubForm;children = orderItemId(SelectData→app_mall_order_item) / name(Text) / qty(Number,必填) / remark(TextArea));新增 3 条 businessRule(create / update / delete → callApi shipment_expand,joined + abort)
app_mall/api/shipment_expand.json新建投影脚本:读主表 items(json) → 按 shipmentId 清旧(query + 逐条 delete,因脚本代理无 deleteByFilter)→ 逐行 modelDataService.create(app_mall_shipment_item, ...)。对 items 读回为 Java List 或数组两种形态均做兼容
app_mall_order.json删除重复的 btn_order_ship_batch(其 action 与 btn_order_ship 完全相同),保留一个「标记发货」并更新确认文案
app_mall_shipment_item.json视图 actions 仅保留 query(add/view/delete/batchDelete/import/export 全 visible:false)→ 界面无法直接改明细
ApiScriptExecutor.ModelDataServiceProxy.createpermCheck 由 true 改为 false(权限随主表,见 §5.3③;create 此前在 app-assets 零使用,波及面为零)

语义与红线

  • 主表 items(json) = 唯一源;app_mall_shipment_item = 派生投影。每次发货单保存全量替换(先删后增),幂等;编辑态增/删/改自动闭环。
  • 绕过发货单直接改明细表不会被回写,且会在下次发货单保存时被覆盖 → 故明细视图收敛为只读、仅查询。
  • 子行 id 每次保存重建(当前无外部引用,可接受)。

待验证(沙箱无 java / 8030)

  • 需 mvn 编译 lt-app-runtime + 重启(代理方法动了 Java)。
  • 真机回归重点:正常发货投影成功;超发被拦 → 整单回滚;发货单删除 → 级联清子行;编辑发货单明细增/删/改闭环。
  • 风险点:getById 读回的 items 是否已解析为 List(脚本已兼容 List / 数组);create 代理改 false 后的安全边界(仅受信脚本可达)。