[{"data":1,"prerenderedAt":671},["ShallowReactive",2],{"rainink-post-workflow-engine-notes":3,"rainink-content-index":531},{"id":4,"title":5,"body":6,"category":515,"cover":516,"date":517,"description":518,"draft":519,"extension":520,"featured":519,"lang":516,"meta":521,"navigation":522,"order":516,"path":523,"period":516,"pinned":519,"repo":516,"seo":524,"slug":516,"stack":516,"stem":525,"tags":526,"thumb":516,"toc":522,"updated":516,"__hash__":530},"content\u002Fposts\u002Fworkflow-engine-notes.md","工作流引擎实操笔记：节点、流转与幂等",{"type":7,"value":8,"toc":506},"minimark",[9,16,20,37,43,46,49,110,250,253,260,395,399,402,427,449,452,455,475,478,481,489,492,495,502],[10,11,12],"blockquote",{},[13,14,15],"p",{},"工作流引擎的复杂度不在「跑通一条流程」，而在「流程改了之后，在途单据怎么办」。",[17,18,19],"h2",{"id":19},"先分清状态机与工作流",[21,22,23,31],"ul",{},[24,25,26,30],"li",{},[27,28,29],"strong",{},"状态机","：状态与迁移在代码里写死，改动需要发版，适合稳定的审批路径。",[24,32,33,36],{},[27,34,35],{},"工作流","：节点与连线可配置，改动只需改配置，适合审批路径经常调整的场景。",[13,38,39,40],{},"多数业务系统的正确姿势是：",[27,41,42],{},"用状态机守住业务语义，用工作流驱动流转顺序。",[17,44,45],{"id":45},"节点模型",[13,47,48],{},"一个够用的节点模型只需要四个要素：",[50,51,52,65],"table",{},[53,54,55],"thead",{},[56,57,58,62],"tr",{},[59,60,61],"th",{},"要素",[59,63,64],{},"说明",[66,67,68,80,90,100],"tbody",{},[56,69,70,77],{},[71,72,73],"td",{},[74,75,76],"code",{},"nodeKey",[71,78,79],{},"节点唯一标识，配置与运行数据都靠它关联",[56,81,82,87],{},[71,83,84],{},[74,85,86],{},"type",[71,88,89],{},"审批 \u002F 抄送 \u002F 条件 \u002F 开始 \u002F 结束",[56,91,92,97],{},[71,93,94],{},[74,95,96],{},"assignee",[71,98,99],{},"处理人规则（指定人、角色、发起人上级）",[56,101,102,107],{},[71,103,104],{},[74,105,106],{},"listeners",[71,108,109],{},"进入或离开节点时触发的动作",[111,112,117],"pre",{"className":113,"code":114,"language":115,"meta":116,"style":116},"language-ts shiki shiki-themes github-light github-dark","interface FlowNode {\n  nodeKey: string\n  type: 'start' | 'approve' | 'cc' | 'condition' | 'end'\n  assignee?: { mode: 'user' | 'role' | 'manager', value: string }\n  onEnter?: string[]   \u002F\u002F 事件名，交给监听器注册表处理\n}\n","ts","",[74,118,119,136,150,183,227,244],{"__ignoreMap":116},[120,121,124,128,132],"span",{"class":122,"line":123},"line",1,[120,125,127],{"class":126},"szBVR","interface",[120,129,131],{"class":130},"sScJk"," FlowNode",[120,133,135],{"class":134},"sVt8B"," {\n",[120,137,139,143,146],{"class":122,"line":138},2,[120,140,142],{"class":141},"s4XuR","  nodeKey",[120,144,145],{"class":126},":",[120,147,149],{"class":148},"sj4cs"," string\n",[120,151,153,156,158,162,165,168,170,173,175,178,180],{"class":122,"line":152},3,[120,154,155],{"class":141},"  type",[120,157,145],{"class":126},[120,159,161],{"class":160},"sZZnC"," 'start'",[120,163,164],{"class":126}," |",[120,166,167],{"class":160}," 'approve'",[120,169,164],{"class":126},[120,171,172],{"class":160}," 'cc'",[120,174,164],{"class":126},[120,176,177],{"class":160}," 'condition'",[120,179,164],{"class":126},[120,181,182],{"class":160}," 'end'\n",[120,184,186,189,192,195,198,200,203,205,208,210,213,216,219,221,224],{"class":122,"line":185},4,[120,187,188],{"class":141},"  assignee",[120,190,191],{"class":126},"?:",[120,193,194],{"class":134}," { ",[120,196,197],{"class":141},"mode",[120,199,145],{"class":126},[120,201,202],{"class":160}," 'user'",[120,204,164],{"class":126},[120,206,207],{"class":160}," 'role'",[120,209,164],{"class":126},[120,211,212],{"class":160}," 'manager'",[120,214,215],{"class":134},", ",[120,217,218],{"class":141},"value",[120,220,145],{"class":126},[120,222,223],{"class":148}," string",[120,225,226],{"class":134}," }\n",[120,228,230,233,235,237,240],{"class":122,"line":229},5,[120,231,232],{"class":141},"  onEnter",[120,234,191],{"class":126},[120,236,223],{"class":148},[120,238,239],{"class":134},"[]   ",[120,241,243],{"class":242},"sJ8bj","\u002F\u002F 事件名，交给监听器注册表处理\n",[120,245,247],{"class":122,"line":246},6,[120,248,249],{"class":134},"}\n",[17,251,252],{"id":252},"流转与条件",[13,254,255,256,259],{},"条件节点的判断逻辑建议抽成",[27,257,258],{},"纯函数 + 表达式白名单","，不要让配置里塞任意脚本。",[111,261,263],{"className":113,"code":262,"language":115,"meta":116,"style":116},"\u002F\u002F 只允许有限的操作符，避免配置被当成代码执行\nconst OPS = {\n  '>': (a: number, b: number) => a > b,\n  '===': (a: unknown, b: unknown) => a === b,\n  in: (a: string, b: string[]) => b.includes(a),\n}\n",[74,264,265,270,283,323,356,391],{"__ignoreMap":116},[120,266,267],{"class":122,"line":123},[120,268,269],{"class":242},"\u002F\u002F 只允许有限的操作符，避免配置被当成代码执行\n",[120,271,272,275,278,281],{"class":122,"line":138},[120,273,274],{"class":126},"const",[120,276,277],{"class":148}," OPS",[120,279,280],{"class":126}," =",[120,282,135],{"class":134},[120,284,285,288,291,294,296,299,301,304,306,308,311,314,317,320],{"class":122,"line":152},[120,286,287],{"class":160},"  '>'",[120,289,290],{"class":134},": (",[120,292,293],{"class":141},"a",[120,295,145],{"class":126},[120,297,298],{"class":148}," number",[120,300,215],{"class":134},[120,302,303],{"class":141},"b",[120,305,145],{"class":126},[120,307,298],{"class":148},[120,309,310],{"class":134},") ",[120,312,313],{"class":126},"=>",[120,315,316],{"class":134}," a ",[120,318,319],{"class":126},">",[120,321,322],{"class":134}," b,\n",[120,324,325,328,330,332,334,337,339,341,343,345,347,349,351,354],{"class":122,"line":185},[120,326,327],{"class":160},"  '==='",[120,329,290],{"class":134},[120,331,293],{"class":141},[120,333,145],{"class":126},[120,335,336],{"class":148}," unknown",[120,338,215],{"class":134},[120,340,303],{"class":141},[120,342,145],{"class":126},[120,344,336],{"class":148},[120,346,310],{"class":134},[120,348,313],{"class":126},[120,350,316],{"class":134},[120,352,353],{"class":126},"===",[120,355,322],{"class":134},[120,357,358,361,363,365,367,369,371,373,375,377,380,382,385,388],{"class":122,"line":229},[120,359,360],{"class":130},"  in",[120,362,290],{"class":134},[120,364,293],{"class":141},[120,366,145],{"class":126},[120,368,223],{"class":148},[120,370,215],{"class":134},[120,372,303],{"class":141},[120,374,145],{"class":126},[120,376,223],{"class":148},[120,378,379],{"class":134},"[]) ",[120,381,313],{"class":126},[120,383,384],{"class":134}," b.",[120,386,387],{"class":130},"includes",[120,389,390],{"class":134},"(a),\n",[120,392,393],{"class":122,"line":246},[120,394,249],{"class":134},[17,396,398],{"id":397},"幂等工作流逃不掉的一课","幂等：工作流逃不掉的一课",[13,400,401],{},"审批按钮被点两次、消息重投、定时补偿重跑 —— 这些都会导致同一个节点被处理多次。三条防线：",[403,404,405,415,421],"ol",{},[24,406,407,410,411,414],{},[27,408,409],{},"业务唯一键","：",[74,412,413],{},"流程实例 + 节点 + 处理人"," 组成唯一索引，重复操作直接冲突失败；",[24,416,417,420],{},[27,418,419],{},"状态前置校验","：处理前检查实例状态是否仍处于该节点；",[24,422,423,426],{},[27,424,425],{},"到期补偿任务幂等","：补偿任务本身也要能重复执行而不产生副作用。",[111,428,432],{"className":429,"code":430,"language":431,"meta":116,"style":116},"language-sql shiki shiki-themes github-light github-dark","-- 唯一索引挡住重复提交\nCREATE UNIQUE INDEX uk_task_node_handler\n  ON wf_task (instance_id, node_key, handler_id);\n","sql",[74,433,434,439,444],{"__ignoreMap":116},[120,435,436],{"class":122,"line":123},[120,437,438],{},"-- 唯一索引挡住重复提交\n",[120,440,441],{"class":122,"line":138},[120,442,443],{},"CREATE UNIQUE INDEX uk_task_node_handler\n",[120,445,446],{"class":122,"line":152},[120,447,448],{},"  ON wf_task (instance_id, node_key, handler_id);\n",[17,450,451],{"id":451},"在途单据怎么办",[13,453,454],{},"流程改版是常态，处理方式有三种，按风险从低到高：",[21,456,457,463,469],{},[24,458,459,462],{},[27,460,461],{},"版本隔离","：新流程新版本号，在途单据继续跑老版本（推荐，改动最安全）。",[24,464,465,468],{},[27,466,467],{},"节点映射","：把老节点映射到新节点，需要人工维护映射表。",[24,470,471,474],{},[27,472,473],{},"强制重算","：高风险，仅适用于流程很短、在途单据极少的场景。",[17,476,477],{"id":477},"可观测性",[13,479,480],{},"工作流出问题时，最需要的是一条时间线，而不是日志海洋：",[111,482,487],{"className":483,"code":485,"language":486,"meta":116},[484],"language-text","[2026-08-30 10:12:03] 实例 I-1001 创建，发起人 u_23\n[2026-08-30 10:12:04] 进入节点 approve_1，处理人 u_07\n[2026-08-30 10:30:11] 节点 approve_1 通过，意见：同意\n[2026-08-30 10:30:11] 进入节点 approve_2（条件命中 amount > 5000）\n","text",[74,488,485],{"__ignoreMap":116},[13,490,491],{},"把每次流转都记为一条事件，界面上就能直接渲染出流程图上的轨迹。",[17,493,494],{"id":494},"小结",[13,496,497,498,501],{},"工作流引擎的选型不是重点，",[27,499,500],{},"幂等、版本、可观测性","这三件事才是长期成本所在。前两个决定系统稳不稳，最后一个决定排查问题要花多久。",[503,504,505],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":116,"searchDepth":138,"depth":138,"links":507},[508,509,510,511,512,513,514],{"id":19,"depth":138,"text":19},{"id":45,"depth":138,"text":45},{"id":252,"depth":138,"text":252},{"id":397,"depth":138,"text":398},{"id":451,"depth":138,"text":451},{"id":477,"depth":138,"text":477},{"id":494,"depth":138,"text":494},"tech",null,"2026-08-30","从状态机到可配置流程：一次工作流引擎落地中关于节点模型、流转条件、幂等与补偿的实操记录。",false,"md",{},true,"\u002Fposts\u002Fworkflow-engine-notes",{"title":5,"description":518},"posts\u002Fworkflow-engine-notes",[35,527,528,529],"后端","架构","幂等","DSh1pIRP555pFSZYdvJSlPAUX6zER7nTh6QJubJX3oo",{"all":532,"posts":626,"essays":636,"projects":643,"tags":653},[533,549,560,564,576,585,602,616],{"title":534,"description":535,"path":536,"slug":537,"category":515,"categoryLabel":538,"kind":539,"tags":540,"date":545,"updated":546,"minutes":229,"pinned":522,"draft":519,"order":547,"stack":548,"period":116,"repo":116,"thumb":116},"用 Nuxt 4 + Nuxt Content 搭一个纯静态个人站","站点骨架的取舍：内容目录怎么定、SSG 怎么预渲染、设计令牌怎么从原型抽成一套 CSS 变量，以及首屏主题闪烁的解法。","\u002Fposts\u002Fnuxt4-static-site","nuxt4-static-site","技术","post",[541,542,543,544],"Nuxt","Nuxt Content","SSG","前端工程化","2026-10-12","2026-10-18",0,[],{"title":550,"description":551,"path":552,"slug":553,"category":515,"categoryLabel":538,"kind":539,"tags":554,"date":558,"updated":116,"minutes":229,"pinned":519,"draft":519,"order":547,"stack":559,"period":116,"repo":116,"thumb":116},"B 端后台底座：把权限、菜单、请求收敛成三层","后台系统做多了会发现，真正需要沉淀的不是页面，而是权限模型、动态菜单和请求层这三块底座。","\u002Fposts\u002Fvue3-admin-base","vue3-admin-base",[555,556,528,557],"Vue","后台","权限","2026-09-24",[],{"title":5,"description":518,"path":523,"slug":561,"category":515,"categoryLabel":538,"kind":539,"tags":562,"date":517,"updated":116,"minutes":229,"pinned":519,"draft":519,"order":547,"stack":563,"period":116,"repo":116,"thumb":116},"workflow-engine-notes",[35,527,528,529],[],{"title":565,"description":566,"path":567,"slug":568,"category":569,"categoryLabel":570,"kind":539,"tags":571,"date":574,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":547,"stack":575,"period":116,"repo":116,"thumb":116},"上半年读书清单：几本反复翻的技术书","上半年读完与重读的几本书，以及它们各自解决了我在工程实践中的哪个具体问题。","\u002Fposts\u002Freading-2026-h1","reading-2026-h1","life","随笔",[572,570,573],"阅读","效率","2026-07-06",[],{"title":577,"description":578,"path":579,"slug":580,"category":569,"categoryLabel":570,"kind":539,"tags":581,"date":583,"updated":116,"minutes":138,"pinned":519,"draft":519,"order":547,"stack":584,"period":116,"repo":116,"thumb":116},"我的写作与输入工具链","从收集、整理到成稿：一套尽量少依赖工具的写作流程，以及几个用了三年以上没换过的选择。","\u002Fposts\u002Ftools-and-writing","tools-and-writing",[582,573,570],"工具","2026-06-15",[],{"title":586,"description":587,"path":588,"slug":589,"category":590,"categoryLabel":591,"kind":590,"tags":592,"date":593,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":123,"stack":594,"period":600,"repo":116,"thumb":601},"Super Admin 后台底座","一套可复用的 B 端后台前端底座：权限三层收敛、动态菜单、请求层封装与通用表格表单组件，新项目接入只需替换业务页面。","\u002Fprojects\u002Fsuper-admin","super-admin","project","项目",[555,556,544],"2026-05-20",[595,596,597,598,599],"Vue 3","TypeScript","Vite","Pinia","Element Plus","2026 年至今","SA",{"title":603,"description":604,"path":605,"slug":606,"category":590,"categoryLabel":591,"kind":590,"tags":607,"date":608,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":138,"stack":609,"period":614,"repo":116,"thumb":615},"轻量工作流引擎","面向审批场景的轻量工作流：节点与连线可配置，支持条件分支、抄送、超时补偿，重点解决幂等与流程改版两类问题。","\u002Fprojects\u002Fworkflow-engine","workflow-engine",[35,527,528],"2026-04-08",[610,611,612,613],"Spring Boot","MyBatis","MySQL","Redis","2026 年上半年","WF",{"title":617,"description":618,"path":619,"slug":620,"category":590,"categoryLabel":591,"kind":590,"tags":621,"date":546,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":152,"stack":622,"period":624,"repo":116,"thumb":625},"雨墨 RainInk 本站","你正在看的这个站：Nuxt 4 + Nuxt Content 的纯静态个人站，手写设计令牌、无评论无用户输入、产物可直接静态托管。","\u002Fprojects\u002Frainink-site","rainink-site",[541,543,544],[623,595,542,596],"Nuxt 4","2026 年 10 月","RI",[627,630,633],{"title":534,"description":535,"path":536,"slug":537,"category":515,"categoryLabel":538,"kind":539,"tags":628,"date":545,"updated":546,"minutes":229,"pinned":522,"draft":519,"order":547,"stack":629,"period":116,"repo":116,"thumb":116},[541,542,543,544],[],{"title":550,"description":551,"path":552,"slug":553,"category":515,"categoryLabel":538,"kind":539,"tags":631,"date":558,"updated":116,"minutes":229,"pinned":519,"draft":519,"order":547,"stack":632,"period":116,"repo":116,"thumb":116},[555,556,528,557],[],{"title":5,"description":518,"path":523,"slug":561,"category":515,"categoryLabel":538,"kind":539,"tags":634,"date":517,"updated":116,"minutes":229,"pinned":519,"draft":519,"order":547,"stack":635,"period":116,"repo":116,"thumb":116},[35,527,528,529],[],[637,640],{"title":565,"description":566,"path":567,"slug":568,"category":569,"categoryLabel":570,"kind":539,"tags":638,"date":574,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":547,"stack":639,"period":116,"repo":116,"thumb":116},[572,570,573],[],{"title":577,"description":578,"path":579,"slug":580,"category":569,"categoryLabel":570,"kind":539,"tags":641,"date":583,"updated":116,"minutes":138,"pinned":519,"draft":519,"order":547,"stack":642,"period":116,"repo":116,"thumb":116},[582,573,570],[],[644,647,650],{"title":586,"description":587,"path":588,"slug":589,"category":590,"categoryLabel":591,"kind":590,"tags":645,"date":593,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":123,"stack":646,"period":600,"repo":116,"thumb":601},[555,556,544],[595,596,597,598,599],{"title":603,"description":604,"path":605,"slug":606,"category":590,"categoryLabel":591,"kind":590,"tags":648,"date":608,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":138,"stack":649,"period":614,"repo":116,"thumb":615},[35,527,528],[610,611,612,613],{"title":617,"description":618,"path":619,"slug":620,"category":590,"categoryLabel":591,"kind":590,"tags":651,"date":546,"updated":116,"minutes":123,"pinned":519,"draft":519,"order":152,"stack":652,"period":624,"repo":116,"thumb":625},[541,543,544],[623,595,542,596],[654,656,657,658,660,661,662,663,664,665,666,667,668,669,670],{"name":573,"count":138,"weight":655},1.6,{"name":528,"count":138,"weight":655},{"name":570,"count":138,"weight":655},{"name":541,"count":123,"weight":659},1.25,{"name":542,"count":123,"weight":659},{"name":543,"count":123,"weight":659},{"name":555,"count":123,"weight":659},{"name":544,"count":123,"weight":659},{"name":556,"count":123,"weight":659},{"name":527,"count":123,"weight":659},{"name":35,"count":123,"weight":659},{"name":582,"count":123,"weight":659},{"name":529,"count":123,"weight":659},{"name":557,"count":123,"weight":659},{"name":572,"count":123,"weight":659},1792657178960]