05 · Payments

支付与 Sparks 经济

Softie 收入分两条线:订阅(聊天解锁)+ Sparks(媒体解锁)。这一页讲清楚两套体系怎么工作、怎么扣费、出错怎么修。新人改任何 /api/payment/* 前必须读完。

核心概念:两条收入线

订阅(Subscription)

解锁聊天的核心模型。不是按消息扣费。

  • 免费用户:每月 5 条免费消息
  • Pro 用户:无限聊天
  • 计费:月付 / 年付 / 终身(套餐配置见 src/config/
  • 状态字段subscriptions.planexpiresAt

Sparks(虚拟币)

仅用于解锁付费媒体(图片/视频/语音)。

  • 用于聊天计费 —— 这是 V2 改造后的明确边界
  • 消费:媒体解锁、角色 UGC 创建(已废除,2026-04-11 改为强制 private + 0 Sparks)
  • 充值:通过订阅赠送 / 单独购买 Sparks 包
  • 账本sparks_balance + sparks_ledger(双写)
V2 计费模型:2025 年起明确分工 —— 订阅管聊天上限,Sparks 管媒体解锁。改任何聊天计费逻辑前,先确认走的是订阅检查(subscription-service.ts),不是 Sparks 扣减(sparks-service.ts)。

订阅套餐(参考)

Free
$0
  • 每月 5 条免费聊天
  • 所有公开角色可见
  • 不能解锁付费媒体
Pro
$9.99/月
  • 无限聊天
  • 赠送 Sparks(按套餐)
  • 优先模型 / 更长记忆
Lifetime
$99
  • 一次性付清
  • 价格从 config 动态读取
  • 禁止写虚假折扣
不允许虚假营销数字(feedback_no_fake_marketing.md):所有展示的折扣、原价、优惠必须从 src/config/ 动态读取。曾经写过「70% OFF」字面量,2026-03-29 已全部清理。新人写 paywall UI 时必须用 config 值,不能 hardcode。

支付通道:Airwallex 主 + Stripe 备

三级降级

这是 2026-03-28 PR #38 修的核心架构 —— 单一 SDK 失败不能整体阻塞付款:

  1. Airwallex 嵌入式 SDK

    默认走 @airwallex/components-sdk,前端嵌入支付组件,体验最顺。

  2. Airwallex URL 直跳

    SDK 加载失败 / 浏览器拦截 → 退回 hosted page URL 直接跳转。

  3. Stripe 兜底

    Airwallex 整体不可用 → 用 Stripe Checkout。Stripe 需要单独的 webhook endpoint。

三层都失败才返回错误给用户。Sentry 会按降级层级打 tag,方便看哪一层挂了多。

支付完整流程

  1. Paywall 曝光

    用户达到 5 条限额 / 点击付费媒体 / 主动点订阅入口 → SubscriptionsDrawer 弹出。曝光时 PostHog 打 paywall_view

  2. 选套餐 → POST /api/payment/create

    API 校验 session → 翻译 session.user.idbusinessUser.id → 在 orders 表插入 PENDING 行 → 调 Airwallex/Stripe 创建支付意图 → 返回 URL/intent_id。

  3. 用户在第三方页面支付

    跳 Airwallex/Stripe hosted page 或嵌入组件。完成后 redirect 回 /payment/success,前端打 payment_success仅兜底,不是权威源)。

  4. Webhook(权威源)

    /api/webhooks/airwallex/api/webhooks/stripe 收到事件 → 验签 → 幂等检查 → 更新 orders.status = COMPLETED → 升级 subscriptions / 加 Sparks → posthog-node 服务端打 payment_success(防前端漏报)。

  5. Reconcile Cron(兜底)

    GHA payment-reconcile-cron.yml 周期跑:找 webhook 漏的 PENDING 订单(超过 N 分钟)→ 主动查 Airwallex/Stripe API 状态 → 若已完成则补单。

  6. Health Cron

    payment-health-cron.yml 每 15 分钟从 PostHog 拉转化率,跌破基线推 Pay-Bot。设计目标:「24h 0 转化」级别故障在 15 分钟内抓到。

ID 翻译陷阱(最重要的事)

这是 Softie 历史上最反复的支付 bug 来源。

系统里有两种 ID

来源类型用途
session.user.id(Better Auth)textauth 内部、前端展示
businessUser.id / users.idUUIDordersmessagesV2subscriptions 等所有业务表的 userId

查询 orders/subscriptions 时如果直接:

// ❌ 错误 —— postgres 直接报错并 500,原始 SQL 还会泄露到浏览器
eq(orders.userId, session.user.id)

正确写法:

// ✅ 先翻译
const businessUser = await getBusinessUserByAuthId(session.user.id);
if (!businessUser) return 401;
eq(orders.userId, businessUser.id)

Branded Types 防御层(5c92239)

2026-04-23 加了 AuthUserId / BusinessUserId 两个 branded type,让 TypeScript 编译期就拒绝混用:

type AuthUserId = string & { __brand: 'AuthUserId' };
type BusinessUserId = string & { __brand: 'BusinessUserId' };

// orders.userId 类型是 BusinessUserId,传 AuthUserId 进去直接编译报错

50 个文件 / 115 处历史错配在那次 PR 里全修了。新代码只要用 branded type,就不会再犯

Webhook 处理要点

Sparks 服务

核心文件:src/lib/sparks/ + src/lib/sparks-service.ts

本地测试支付

  1. 用 sandbox key(NEXT_PUBLIC_AIRWALLEX_ENVIRONMENT=demo
  2. 本地起 ngrok 暴露 localhost:3000
  3. 在 Airwallex/Stripe 控制台改 webhook URL → ngrok 地址
  4. 在 demo 模式发起支付 → 用测试卡号完成
  5. 测完改回,否则同事的测试事件会发到你的 ngrok

验证:pnpm payment:checkscripts/check-airwallex-config.ts)会自检 key 是否配齐。

关键文件速查

文件职责
src/lib/payment/payment-service.ts统一支付服务入口
src/lib/payment/payment-types.ts类型定义(Branded Types 在这里)
src/lib/payment/providers/Airwallex / Stripe 各自的 provider 实现
src/lib/airwallex-client.tsAirwallex SDK 封装
src/lib/unified-payment-service.ts三级降级路由
src/lib/subscription-service.ts订阅状态读写
src/lib/sparks-service.tsSparks 余额 + ledger
src/lib/sparks-config.ts套餐定价、赠送规则(动态读取,禁止 hardcode)
src/lib/media-permission-service.ts付费媒体权限
scripts/check-payment-health.ts15 分钟健康检查
scripts/reconcile-payments.ts对账补单(GHA cron)

常见事故与修法

症状真因
POST /api/payment/create 500 查 orders 用了 session.user.id(text)当 UUID 翻译成 businessUser.id;并用 Branded Type 编译期防御
支付成功但订阅没升级 webhook 验签失败 / 超时 / 没幂等 看 webhook 入口 Sentry;reconcile cron 会兜底
Sparks 重复扣 / 重复加 没有去重,同一 event_id 处理两次 webhook events 表强制 unique;ledger 用 transaction
SubscriptionsDrawer 弹不出来 snapPoints 数组越界(98% 流失率根因) 已修;改 drawer 时确认 snapPoints[index] 不越界
支付成功但 PostHog 没事件 只靠前端打点,redirect 时丢了 webhook 必须用 posthog-node 服务端打
反复改两轮没修好的 Better Auth 问题 手动模拟 cookie 格式 不要手工构造 session_data;只设 session_token,让 Better Auth 自己处理
← 上一篇
04 · 监控与告警