Softie 收入分两条线:订阅(聊天解锁)+ Sparks(媒体解锁)。这一页讲清楚两套体系怎么工作、怎么扣费、出错怎么修。新人改任何 /api/payment/* 前必须读完。
解锁聊天的核心模型。不是按消息扣费。
src/config/)subscriptions.plan、expiresAt仅用于解锁付费媒体(图片/视频/语音)。
sparks_balance + sparks_ledger(双写)subscription-service.ts),不是 Sparks 扣减(sparks-service.ts)。
src/config/ 动态读取。曾经写过「70% OFF」字面量,2026-03-29 已全部清理。新人写 paywall UI 时必须用 config 值,不能 hardcode。
这是 2026-03-28 PR #38 修的核心架构 —— 单一 SDK 失败不能整体阻塞付款:
默认走 @airwallex/components-sdk,前端嵌入支付组件,体验最顺。
SDK 加载失败 / 浏览器拦截 → 退回 hosted page URL 直接跳转。
Airwallex 整体不可用 → 用 Stripe Checkout。Stripe 需要单独的 webhook endpoint。
三层都失败才返回错误给用户。Sentry 会按降级层级打 tag,方便看哪一层挂了多。
用户达到 5 条限额 / 点击付费媒体 / 主动点订阅入口 → SubscriptionsDrawer 弹出。曝光时 PostHog 打 paywall_view。
API 校验 session → 翻译 session.user.id → businessUser.id → 在 orders 表插入 PENDING 行 → 调 Airwallex/Stripe 创建支付意图 → 返回 URL/intent_id。
跳 Airwallex/Stripe hosted page 或嵌入组件。完成后 redirect 回 /payment/success,前端打 payment_success(仅兜底,不是权威源)。
/api/webhooks/airwallex 或 /api/webhooks/stripe 收到事件 → 验签 → 幂等检查 → 更新 orders.status = COMPLETED → 升级 subscriptions / 加 Sparks → posthog-node 服务端打 payment_success(防前端漏报)。
GHA payment-reconcile-cron.yml 周期跑:找 webhook 漏的 PENDING 订单(超过 N 分钟)→ 主动查 Airwallex/Stripe API 状态 → 若已完成则补单。
payment-health-cron.yml 每 15 分钟从 PostHog 拉转化率,跌破基线推 Pay-Bot。设计目标:「24h 0 转化」级别故障在 15 分钟内抓到。
系统里有两种 ID:
| 来源 | 类型 | 用途 |
|---|---|---|
session.user.id(Better Auth) | text | auth 内部、前端展示 |
businessUser.id / users.id | UUID | orders、messagesV2、subscriptions 等所有业务表的 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)
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,就不会再犯。
constructEventposthog-node 在 webhook 里打 payment_success / payment_failed,不能只靠前端(前端可能在 redirect 时丢事件)withSentry 或 try/catch + captureException核心文件:src/lib/sparks/ + src/lib/sparks-service.ts
sparks_balance 和 sparks_ledger,事务包裹media-permission-service.ts —— 既要看 Sparks 余额,也要看是否已购买NEXT_PUBLIC_AIRWALLEX_ENVIRONMENT=demo)localhost:3000验证:pnpm payment:check(scripts/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.ts | Airwallex SDK 封装 |
src/lib/unified-payment-service.ts | 三级降级路由 |
src/lib/subscription-service.ts | 订阅状态读写 |
src/lib/sparks-service.ts | Sparks 余额 + ledger |
src/lib/sparks-config.ts | 套餐定价、赠送规则(动态读取,禁止 hardcode) |
src/lib/media-permission-service.ts | 付费媒体权限 |
scripts/check-payment-health.ts | 15 分钟健康检查 |
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 自己处理 |