工作流引擎的退回修改:金额从 6666 改成 666,重提还是回到退回它的那个人

一、这个需求一定会来找你

报销流程:张三发起 6666 元,部门李四批了,总监赵六看了一眼说"发票缺行程单",退回给张三。

张三补了行程单,顺手把金额按发票更正成 666,提交。

如果你的引擎这时候把单子送回李四桌上,那你已经回答了这篇的问题------李四会再批一次他上个月就批过的东西。

这不是某个引擎的 bug,是"退回发起人"这个动作的默认语义:回到发起节点,然后按发起节点的正常出边重走一遍。重走就意味着前面所有节点重新执行,包括已经批过的审批节点和已经算过的条件分支。

大多数自研审批流走到这里会开始打补丁:给流程实例记一个"上次批到哪儿"的标记、给发起节点加一条"跳过前序"的特殊出边、或者让前端记住来源节点 id 然后调"跳转到指定节点"。第三条最像能成,但它把审批语义变成了节点跳转,流转记录里会出现一条"谁都没办理"的历史,账就对不上了。

jeeflow 这次给的答案是在审批节点上放一个配置,不是改"退回发起人"的行为。

二、配置就一行

看这条流程。五个业务节点(开始和结束另算),比常见的报销流程多了一个条件分支------金额大于 1000 走总监,否则走经理:

json 复制代码
{
  "name": "expense-reapply-demo",
  "displayName": "报销审批(退回修改演示)",
  "type": "approval",
  "nodes": [
    { "id": "start", "type": "snaker:start",
      "text": { "value": "开始" } },
    { "id": "apply", "type": "snaker:task",
      "text": { "value": "发起申请" },
      "properties": { "assignee": "applicant", "form": "expense-form",
                      "taskType": 0, "performType": 0 } },
    { "id": "dept", "type": "snaker:task",
      "text": { "value": "部门审批" },
      "properties": { "assignee": "leader", "form": "check-form",
                      "taskType": 0, "performType": 0 } },
    { "id": "decision1", "type": "snaker:decision",
      "text": { "value": "金额大于1000" } },
    { "id": "taskHigh", "type": "snaker:task",
      "text": { "value": "高额审批" },
      "properties": { "assignee": "director", "form": "check-form",
                      "taskType": 0, "performType": 0,
                      "reapplyMode": "RETURN_TO_SOURCE" } },
    { "id": "taskLow", "type": "snaker:task",
      "text": { "value": "普通审批" },
      "properties": { "assignee": "manager", "form": "check-form",
                      "taskType": 0, "performType": 0 } },
    { "id": "end", "type": "snaker:end",
      "text": { "value": "结束" } }
  ],
  "edges": [
    { "id": "e0", "sourceNodeId": "start", "targetNodeId": "apply" },
    { "id": "e1", "sourceNodeId": "apply", "targetNodeId": "dept" },
    { "id": "e2", "sourceNodeId": "dept", "targetNodeId": "decision1" },
    { "id": "e3", "sourceNodeId": "decision1", "targetNodeId": "taskHigh",
      "properties": { "expr": "amount > 1000" } },
    { "id": "e4", "sourceNodeId": "decision1", "targetNodeId": "taskLow",
      "properties": { "expr": "amount <= 1000" } },
    { "id": "e5", "sourceNodeId": "taskHigh", "targetNodeId": "end" },
    { "id": "e6", "sourceNodeId": "taskLow", "targetNodeId": "end" }
  ]
}

整份 JSON 里只有 taskHigh(高额审批)多了一个键:

properties.reapplyMode 退回发起人后,重提时
不填 / NORMAL 按发起节点的正常出边重新审批(也就是回到第一节那个局面)
RETURN_TO_SOURCE 直接回到本次退回的来源节点建待办

这个键是节点属性,不是流程属性、不是发起参数。同一条流程里可以让总监那格直返、让经理那格照旧,两张单各走各的。

配错值不会被静默当成缺省处理,会当场拒掉。但拒在哪一步,两种实现不一样 :Java 参考实现在解析流程定义时就抛(TaskParser 调 ReapplyModeEnum.parse,仓里那条用例的断言原话是"非法 reapplyMode 必须在解析期报错"),而我这次真跑的 Python 实现是到有人在这格点"退回发起人"那一刻才报。第五节把两边的原文都贴出来。

