Formbricks Workflows 数据模型与领域包:从节点图、触发器到运行时执行的完整解析
2026/9/15 12:16:19 网站建设 项目流程

Formbricks Workflows 数据模型与领域包:从节点图、触发器到运行时执行的完整解析

【免费下载链接】formbricksOpen Source Qualtrics Alternative项目地址: https://gitcode.com/GitHub_Trending/fo/formbricks

Formbricks Workflows 是 Formbricks 中用于在“用户提交问卷响应”等事件发生后自动执行业务动作(如自动发送邮件)的工作流能力。@formbricks/workflows是这套能力背后框架无关的领域包,它用一组 Zod schema 定义了工作流的持久化结构、可执行结构、触发器、动作、条件以及运行日志的全部契约。本文以 packages/workflows/README.md 及其数据模型文档 packages/workflows/src/types/README.md 为主线,结合仓库源码,完整讲解该包的设计边界、每个数据对象的字段语义与 JSON 示例、图形校验规则,以及如何安全地扩展新的动作与触发器。

包定位与设计边界:为什么 Workflows 需要独立成包

Formbricks Workflows 的核心诉求是:同一套工作流逻辑可以在多种运行时(runner、builder、API 层)之间复用。因此@formbricks/workflows被设计为一个领域层包,只负责定义和校验“工作流长什么样”,不关心它被存储、渲染或执行的具体环境。

从 packages/workflows/package.json 可以看到它的依赖面非常窄——运行时依赖只有zod(其余均为 lint / 测试 / 构建工具链),并对外暴露两个入口:

  • @formbricks/workflows:核心类型与校验(由 packages/workflows/src/index.ts 统一导出analyticscontractsexecutionrecipientstypes五个子模块);
  • @formbricks/workflows/server:面向 runner 的服务端辅助入口(packages/workflows/src/server/index.ts)。

包本身在 packages/workflows/README.md 中明确了三条硬性边界:

  • 不得导入apps/web或任何 Next.js 专属 API——保持框架中立;
  • 运行时适配器保持薄而显式——执行相关的胶水代码必须显式、克制;
  • 依赖收窄,应用/运行时关注点通过接口传入——应用侧可以通过接口把存储、发送邮件等能力注入进来,而不是让领域包直接依赖具体实现。

这种设计带来一个直接好处:工作流对象可以在前端 builder(用于编辑)、后端 API(用于持久化)与 runner(用于执行)之间安全地传递,而对象形状只定义一处。

核心抽象:一触发、多节点、有向边的持久化图

@formbricks/workflows的整套数据模型可以用四句话概括(见 packages/workflows/src/types/README.md):

  1. 一个工作流有且只有一个触发器(trigger);
  2. 触发器之后的工作由子节点(child node)表示;
  3. 边(edge)把节点连接成一张图;
  4. 运行负载(payload)与运行日志(run log)记录触发器被触发时实际发生了什么。

围绕这四句话,类型目录(packages/workflows/src/types/)被组织为actions/(动作)、triggers/(触发器)、conditions/(条件)、document.ts(图与文档)、runs.ts(运行数据)、common.ts(共享对象)与content.ts(内容辅助)。

其中有两个贯穿全文的关键概念:

  • config= 用户配置:触发器与动作上的config字段只存放用户在工作流编辑器中填写的配置。任何运行时数据(比如本次触发时的响应内容)都不能放进config,而应进入触发器负载、运行数据或运行日志。
  • ui= 纯构建器元数据:只用于帮助工作流 builder 渲染画布,绝不能影响执行

节点基类ZWorkflowNodeBase

所有节点(trigger、action、if_else)都扩展自 packages/workflows/src/types/common.ts 中的ZWorkflowNodeBase

字段类型语义
idstring定义内稳定的节点 id,边(edge)通过它引用节点
type"trigger"|"action"|"if_else"节点种类
labelstring(可选)人类可读的节点标签,长度限制 1–120
uiZWorkflowNodeUi(可选)仅供构建器使用的元数据

