Appearance
方案 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.execute | ModelDataServiceImpl.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 个属性(其余全部复用现有定义):
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
storage | string enum embedded|relation | 否,默认 embedded | 存储形态。缺省即现状,向后兼容 |
relationField | string | storage=relation 时必填 | 子模型中指向父表的外键名,如 shipmentId |
childFieldMapping | array | 否 | 写子行时从父记录注入的字段 |
同步更新 light-app/docs/metadata-schema.md 与 field-types.md 的 SubForm 章节。
校验约束(保存模型时 reject,不静默降级):
storage=relation时必须同时提供dataSource.modelName与relationFieldrelationField指向的字段必须存在于子模型- 一期禁止嵌套 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) | handleLinkChildDataFields | subFormRelationHandler.syncChildRows(data, modelMeta, id) |
:1240 后(private updateById) | handleLinkChildDataFields | subFormRelationHandler.syncChildRows(data, modelMeta, id) |
:230 旁(deleteInternal) | deleteLinkChildDataField | subFormRelationHandler.deleteChildRows(id, modelMeta) |
getById 返回前 | — | subFormRelationHandler.loadChildRows(...) 回填 |
5.3 三条硬约束(方案 A 的灵魂)
子行必须逐行调用
modelDataService.create(appId, childModelName, rowData, false),禁止批量 mapper insert。 只有走子模型create才能触发子模型的validationRule(归属校验)与businessRule(callApi → shipment_recompute)。批量 insert 会让防超发静默失效。事务:
create/updateById已在transactionTemplate.execute内;嵌套modelDataService.create默认PROPAGATION_REQUIRED加入同一事务 → 任一行抛错,父记录与所有子行一起回滚。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 配置器交互设计(设计器侧)
现状(内嵌模式)实测路径 —— 子字段是「新建」的,不是「引用」的:
- 从左侧控件库拖「子表单」到画布 → 生成
type=SubForm, children=[] - 选中它 → 右侧属性面板:标题 / 标识 / 必填 / 占位符 / 子字段配置 / 默认值 / 描述 / 宽度 / 可见 / 可编辑
- 点「添加子字段」→ 下拉选字段类型(
availableFieldTypes已过滤掉 SubForm 自身以杜绝无限嵌套)→ 生成默认子字段{id,type,name,label,placeholder,required,visible,editable,width}→ 自动选中 → 属性面板切换为该类型的Properties.vue(选数字就出最小值/最大值/精度) - 子字段列表可拖拽排序;点铅笔回到该子字段配置,点垃圾桶删除
- 画布上的子表单(
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. 实施拆解
| 阶段 | 内容 | 产出 |
|---|---|---|
| M0 | schema + 属性面板(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 做法
app_mall_shipment加一个普通内嵌 SubForm 字段(items,json 列)—— 用现有能力,前端零改动就有一屏录入- 给
shipment挂create/update的 businessRule →callApi → shipment_expand - 脚本
shipment_expand做的事:
读父记录的 json 明细列
→ deleteByFilter(shipmentId = 当前发货单) // 全量清旧
→ 逐行 modelDataService.create(app_mall_shipment_item, ...) // 重投影- 每行仍走子模型
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.create | permCheck 由 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后的安全边界(仅受信脚本可达)。