用 TanStack Start 和 Cloudflare 构建 RapVideoGo:不止是调用 AI 接口
AI 视频产品的界面看着简单:上传一张照片,描述一个想法,点击生成。但在这个按钮背后,应用要回答好几个问题:用户提交了两次怎么办?供应商接受了请求但连接断了怎么办?积分该什么时候扣,失败之后又怎么退回?
我是 RapVideoGo 的开发者,这是一个用参考照片和文字提示生成个性化说唱视频的网页应用。本文讲的是它背后的一些实现决策,尤其是界面和生成服务供应商之间那一层。
技术栈和起点
这个应用用到了:
- TanStack Start、React 和 TypeScript 构建网页应用
- TanStack Query 负责客户端数据请求
- Cloudflare Workers 作为应用运行时
- Cloudflare D1 和 Drizzle 存储应用数据
- Cloudflare R2 存放媒体文件
- better-auth 做身份认证
- Paraglide 支持中英文国际化
我基于一套现成的 SaaS 基础搭建,账号、支付和积分都已经有了。这样我就能把产品开发的重点放在创作流程和它的各种失败情况上。
视频模型通过一个外部供应商运行。应用要做的是把用户的创作输入变成一个合法请求,跟踪它的进度,并保持相关账号状态一致。
1. 让每次生成都有持久身份
基本的任务生命周期是:pending → processing → success
↘ failed:一条任务记录会保存所有者、生成设置、参考图 URL、提示词、服务商信息和积分费用。这条记录让应用能为任务保留一个持久身份,浏览器不必是唯一记住“生成正在运行”的地方。
重复请求也不能只检查 ID 是否已存在。在 RapVideoGo 中,已有任务必须与请求用户、产品模块和请求指纹(用来标识请求内容)一致。用不同输入复用同一个 ID 会产生冲突。这个区分很重要:重试同一个请求应能找回已有任务,而请求变了就不该悄悄复用不相关的任务。
- 将任务和积分扣费一起保存
有一类一致性问题很容易说明:扣积分
创建任务时,如果第二步操作失败,用户已经扣了积分,却没拿到任务。如果颠倒顺序,又会出现另一种不完整状态:任务建好了,却没扣积分。这套实现的做法是先规划积分消耗,再插入任务,并通过一次原子性的数据库操作完成积分相关操作。
在 D1 上,项目里的数据库辅助工具会使用一批语句(batch)。Cloudflare 的文档说明,batch 中只要有一条语句失败,整个序列就会回滚。参考:Cloudflare D1 batch 文档。
不过,写入前先读一遍余额并不够。两个请求可能读到相同的可用积分。因此积分操作加入了数据库层面的校验条件:如果另一个请求消耗掉了当前操作预设要用的积分,这个校验就会阻止过期方案继续完成。任务创建随后会在有限次数的重试循环里重新规划。
调用服务商(provider)的请求是单独进行的。数据库 batch 无法让外部 API 调用也变成事务性的,因此提交给服务商这一步仍然需要自己的恢复逻辑。
3. 区分「明确拒绝」与「结果未知」
超时并不一定意味着服务商拒绝了任务。下面两种情况有本质区别:
- 服务商明确拒绝了请求。
- 应用没收到响应,无法判断服务商是否已经接受。
在第二种情况下自动重新提交,可能产生重复的付费任务。这套实现会区分这两种情况。在服务商支持幂等键(idempotency key)的地方,恢复时沿用同一个任务标识和已保存的请求。其他路径则要求等待确认或等待过期,而不是盲目地再建一个任务。
任务更新同样带条件。完成状态的更新会校验预期状态和服务商任务 ID,避免较早的响应覆盖较新的状态。失败处理也需要防重复:积分恢复流程会先声明对原始消耗记录的占用,再执行恢复。重复的失败通知不应该把同一笔扣费恢复两次。
这样做并不能让所有外部故障都消失,但它给了应用一套明确的规则,用来处理不确定性。
4.
把照片顺序也当成提示词的一部分
另一个问题出在界面层。设想一段对唱,里面有两个参考位:开场那句由 @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);
}
关键点在于:输出顺序始终按位置编号排列,与上传完成的先后无关。切换到单图模式时,还需要排除不生效的参考图,但不能把它们从草稿里删掉。这些看似细小的界面决策,会直接影响生成请求的内容。测试用例中既覆盖了先上传第二张照片的情况,也覆盖了在照片数量之间来回切换的情况。
5. 本地化要与路由和页面元数据打通
RapVideoGo 提供英文和中文界面。本地化不只是按钮和表单字段,还包括导航和页面元数据。路由在内部保持不带语言前缀,由路由层负责处理带语言的 URL。路由的 loader 负责解析当前语言,供标题和描述使用。
把这套逻辑集中在一个共享层里,可以减少各个组件自己拼接带语言路径的情况,也让页面 URL、显示内容和元数据之间的关系更容易维护。
6. 重点测试代价高昂的失败场景
项目包含针对额度校验、原子写入、生成请求、参考图排序和创作历史的测试。另外还有一条独立的测试路径,专门针对本地 D1 运行原子操作。
对于一个要消耗额度、调用付费服务的应用来说,这些边界值得重点关注。仅靠一次成功的预览,无法证明重复提交或并发扣费已被正确处理。这类行为需要靠测试去覆盖相应的状态转换和数据库操作。
这个项目给我最有用的经验是:把生成过程当成一个有持久状态的工作流来对待。
界面负责收集用户的创作意图;而应用要在上传、重试、服务商响应和账户更新这一连串环节中,始终把这份意图保持住。对于开发类似产品的开发者来说:如果一次向服务商的请求可能已经成功,但你的应用始终没收到响应,该怎么处理?
AI 视频产品的界面看起来简单得具有欺骗性:上传一张照片、描述一个想法、点击生成。但在这个按钮背后,应用需要回答好几个问题。用户重复提交了怎么办?服务商接了请求但连接断了怎么办?积分该在什么时候扣,失败之后又该怎么退?我是 RapVideoGo 的作者,这是一个根据参考照片和文字提示制作个性化说唱视频的 Web 应用。这篇文章。