每个和"报销"打过交道的后端,都写过这样的代码:
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。)
三个值得注意的细节:
assignee: "applicant"是一个特殊值,意思是"这个节点归流程发起人"——发起申请节点由发起人自己完成,引擎在"发起"这个动作里会自动替你办掉它;- 表达式写在边上,不写在节点上——决策节点本身只是一根分叉的电线杆,走哪条线由每条出边的
expr说了算; 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" |
| 两张单的实例 id | 14 位雪花串 | 96 / 99 | 16 位雪花串 |
| 大额单走向 | 经理审批 | 经理审批 | 经理审批 |
| 小额单走向 | 总监审批 | 总监审批 | 总监审批 |
| 归档状态 | 20 | 20 | 20 |
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,现在是时候把它们请出去了。
参考资料(往期相关篇目与链接):
- jeeflow 文档站:jeeflow-doc.mldong.com
- 本文示例仓库:github.com/mldong/jeef… 壳 + flows/03-decision-expr.json)
- 前端工作台:github.com/mldong/jeef…
- Go / Node 实现:github.com/mldong/jeef… · github.com/mldong/jeef…
- 在线演示:开源演示站 jeeflow-demo.mldong.com · 集成演示站 jeeflow-pro.mldong.com
- 往期:《一份 LogicFlow JSON 全解》(流程定义结构)·《你的 Vue3 项目也能有钉钉同款审批流设计器》(设计器那条路)·《Node 开发者也有自己的轻量工作流引擎》(id 字符串契约与 decision 边界)·《零依赖的秘密:8 大 SPI 设计》(表达式求值器外置)