
低代码平台有个经典的"三连翻车"剧本,做过业务系统的都熟:
所以低代码平台演示的时候都很好看,真到进销存这种场景就露馅——因为进销存恰好把三个翻车点占全了:单据全是主子结构,库存变更全是业务逻辑,每张单据都要走审批。
Forge Admin 最近干了一件事:用纯低代码方式,把一个完整的采购仓储模块搭了出来——物料、供应商、仓库、采购、出库、调拨,10 张业务表、10 个业务对象、6 个应用入口,没有写任何 Vue 页面,也没有写任何 Java Controller。
这篇文章就拆它是怎么扛住这三个翻车点的。不讲概念,直接看实现。
先把话说清楚,免得评论区吵架。
"零代码"不是说这个系统里没有代码——动作引擎、台账服务、运行时装配,这些平台能力当然是代码写的,而且写得很硬核。
"零代码"指的是:交付这个进销存应用本身,没有产生一行业务代码。没有 PurchaseOrderController.java,没有 purchase-order.vue。交付物是一组配置数据:
复制代码ai_lowcode_domain 业务领域(采购仓储)
└── ai_business_suite 业务套件
└── ai_business_object 业务对象 ×10(物料/供应商/仓库/采购单/采购明细/...)
└── ai_crud_config 运行配置(页面协议 → AiCrudPage 五件配置)
└── designer_options.actions 业务动作(审批回调、入库、锁定...)
└── ai_business_app 应用入口 ×6(RUNTIME 模式,指向 configKey)
└── ai_business_binding 能力挂接(流程回调绑定)
这些配置全部通过初始化 SQL 交付, tenant_id = 1。换句话说:应用即数据。这是判断一个低代码平台成色的分水岭——应用到底是"画出来的页面",还是一套可以版本化、可以迁移、可以审查的结构化资产。

采购单长这样:主表是单据头(单号、供应商、仓库、备注),子表是采购明细行(物料、数量、单价)。这是业务系统里最普通的结构,也是低代码平台最常见的翻车现场。
Forge 的主从页面不是"画"出来的,是协议驱动的。ai_crud_config.page_schema 里的核心结构:
复制代码{
"layoutType": "master-detail-crud",
"primaryModelCode": "PW_PURCHASE_ORDER",
"modelRefs": [
{ "modelCode": "PW_PURCHASE_ORDER", "primary": true,
"relations": [{ "relationType": "ONE_TO_MANY",
"sourceField": "id", "targetField": "purchaseId" }] },
{ "modelCode": "PW_PURCHASE_ORDER_ITEM", "primary": false,
"props": { "saveMode": "merge", "recordSelector": { "...": "..." } } }
]
}
发布时,LowcodeRuntimeConfigBuilder.buildRuntimeConfig() 把这份协议编译成 AiCrudPage 的运行配置(searchSchema / columnsSchema / editSchema / apiConfig / options),主从结构编进 options.masterDetailConfig。运行页按 configKey 请求 GET /ai/crud-config/render/{configKey},拿到 layoutType = master-detail-crud 后加载主从模板组件。
链路里没有一行是采购专用的——换成"订单 + 订单明细"、"报销单 + 费用明细",协议结构一模一样。
协议谁都会写,运行态的两个细节才见功力。
细节一:记录选择器。 明细行不是只能手敲。子表头部有"选择物料"按钮,弹出记录选择器——支持多字段关键词搜索、支持 ${formData.xxx} 动态过滤(比如只显示当前供应商有报价的物料),选中的记录按 fieldMappings 映射后批量追加到子表。这个交互在手写页面里都经常被偷懒省掉。
细节二:merge 保存。 主子表保存最烦的问题:用户在编辑页删了一行明细,提交时怎么办?
简单粗暴的做法是"全删重插"(Forge 里叫 replace 模式,也是默认模式),但这对有审计要求的单据不可接受。采购明细用的是 merge 模式:前端删除一行时不物理移除,而是打上 _deleted 标记,提交时组织成这样的 payload:
复制代码{
main: { /* 主表字段 */ },
children: {
pw_purchase_order_item: [
{ id: 101, _deleted: true }, // 删除(走逻辑删)
{ id: 102, quantity: 50 }, // 更新(先校验行归属)
{ materialId: 'MAT-003', quantity: 200 } // 无 id,新增
]
}
}
后端 DynamicCrudService.mergeMasterDetailChildRows() 按标记分流:_deleted + 有 id → 逻辑删除;无 id → 插入;有 id → 先校验这一行确实属于当前主记录再更新。最后这条校验很关键——没有它,改个 id 就能改别人单据的明细行,这是主从表接口的经典越权漏洞。

