SayCraft 做到付费这一步,比我预想的晚了两周。不是因为 Stripe 文档难读——恰恰相反,文档写得太好了,好到让你觉得"就这?"然后一头扎进去,被各种边界情况反复教育。
嵌入还是跳转
Stripe 给你两条路:PaymentElement 嵌入到自己页面里,或者 Hosted Checkout 直接跳到 Stripe 的托管页面。后者几乎零维护,Stripe 帮你处理所有支付方式、3DS 验证、错误提示。听起来很香。
但 SayCraft 是个 SaaS 产品,用户正在看套餐、选方案、准备付钱——这时候把人跳走,再跳回来,中间要是网络抖一下,整个购买流程就断了。对于电商那种"买完就走"的场景,跳转无所谓。对于我们这种"付完钱马上要用"的产品,用户不离开页面才是对的。
所以选了 PaymentElement。代价是自己要处理支付成功、失败、3DS 弹窗这些状态。这笔账算得过来。
僵尸订阅
这个坑值得单独拿出来说。
测试阶段我在 Stripe Dashboard 上手动取消了几个订阅,本地数据库也标记成了 cancelled。看起来一切正常。但有一天我去查 Stripe 后台,发现那些"已取消"的订阅还在 active 状态,下个月照样会扣钱。
原因是:Stripe CLI 的 listen 命令只把 webhook 事件转发到本地开发环境,生产环境的 webhook 是独立注册的。测试期间我直接在 Dashboard 操作,产生的事件没有任何 webhook 接收方。本地 DB 改了,Stripe 那边根本不知道。
这就是僵尸订阅——你以为它死了,其实它还活着,每个月安静地从用户卡里扣钱。
修复方案说起来很朴素:所有涉及订阅变更的操作,必须同时调 Stripe API 和写本地 DB。不能只写一侧。先调 Stripe,成功了再写 DB。Stripe 那边失败了就整个回滚,不要出现两边状态不一致的情况。
降级没你想的那么简单
用户从 Max 降到 Pro,你觉得应该怎么处理?直接改数据库里的 tier 字段?
Stripe 不是这么玩的。降级是"下个账单周期生效",这个周期内用户还是 Max。但你的本地 DB 已经写成 Pro 了——用户刷新页面一看,"我的套餐怎么变了?我这个月的钱白花了?"
所以我引入了两个状态:effectiveTier 和 pendingTier。effectiveTier 从 Stripe 的实际授权记录反推,代表"现在你能用什么";pendingTier 代表"下个周期会变成什么"。UI 上就显示:"当前方案仍为 Max,下个周期变更为 Pro"。
核心原则:Stripe 是权威数据源,本地 DB 是缓存。任何时候两边对不上,以 Stripe 为准。
积分怎么发
SayCraft 的计费单位是积分。定时任务发还是用的时候再发?
我选了懒发放。用户访问账单页面时,系统检查这个月的积分有没有发过,没发过就当场发。幂等靠 userId + kind + ref 的组合约束,ref 里带月份标识,同一个月不会重复发。
积分公式是 6 乘以会议分钟数加上 290 乘以美元金额。这个公式背后有个让我意外的发现:ASR(语音识别)占了总成本的 76%,LLM 只占 14%。我一直以为大模型才是烧钱大户,结果实时语音转录才是。这直接影响了定价策略——免费用户的会议时长限制比我最初计划的更紧。
审计出来的问题
集成基本跑通之后,我做了一轮代码审计。发现了三个让我冒冷汗的东西。
第一个:spendCredits 函数零调用者。积分余额一直在发放,但从来没有被消费过。用户用了多少会议时长,积分余额纹丝不动。等于免费用。
第二个:FOR UPDATE 加在了 SUM 聚合查询上。PostgreSQL 不允许你锁一个聚合结果——你锁的是行,不是计算结果。这条 SQL 在某些场景下会直接报错。
第三个:所有 /billing 开头的接口,认用户身份靠的是 x-user-id 这个 HTTP header。没有 JWT 校验,没有 Clerk 鉴权。换句话说,你随便改一下这个 header 的值,就能查看甚至操作别人的账单。
这三个问题没有一个是 Stripe 的锅,全是自己写的业务代码里的漏洞。Stripe 的 API 设计得足够安全,但你自己这一层不上心,该出的事一个都不会少。
总结
Stripe 集成不难在"接进去",难在"接对了"。webhook 和本地 DB 的状态同步、订阅生命周期的边界情况、计费扣减的闭环——这些东西文档里不会手把手教你,得自己踩过才知道。
如果你也在做 SaaS 付费,记住一件事:Stripe 是账本,你的 DB 是笔记本。账本和笔记本对不上的时候,永远信账本。
SayCraft 是一个用对话生成完整 Web 应用的 AI 工具,感兴趣可以看看 saycraft.ai。