Bunship 的 Provider 扩展设计:支付与文件上传如何接入
Bunship 如何接入不同支付和上传 Provider?拆解支付事件归一化、订单与积分边界、用户上传目标选择,以及权限校验和资源地址的处理方式。
做 Bunship 时,我不太希望使用者刚换一家服务,就要从页面到数据库改一遍。生成模型有 Provider,支付和文件上传也有 Provider,但它们面临的不是同一种问题。模型接入主要处理执行与结果;支付要解释到账之后应该发生什么;上传则要确定谁能写到哪个位置,以及文件以后从哪里读取。
上一篇生成任务设计讲了队列与模型适配。这篇接着看支付和上传:如何把第三方协议留在边界上,同时让订单、积分、权限和资源地址仍然由应用自己掌握。
本文延续 2026 年 5 月 27 日的代码状态。支付以当时的 Stripe、Creem 接口为例,上传讨论 5 月完善的运行时 Provider 路由。流程图按代码中的职责和调用关系整理,不代表每条生产链路都做过真实支付或上传验证。
先决定什么由 Provider 管,什么留在业务里
最容易起步的支付写法,是在订单接口里直接创建某家平台的 checkout,再在 webhook 里顺手改订单、发积分和发通知。第一次接通时很快;第二家平台进来,才发现这些操作已经绑在一起。复制一份 webhook,等于复制一份业务规则。
Bunship 把 Provider 放在靠近外部系统的一侧。它知道怎样创建收银台、怎样校验签名、怎样查询外部订阅,也知道如何把供应商事件转换成内部事件。应用侧的 handler 再决定这个事件如何影响订单、订阅和钱包。
上传的边界类似,但契约更短:根据服务器上的配置解析目标服务、bucket 与客户端,再交给上传协议处理。用户路径、文件类型和大小限制仍在应用路由里,不因为存储服务换了一家就一起消失。
左右滑动查看完整表格
层次 | 支付里处理什么 | 上传里处理什么 |
|---|---|---|
外部适配 | checkout、签名、事件字段 | 客户端工厂、签名与对象写入 |
配置选择 | 产品对应的支付渠道与价格引用 | 用户覆盖、默认目标、环境配置 |
应用规则 | 订单、订阅、积分与激活 | 身份、路径、文件限制 |
可见结果 | 本地订单与权益状态 | 文件 key 与访问地址 |
这不是要发明一个包罗一切的 Provider 基类。支付要处理 webhook 和账单周期,上传要处理对象 key 和访问域名。如果把它们塞进同一个“通用 execute”,反而会失去最需要表达的业务含义。
下单时,先把产品和外部价格对应起来
同一个产品可以在不同支付平台拥有不同的价格或产品 ID。对应用而言,它仍然是同一个积分包或订阅套餐;对供应商而言,checkout 必须引用那边实际存在的对象。因此,内部 product ID 与 externalPriceId 都要保留,不能把外部 ID 直接当成产品模型。以后调整供应商价格时,产品介绍和内部权益不必跟着改名;只需要核对那个渠道的映射以及金额是否一致。
订单入口会根据显式选择的支付渠道、产品配置或默认 Provider 解析实现。用户明确选了某个渠道时,还要找到该产品在这个渠道下处于启用状态的支付配置。缺少映射应当报错,而不是偷偷借用另一家的价格引用。
内部 pending 订单先创建,记录用户、产品和选定的支付来源,再把 order ID 等 metadata 传给 checkout。供应商后续回调时就能找到应用里的那笔订单。创建收银台得到的 session ID 是外部会话身份,不是“款项已经到账”的证明。这里的记录顺序很重要:如果先跳到外部收银台,回调回来却没有内部订单,就很难把真实付款和用户要买的产品可靠地连起来。
PaymentProvider 契约除了创建一次性或订阅 checkout,还包含客户门户、订阅查询、取消与恢复、webhook 验签和事件解析。不同实现通过 registry 注册,应用按名称拿到它,而不是在每个业务入口反复判断平台名称。
接口统一也不意味着能力相同。契约里保留了是否支持客户门户、暂停、恢复、试用和周期末取消等能力标记。页面与业务应该尊重这些能力,不能因为另一个 Provider 有暂停按钮,就给所有渠道都展示一个看起来能用的同名操作。这样新增渠道时,缺少的能力会在接口边界被看见,而不是到用户点下按钮才暴露。
Webhook 先验证,再翻译,最后进入业务
支付页面跳回成功地址,只能说明浏览器完成了一次跳转。真正改变支付业务状态,需要依据服务端确认过的支付结果。浏览器可能被提前关闭,也可能在 webhook 到达之前返回,因此回跳页更适合展示并查询本地订单状态。
Stripe 与 Creem 的回调路由都先保留原始请求文本,再交给各自 Provider 验签。这样做很具体:签名针对的是收到的原始内容,不能让框架先转成 JSON 对象,再重新序列化一遍拿去验证。字段顺序或空白改变,都可能让校验对象与收到的内容不同。
验证通过之后,再解析成 NormalizedPaymentEvent。事件里保留 provider、原始事件 ID,以及 checkout、payment、subscription、invoice 或 refund 对应的数据。业务 handler 接收这份内部结构,避免在每个积分分支里都认识两家的响应格式。
下面是路由处理顺序的简化示意:
const webhook = {
rawBody,
signature,
};
const verified =
await provider.verifyWebhook(
webhook,
);
if (!verified) {
throw new Error("Bad signature");
}
const event =
await provider.parseWebhookEvent({
rawBody,
rawEvent: verified,
});
if (event) {
await handlePaymentEvent(event);
}在这两个实现里,Stripe 验签成功后返回已解析事件,Creem 返回布尔值,所以除了处理异常,也要检查拒绝值。上面省略了具体路由的错误响应与类型细节,保留的重点是:只有验证通过,事件才可以进入业务处理。
事件名字相近,也不代表业务含义一样。Stripe 的 checkout 完成事件与 payment intent 成功事件,在内部有不同作用;订阅续费又需要根据 invoice 和周期处理。先把这些含义拆开,比把所有包含 completed 的事件一律标成“已付”稳妥得多。
这个历史版本恰好也保留了一个值得记录的边界:Creem 的 checkout.completed 会被归一化成 checkout 完成事件,而当时一次性 checkout handler 主要回填外部关联 ID。因此,仅看见这一映射,不能推导出 Creem 一次性支付已经完整走通 paid 状态与权益发放。新 Provider 接入验收必须追到业务终点,而不能停在 webhook 返回成功。
幂等要落到业务动作上
支付回调可能重复,也可能先后顺序不同。如果把所有防重复逻辑都放在“见过这个 event ID 就结束”,仍然回答不了业务执行到一半失败的问题:订单改了,积分没发,下一次回调应该从哪里继续?反过来,供应商用不同事件报告同一笔付款,也不能简单地把它们当成两笔权益。
Bunship 在多个业务动作上设置各自的识别方式。一次性积分产品发放使用内部 order ID 组成幂等键;订阅续费会查找外部 invoice 对应的历史订单;退款回收积分也以退款身份构造独立流水键。这些键说明“这一次业务动作是什么”,而不只是“这个 HTTP 请求来过没有”。
例如,一次性产品的积分发放身份采用这样的结构:
order:{orderId}:credits
refund:{refundId}:revoke订阅还有自己的周期语义。试用开始、试用转正式付费、正常续费,未必都应当发同样一笔积分。代码因此区分试用与普通账单,保存周期起止时间,并在账单处理里保留重放检查。这样更容易解释用户的套餐额度来自哪一周期,而不是把所有余额都混成一个无法追溯的数字。
历史实现也不是一个已经封闭所有故障窗口的结算引擎。一次性订单先被标成 paid,再尝试发放积分;发放失败会记日志,已经更新的订单不会因此回滚。如果同一个成功事件再次进入 paymentSucceeded,它遇到非 pending 订单会报错,不能靠重送 webhook 自动补足这笔积分。订阅账单也先建立 paid 订单,再更新钱包;重放时如果发现 paid 订单就会跳过钱包操作。退款处理当时主要覆盖全额退款,不能自然推导出按比例回收部分退款权益。业务幂等键很有价值,但它不能替代补偿任务、对账与失败恢复。
如果继续完善,我会把“已收到可信支付结果”与“权益已经完成发放”分别记录,让重放或补偿能够只补未完成的一段。这样在排查时可以准确告诉用户钱是否已收到、哪一步尚未完成,而不是面对一个 paid 状态却无法解释为什么余额没变。
支付 Provider 的验收因此至少要看下单、签名验证、重复回调、事件乱序、续费和退款,再结合实际产品检查权益。对一个纯代码模板来说,把这些边界暴露清楚,比展示更多支付品牌图标更有用。
上传先选择目标,再建立受限的写入流程
文件上传看起来简单一些,但“换个 bucket”也会牵动很多地方。上传组件要知道调用哪个接口,服务端要确认用户身份,对象存储需要正确的客户端配置,页面最后还要拼出能访问的地址。只改一个 endpoint,往往只完成了其中一段。
Bunship 的 resolveUploadTargetForUser 集中处理上传目标。它先读取设置里的用户覆盖项,再看全局默认设置,最后回退到环境配置。目标由 provider、bucketName 与客户端构造结果组成。解析不到 bucket 时直接报配置错误,不把一个缺失配置伪装成上传网络异常。
服务端的客户端工厂包括 AWS、Cloudflare、Backblaze、DigitalOcean、MinIO、Tigris、Wasabi 与 custom 等入口。这些工厂把连接参数转换成对应客户端;业务上传路由不用复制一遍。为兼容项目原有部署方式,通用 S3 环境变量也会映射到各工厂需要的字段。
配置优先级有两层,值得单独区分。第一层选择目标:设置中的用户 ID 覆盖项、全局默认设置、环境默认。第二层合并凭据与连接参数:设置中显式给出的 client 字段覆盖环境兜底。全局设置读取失败时也会退回环境配置。把“使用哪家服务”和“从哪里拿这个服务的配置”分开,排查时才不会把两件事混在一起。
这也意味着目标解析是一条服务端决策路径。浏览器不应该凭一个随意填写的 provider 名称就获得另一个 bucket 的写入能力。用户身份来自认证上下文,目标来自服务器管理的配置,两者在路由里汇合。
上传授权、文件写入和业务保存是三个阶段
普通用户上传入口先经过认证,再根据当前用户解析目标,构建 Better Upload 路由。对象 key 在服务端生成,带上用户路径前缀与随机文件名;commons 路由限制媒体、PDF、文本等类型,并设置单文件大小上限。管理员上传有单独入口和权限上下文。
在这个设计里,API 主要安排受限的上传流程,文件走直接上传到对象存储的路径。长期存储凭据用于服务端构造客户端,而不是交给页面长期保存。路由还保留 parse: "none",把原始 Request 交给上传库处理,避免框架先消费请求体。普通用户的 commons 路由允许图片、视频、音频、PDF 和纯文本;管理员的 commons 路由只列出前三类,两者的单文件上限都是 40 MB。这是路由规则,真正开放什么对象存储权限仍取决于部署配置。
不过,服务器生成了用户专属路径,不等于文件天然私有。这个阶段的公共媒体路径与缓存设置面向可公开访问的资源;需要私有附件时,还要另外设计读取鉴权、短期访问地址和缓存策略。目录中有 user ID 只能说明资源归属,不能代替读取权限。
同样,上传成功也不等于业务保存成功。一张图片已经进入 bucket,文章表单却可能没提交;头像文件已经传完,用户资料更新也可能失败。上传系统负责得到一个可引用的对象,文章、头像或素材库模块仍要完成自己的关联保存。后续做清理时,应区分仍被引用的文件与上传后没有绑定的孤立对象。
这些限制没有必要全塞进一个巨大上传组件。组件负责选择文件、展示进度和错误;路由负责身份与限制;存储适配负责传输;业务模块负责“这个文件被用在哪里”。按这一条线分工,新增使用场景时更容易复用。
能上传到新服务,不代表访问地址也自动迁移
上传结果至少涉及三个容易混淆的概念:存储服务的 API endpoint、bucket,以及页面拿来展示的公共 URL。API endpoint 负责对象操作,公共域名可能经过 CDN 或自定义域名,两者不一定相同。
前端 resolveUploadFileUrl 会优先读取返回的 object key;普通 key 与公共存储地址拼接,已经是完整 URL 的 key 则直接使用,再考虑 completedUrl 或 url。这个顺序使历史调用方式能继续工作,但也暴露了多存储路由需要继续处理的一件事:即使上传接口已经为甲、乙用户选了不同 bucket,页面仍可能把两个 key 都拼到同一个公共域名下。当不同目标对应不同域名时,一个全局 URL base 未必足够。
因此,这个版本可以按用户选择上传目标,却不能据此宣称多个彼此独立的访问域名已经完全透明。要进一步支持,需要让返回结果携带正确的可访问地址或目标来源,并确保业务记录在将来仍能解析它。换服务之后,旧文件也不会被自动搬过去。
另外,普通用户的 Better Upload 路由与 AI 生成产物保存不是完全相同的入口。五月版本的生成处理器仍通过自己的 S3Service 把供应商结果写入配置好的存储;不能把“用户上传支持 Provider 路由”延伸成“所有生成输出都会自动跟随用户切换存储”。这一点也正好与上一篇的产物保存阶段衔接。
未来若要统一两条路径,我会先定义资源身份和目标解析结果,再统一服务端写入方式。只把两个函数改成同名,解决不了旧链接、跨 bucket 删除、访问权限与生命周期的问题。
复用的是边界,不是所有业务语义
支付与上传放在同一篇讲,是因为它们都需要回答:外部服务换掉以后,哪些规则应该继续留在 Bunship 中?支付保留订单、订阅与积分语义;上传保留身份、对象归属与业务引用。Provider 将协议差异隔开,业务层才能继续解释用户真正关心的结果。
两者验收的终点又不一样。支付要看到可信事件如何落成订单和权益,上传要看到文件如何变成能正确访问的业务资源。一个接口返回 200、一个 SDK 成功初始化,都只能说明过程中的某一段成立。
这轮设计最值得保留的地方,是把选择配置、外部协议和内部业务分开。它没有消灭第三方差异,也没有让复杂流程突然变得简单,但让差异出现在明确的位置。下一次接入新渠道或新存储时,至少知道该改哪一层,以及哪些已有行为需要重新核验。
这两篇分别讲执行链路与接入边界;如果想先了解 Bunship 包含哪些产品能力,可以回到上线介绍,再按自己的场景选择从生成任务、支付或上传开始。
