一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎

0 阅读8分钟

每个和"报销"打过交道的后端,都写过这样的代码:

if (amount > 1000) { 派给经理审批(); } else { 派给总监审批(); }
经理审批通过后还要判断:if (驳回) { 退回发起人(); } ...

第一年还好,第三年你也说不清全系统有多少个这样的 if——改一次审批权限,全库搜索 manager

这篇文章做的事,是把这条 if/else 完整地交给工作流引擎:流程写成一份 JSON,交给引擎部署,然后发起两张金额不同的报销单——一张 6666 元,一张 666 元——你会看到引擎自己把大额单派给经理、小额单派给总监,审批动作在前端点完,单子归档,全程留痕。

全过程不需要写一行流转代码,命令可以逐条复制粘贴。开始。

一、这条报销流程长什么样

流程不长,一屏画得下:

开始 → 发起申请 → 填写报销单,然后进入一个决策节点,身上挂着两条带表达式的出边:

  • amount > 1000 → 经理审批
  • amount <= 1000 → 总监审批

两条审批路殊途同归到结束节点。对应的 JSON(摘自 jeeflow 内置的 15 个共享示例,编解码结构在《一份 LogicFlow JSON 全解》那篇里逐节点拆过,这里只看关键部位):

{
  "name": "decision-expr",
  "displayName": "决策表达式流程",
  "type": "approval",
  "nodes": [
    { "id": "apply",  "type": "snaker:task", "text": { "value": "发起申请" },
      "properties": { "form": "apply-form", "assignee": "applicant" } },
    { "id": "task1",  "type": "snaker:task", "text": { "value": "填写报销单" },
      "properties": { "form": "expense-form", "assignee": "leader" } },
    { "id": "decision1", "type": "snaker:decision", "text": { "value": "金额>1000?" } },
    { "id": "task2",  "type": "snaker:task", "text": { "value": "经理审批" },
      "properties": { "assignee": "manager" } },
    { "id": "task3",  "type": "snaker:task", "text": { "value": "总监审批" },
      "properties": { "assignee": "director" } }
  ],
  "edges": [
    { "id": "e3", "sourceNodeId": "decision1", "targetNodeId": "task2",
      "properties": { "expr": "amount > 1000" } },
    { "id": "e4", "sourceNodeId": "decision1", "targetNodeId": "task3",
      "properties": { "expr": "amount <= 1000" } }
  ]
}

(为省篇幅,节点的坐标字段 x/y 和边里无表达式的普通连线省略了,完整文件在仓库 flows/03-decision-expr.json。)

三个值得注意的细节:

  1. assignee: "applicant" 是一个特殊值,意思是"这个节点归流程发起人"——发起申请节点由发起人自己完成,引擎在"发起"这个动作里会自动替你办掉它;
  2. 表达式写在边上,不写在节点上——决策节点本身只是一根分叉的电线杆,走哪条线由每条出边的 expr 说了算;
  3. amount 是个裸变量名——它不在 JSON 里,发起流程时由调用方塞进来。引擎只认变量,不关心变量从表单来还是从数据库来。

就这些。接下来把它跑起来。

二、先起一个引擎

jeeflow 是嵌入式引擎,正常用法是装进你的应用里(pip / go get / npm 都行,往期有 per-language 的上手文)。这篇文章为了聚焦"流程本身",用仓库自带的 demo 壳——内存存储、零配置、启动即带上面那份流程在内的 15 个预置流程:

git clone https://github.com/mldong/jeeflow-python.git
cd jeeflow-python
pip install fastapi uvicorn
python demo/main.py

看到健康检查就绪:

$ curl http://localhost:8100/healthz
{"status":"UP","backend":"python"}

demo 起在本机 8100 端口。顺手把前端也起了(Vue3 写的审批工作台,可选,第六节会用):

git clone https://github.com/mldong/jeeflow-ui.git
cd jeeflow-ui
pnpm install && pnpm dev    # http://localhost:5173