画布位置ZWorkflowNodePosition与构建器元数据ZWorkflowNodeUi

ZWorkflowNodePosition表示节点在 workflow builder 画布中的坐标,只有xy两个数字字段:

{ "x": 320, "y": 0 }

ZWorkflowNodeUi是可选元数据:position(可选)、collapsed(可选,节点是否折叠渲染),并通过catchall(z.unknown())允许额外键——构建器可以自由添加颜色、视图偏好等展示信息而不必改动执行契约:

{ "collapsed": false, "color": "blue", "position": { "x": 0, "y": 0 } }

动态数据引用ZWorkflowDataRef

ZWorkflowDataRef表示“运行时可用数据的一个引用”:它用点路径(dot path)指向运行上下文中的某个值,而不是把值拷贝进工作流定义:

字段类型语义
pathstring运行上下文中的点路径
fallbackstring(可选)路径无法解析时使用的兜底字符串
{ "fallback": "support@example.com", "path": "response.email" }

工作流图对象:边、持久化定义与可执行定义

ZWorkflowEdge

边是图中两个节点之间的连接(packages/workflows/src/types/document.ts):

字段类型语义
idstring定义内稳定的边 id
sourcestring边起点的节点 id
targetstring边终点的节点 id
sourceHandlestring(可选)源侧句柄;if_else节点的出边必须使用thenelse
targetHandlestring(可选)目标侧句柄,主要为 builder UI 与未来节点类型预留
{ "id": "trigger-send-email", "source": "trigger", "target": "send-email", "targetHandle": "input" }

定义基类ZWorkflowDefinitionBase

ZWorkflowDefinitionBase是所有持久化工作流文档的公共形状,ZWorkflowDefinitionZWorkflowExecutableDefinition都基于它做不同强度的校验:

字段类型语义
schemaVersionnumber文档 schema 版本,默认1(常量WORKFLOW_SCHEMA_VERSION = 1
triggertrigger 节点 | null工作流唯一触发器;草稿尚无触发器时为 null(源码中.nullable()注释明确说明)
nodes子节点数组触发器之后运行的节点,只能是 action 或 condition 节点,不能是 trigger
edges边数组触发器与子节点之间的连接
entryNodeIdstring | null工作流入口,必须引用触发器节点 id;无触发器时为 null

一个完整的持久化工作流文档示例(response.completed触发器 +send_email动作):

{ "schemaVersion": 1, "entryNodeId": "trigger", "trigger": { "id": "trigger", "type": "trigger", "triggerType": "response.completed", "config": { "surveyId": "cm9zr4mps000008l8btfy1vtz", "endingCardIds": [] } }, "nodes": [ { "id": "send-email", "type": "action", "actionType": "send_email", "config": { "to": "{{response.email}}", "from": "noreply@example.com", "replyTo": ["support@example.com"], "subject": "Thanks for your response", "body": "We received your response.", "attachResponseData": true, "includeHiddenFields": false, "includeVariables": false } } ], "edges": [ { "id": "trigger-send-email", "source": "trigger", "target": "send-email" } ] }

持久化定义ZWorkflowDefinition:宽容的保存期校验

ZWorkflowDefinition通过superRefine(validateWorkflowGraph)对图做引用级校验(packages/workflows/src/types/document.ts 第 51–160 行)。它拒绝以下情况:

  • 重复的节点 id(报Duplicate workflow node id: ...);
  • entryNodeId与触发器 id 不一致(无触发器时 entryNodeId 必须为 null);
  • 边引用了不存在的节点(source 或 target 不在节点集合中);
  • if_else节点的出边未使用then/elsesourceHandle,反之非if_else节点不允许使用这两个句柄;
  • 触发器出边超过一条。注意:只含触发器、没有任何节点和边的草稿是合法可保存的——宽松校验保证“保存工作进度”不被打断,而“必须有一条出边”是执行期的要求。

同时它对每个if_else节点强制要求恰好一条then出边与恰好一条else出边

可执行定义ZWorkflowExecutableDefinition:严苛的运行期校验

ZWorkflowExecutableDefinition当前 runner 真正能执行的定义快照。它收紧了两处类型:triggerentryNodeId不再允许 null(草稿期空值在启用时被剔除)。在此之上,validateExecutableGraph(同文件第 175–267 行)叠加了更严格的规则:

  • 触发器必须恰好一条出边An executable workflow must have exactly one outgoing trigger edge);
  • 每个子节点都必须从触发器可达(从 trigger 出发做 BFS/DFS,不可达节点报unreachable node ids);
  • 图必须无环(基于三色标记的 DFS 环检测,Executable workflow graph must be acyclic);
  • if_else节点在当前版本中被拒绝执行if_else nodes are not executable in this version of workflows)——条件分支已纳入数据模型并可通过持久化校验,但 runner 尚未支持;
  • send_email动作必须内容非空:源码通过getBlankSendEmailContentFields(node.config)(packages/workflows/src/types/content.ts)找出空白的邮件字段并报错(send_email node ... is missing ...)。原因正如源码注释所述:持久化 schema 是宽容的以便保存半成品,但“可执行”的send_email必须有真正可发送的内容。