还有三条准入边界,配之前得先看清(规范 04 的原文):申请节点必须唯一 、来源必须是普通审批任务 ,以及会签节点和子流程不在这一版契约里。也就是说,"会签签了一半退回发起人、改完再回会签"这种玩法目前不成立,别往会签格上配这个键。

先确认你的版本有没有这个键 。两条最快的路:装好引擎后在源码里搜 reapplyMode(Java 是 ReapplyModeEnum,其余语言各有对应的解析入口);或者看文档站规范 04「引擎操作」里有没有「退回修改、重提直返」这一节,同一站的更新日志记着八种语言各自从哪一版起带上它。八种语言各自发版、号不同,别拿 Java 的号去比 Python 的。

部署就是把它交给门面:POST /wf/processDefine/deploy,body 里 content 放上面那份 JSON 字符串(同名定义重复部署会 version+1,不会覆盖旧的);想走界面就贴进流程设计器保存后点发布,落的是同一个接口。

三、真机跑一遍

流程 deploy 完(定义 id 120),张三发起,amount=6666。下面每一步的读数都是从门面接口原样取的。实例 id 95788036947143。

先说清 submitType 是什么------它是 mldong 生态给审批动作编号的那个枚举,全量八档:0 发起申请、1 同意、2 拒绝并结束、3 退回上一步、4 跳转到指定节点、5 重新提交、6 退回发起人、20 会签拒绝。这篇只用到 1/5/6 三档。

动作(提交参数) 之后各人手上的待办
张三发起,amount=6666 李四:部门审批 1 条
李四同意 submitType=1 赵六:高额审批 1 条(走金额大于 1000 那条边)
赵六退回发起人 submitType=6 张三:发起申请 1 条;李四 0 条、赵六 0 条
张三重提 submitType=5,金额改 666 赵六:高额审批 1 条(新行);李四 0 条、经理王五 0 条
赵六同意 submitType=1 实例 state=20 办结

标题里那句"金额从 6666 改成 666 还是回到退回它的那个人"就是第四行:条件字段改了,单子没掉进 amount<=1000 那条边,也没回部门审批重走。

顺带交代一个 mldong 侧的老约定,因为它在这儿会咬人:表单字段带 f_ 前缀,条件表达式吃的是裸变量 。我这几步是走门面接口提交的,每次都同时给 amount(决策节点看)和 f_amount(表单渲染看)。如果只改了 f_amount 而没人替你同步 amount,决策节点看到的还是旧值------这不是直返的问题,任何带条件分支的流程都一样。至于审批界面自己会不会把表单金额回填成决策变量,我这次没测,别拿我的例子去推断界面行为。

上面这两格是同一份 JSON 的另一次走查(全程点界面,实例 95779170316800),跟下面这张账不是同一张单。

再看账。办结后 processInstance/approvalRecord 回来 5 条(第 3 行是赵六退回那一跳,第 4 行是张三重提那一跳):

ini 复制代码
发起申请   operator=flow.auto  submitType=0  taskState=20
部门审批   operator=leader     submitType=1  taskState=20
高额审批   operator=director   submitType=6  taskState=20
发起申请   operator=user1      submitType=5  taskState=20
高额审批   operator=director   submitType=1  taskState=20

这张图里第 4 行(张三"重新提交")的意见印的是赵六那句------因为界面上那个视图没有审批意见输入框,我重提时没填,它沿用了上一轮的 tf_approvalComment。看账只看节点、处理人和操作这三列。

三条口径值得单独说,因为它们是"账能对上"的原因:

  • 实例从头到尾是同一个 ,业务单据没换,退回时实例仍是 10 进行中,没有变成"已拒绝"(45)再重开。
  • 赵六退回那条是历史行 (taskState=20,留痕 submitType=6),重提后新建的是一条新的高额审批待办。没有把旧任务改回待办,也没有伪造李四的第二次同意。
  • 新待办的血缘指向本轮的申请任务,taskParentId 能一路倒着查回去;李四那格在流转记录里只有一条,不会重复出现。

顺手做一个对照:同一份流程,如果退回发生在「普通审批」(没配 reapplyMode),王五退回给张三,张三把金额从 666 改成 6666 重提,单子回到的是部门审批------老老实实从头重走。默认行为一行都没动,这是配置出来的分支,不是引擎改脾性。

四、金额改小为什么不重算