这是全文的核心,也是低代码平台最心虚的地方。
先说 Forge 的立场:业务逻辑不靠生成代码,也不靠脚本引擎,而是靠一个白名单制的"动作引擎" 。
一个业务动作(action)挂在业务对象上,类型为 COMMAND,由若干步骤(step)组成。步骤类型是白名单,目前六种:
| 步骤类型 | 语义 |
|---|---|
UPDATE_FIELD | 更新当前/目标记录字段 |
CREATE_RECORD | 创建记录 |
START_FLOW | 发起审批流程 |
SEND_MESSAGE | 发送消息 |
FOREACH | 循环集合(如明细行),嵌套执行子步骤 |
DOMAIN_ACTION | 调用领域动作 SPI,首个实现是 QUANTITY 数量台账 |
为什么不做成"写一段 JS/Groovy 随便跑"?因为脚本引擎是个无底洞:没有权限边界、没有审计、没有事务保证、出了问题没法排查。白名单步骤每一项都有明确的语义、参数协议和权限校验,BusinessActionExecutionService 统一入口执行:幂等检查(RUNNING 预占日志 + 请求摘要防并发)→ TransactionTemplate 单事务顺序执行 → 写执行日志。宁可步骤类型少一点,也不把动作引擎做成任意脚本执行器。
很多系统里"库存"就是商品表上的一个 stock 字段,扣库存就是 update stock = stock - ?。这在演示里没问题,在生产里是灾难:并发超卖、重复提交重复扣、出了问题查无对证。
Forge 把数量做成了台账,三张表:
ai_business_quantity_balance:余额表(账户 + 项目 + 维度,当前数量、锁定数量)ai_business_quantity_ledger:流水表(每笔变动,idempotency_key 唯一键)ai_business_quantity_lock:锁定表(预占、释放、核销)操作类型五种:INBOUND(入库)、LOCK(锁定)、RELEASE(释放)、COMMIT(核销)、TRANSFER(转移)。所有数量必须是整数最小单位,传小数直接抛错——和"金额用分"是同一个工程哲学。
看真实配置。采购单审批通过后的入库动作 inbound_purchase_stock:
复制代码{ "actionConfig": { "successBehavior": "refreshList", "steps": [
{ "stepCode": "foreach_purchase_items", "stepType": "FOREACH",
"stepConfig": {
"collectionPath": "record.children.pw_purchase_order_item",
"itemAlias": "item",
"steps": [
{ "stepCode": "purchase_item_inbound", "stepType": "DOMAIN_ACTION",
"stepConfig": {
"actionType": "QUANTITY", "operationType": "INBOUND",
"params": {
"accountCode": "${record.main.warehouseId}",
"itemCode": "${item.materialId}",
"quantity": "${item.quantity}",
"sourceDetailId": "${item.id}",
"remark": "采购审批通过入库"
} } } ] } } ] } }
读法很直白:遍历采购单的每一行明细,以仓库为账户、物料为项目,逐行入库。${...} 表达式取上下文里的值,sourceDetailId 把台账流水和明细行关联起来——以后任何一笔库存都能反查出是哪张单据的哪一行造成的。
幂等是强制要求:动作没带幂等键时,引擎用 来源对象|来源记录|明细行|动作|步骤|操作类型 拼一个稳定基再哈希,保证同一个审批回调重试多少次都只记一次账。
出库单比入库更进一步,用的是三件套:提交时 LOCK(预占,不影响可用判断)、审批通过 COMMIT(按锁定核销)、审批驳回 RELEASE(释放锁定) 。调拨则是 TRANSFER,从源仓库扣、向目标仓库加,拆成源端/目标端两条流水。
这就是"库存怎么算"的答案:不是一个字段,是账户模型 + 流水 + 幂等 + 锁定语义,而且全部是配置,不是代码。

最后一个翻车点:流程和业务怎么接上。
Forge 的原则是不新建审批引擎——流程继续走 Flowable,低代码只负责"审批结果出来之后干什么"。接法分三步:
第一步,行按钮发起审批。 采购单列表上有"提交审批"按钮,对应一个 COMMAND 动作,里面是一个 START_FLOW 步骤:配置流程模型 key,用 fieldMapping 把 record.main.purchaseNo 等字段映射成流程变量。注意这里用的是平台通用审批流(leave_multi),不是为采购定制的流程——零定制的又一个佐证。
第二步,绑定回调动作。 在 ai_business_binding 里配一行 JSON,把"审批通过"接到入库动作:
复制代码-- 采购单:审批通过 → 入库
JSON_OBJECT('APPROVED', 'inbound_purchase_stock')
-- 出库单:通过 → 核销锁定,驳回 → 释放锁定
JSON_OBJECT('APPROVED', 'commit_outbound_stock',
'REJECTED', 'release_outbound_stock')
第三步,引擎回调执行。 流程引擎事件通过 @FlowCallback 注解进入 BusinessFlowService,按审批结果解析出 actionCode,构造执行请求调同一个动作引擎。回调的幂等键格式是 flowCallback:{processInstanceId}:{result}:{actionCode}——同一个流程实例的同一个结果,副作用只执行一次。
而且 spec 里有一条硬性要求:流程回调失败必须可见,不允许"审批成功但库存没动"这种静默失败。回调执行同样写执行日志,失败了能查、能追溯、能重放。
到这里,三张单据的完整闭环就出来了:
复制代码采购单:提交审批(START_FLOW) → 通过(APPROVED) → FOREACH 明细逐行 INBOUND
出库单:提交时 LOCK → 通过 COMMIT / 驳回 RELEASE
调拨单:审批通过 → TRANSFER(源仓库减、目标仓库加,双流水)
最后交付了什么:
也得说清楚没做到什么:像素级的 UI 还原还没做到——统计卡片、底部固定操作栏这类视觉细节,是平台 UI 模板层的能力缺口,不是业务逻辑缺口。业务逻辑层面,验收文档的结论是 11 个原型页面全部"低代码等价还原"。
这个边界很重要。低代码平台吹牛的常见方式是把"UI 自由度"和"业务能力"混为一谈。Forge 这轮恰好反过来:业务能力(主从、台账、审批联动)做到了零代码,UI 模板的丰富度承认还有差距。前者是地基,后者是装修——地基没打好的平台,装修再漂亮也没用。
复盘一下这轮设计里最有价值的三个判断:
下一篇预告:动作引擎的执行底座再往下拆一层——幂等键怎么设计、并发怎么防、执行日志怎么查,把"业务动作"这件事的工程细节讲透。
你用过哪些低代码平台?主从表和业务逻辑这两关,它们过去了吗?评论区聊聊。