这个“保存宽容、执行严格”的双层设计,是理解整套 workflow 契约的钥匙。

触发器对象:以response.completed为例

枚举与判别字段

触发器 id 集中在 packages/workflows/src/types/triggers/enum.ts:当前只有RESPONSE_COMPLETED = "response.completed"一种。动作 id 同理,见 packages/workflows/src/types/actions/enum.ts:当前只有SEND_EMAIL = "send_email"

一个容易被忽略但很重要的设计原则(README 中专门强调):判别字段(discriminator)挂在节点上(actionType/triggerType),而不是藏在config内部。这样config可以纯粹地只描述用户配置,且可以利用 Zod 的discriminatedUnion做精确的类型收窄。

触发器配置ZResponseCompletedTriggerConfig

“问卷响应完成”触发器的配置(packages/workflows/src/types/triggers/response-completed.ts):

字段类型语义
surveyIdcuid2响应完成会触发工作流的问卷 id
endingCardIdscuid2 数组,默认[]应触发工作流的结束卡 id;空数组 = 所有结束
{ "surveyId": "cm9zr4mps000008l8btfy1vtz", "endingCardIds": ["cm9zr4q7i000108l84gozfggr"] }

触发器节点ZWorkflowResponseCompletedTriggerNode

type恒为triggertriggerType恒为response.completed,其余字段(id、label、ui)继承节点基类:

{ "id": "trigger", "type": "trigger", "triggerType": "response.completed", "label": "Survey response completed", "config": { "surveyId": "cm9zr4mps000008l8btfy1vtz", "endingCardIds": [] }, "ui": { "position": { "x": 0, "y": 0 } } }

运行时负载ZWorkflowTriggerPayload

负载是运行时事件上下文而非用户配置,由 runner 在触发器触发时产生(注意源码中使用了.catchall(z.unknown())允许未来附加运行时元数据):

字段类型语义
type"response.completed"负载类型
workspaceIdcuid2运行所属的工作空间/租户上下文
surveyIdcuid2收到响应的问卷
responseIdcuid2触发工作流的已完成响应
endingCardIdcuid2(可选)响应到达的结束卡
datarecord(可选)提供给动作与条件使用的触发器数据,如响应字段
{ "type": "response.completed", "workspaceId": "cm9zr4wsp000508l8y6nh9r2v", "surveyId": "cm9zr4mps000008l8btfy1vtz", "responseId": "cm9zr4rsp000708l8bqccpfrx", "endingCardId": "cm9zr4q7i000108l84gozfggr", "data": { "response": { "email": "jane@example.com", "score": 9 } } }

动作对象:send_email的配置与运行语义

动作配置ZWorkflowSendEmailActionConfig

send_email是目前唯一的动作(packages/workflows/src/types/actions/send-email.ts):