这是整个功能里唯一一处需要拍板的地方,也是实现者最容易做歪的地方。

直返的语义是不重放来源节点之前的路径。来源之前有一个条件分支,那就意味着这次重提不重新求值它------所以金额从 6666 改成 666,单子仍回总监。

而来源节点之后 的条件节点照常求值。引擎夹具里那条流程接着还有一个二级条件(level > 1 走 X、否则走 Y),赵六同意后走到那儿,代入的是重提时改成 666 之后的数据,该走哪条走哪条。一句话:改数据只影响未来的判断,不影响已经决定的路由。

业务上为什么这样合理:赵六退回去的理由是"缺行程单",他要复核的是同一份材料。张三补完交回来,引擎再替他算一遍"这单子该给谁"是多余的,尤其算出来的结果可能把赵六换掉------他刚退回的单子换个人批,退回这个动作就失去意义了。

反过来的业务需求也确实存在:有些公司要求"金额降下来就该走更便宜的通道"。那这条流程就别配直返,配 NORMAL,接受从头重走。要不要重算是流程设计者定的,不该让引擎猜。这也是为什么它做成节点属性而不是一条全局开关。

还有一条容易被忽略:回到来源节点不等于交回给同一个人 。新待办的参与者是按来源节点的规则重新解析的。我这次跑的时候,高额审批那格的参与者解析出来是 ["director","boss"] 两个人------赵六退回、张三重提后,这条待办钱七也能抢走。如果业务要求"谁退回就必须谁批",那得去改那格的参与者规则,引擎不替你锁人。

五、实现上最省的一件事:没加表没加列

退回之后要能回到来源,得记住"来源是谁"。最直觉的做法是给任务表加列,或者建一张"退回记录表"。jeeflow 这轮两个都没建,把两个控制键塞进任务变量 JSON 的保留键里:

_reapplyContext 记退回绑定,退回时写在引擎新建的那条申请待办上。这是我从真机响应里原样摘的 ext(就是上面那张账的同一轮,来源行 id 95788036965578):

json 复制代码
"_reapplyContext": {
  "mode": "RETURN_TO_SOURCE",
  "sourceTaskId": "95788036965578",
  "sourceNode": "taskHigh",
  "branch": []
}

同一次响应里 instanceExt 没有这两个键,任务变量的往返也不动表结构------同一张单子上一轮的退回留痕(submitType=6 和那句审批意见)仍在来源任务自己的历史行里。

并行分支用另一个键 _branchCtx 记分支归属({fork, join, round, root}),用来区分"同一节点被重复执行"和"不同分支各自退回"。存量任务没这两个键时,汇合处回落到既有判据,行为不变。

三条边界里,前两条我在 Python 演示站逐条真跑;第三条 Java 和 Python 的报错时机不一样,两边原文都贴出来。

一、带直返标记的申请待办只接受 submitType=5。我拿那张待办改用普通同意提交:

ini 复制代码
code=99999999
msg=带重提直返标记的申请待办只接受重新提交(submitType=5)

拒完之后张三的申请待办还在,赵六手上也没冒出新单。跳转、拒绝、再次退回发起人这几个入口同样要先过这道闸、且都在状态变更之前拒------这是发版记录里立的口径,我这次只实测了"普通同意"这一个入口。

二、客户端自己传同名保留键没用 。我发起时往参数里塞了一个假的 _reapplyContext,把 sourceNode 写成 taskLow(想让重提落到经理那格)。单子正常走到高额审批,赵六退回后,引擎在新建申请待办上写的是:

json 复制代码
"_reapplyContext": {
  "sourceTaskId": "95778104956638",
  "sourceNode": "taskHigh", ...
}

sourceNode 是 taskHigh,不是我伪造的 taskLow;重提之后总监手上多了待办,经理和部门都是空的。入参里的同名键先剥离,路由只认引擎自己写的那份。

三、配错值不会被静默当成缺省,但报在哪一步两种实现不一样 。把 reapplyMode 故意写成 RETURN_TO_SOURSE(少一个字母)。我在 Python 演示站上跑的是:deploy 不报错、起单不报错、单子也照样走到这一格,报错发生在有人点"退回发起人"那一次点击------

ini 复制代码
code=99999999
msg=节点[taskHigh]的 reapplyMode 配置非法: RETURN_TO_SOURSE

