AI 视频应用如何处理提交、扣费与失败
RapVideoGo 作者介绍基于 TanStack Start 和 Cloudflare 构建视频生成应用的工程取舍。重点涉及重复提交、供应商接单后断连,以及积分扣除与失败返还等生成接口周边问题。
AI 深度解读
AI 视频产品最容易低估的部分,是点击 Generate 之后的状态管理。重复提交会不会多扣积分?请求超时后,视频到底有没有开始生成?失败通知来了两次,又该退几次钱?RapVideoGo 作者分享的重点,就是这些夹在界面和模型服务之间的问题。
作者的结论很明确:生成任务需要被当作一条有持久状态的工作流。对准备接入付费 AI 服务的前端和全栈开发者,这篇的价值在于把「接口调通」之后仍需处理的边界列了出来,同时也留下了一些实现细节需要追问。
一次生成,为什么必须先有自己的身份
RapVideoGo 根据参考照片和文字提示生成个性化说唱视频。应用使用 TanStack Start、React 和 TypeScript,客户端数据获取交给 TanStack Query;运行环境是 Cloudflare Workers,数据存储使用 Cloudflare D1 和 Drizzle,媒体放在 Cloudflare R2,认证使用 better-auth,英文与中文本地化使用 Paraglide。
作者并非从零搭建全部系统,而是复用了已有 SaaS 基础中的账号、支付和积分能力。这让他能够集中处理创作流程及其失败情况。
视频生成由外部服务提供,应用负责保存用户输入、提交有效请求、跟踪进度,并维持账号状态的一致性。任务生命周期被表示为:
pending → processing → success
↘ failed
要留意 processing 后的两条分支:界面不能只记住「正在加载」,还需要知道任务最终如何结束。
任务记录保存所属用户、生成设置、参考图 URL、提示词、服务商信息和积分成本。这样即使浏览器关闭,任务仍有可查询的身份。
重复提交也不能只判断「这个 ID 已存在」。作者要求已有任务同时匹配请求用户、产品模块和请求指纹。请求指纹用于判断这次提交是否仍是同一份输入;原文没有交代它具体如何生成。
重试同一请求应该找回已有任务,换了输入却复用同一 ID 则应该报冲突。
这个区分把恢复任务和创建新任务分开,也让后续扣费、回调和退款有了共同的关联对象。
扣积分和调用模型,为什么要分两段处理
先扣积分再建任务,可能扣完钱却没有任务;反过来,也可能留下没有对应扣费的任务。
作者的处理方式是先规划积分消费,再通过一次原子数据库操作插入任务并执行积分变更。在 D1 上,项目的数据库辅助层使用 batch statements;按原文引用的 Cloudflare 文档说明,批次中的语句失败会导致整组操作回滚。
但原子写入还不足以解决并发。两个请求可能先后读到同一份可用余额,再分别制定消费计划。
因此积分操作还包含数据库保护条件:如果其他请求已经消耗了当前计划依赖的积分,旧计划就不能继续完成。任务创建随后可以在有次数上限的重试循环里重新规划。原文没有给出保护条件的 SQL,也没有给出重试上限。
这里有一道明确的边界:数据库 batch 无法把外部 API 调用也纳入事务。任务和扣费保存成功,并不代表服务商一定收到请求;应用没收到响应,也不代表服务商没有接单。
作者因此区分了两种情况:
- 服务商明确拒绝请求,可以按确定失败处理。
- 连接中断导致结果未知,需要恢复或等待确认。
在服务商支持幂等键(idempotency key)的路径中,恢复请求沿用同一任务身份和已保存的输入。其他路径则等待确认或过期,避免立即创建另一个付费任务。
任务完成时也采用条件更新,检查预期状态与服务商任务 ID,降低旧响应覆盖新状态的风险。退款则先认领原始积分消费记录,再恢复积分,防止同一失败被通知多次后重复退款。
这里处理的是不确定结果,而不是单纯的接口报错。
照片顺序和语言路由,为什么也属于生成链路
有些错误发生在前端,却会直接改变模型收到的创作意图。
例如双人说唱中,提示词指定 @1 唱开场,@2 接唱。如果第二个槽位的照片先上传完成,应用不能把上传完成顺序当成人物顺序。
作者保留显式槽位编号,再按槽位推导当前启用的照片列表。下面是原文给出的简化辅助函数:
function activePhotos<T extends { slot?: number }>(
photos: T[],
count: number
): T[] {
return Array.from({ length: count }, (_, slot) =>
photos.find((photo) => photo.slot === slot)
).filter((photo): photo is T => photo !== undefined);
}
关键是 photo.slot === slot:输出顺序来自槽位,而非数组中照片出现的先后。函数最后会过滤空槽,至于缺图时是否允许提交,原文没有说明。
切换到单照片模式时,非活动槽位也要从生成请求中排除,但照片仍保留在草稿里,方便用户切回。作者为「先上传第二个槽位」和「切换照片数量」安排了测试。
本地化则由共享路由层统一处理。内部路由不带语言信息,路由层负责本地化 URL,路由加载器解析标题和描述使用的语言。这样组件不必各自拼接语言路径,页面 URL、可见内容和元数据之间的关系也更容易维护。
用户在界面里表达的意图,经过上传、模式切换和页面导航后,仍应保持一致。
我的判断
我认同作者把精力放在付费调用周围的状态边界。按钮禁用能减少当前页面的重复点击,却无法独自覆盖网络重试、并发扣费和重复失败通知。持久任务、条件更新和退款防重,分别约束了不同环节。
不过,这是一份实现经验分享,原文没有给出故障率、并发规模或替代方案的对照数据,不能据此判断这套方案在性能或可靠性上优于其他设计。
它也有几个前提。作者已有账号、支付和积分基础;数据库原子操作的说明依赖 D1 batch 的语义;服务商支持幂等键时,恢复路径才更明确。对于不支持幂等的服务商,等待多久、如何确认、何时过期,原文没有展开。
测试覆盖了积分保护、原子写入、生成请求、参考图排序和创作历史,还有单独针对本地 D1 原子操作的测试路径。这说明作者确实围绕边界做了验证,但原文未展示完整测试用例及结果。可以借鉴其验证方向,不能把测试清单等同于已经证明所有故障都能正确恢复。
能拿来做什么
- 给日常 AI 编程项目补上持久任务记录,明确用户、输入、状态和服务商任务 ID 的关联,并区分相同请求重试与不同输入复用 ID。
- 在 Node 或全栈服务端,把积分扣减与任务创建放进数据库支持的原子操作,同时检查并发消费保护条件,避免只靠提前读取余额。
- 将服务商错误拆成明确拒绝和结果未知两类;接入前先确认幂等能力,并为未知结果设计恢复、确认或过期路径。
- 为前端多图输入保留稳定槽位,验证乱序上传和模式切换;为服务端优先验证并发消费、重复失败通知及原子写入失败等会产生实际成本的情况。