字段类型语义
tostring收件人表达式:字面邮箱地址,或持有受访者邮箱的问卷问题/隐藏字段的 element id
fromemail发件人邮箱(语义见下文说明)
replyToemail 数组收件人回复时使用的地址
subjectstring邮件主题,最大长度 998MAX_SUBJECT_LENGTH,对齐 RFC 5322 行宽上限)
bodystring邮件正文,最大长度 100,000MAX_BODY_LENGTH,Gmail 对超过约 102KB 的邮件会截断渲染)
attachResponseDataboolean是否在邮件中包含响应数据
includeVariablesboolean(可选)附加响应数据时是否包含调查变量
includeHiddenFieldsboolean(可选)附加响应数据时是否包含隐藏字段
{ "to": "{{response.email}}", "from": "noreply@example.com", "replyTo": ["support@example.com"], "subject": "Thanks for your response", "body": "We received your response.", "attachResponseData": true, "includeHiddenFields": false, "includeVariables": false }

源码注释揭示的运行细节(重要安全语义)

该文件头部有一大段高质量注释,揭示了若干与表面字段不一致的真实运行语义,值得展开:

  • to的两类取值行为不同:若为字面邮箱地址,则必须属于能访问该工作流 workspace 的用户——启用/测试时校验失败会被拒绝,runner 也拒绝向其发送,防止把响应数据转发到任意外部邮箱(对应 ENG-2029)或被撤销访问权的人(ENG-2186);若为 element id,则解析为受访者自己的邮箱地址,不做 allowlist 校验。
  • body支持 recall token:正文是 HTML,支持#recall:[elementId]/fallback:x#形式的回填 token,运行时基于响应展开后再经白名单清理,并包裹进品牌化的 Follow-ups 邮件模板;subject则原样使用,不应用 recall。
  • from是“残留”字段:实际发件人始终来自部署的MAIL_FROM环境变量(与 Follow-ups 保持一致),from仅用于派生稳定的 Message-ID 域名。
  • 长度上限是刻意的分叉subjectbody在此有长度上限而 Follow-ups 没有——因为持久化 schema 宽容、任何消费方(空白判定、recall 展开、清理)都会遍历 body,若不设上限唯一的封顶就是 16MB 的代理 body 限制。

这一整套语义的目标是与既有调查 Follow-ups 能力保持 1:1 字段对等(源码注释明确写到 “1:1 field parity with survey Follow-ups”),从而让两种能力共享同一套运行时渲染逻辑。

动作注册表与判别联合

动作的注册集中在一处(packages/workflows/src/types/actions/index.ts):

export const WORKFLOW_ACTION_CONFIG_SCHEMAS = { [WORKFLOW_ACTIONS.SEND_EMAIL]: ZWorkflowSendEmailActionConfig, } as const; export const ZWorkflowActionNode = z.discriminatedUnion("actionType", [ZWorkflowSendEmailActionNode]);

触发器侧对应(packages/workflows/src/types/triggers/index.ts):ZWorkflowTriggerNode = z.discriminatedUnion("triggerType", [ZWorkflowResponseCompletedTriggerNode]),配置则通过TWorkflowTriggerConfigSchemas接口类型映射到ZResponseCompletedTriggerConfig

条件对象:if_else分支(已建模、暂不可执行)

条件逻辑由三部分组成(packages/workflows/src/types/conditions/)。

单条条件ZWorkflowCondition

字段类型语义
idstring稳定的条件 id
leftZWorkflowDataRef被检查的值引用
operatorstring比较运算符:equalsnotEqualslessThanlessEqualgreaterThangreaterEqualcontainsnotContainsexistsnotExists
rightany(条件性)右侧比较值;存在性运算符exists/notExists必须省略right,其余运算符必须提供
{ "id": "score-high", "left": { "path": "response.score" }, "operator": "greaterThan", "right": 8 }

条件组ZWorkflowConditionGroup

字段类型语义
idstring稳定组 id
connector"and"|"or"and= 所有子项通过;or= 至少一个子项通过
conditions非空数组ZWorkflowCondition或嵌套的ZWorkflowConditionGroup,支持嵌套逻辑
{ "id": "qualified-response", "connector": "and", "conditions": [ { "id": "email-exists", "left": { "path": "response.email" }, "operator": "exists" }, { "id": "score-high", "left": { "path": "response.score" }, "operator": "greaterThan", "right": 8 } ] }