退完再看,赵六那格待办还在,张三没收到单。而 Java 参考实现更早:解析流程定义时就抛,仓里那条用例的断言原话是"非法 reapplyMode 必须在解析期报错"。同一份配置,两种时点,配错值时别拿另一栈的经验去猜你什么时候会看见它。

并发这格要说清楚它的边界 。同一张单上的办理是串行的:Python 侧是一枚按实例 id 建的 asyncio.Lock,Java 侧有一条"两个线程同时重提同一张单、只许一个成功"的用例。⇒ 两条都是进程内的互斥,多进程、多实例部署还得你自己在仓储侧出跨进程锁,引擎没带这一层。另外这条我只在引擎用例里读到(Java 那条跑的是内存仓储),没在演示站上做双线程真跑。

六、还有一格:分支里的退回

并行分支里退回发起人,行为是:其他分支继续办不受影响,汇合点等这张改完的单子回来,等齐了才放行,且只放行一次。嵌套并行时,来源节点配对的汇合点取的是"最外层那个、拿掉它之后流程就走不通的汇合点",这样外层的汇合不会被内层的分叉错认、提前放行。

这两格我这次只在引擎夹具(并行那份 15-return-to-source-fork.json、嵌套那份 18-return-to-source-nested.json)的用例里验过,没在演示站上做真机走查------所以这里只陈述契约。你要在自己的分支形状上落地,可以照这两份夹具的结构造一个同形状的样本先跑一遍。

另外两处如实说:App 端这次没做真机走查;各语言仓里都放了直返的测试文件(Java/Python/Rust/MoonBit/C# 我点到了文件名,Go/Node/PHP 没逐个打开看),但真库 JDBC 那一路只在 Java 上实测过。

落地清单

  1. 先确认版本带不带这个键:装好的引擎里搜 reapplyMode,或看文档站更新日志里你那一语言从哪版起带上它。
  2. 只在普通审批节点 上配 RETURN_TO_SOURCE,会签节点和子流程这一版不支持;确认流程里申请节点只有一个。
  3. 前端"退回发起人"那个按钮要打 submitType=6,发起人重提打 submitType=5;别用 3/4 那两条跳转入口模拟直返。
  4. 表单字段带 f_ 前缀、条件表达式吃裸变量,两边都要写值,否则决策看到的还是旧值。
  5. 上线前跑一遍你自己的样本:中间某格退回 → 改数据 → 重提,看新待办落在退回它的那一格,且前序已办的人没有新待办。

参考资料

jeeflow 文档站 规范 04「引擎操作」------退回修改、重提直返(reapplyMode)条文 jeeflow-doc.mldong.com/spec/04-eng...

jeeflow 文档站 更新日志(八引擎各自代次) jeeflow-doc.mldong.com/changelog/

jeeflow Java 参考实现 github.com/mldong/jeef...

八语言演示站(在线可试;预置流程里 14/15/16/18-return-to-source*.json 四份是直返夹具,其中「条件分支退回直返」跟这篇场景最接近) jeeflow-demo.mldong.com/

相关推荐
陈随易1 小时前
一个网吧网管,做了个网页,卖了 5000 万
前端·后端·程序员
深蓝AI10 小时前
Python 3.13 的 Queue.shutdown:异步消费者终于能正常收工了
后端
武子康11 小时前
MultiNet6 空闲崩溃:`EXCVADDR=0x8` 后又出现 `CORRUPT HEAP`
人工智能·后端·agent
海宇服务11 小时前
零信任架构实战:基于海宇全能小微企业报告构建自动化园区商户免押租赁资信核验网关
大数据·人工智能·架构·自动化
武子康12 小时前
MultiNet7 每轮都 clean,ESP32-P4 内存为什么还在掉?
人工智能·后端·agent
zhangfeng113312 小时前
6篇RAG核心论文的工程启示:Vector + Graph + Agent路由混合架构实战
人工智能·架构·llm·agent
超级架构师12 小时前
PRAXO-CORE 2.5.0:用 conformance 和显式边界构建 Go Agent 框架
开发语言·人工智能·后端·golang·ai编程·智能体框架·praxo-core
陈随易13 小时前
AI 运维服务器,安全又可靠的方法
前端·后端·程序员
Decay__13 小时前
09_MySQL 闪回与时间点恢复 —— binlog 回放、mysqlbinlog、PITR 与误删恢复
运维·数据库·sql·mysql·架构