Corsair Formbricks 插件接入指南:47 个操作、双版本 API 与风险分级的完整实践
【免费下载链接】corsairConnect your users to their apps项目地址: https://gitcode.com/GitHub_Trending/corsa/corsair
本篇技术指南以 packages/formbricks/README.md 为核心,结合该包源码(packages/formbricks/index.ts、client.ts、endpoints/与schema/)系统讲解如何在 Corsair 中集成 Formbricks——一个开源的用户体验管理平台,用于应用内问卷与用户反馈收集。读完本文,你将掌握插件的安装与认证方式、47 个端点操作与风险等级的完整映射、Formbricks 同时暴露 v1/v2 两套 API 时的路由选择策略,以及错误重试、数据镜像与实时测试的最佳实践。
插件定位与安装
@corsair-dev/formbricks是 Corsair 生态中专门连接 Formbricks 的官方插件,包描述为 "Formbricks plugin for Corsair"。它把 Formbricks Cloud(以及自托管实例)的 API 能力封装为结构化的端点注册表,供 Corsair 的权限、审计、错误处理与数据镜像体系统一调度。
安装非常简单,通过 pnpm 添加即可:
pnpm add @corsair-dev/formbricks从 packages/formbricks/package.json 可以看到,该包以 ESM 形式发布("type": "module"),运行时入口为./dist/index.js,类型声明为./dist/index.d.ts。它声明了如下 peer 依赖,使用时需保证宿主环境已安装:
corsair:>=0.1.0,Corsair 核心运行时;zod:^4.1.13,所有端点输入/输出 schema 的校验库。
源码包内提供了构建、类型检查与测试脚本(见 package.json):
pnpm build # 清空 dist 后由 tsc 与 tsup 完成构建 pnpm typecheck # 仅做类型检查 pnpm test # 运行 jest 单元测试(client.test.ts、endpoints.test.ts、schema.test.ts) pnpm test:live # 运行需要真实凭据的集成测试(详见下文"实时集成测试")认证:API Key 与作用域陷阱
Formbricks 的认证方式为API key:请求时在x-api-key请求头中携带个人 API key(在 Formbricks 控制台 Settings → API Keys 中创建),而不是 Bearer Token。两份 OpenAPI 文档都只声明了apiKeyAuth这一种安全方案,目录中没有 OAuth 表面,因此插件只声明了api_key一种认证类型(见 index.ts 的formbricksAuthConfig)。
首次使用时,Corsair 会提示你的租户提供凭据;插件通过keyBuilder(index.ts)在端到端调用时解析该 key:若插件选项里显式传了key则直接使用,否则从ctx.keys.get_api_key()读取,取不到即抛出AuthMissingError。
比大多数服务商更关键的一点是 key 的作用域。Formbricks 的 API key 可以是组织级(organization-scoped)或工作区级(workspace-scoped):
- 组织级 key 在几乎所有的工作区级管理路由上都会返回 401——而这类路由恰恰占大多数;
me.get操作可以报告当前 key 属于哪种作用域;- 插件的
AUTH_ERROR处理器也会在日志中提示这一点,因为作用域不匹配的失败看起来很像"key 无效",实际却是"key 作用域不对"。
插件还支持host选项来覆盖 API 主机,用于对接自托管 Formbricks 实例(见 index.ts 的FormbricksPluginOptions)。默认主机是 Formbricks 云https://app.formbricks.com(见 client.ts 的FORMBRICKS_CLOUD_HOST)。需要说明的是:插件默认针对云端验证通过;自托管在 Formbricks 上游虽受支持,但需要 Ubuntu 虚拟机、自定义域名与 80/443 端口,本仓库未对其做过实测,host选项存在只是因为两种部署的差异仅在于主机地址。
端点全景:47 个操作、46 个目录 id、38 条路由
插件的端点注册表(index.ts 的formbricksEndpointsNested)覆盖了目录中的全部 46 个 id,共实现47 个操作、38 条不同路由。三个数字不一致并非疏漏,而是如实反映了几类值得注意的情况:4 个操作是别名、3 个已移除路由的 id 对应真实能力、1 个操作没有目录 id、还有多个操作共享同一条 URL。下表完整继承自 README.md,并按风险等级组织:
读操作(read)
| 操作 | 操作 ID | 说明 |
|---|---|---|
actionClasses.list | formbricks.api.actionClasses.list | 列出可触发问卷的 action class |
client.environment | formbricks.api.client.environment | 读取工作区的客户端环境包 |
contactAttributeKeys.get | formbricks.api.contactAttributeKeys.get | 获取单个联系属性键 |
contactAttributeKeys.getClass | formbricks.api.contactAttributeKeys.getClass | 以目录中旧的 "attribute class" 名称获取属性键——与get同路由 |
contactAttributeKeys.list | formbricks.api.contactAttributeKeys.list | 列出工作区定义的联系属性键 |
contactAttributeKeys.listClasses | formbricks.api.contactAttributeKeys.listClasses | 以旧的 "attribute class" 名称列出属性键——与list同路由 |
contactAttributes.list | formbricks.api.contactAttributes.list | 跨联系人列出属性值 |
contacts.get | formbricks.api.contacts.get | 获取单个联系人 |
contacts.getPerson | formbricks.api.contacts.getPerson | 以旧的 "person" 名称获取联系人——与get同路由 |
contacts.list | formbricks.api.contacts.list | 列出工作区内的联系人 |
contacts.listPeople | formbricks.api.contacts.listPeople | 以旧的 "people" 名称列出联系人——与list同路由 |
health.check | formbricks.api.health.check | 检查服务健康状态 |
health.list | formbricks.api.health.list | 读取服务健康状态 |
me.get | formbricks.api.me.get | 获取 API key 的身份、工作区与组织 |
me.getAccountInfo | formbricks.api.me.getAccountInfo | 获取账户信息 |
me.getManagement | formbricks.api.me.getManagement | 仅限工作区级 key,获取 v1 账户负载 |
responses.list | formbricks.api.responses.list | 列出问卷回复,可选按单个问卷过滤 |
roles.list | formbricks.api.roles.list | 列出成员可持有的组织角色 |
surveys.list | formbricks.api.surveys.list | 列出工作区内的问卷 |
teams.list | formbricks.api.teams.list | 列出组织内的团队 |
teams.listWorkspaceTeams | formbricks.api.teams.listWorkspaceTeams | 列出哪些团队可访问哪些工作区 |
webhooks.get | formbricks.api.webhooks.get | 获取单个 webhook |
webhooks.list | formbricks.api.webhooks.list | 列出工作区上的 webhook |
写操作(write)
| 操作 | 操作 ID | 说明 |
|---|---|---|
actionClasses.create | formbricks.api.actionClasses.create | 创建 action class |
client.contactsState | formbricks.api.client.contactsState | 读取受访者状态,userId 为新的则创建联系人 |
client.createDisplay | formbricks.api.client.createDisplay | 记录问卷曾展示给某人 |
client.createUser | formbricks.api.client.createUser | 创建客户端用户 |
client.identifyUser | formbricks.api.client.identifyUser | 创建或识别客户端用户 |
contactAttributeKeys.create | formbricks.api.contactAttributeKeys.create | 创建联系属性键 |
contactAttributeKeys.update | formbricks.api.contactAttributeKeys.update | 更新联系属性键的定义——不涉及任何联系人取值 |
contacts.create | formbricks.api.contacts.create | 创建联系人 |
contacts.updateAttributes | formbricks.api.contacts.updateAttributes | 按 userId 设置联系人属性值,联系人不存在则创建 |
contacts.uploadBulk | formbricks.api.contacts.uploadBulk | 一次请求创建大量联系人 |
responses.create | formbricks.api.responses.create | 记录一次问卷回复 |
responses.update | formbricks.api.responses.update | 更新一次问卷回复 |
storage.uploadPrivate | formbricks.api.storage.uploadPrivate | 为私有受访者文件申请上传 |
storage.uploadPublic | formbricks.api.storage.uploadPublic | 为公共文件申请上传 |
surveys.create | formbricks.api.surveys.create | 创建问卷 |
surveys.update | formbricks.api.surveys.update | 更新问卷 |
webhooks.create | formbricks.api.webhooks.create | 创建 webhook 并接收其签名密钥 |
webhooks.update | formbricks.api.webhooks.update | 更新 webhook |
破坏性操作(destructive)
| 操作 | 操作 ID | 说明 |
|---|---|---|
contactAttributeKeys.delete | formbricks.api.contactAttributeKeys.delete | 删除联系属性键 |
contacts.delete | formbricks.api.contacts.delete | 删除联系人,移除受访者身份 |
responses.delete | formbricks.api.responses.delete | 删除问卷回复,抹除受访者提交的答案 |
surveys.delete | formbricks.api.surveys.delete | 删除问卷及其收集到的回复 |
teams.delete | formbricks.api.teams.delete | 删除团队,撤销其授予的工作区访问权 |
webhooks.delete | formbricks.api.webhooks.delete | 删除 webhook,使其签名密钥失效 |
风险等级为什么这样划分
风险等级按"操作能毁掉什么"而非"用了哪个 HTTP 方法"来划分,index.ts 的formbricksEndpointMeta中给出了若干不太直观的决策依据:
surveys.delete是 destructive 而非 write:它会连同问卷收集到的回复一起删除——那是受访者数据,不只是配置;webhooks.delete是 destructive:重建订阅会签发新的签名密钥,所有校验签名的接收端都得跟着更新,爆炸半径远大于一条记录;teams.delete是 destructive:团队可能是成员访问工作区的唯一通道,删除会导致他人连带失去访问权;responses.delete是 destructive,同时也是工作区所有者响应擦除请求(erasure request)时会用的操作,因此必须如实上报结果,不能乐观处理;contacts.delete是 destructive:它移除一个人的身份并解除其回复的关联;contacts.uploadBulk是 write 而非 read,而且是这里单次量最大的操作:一次创建大量联系人,重放会重复整个批次。
别名与共享路由背后的设计考量
四个别名操作(contacts.listPeople、contacts.getPerson、contactAttributeKeys.listClasses、contactAttributeKeys.getClass)是因为 Formbricks 把 "people" 改名为 "contacts"、"attribute classes" 改名为 "contact attribute keys" 并删除了旧路由,但目录仍同时保留两个名字。它们调用的是完全相同的 URL,存在的意义是让按旧条目工作的调用方不会 404——而不是背后有第二套能力。endpoints.test.ts会断言每个别名仍指向其主操作的路由,且每个别名发出自己的审计事件,使两者在日志中可区分(见 contacts.ts)。
另有三个已移除路由的 id 是真实能力,映射到当前路由实现:DELETE_PERSON→contacts.delete、CREATE_ATTRIBUTE_CLASS→contactAttributeKeys.create、DELETE_ATTRIBUTE_CLASS→contactAttributeKeys.delete。而UPDATE_CONTACT_ATTRIBUTES在管理面完全没有对应路由(五个候选路由要么 404 要么 405),因此contacts.updateAttributes改走客户端用户路由,按userIdupsert(详见下文"联系人"一节)。
contactAttributeKeys.update则是一个不认领任何目录 id的操作:它编辑属性键的定义(显示名与描述),目录里没有它的 id,但能力真实存在,故予以保留。
别名的存在并非唯一的路由共享原因:两个 health id、两个 v1meid、三个 post 到客户端用户路由的操作同样共享 URL——这正是 47 个操作落到 38 条路由的由来。
请求层的两个关键决策
client.ts 的makeFormbricksRequest统一负责发请求,其中两处决策与代码生成模板不同,且都直接影响正确性:
错误不包装。模板会捕获一切异常并重抛为
FormbricksAPIError,这会丢弃ApiError及其携带的 HTTP 状态码。而本插件所有错误处理器都按状态码分类——删除流程更依赖区分 404 与 500 来判断记录是否已不存在。包装会摧毁这一切,因此ApiError被原样上抛。query 参数在所有方法上发送,而不只 GET。模板在写请求上会丢弃 query。Formbricks 在非 GET 请求上接受 query 参数,一个被静默丢弃的参数是最糟的 bug:请求成功执行了,但干的事和调用方要求的不是一回事。
Formbricks 的所有响应都包裹在{ data }信封中(v2 列表还会加meta),shared.ts 的unwrap在端点层统一解包,因此输出 schema 描述的是调用方实际拿到的记录,而非外层包装;meta被有意丢弃(理由见下文分页一节)。请求体则经compactBody剔除值为undefined的字段——Formbricks 区分"字段缺失"(保留原值)与"显式null"(清空值),序列化undefined两者都不是。
双版本 API:v1 与 v2 并存是常态而非迁移过渡
Formbricks 同时暴露v1 与 v2 两套 API,且操作面横跨两者(client.ts):
- v1拥有问卷、回复、action class、联系人、联系属性与存储;
- v2拥有 webhook、组织、团队、角色、健康检查、联系人批量上传与联系属性键的写操作。
这不是一场可以等它结束的迁移——两套版本承载着不同的资源,完整的插件必须同时使用两者。少数资源在两版中都存在,最典型的是 webhook:通过POST v1/webhooks创建的 webhook 会被GET v2/management/webhooks返回,说明这是同一存储的两个表面而非两种资源。遇到这种情况,插件选择 OpenAPI 文档描述的那个版本并在操作注释中说明。
因此每个调用都显式指定版本,而非设定默认值——默认值会把 v2-only 的请求悄悄发往 v1,得到一个看起来像"记录不存在"的 404。
版本差异在端点实现中处处可见,例如:
- 问卷:四个操作全是 v1,v2 没有问卷 CRUD(surveys.ts);
- 联系人:读走 v1、写走 v2——不是偏好,而是 v1 对联系人只暴露 GET,对属性键没有 create/delete(contacts.ts);
- 联系属性键列表:读走v2,因为只有 v2 支持分页——v1 路由完全忽略
limit返回全部键,v2 路由尊重limit+skip并返回meta: {total, ...},两者返回完全相同的十个字段(已逐字段比对验证); - 属性键更新:走v1,因为只有 v1 接受部分更新——v2 会重新校验整个请求体(详见下节)。
端点纵深解析:核心操作与输入输出契约
问卷(surveys)
问卷是整个 API 的锚点:回复、展示记录与 webhook 都引用问卷 id。四个操作均为 v1,且只镜像问卷实体——问卷的内容(问题、结束页、样式)不建模,因为其形状取决于每种问题类型,在 Formbricks 编辑器里创作,一个封闭 schema 会在用户用到插件未枚举的问题类型时拒绝合法问卷(见 schema/database.ts)。
surveys.create:workspaceId必须在请求体中,缺失返回 400"workspaceId must be provided"——尽管 API key 已经作用域到该工作区;返回200 而非 201(同一 API 的联系人创建却返回 201,调用方不能想当然)。questions由产品而非 schema 要求——空问题问卷 API 会接受但无法作答,故留为可选,以便创建壳问卷(Formbricks 编辑器自己就是这么做的);surveys.update:PUT 而非 POST——v1 文档声称更新用 POST,但POST到条目路由返回405 且响应体为空(types.ts 与 surveys.ts 均验证过)。对同一 body 应用两次状态不变,故不在非幂等集合中;surveys.delete:不可逆,且会带走回复,因此标记为 destructive;返回200 并带删除的记录,而非 204。镜像行做 best-effort 驱逐:问卷本身不含个人数据,陈旧行只是不整洁而非泄露——与 webhook 的强制驱逐形成对照。
回复(responses)
responses.list有一个重要契约:surveyId是该路由唯一真正生效的过滤器。目录还文档化了contactId、startDate、endDate、filterDateField、sortBy、order,但逐一用效果验证后确认它们全被接受(200)后忽略:?contactId=<不存在的id>返回全部 3 行、不可能的日期范围也返回全部行、order=asc与order=desc顺序一致。因此这些参数不声明——一个静默返回所有受访者答案的过滤器比缺失更糟:调用方要一个人的回复却拿到所有人的,且无从察觉。
responses.update是全插件唯一为绕开服务端 bug 而设的必填输入:PUT v1/management/responses/{id}在data缺失时返回500internal_server_error({finished: true}会崩,{data: {}, finished: true}则成功),本应返回 422。在 schema 里强制data必填,就把 500 变成了本地校验错误。有趣的是该操作不要求workspaceId(已验证不带它返回 200)。
联系人(contacts)与属性键(contact attribute keys)
这一族内部就划分了"镜像"与"不镜像"的边界(contacts.ts):
- 属性键被镜像:它们是schema(
email、userId、firstName……)而非取值,属于配置,量小,且是让属性行可读的查找表; - 联系人与属性值不镜像:它们是受访者身份与个人详情。属性行把
attributeKeyId与value配对,镜像它等于把某人的邮箱地址存进本地。
几个值得注意的输入契约(全部来自 types.ts):
contacts.create:attributes是普通对象(按属性键为 key),返回201;contacts.uploadBulk:attributes是数组{attributeKey: {key, name}, value}——与单个创建形状不对称,把对象形式发过去是 422。本地强制两条约束(均源自 API 自身的报错):每批最多 250 行(422 "Maximum 250 contacts allowed at a time."),且每行必须带email属性(422 "Email attribute is required for contact at index 0",本地校验也按行报索引)。整批在实践上是原子的,422 重试不会重复插入;输出是{status, message}而非上传的联系人数组;contacts.updateAttributes:对应目录的UPDATE_CONTACT_ATTRIBUTES,是全插件唯一不走管理路由的操作——管理面没有任何路由能设置联系人属性值(五个候选路由逐一实测全部 404/405),因此 post 到客户端用户路由client/{workspaceId}/user,按userId(调用方自己的标识)upsert:id 不存在就创建联系人,没有"仅更新"形态,这正是它被标为write而非 update、并列入非幂等集合的原因。它返回受访者状态而非联系人记录,需要最新记录得再调get;contactAttributeKeys.create:description必填——缺失返回 422,尽管它读起来像文档字段;contactAttributeKeys.update:编辑的是属性键的定义(显示名、描述),不碰任何联系人的存量值(已通过读取联系人修改前后的取值验证)。它走v1是因为 v2 要求整包提交:PUT v2 {name}返回 422 缺description、PUT v2 {description}返回 422 缺name,只有 v1 允许只带一个字段。值得警惕的是PUT v1/management/contact-attribute-keys/{id}并不在已发布的 v1 文档中,是实况验证得到的——规范本身不完整,对DELETE v1/management/contacts/{id}同样如此。
webhook:订阅管理是端点,不是触发器
Formbricks 在收到回复时会发出出站 webhook,插件中的五个 webhook 操作管理这些订阅——但它们是端点,不是 Corsair 触发器。目录列出的触发器数量为零,Webhooks 一节写着 "No webhooks",因此没有注册任何 webhook 处理器,生成的 webhook 脚手架也被移除而非留作死代码(index.ts)。
创建 webhook 的输入契约值得细看(types.ts),每个约束都来自 API 的 422 报错而非文档猜测:
source枚举为"user" | "zapier" | "make" | "n8n",user是 API 创建时的取值;triggers枚举为"responseFinished" | "responseCreated" | "responseUpdated"。responseCreated在受访者开始时触发、responseUpdated在每次保存时触发、responseFinished仅在完成时触发——订阅三者会按设计收到同一条回复多次;url仅允许http:/https:协议,javascript:/ftp:/裸字符串一律拒绝;surveyIds是必填数组,空数组表示"所有问卷"(故不加min(1));?surveyIds[]=<id>会被接受但毫无效果(返回全部 webhook),故不过滤参数不声明;webhooks.update是全量替换而非补丁:v2 对整包重新校验,漏掉source就 422。早期版本恰好漏了它,导致该操作完全无法调用成功;现在所有字段必填,替换语义直接体现在签名里。
webhooks.delete之所以是 destructive,正如风险等级一节所述:重建订阅会签发新签名密钥,所有校验签名的接收端都要跟着改。它的镜像驱逐是强制的:一个存活的镜像行描述着一个账户认为自己已删除的集成,比"不整洁"严重得多。
客户端 API(client)与存储(storage)
客户端 API 是问卷 widget 使用的表面,工作区作用域写在路径上:
client.createDisplay:关联参数是userId而非contactId——传contactId会被接受(200)并忽略:展示记录以contactId: null落库且调用方毫无信号(已用真实联系人 id 验证)。此外问卷必须是inProgress状态,draft问卷返回403 "Survey is not accepting submissions",听起来像权限问题,实为问卷状态问题;client.contactsState:目录描述为"获取客户端联系人状态",需要userId。四个 GET 形态(contacts/{userId}/state、user/{userId}/state、两个版本的contacts/state)全部 404——读取该状态的唯一方式是 POST,而它会把联系人 upsert 出来:未见过的userId会被创建而非报缺失。这就是它被标为write而非read的原因(index.ts 注释详细记录了这次修正);storage.uploadPublic/uploadPrivate:两个操作是同一路由,仅accessType不同(POST v1/management/storage)。响应是 S3预签名 POST 授权——插件只负责拿到授权,传输字节是调用方自己的 multipart POST,目录文档化的 5MB 上限由 S3 在那一步强制执行,而非此处。早期版本曾把私有上传指向客户端路由,结果任何 body 都返回400 "Fields are missing or incorrectly formatted",该操作根本无法成功。
分页:Formbricks 对自己都不一致,插件如何统一
分页是这份集成里最容易被"200 掩盖"的坑。shared.ts 的PageStyle表按效果(播种至少三行后比较?limit=1与?limit=1&offset=1、?limit=1&skip=1的返回)鉴别了每条路由实际认可的游标参数:
| 路由 | limit | offset | skip |
|---|---|---|---|
| v1 management/surveys | 支持 | 支持 | 不支持 |
| v1/v2 management/responses | 支持 | 不支持 | 支持 |
| v2 management/webhooks | 支持 | 不支持 | 支持 |
| v2 organizations/{id}/teams | 支持 | 不支持 | 支持 |
| v2 organizations/{id}/workspace-teams | 支持 | 不支持 | 支持 |
| v2 management/contact-attribute-keys | 支持 | 不支持 | 支持 |
| v1 management/contacts | 不支持 | 不支持 | 不支持 |
| v1 management/action-classes | 不支持 | 不支持 | 不支持 |
| v1 management/contact-attributes | 不支持 | 不支持 | 不支持 |
| v1 management/contact-attribute-keys | 不支持 | 不支持 | 不支持 |
| v2 roles | 不支持 | 不支持 | 不支持 |
两个主动误导的陷阱被特别点名:
meta会说谎。v2 列表返回meta: {total, limit, offset}——在忽略offset参数、只认skip的路由上报告一个offset字段。信信封,正是错误结论得以存活的原因;- 四条 v1 路由连
limit都忽略。不只是游标,连页大小都不认,永远返回全部行。这些操作因此不接受任何分页参数——声明一个会被提供商丢弃的输入字段,等于让插件自己误导调用方:设了limit: 10却拿到 4000 行,这是插件的错,不是 Formbricks 的。
插件的应对是:调用方面向的名字统一为offset(对可分页的操作),由listParams(style, input)在一处把它翻译成对应路由实际认的 wire 参数。v1 的management/surveys是全 API 唯一认offset的路由——早期版本正是"只测了这一条路由"就在所有地方发offset,导致六个列表操作在返回 200 的同时分页错误。
limit被钳制在1–250,这是目录文档化的响应上限(API 自身不强制:?limit=1000也返回 200,故这是客户端侧防御,取文档值而非凭空设定;此前 100 的上限低于目录承诺)。不能分页的列表操作则干脆不声明分页参数——例如contacts.list是四条不可分页路由中最要紧的一条:联系人列表原则上无界且含个人数据,调用方有充分理由要一页,却没有任何办法拿到一页(客户端切片仍会传输全部行),该问题已向维护者上报而非在本地掩盖。
错误处理与重试策略
error-handlers.ts 注册的处理器几乎全部按 HTTP 状态匹配。由于 Corsair 按声明顺序取第一个匹配的处理器,而ApiError的 message 内嵌了响应体,所有消息嗅探型处理器都以"无状态"为前提门控(hasNoStatus)——否则一个可重试的 500 若响应体恰好含 "not found" 就会被NOT_FOUND_ERROR截胡、永不重试。
| 处理器 | 匹配 | 行为 |
|---|---|---|
RATE_LIMIT_ERROR | 429,或无线程状态且消息含 "too many requests" | 最多重试 3 次,可带上retry-after;Formbricks 未文档化限流预算,这是防御性处理 |
AUTH_ERROR | 401 | 不重试;日志提示检查 API key 及作用域(组织级 key 够不到工作区路由) |
PERMISSION_ERROR | 403 | 不重试;与 401 区分是因为修复方式不同:401 是 key 坏,403 是 key 没权限 |
NOT_FOUND_ERROR | 404 | 不重试;Formbricks 只有一种 404 形态,无法从 body 区分"记录没了"与"路径错了",缓解措施是路径全为端点文件里的字面量并由路由测试兜底 |
VALIDATION_ERROR | 400 / 422 | 不重试;describeValidationFailure能读出 v2 错误体details数组里的{field, issue},点名词条字段(v1 则读message) |
SERVER_ERROR | 500–599 | 重试 3 次(非幂等操作除外);其中responses.update缺data的 500 是服务端 bug,已被 schema 前置拦截 |
NETWORK_ERROR | 无状态且消息含 network/connection/ECONNREFUSED 等 | 按非幂等性决定重试 0 或 3 次 |
DEFAULT | 兜底 | 不重试 |
非幂等集合(error-handlers.ts)是重试策略的核心:Corsair 请求重试时会整体重放整个端点(packages/corsair/core/endpoints/bind.ts),网络失败若发生在 Formbricks 已提交写入之后,就会应用两次,而 Formbricks 不接受任何幂等键。因此以下操作被列入集合,重试次数为 0:surveys.create、responses.create、actionClasses.create、webhooks.create、contacts.create、contacts.uploadBulk、contacts.updateAttributes、contactAttributeKeys.create、client.createDisplay、client.createUser、client.identifyUser、client.contactsState。其中后四个按"注册表名"而非资源名命名(早期草稿写的displays.create之类根本不是注册操作,被endpoints.test.ts的漂移测试首次运行就全部抓住)。删除命名资源与更新操作有意缺席:删除重复执行不是重复风险(第二次报 not-found 恰好被删除流程读作"确认已不存在"),PUT对命名记录在效果上幂等。
数据镜像策略:配置镜像、数据不镜像
插件通过 Corsair 的数据库将部分实体镜像到本地(persist.ts 提供cacheEntities/cacheEntity),划分原则是configuration 与 collected data 的分界(schema/database.ts):
- 镜像(配置):问卷、action class、webhook、联系属性键、团队——账户创作、极少变化,且是其他操作所需的查找表(回复引用
surveyId、联系属性引用attributeKeyId); - 不镜像(收集数据):回复、联系人、联系属性、展示记录——从真实受访者持续流入的数据洪流,本地副本到达即过期,且那是别人的个人数据的副本。这一点在 Formbricks 上比多数服务商更尖锐:一条回复就是可识别个人的问卷答案,收集它正是产品本身的目的。
审计事件同样遵循此原则:联系人创建/更新的审计记录属性键名而非属性值(值可能是某人的邮箱和姓名),userId也从不入日志。
实时集成测试:如何跑、测什么
integration.test.ts 是针对真实 Formbricks Cloud 工作区的实况测试套件,默认运行被jest.config.cjs的testPathIgnorePatterns排除、CI 命令行同样排除、无 key 时自跳(describeLive),因此不带凭据的检出跑全绿且不触网。运行方式:
FORMBRICKS_API_KEY=<key> pnpm test:live关键约定:key 必须工作区级——组织级 key 在大多数管理路由上返回 401(v1/management/me会明说)。该套件会写入,且每次写入都在finally中清理;测试前后比对计数,不匹配即判失败而非悄悄收拾。不触碰的是存量记录:工作区预置的问卷只读不改不删,联系人清理只删本套件创建的 id/邮箱,绝不GET management/contacts后全量清空。
测试断言也印证了前面的实现事实:expectApiError匹配的是ApiError.body而非message——因为ApiError.message对 Corsair 已映射的状态是状态码的通用名(403 到达时是"Forbidden",不是 Formbricks 的"Survey is not accepting submissions"),对未映射的 422 则把 body 插值进字符串,对象 body 会渲染成字面量"[object Object]"。
常见陷阱速查
最后把文档与源码中反复强调的坑汇总为速查表,供接入时对照:
- key 作用域:组织级 key 在绝大多数管理路由上 401,先用
me.get或GET /api/v2/me核验; workspaceId在 body:多数写操作要求它显式出现在请求体中(responses.update例外);- 双版本:问卷/回复在 v1,webhook/组织/团队/批量上传/属性键写在 v2,别用默认版本;
- 分页参数名不一致:除
surveys外全部认skip;四条 v1 列表根本不支持分页; contacts.list无界返回:一次调用返回工作区全部联系人(个人数据),且无分页替代方案;contacts.updateAttributes按userIdupsert:想"检查某人是否存在"会创建他;- webhook 更新是全量替换:漏
source必 422; responses.update必须带data:否则触发服务端 500 bug;surveys.update用 PUT:POST 到条目路由是 405;client.createDisplay认userId:传contactId被静默忽略;问卷须inProgress否则 403;- 批量上传 ≤250 行且每行要
email; - webhook 删除使签名密钥失效:所有接收端要跟着更新。
以上全部结论均可回到 packages/formbricks 的 README、端点源码与测试中逐一核对,插件许可证为 Apache-2.0,完整类型与示例可参阅 packages/formbricks/README.md 中指向的插件文档页。
【免费下载链接】corsairConnect your users to their apps项目地址: https://gitcode.com/GitHub_Trending/corsa/corsair
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考