分支节点ZWorkflowIfElseNode

type恒为if_elseconfig.condition是条件组。边规则:恰好一条出边用sourceHandle: "then",恰好一条出边用sourceHandle: "else"(持久化校验强制,且文档图校验在validateWorkflowGraph中实现)。该节点在通用工作流定义中合法,但当前 runner 不可执行(可执行定义会直接拒绝)。

{ "id": "condition", "type": "if_else", "label": "Qualified response", "ui": { "collapsed": true }, "config": { "condition": { "id": "qualified-response", "connector": "and", "conditions": [ { "id": "score-high", "left": { "path": "response.score" }, "operator": "greaterThan", "right": 8 } ] } } }

运行与版本对象:一次触发如何被记录

工作流不只是“定义”,还包括“运行痕迹”。这些对象定义在 packages/workflows/src/types/runs.ts 并由 packages/workflows/src/types/index.ts 统一导出。

ZWorkflowTriggerRunPayload

触发器负载的运行时快照:继承ZWorkflowTriggerPayload全部字段,并增加triggeredAt(ISO 时间字符串,记录触发器被捕获的时刻)。

ZWorkflowRunLogInput/ZWorkflowRunLogOutput

单个步骤被捕获的任意输入/输出对象,形状取决于步骤类型与提供方:

{ "subject": "Thanks", "to": "jane@example.com" }
{ "messageId": "message-1", "provider": "smtp" }

ZWorkflowStepResult

单步执行结果(内存中或序列化后):

字段类型语义
stepIdstring被执行步骤的节点 id
stepTypestring步骤类型,如send_email
statuspending|running|succeeded|failed|skipped步骤状态
input/outputobject(可选)步骤输入 / 输出
errorstring(可选)错误消息
startedAt/finishedAtISO string(可选)开始 / 结束时间
{ "stepId": "send-email", "stepType": "send_email", "status": "failed", "input": { "subject": "Thanks", "to": "jane@example.com" }, "output": { "provider": "smtp" }, "error": "SMTP provider rejected the message", "startedAt": "2026-06-09T12:01:01.000Z", "finishedAt": "2026-06-09T12:01:03.000Z" }

ZWorkflowRunData

运行级数据快照,把触发器负载与步骤结果存放在一起;允许额外键(如示例中的attempt,为未来重试元数据预留):

字段类型语义
triggerZWorkflowTriggerRunPayload(可选)触发器快照
stepsZWorkflowStepResult数组,默认[]步骤结果

ZWorkflowVersion

不可变的已发布工作流定义,供运行引用:

字段类型语义
idstring版本 id
workflowIdstring所属工作流 id
workspaceIdstring工作空间/租户 id
versionnumber单调递增的正整数版本号
definitionZWorkflowExecutableDefinition可执行定义快照
publishedAtISO string发布时间
publishedBystring | null(可选)发布该版本的用户 id

ZWorkflowRunLog

一条持久化的运行日志行,是ZWorkflowStepResult的落库形态,增加idrunIdsequence(用于排序的序号)字段,且errorstartedAtfinishedAt可为 null。

三组状态枚举

状态贯穿工作流生命周期、整次运行与单个步骤三个层级(见 packages/workflows/src/types/common.ts 与 packages/workflows/src/types/README.md):

  • 工作流生命周期ZWorkflowStatusdraft(可编辑、未激活)、enabled(激活)、disabled(停用但保留)、archived(软删除,默认读取排除);
  • 整次运行ZWorkflowRunStatusqueued(排队中)、running(执行中)、completed(成功完成)、failed(出错结束)、canceled(完成前被终止);
  • 单步日志ZWorkflowRunLogStatuspending(未开始)、running(执行中)、succeeded(成功)、failed(出错)、skipped(有意跳过)。

扩展指南:如何新增动作与触发器