这个工作台右上角有一排语言切换(Java / Go / Python / Node / PHP / Rust / MoonBit / C#),切到 Python 就是刚才起的这个后端。右上角的用户切换里有八位具名用户:张三(工程师)、李四(组长)、王五(经理)、赵六(总监)……后面审批要挨个上场。

三、上线:一次 deploy,JSON 变成流程定义

引擎里"可发起的流程"叫流程定义。把 JSON 交给引擎用的是 deploy 接口,content 字段就是那份 JSON 的原文:

# 用 python 把 JSON 文本包进请求体(转义的事交给 json.dumps)
python -c "import json;print(json.dumps({'operator':'user1','content':open('03-decision-expr.json',encoding='utf-8').read()}))" > deploy.json

curl -X POST http://localhost:8100/wf/processDefine/deploy \
  -H "Content-Type: application/json" \
  --data @deploy.json
{"code":0,"msg":"成功","data":{"processDefineId":"111"}}

code=0 是这套引擎的统一成功信封(对齐 mldong 框架契约,错误也是统一的 code=99999999)。拿到 processDefineId: "111"——注意它是字符串,雪花 id 超出 JavaScript 的安全整数范围,全链路按字符串传(Node 上手文里专门写过这个坑,这里不展开)。

打开前端「流程定义」页,能看到这次部署的战果:

列表里的 decision-expr 显示 v2——因为 demo 启动时已预置了它的 v1。deploy 的版本规则很朴素:同名流程再部署,版本 +1,老版本定义和历史实例都还在,新发起的单子走新版本。改流程不用怕"在线单子怎么办",这就是引擎替你管的第一件事。

顺带一提:不想手写 JSON 的话,前端「流程设计」页可以画完直接点"发布",落的是同一个接口。画布那条路在钉钉风格设计器那篇里讲透了,本文专注 JSON 这条最短路径。

四、发起:两张金额不同的报销单

发起流程的接口是 startAndExecute——"发起并执行",指的正是第一节说的:引擎自动把 assignee: applicant 的申请节点替发起人办掉,流程直接推进到下一个节点。金额、事由这些业务变量,直接平铺在请求体里:

# 大额单:6666 元
curl -X POST http://localhost:8100/wf/processDefine/startAndExecute \
  -H "Content-Type: application/json" \
  -d '{"processDefineId":"111","operator":"user1","amount":6666,
       "f_reason":"客户现场支持差旅报销","f_category":"travel","f_amount":6666}'

{"code":0,"msg":"成功","data":{"processInstanceId":"91789618766944"}}

# 小额单:666 元
curl -X POST http://localhost:8100/wf/processDefine/startAndExecute \
  -H "Content-Type: application/json" \
  -d '{"processDefineId":"111","operator":"user1","amount":666,
       "f_reason":"团队团建餐饮报销","f_category":"meal","f_amount":666}'

{"code":0,"msg":"成功","data":{"processInstanceId":"91789619039331"}}

两条实例拿到了。请求体里的变量名看着有两拨,是有意为之:

  • amount:给流程引擎用的。决策节点的表达式读的就是它;
  • f_reason / f_amount / f_category:给表单展示用的。f_ 前缀是 mldong 系的表单字段契约,前端渲染"申请信息"时按这个前缀取数(好比把"给机器读的"和"给人看的"分成两本账)。

发完之后引擎其实已经干了不少活:u_* 开头的发起人信息(姓名、部门、岗位)被自动注入变量,还顺手生成了单据标题 张三的决策表达式流程-2026-09-17 17:48——发起人不用填标题,这种脏活引擎包了。

此刻两张单都停在「填写报销单」,处理人是李四(assignee: leader)。

五、分流:金额决定谁审批

李四的待办里现在躺着两张「填写报销单」。他把两张都提交同意(submitType: 1 是这套契约里的"同意"):

curl -X POST http://localhost:8100/wf/processTask/execute \
  -H "Content-Type: application/json" \
  -d '{"processTaskId":"<任务id>","operator":"leader","submitType":1}'

魔法发生在提交后的下一秒:流程推进到决策节点,引擎逐条试出边上的表达式,变量表里 amount=6666 的那条实例命中 amount > 1000,走向经理审批;amount=666 的那条命中 amount <= 1000,走向总监审批。分别看两个人的待办:

$ curl -X POST http://localhost:8100/wf/processTask/todoList ... -d '{"operator":"manager"}'
经理审批  ← 实例 91789618766944(6666 元)

$ curl -X POST ... -d '{"operator":"director"}'
总监审批  ← 实例 91789619039331(666 元)

王五的待办里只有大额单,赵六的待办里只有小额单。同一份流程定义,两张单走出了两条不同的路——分流的判断一行代码没写,它是流程定义的一部分。

在前端「我发起的」里点开两条单的流程图,高亮路径把这件事说得更直白:

大额单绿色高亮压在「经理审批 · 王五」上,小额单压在「总监审批 · 赵六」上,条件分支的边标签(金额>1000 / 金额≤1000)直接画在图里。这张高亮图也不是前端自己算的——引擎提供独立的 processInstance/highLight 端点,前端只是把结果画出来。

六、批完:从待办到归档

剩下就是审批人的日常。王五登录工作台,待办里那张大额单点「办理」,抽屉里申请信息(事由、金额、类别——就是发起时那些 f_ 变量)和报销单表单都在,填一句意见,点「同意」:

赵六那边同样操作。两张单随即归档,实例状态到 20(已完成)——这套契约里 10 是进行中、20 已完成、45 被驳回,和前端角标是同一套语言。

再点开「审批记录」,这条单的一生都在:

张三发起申请 → 李四填写报销单(同意)→ 王五经理审批(同意),谁在什么时候做了什么,一条时间线。审批不再是散落在业务表里的状态字段,而是一条引擎替你记好的链路。

七、同一份 JSON,换个语言再跑一遍

上面全程跑在 Python 引擎上。把同一个 deploy 请求、同两条发起请求原样发给 Go 和 Node 的 demo(各自 git clone 后一条命令起服务,端口 8081 / 8082):

Python (:8100)Go (:8081)Node (:8082)
deploy 返回的定义 id"111""2""1"
两张单的实例 id14 位雪花串96 / 9916 位雪花串
大额单走向经理审批经理审批经理审批
小额单走向总监审批总监审批总监审批
归档状态202020

id 的"长相"各栈不同(内存仓储的起始序列不一样),但行为完全一致:同一条表达式、同样的分流、同样的状态机。这不是巧合——几个语言引擎共享同一份流程定义规范和一套跨语言契约测试,任何行为漂移都会在发版前被拦下。多语言联邦怎么对齐,方法论那篇有完整叙述。

对你的意味着什么:流程资产是语言无关的。今天用 Python 起 demo,明天团队换了 Go,这份 decision-expr.json 原封不动搬过去。

八、三个值得知道的边界

教程走完了,有三件事需要知道,免得拿去真实项目时踩坑:

1. 从前端发起的话,表达式要跟着 f_ 走。 前端发起抽屉会把表单字段统一加上 f_ 前缀再提交——于是实例变量里只有 f_amount、没有裸 amount,决策节点的 amount > 1000 两边都不命中。我们实弹试了一发:只传 f_amount=666 发起一张小额单,李四提交后它走向了经理审批——小额单走了大额通道,流程还在跑,结果已经错了。两种改法:表达式写成 f_amount > 1000 跟表单契约对齐;或者像本文一样发起走 API、把裸变量直接喂给引擎。核心是记住:表达式读的是引擎变量表,变量叫什么名字,要和写表达式的人对齐

2. demo 的表达式求值器是个简易桩。 它只认 变量名 比较符 数字 这一种形态。表达式求值在 jeeflow 里是一个 SPI 点——生产上你可以接 SpEL、Aviator、或任何你顺手的表达式引擎,流程 JSON 不用改(SPI 设计那篇讲过引擎为什么把这类能力全部外置)。反过来说,别拿 demo 求值器的表达力去推测引擎的上限。

3. 表达式全不命中时,引擎不会替你猜。 逐边试完都为 false 时,这颗 Python 引擎会走"无表达式的默认边",连默认边都没有就退到第一条边——单子继续走,但走向已经不保证是你想的那条。这类边界语义在多语言引擎之间存在允许范围内的差异(Node 上手文里记录过"无求值器时 Node 停在原地 vs Python 走第一条边"的两副面孔),接入前值得花十分钟把你所用栈的决策语义读一遍,或者在流程里永远给决策节点留一条无表达式的默认边。

写在最后

回顾一下这条最短路径:

一份 JSON  →  deploy(变成流程定义,带版本)
           →  startAndExecute(发起,引擎代办申请节点)
           →  引擎按变量分流、按 assignee 派单
           →  审批人在前端办理,归档留痕

业务代码从"审批流转"里彻底退场,只剩两件事:把单据数据塞给引擎,以及接住引擎派给特定人的待办。审批权限调整从"改代码发版"变成"改一份 JSON 重新 deploy"。

如果你的系统里还住着几个手写的审批 if,现在是时候把它们请出去了。

参考资料(往期相关篇目与链接):