资讯动态

决策请求统一建模:基于Builder模式打造可复用流程系统

发布时间:2026/10/19 15:12:00 来源:尧图企业网站定制
几个月前我被拉进一个内部流程平台的改造项目。业务方提的需求听起来特别简单在弹窗里加一个“同意/驳回”按钮把用户的选择记下来。但真做起来才发现这个“记下来”牵扯的东西远比想象中多——谁做的决策、针对哪条数据、决策依据是什么、优先级多高、需不需要通知下游、超时没人处理怎么办。每个需求都把这些逻辑重新写一遍接口参数五花八门日志格式乱七八糟。后来我们干脆做了一个 Decision Request Builder把所有决策请求统一抽象成结构化对象一次建模到处复用。这篇文章就把这个项目的设计思路、核心模型和落地过程完整梳理一遍希望能给同样在折腾流程类系统的朋友一点参考。1. 为什么 Decision Request Builder 值得做装在我肚子里的那些坑1.1 一切从“拍脑袋接需求”开始我们最开始处理决策需求基本是“来一个需求写一堆代码”。业务方说要做运营审核我就写一堆 if else说要做退款审批我又复制一份类似逻辑改改字段。短期看没啥问题但三个月后这套代码就成了一团乱麻。真正让人崩溃的不是功能本身而是决策请求的“形状”。有的需求里决策人可能是一个人也可能是一个角色有的决策上下文只有订单号有的却要带一整份风险画像有的决策需要同步返回结果有的只需要异步触发。没有统一模型这些差异就直接体现在接口签名和数据库表里。每个开发都有自己的命名习惯今天叫 approve明天叫 pass后天叫 auditResult下游系统对接的时候简直是灾难。我当时做的第一件事不是写代码而是把所有决策相关需求聚在一起问了一个很笨的问题它们共同要表达什么答案其实只有一句话——某个主体在某个时间节点围绕某件业务对象从若干可选动作中做出选择并留下依据。这句话翻译成工程语言就是一个 Decision Request。1.2 决策请求到底在表达什么如果把决策请求拆开核心要素就六类。决策类型也就是这个请求是要做审批、评分、打标签还是路由选择决策者信息可以是具体用户、用户组也可以是外部系统决策对象就是被评估的业务实体比如订单、工单、文章候选动作也就是同意、驳回、转人工、升级等可选项决策上下文包括风险分、信用等级、历史行为等辅助信息最后是时效和优先级说明这个请求多久内必须被处理以及能不能被并行处理。拿点餐来类比。你去餐厅服务员递给你菜单你勾选菜品这份菜单就是决策请求的模板菜品就是候选动作你的口味偏好和忌口就是决策上下文就餐时间就是时效。只不过在业务系统里这个请求不是给人看的而是给规则引擎、工作流引擎或人工审核界面消费的。理解了这一点Decision Request Builder 要解决的问题就非常清晰了。它不负责替你决策它只负责把“决策这件事”用标准化的方式表达出来让不同系统之间能顺畅对话。我们内部常开玩笑说它就是决策领域的“通用快递箱”——不管里面装的是手机还是砖头外面的箱子尺寸、面单格式必须统一。2. 核心模型设计一个决策请求该长什么样2.1 数据结构的骨架决策请求的模型设计是整个项目的基石。我们最终落地的 JSON Schema 并不复杂但每个字段都经过反复推敲不是随便拍脑袋定的。{ $schema: http://json-schema.org/draft-07/schema#, type: object, required: [requestId, type, actor, target, actions], properties: { requestId: { type: string, description: 全局唯一请求ID用于幂等和追踪 }, type: { type: string, examples: [approval, review, classification, routing], description: 决策类型决定后续使用哪个处理器 }, actor: { type: object, required: [type, value], properties: { type: { type: string, enum: [user, group, system] }, value: { type: string, description: 用户ID、用户组ID或系统标识 } } }, target: { type: object, required: [objectType, objectId], properties: { objectType: { type: string, description: 业务对象类型如 order、ticket、article }, objectId: { type: string, description: 业务对象唯一标识 } } }, actions: { type: array, minItems: 1, items: { type: object, required: [code, label], properties: { code: { type: string, description: 动作编码如 APPROVE、REJECT }, label: { type: string, description: 展示名称 } } } }, context: { type: object, description: 决策上下文key-value形式支持嵌套 }, ttl: { type: integer, description: 决策过期时间单位秒 }, priority: { type: integer, enum: [1, 2, 3], description: 1高、2中、3低 } } }这个模型看起来平平无奇但它背后有一个设计原则稳定性和扩展性分离。requestId、type、actor、target、actions 这五个字段是任何决策请求都必须具备的所以它们被定义为必需字段。而 context 是一个自由容器业务方想塞什么上下文都可以不影响整体结构稳定。ttl 和 priority 是可选增强字段有则用之无则不阻塞。2.2 字段选型的经验这里重点说几个容易踩坑的决策。requestId 为什么要单独存在很多同学会觉得有数据库自增 ID 就够了。但在分布式环境下决策请求可能在多个服务间流转自增 ID 无法跨服务保证全局唯一。我们用 UUID 或雪花 ID 在请求创建时生成目的是幂等。下游处理器即使收到重复请求也能根据 requestId 去重避免用户点了两次同意按钮结果触发两遍后续操作。actor.type 用枚举而不是直接存字符串是为了避免“user”和“User”这种语义混乱。我们曾经吃过这个亏有的接口传 U有的传 user解析逻辑写了一堆兼容代码。后来统一在 Builder 里做了枚举映射非法输入直接抛错。actions 必须是数组哪怕业务上只有同意和驳回两个选项。原因很简单决策请求不是静态的业务方可能随时加一个“转人工”或“加急处理”。数组天然支持动态扩展渲染端只需要遍历展示不需要改接口。另外actions 里的每项都带 label这是为了让人工审核界面可以直接用不需要再查一次字典表。context 虽然自由但我们在文档里明确约定了几个常见命名空间。比如 riskScore、creditLevel、sourceChannel 等。约定命名空间不是限制而是方便下游写规则时直接引用减少对接成本。实际使用中context 里的值必须是可序列化的基础类型或嵌套 JSON 对象不允许出现函数、Date 实例等特殊对象否则存库和跨服务传输都会出问题。3. 手写一个 Decision Request Builder一步步搭出可复用的构建器3.1 用 Builder 模式封装构造逻辑模型定义好之后下一步就是写构造器。为什么用 Builder 模式而不是直接 new 一个对象然后 set 字段因为决策请求的字段太多有些字段有默认值有些字段之间互相依赖直接构造函数会让调用方写出一长串难以阅读的代码。我们用 TypeScript 实现了一个链式调用的 Builder。核心代码大致是这样的type ActionCode string; interface DecisionRequest { requestId: string; type: string; actor: { type: user | group | system; value: string }; target: { objectType: string; objectId: string }; actions: Array{ code: ActionCode; label: string }; context: Recordstring, unknown; ttl?: number; priority?: 1 | 2 | 3; } class DecisionRequestBuilder { private request: DecisionRequest { requestId: , type: , actor: { type: system, value: system }, target: { objectType: , objectId: }, actions: [], context: {} }; withRequestId(id: string) { this.request.requestId id; return this; } withType(type: string) { this.request.type type; return this; } withActor(actorType: user | group | system, value: string) { this.request.actor { type: actorType, value }; return this; } withTarget(objectType: string, objectId: string) { this.request.target { objectType, objectId }; return this; } addAction(code: ActionCode, label: string) { this.request.actions.push({ code, label }); return this; } withContext(key: string, value: unknown) { this.request.context[key] value; return this; } withTtl(ttl: number) { this.request.ttl ttl; return this; } withPriority(priority: 1 | 2 | 3) { this.request.priority priority; return this; } build(): DecisionRequest { this.validate(); return { ...this.request, actions: [...this.request.actions] }; } private validate() { if (!this.request.requestId) { throw new Error(requestId is required); } if (!this.request.type) { throw new Error(type is required); } if (!this.request.target.objectType || !this.request.target.objectId) { throw new Error(target is required); } if (this.request.actions.length 0) { throw new Error(at least one action is required); } } }使用起来效果就很直观了const decisionRequest new DecisionRequestBuilder() .withRequestId(generateUuid()) .withType(approval) .withActor(user, user_10086) .withTarget(order, order_20250101_001) .addAction(APPROVE, 通过) .addAction(REJECT, 驳回) .addAction(ESCALATE, 转人工) .withContext(riskScore, 86) .withContext(sourceChannel, app) .withTtl(3600) .withPriority(1) .build();这段代码读起来已经接近自然语言了。业务方不需要关心 requestId 怎么生成不需要记得 actor 里 type 和 value 谁先谁后只要按照语义一步步搭最后 build 一下就会得到一个合法对象。3.2 校验与智能默认值Builder 里的 validate 方法是后来我们自己补上的。最初版本没有校验结果上线第一周就有好几个线上告警全是因为调用方漏了必填字段后续处理器拿到空 target 直接抛空指针。在构造阶段就把错误暴露出来比等到消费阶段再排查要快得多。智能默认值这块也值得展开。比如 actor 的默认值是 system这个不是随意定的。因为有一部分决策请求根本不需要人参与比如定时任务触发的批量审批。如果每次都要显式写 withActor(system, system)调用方会觉得烦所以 Builder 在初始化时直接给了默认值。target 没有默认值因为决策对象是请求的灵魂漏掉它没有合理的兜底方案。action 默认给空数组但 build 时校验必须至少有一个动作。还有一个细节build 返回的不是 this.request 本身而是深拷贝结果。这是一个经典的 Java 后端教训——如果直接返回内部对象引用调用方拿到手后还能随便改内部字段Builder 的意义就名存实亡了。所以我们在返回时对 actions 数组做了浅拷贝context 直接用引用也可以接受因为 context 是自由字段调用方改了自己传进来的对象也不算破坏 Builder 的封装。ttl 和 priority 这两个可选字段我们在 Builder 里没有给默认值而是让消费端自己处理缺省情况。ttl 缺省时某些处理器会认为该请求永不过期priority 缺省时默认当低优先级处理。这样做的好处是保持模型简洁坏处是每个消费端都要自己写一段缺省逻辑。权衡之后我们还是决定不在 Builder 里硬编码因为不同场景的默认策略差异太大。4. 接入真实系统的过程路由、持久化与审计4.1 将请求路由到正确处理器Builder 只负责生产标准化的请求真正干活的是后面的处理器。我们在接入现有系统时实现了一个最简单的路由逻辑——根据请求的 type 字段分发到不同的处理管道。type Handler (request: DecisionRequest) PromiseDecisionResult; const handlerRegistry: Recordstring, Handler { approval: async (req) { // 调用人工审核接口创建待办任务 return { status: PENDING, ticketId: createTodo(req) }; }, classification: async (req) { // 调用规则引擎打分 return { status: DONE, category: await classify(req) }; } }; async function route(request: DecisionRequest) { const handler handlerRegistry[request.type]; if (!handler) { throw new Error(unsupported decision type: ${request.type}); } return handler(request); }这段代码本身很简单但有一个设计决策很重要handlerRegistry 里的 handler 都是异步的。因为决策请求最终可能是要调外部规则引擎、人工审核工作流或者消息队列这些操作天然异步。不过在实际项目中同步返回和异步返回我们都遇到过这里为了统一接口让所有 handler 都返回 Promise即便内部是同步操作也包一层 resolved Promise。路由逻辑没有做成复杂的规则引擎是因为我们不想在初期过度设计。Decision Request Builder 解决了“请求怎么来”的问题路由只需要解决“请求往哪去”的问题。等未来决策类型增长到十几个再考虑把路由表配置化也不迟。4.2 序列化、存储与日志联动决策请求在服务间传递通常用 JSON 序列化。我们特意只使用原生 JSON 字段没有引入 Protobuf 或者 MessagePack目的是降低各端对接成本。Java 后端、Node.js 服务、前端页面只要支持 JSON 就能消费。性能上没有成为瓶颈因为决策请求的体量一般不大几 KB 的量级JSON 的序列化开销完全可以接受。存储方面我们给决策请求单独建了一张表而不是塞进业务表里。表结构基本就是 JSON 字段一一映射外加一个 result 字段存储决策结果。这里有个隐蔽的坑如果直接把整个 DecisionRequest JSON 存进一个 text 字段虽然灵活但没办法做 SQL 查询。比如你想统计“昨天 type 为 approval 且 priority 为 1 的请求有多少”如果只有 JSON 字段就得全表扫。所以我们把 type、priority、status、created_at 等高频查询字段单独建列context 和 actions 塞进 JSON 字段兼顾查询灵活性和扩展性。日志联动是上线后被迫补上的。以前出问题我们只能看到一堆“请求处理失败”的日志根本不知道是哪个决策请求、上下文是什么。后来在 Builder 的 build 方法里加了一行结构化日志把 requestId、type、actor、target、actions 全部输出并且把 requestId 通过 traceId 机制贯穿整个调用链。现在再排查问题直接按 requestId 搜索日志从构建到路由到处理结果一条链路看得清清楚楚。5. 踩坑实录Decision Request Builder 的常见问题与排查技巧5.1 高频问题速查表这个 Builder 用到现在收集了不少线上问题和同事的吐槽。我整理成一张速查表方便大家直接对照排查。现象根因解决办法请求构建时报错“actions is empty”调用方忘了添加任何候选动作把 addAction 调用提前到 withTarget 之前或在 build 前打印一条调试日志下游收到请求后无法解析 context 里的日期调用方塞了 Date 对象JSON 序列化变成了字符串格式不一致明确约定 context 只允许 ISO8601 格式的字符串禁止 Date 对象同一个 requestId 出现多条记录重试机制没有实现幂等在消费端先查 requestId 是否已处理已处理直接返回既有结果请求一直不生效但日志没有报错ttl 缺省导致请求永远不过期进入了慢处理队列在需要时效性的场景显式调用 withTtl人工审核界面显示不出动作名字actions 里的 label 没有传展示端拿到的全是 code校验逻辑补充 label 非空缺失时使用 code 作为展示文本路由配置了 type但 handler 还是没找到handlerRegistry 初始化顺序问题或其他模块的静态变量被重置改为显式 register 方式并启动时输出已注册 type 列表排查技巧方面我个人的习惯是先把 requestId 打出来。只要这个 ID 在日志里连续出现问题基本就定位到一半了。如果连 requestId 都是空的那问题大概率出在 Builder 调用之前的数据准备阶段。5.2 两个容易忽视的设计细节第一个细节是 requestId 的生成时机。很多人会把 requestId 放在 build 方法里自动生成这看起来挺方便但会带来一个问题如果你在业务逻辑里先创建了 Builder然后发起异步操作再在回调里 build这个 requestId 的生成时间就变得不确定。对于日志追踪和幂等来说requestId 应该在业务流程一开始就确定。所以我们把它设计成显式传入由调用方在入口处生成。第二个细节是 actions 的顺序敏感。有些同学觉得 actions 数组无所谓顺序渲染端展示出来就行。但实际上我们内部有一个“第一个动作作为默认推荐动作”的约定。比如审核界面第一个动作按钮会被高亮用户回车时默认触发。这个约定写在文档里不显眼但一旦有人把“驳回”放在“通过”前面真实用户就很容易误操作。所以 Builder 里没有对 actions 做排序是为了保留调用方定义的顺序调用方需要自己对这个顺序负责。还有一个经验不要试图让 Builder 去支持所有的“特殊情况”。我们曾经收到一个需求希望 context 里能直接放一个回调 URL等决策完成时回调业务方。听起来很合理但我们拒绝了。因为回调这件事涉及更复杂的生命周期管理放进 context 会导致这个字段从“自由扩展”变成“隐式协议”失去灵活性。最终的方案是在决策结果里带上 requestId让业务方自己订阅结果事件。这个经验告诉我Builder 的职责边界必须清晰它只做“构建请求”不做“执行业务”。6. 从 Builder 到决策基础设施我们的下一步规划现在的 Decision Request Builder 已经不只服务于原始的审批场景。规则引擎接进来了A/B 测试平台也在用它的模型来表达实验分组决策。让我特别意外的是有个数据团队甚至用它来描述特征筛选的“取舍请求”这说明统一抽象的价值确实会超出最初预期。我们接下来准备做两件事。第一件是把 Builder 从代码库中独立成一个小小的 SDK支持 TypeScript 和 Java 两个版本让其他团队不用重复造轮子。第二件是做一个基于 JSON Schema 的可视化配置界面让业务同学在网页上拖拽字段就能生成一个标准的决策请求模板开发只需要把模板挂在后台。在我个人看来这个项目最有意思的地方不是那几十行 Builder 代码而是它逼着我们重新定义了“决策”这个概念。以前大家各说各话现在只要提到 decision request所有人脑子里浮现的都是同一个结构。统一模型带来的沟通成本下降是写多少工具函数都换不来的。如果你也在维护流程类系统不妨花一周时间整理一下自己的决策请求长什么样哪怕不写框架先统一字段命名都会比继续堆 if else 好很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