packages/workflows/README.md 给出了明确的扩展开箱步骤,结合源码可以看到每一步对应的落点。

新增一个动作(action):

  1. src/types/actions/enum.ts中把动作 id 加入WORKFLOW_ACTIONS常量(枚举同时驱动ZWorkflowActionType的取值);
  2. src/types/actions/下新增 config schema 与 node schema(参考send-email.ts的模式:ZWorkflowXxxActionConfig+ZWorkflowXxxActionNode,node 通过ZWorkflowNodeBase.extend固定type: "action"与对应的actionType字面量);
  3. 把 config schema 注册进WORKFLOW_ACTION_CONFIG_SCHEMAS(packages/workflows/src/types/actions/index.ts);
  4. 把 node schema 加入ZWorkflowActionNodediscriminatedUnion("actionType", [...])列表;
  5. 新增或更新 fixtures 与测试,用新动作校验一份完整的工作流文档。

新增一个触发器(trigger):

  1. src/types/triggers/enum.ts中把触发器 id 加入WORKFLOW_TRIGGERS
  2. src/types/triggers/下新增 config schema、需要的 payload schema 与 node schema(参考response-completed.ts);
  3. 把 config schema 注册进TWorkflowTriggerConfigSchemas接口映射(packages/workflows/src/types/triggers/index.ts);
  4. 把 node schema 加入ZWorkflowTriggerNodediscriminatedUnion("triggerType", [...])
  5. 新增或更新 fixtures 与测试。

贯穿始终的纪律:config schema 只聚焦用户提供的配置;任何运行时数据必须放进触发器负载、运行数据、运行日志或服务层输入,绝不能混入config。同时保持ui字段只承载构建器元数据、不影响执行。

测试与示例:契约的落地验证

该包配有完整的测试与 fixture,是理解契约语义的绝佳佐证:

  • 图校验测试packages/workflows/src/types/index.test.ts:覆盖持久化定义与可执行定义各自拒绝/接受哪些文档;
  • 内容校验测试packages/workflows/src/types/content.test.ts:验证send_email空白字段判定与内容边界;
  • 服务层测试packages/workflows/src/services/workflows.service.test.ts:覆盖领域服务行为;
  • handler 测试packages/workflows/src/handlers/workflows.handlers.test.ts 与执行计划测试packages/workflows/src/execution/plan.test.ts:验证 HTTP 处理抽象与执行计划生成。

fixtures 目录 packages/workflows/src/types/fixtures/ 提供了六份可直接参考的完整 JSON 样例:workflow-definition.full.json(持久化定义)、workflow-executable-definition.full.json(可执行定义)、workflow-trigger-payload.full.json(触发器负载)、workflow-run-data.full.json(运行数据)、workflow-run-log.full.json(运行日志)与workflow-version.full.json(版本快照)。新增动作/触发器时,README 明确要求“新增或更新 fixtures 和测试来校验包含新节点的完整工作流文档”。

此外,analytics/contracts/execution/handlers/recipients/services/子模块分别承载定义摘要、契约漂移检测(spec-drift.test.ts)、执行规划、HTTP 处理抽象与收件人解析等能力,共同构成一个“定义、校验、规划、执行、记录”闭环的领域包。

小结

@formbricks/workflows通过“框架无关的领域包 + Zod 契约 + 双层校验”这套组合,把工作流的定义、校验与运行支撑完整地封装起来:ZWorkflowDefinition宽容地守护构建期草稿,ZWorkflowExecutableDefinition严格地守护运行期快照;response.completed触发器与send_email动作构成当前可用的最小闭环,if_else条件分支则已进入数据模型、等待 runner 支持。对于想要深度集成或扩展 Formbricks Workflows 的开发者,理解config/ui/payload 的三分法、节点判别字段的位置,以及“持久化宽容、执行严格”的校验哲学,是正确使用和扩展这套能力的关键。

【免费下载链接】formbricksOpen Source Qualtrics Alternative项目地址: https://gitcode.com/GitHub_Trending/fo/formbricks

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询