跳转到主要内容
Vibestrap 内置的积分账本刻意做得很小:每用户一行可变余额(userCredit), 加一份只追加的日志(creditTransaction),只允许 4 种交易类型。所有支付 Provider 最终都汇入同一组 addCredits / consumeCredits / refundCredits 原语,所以积分行为不管用户从 Stripe 还是微信付的钱都完全一样。

4 种交易类型

就这些。别再加 —— 细分语义放在 sourceType 里。
Type何时sourceType 示例
GRANT发放register_giftsubscriptionone_timemanual
CONSUME消耗ai_callimage_generation
EXPIRE到期(cron)expiration_cron
REFUND退还failed_callmanual_reversal
Schema 在 src/db/app.schema.ts;原语在 src/credits/server.ts;业务封装在 src/credits/index.ts

原子原语

三个原语都把 UPDATE + INSERT 放在同一个事务里,余额和日志永远不会漂移:
export async function addCredits(input: AddCreditsInput): Promise<void> {
  await ensureUserCredit(input.userId);
  await db.transaction(async (tx) => {
    await tx
      .update(userCredit)
      .set({ currentCredits: sql`${userCredit.currentCredits} + ${input.amount}` })
      .where(eq(userCredit.userId, input.userId));
    await tx.insert(creditTransaction).values({
      type: 'GRANT', amount: input.amount, ...
    });
  });
}
consumeCredits 在事务内检查余额,不够时返回 { ok: false, reason: 'INSUFFICIENT' },不会抛错。refundCreditsaddCredits 对称,只是 type: 'REFUND'

业务封装

多数调用方不直接 addCredits,而是用 src/credits/index.ts 里的高层 helper:
addRegisterGiftCredits(userId);                     // 注册赠送
grantPlanCredits({ userId, planId, paymentId });    // demoPlans 任意一档
注册赠送来自 siteConfig.credits.registerGift;套餐发放数来自 siteConfig.demoPlans 每一档的 creditsGranted。改 demoPlans 就能改整个 发放矩阵。

支付怎么发积分

src/payment/handlers/core.ts 里的 webhook handler 只有一句 dispatch:
if (p.scene === siteConfig.product.id) return; // Vibestrap 本体走 license,不发积分
await grantPlanCredits({ userId: p.userId, planId: p.scene, paymentId });
grantPlanCredits 按 id 查 siteConfig.demoPlans,每次成功扣款都按 creditsGranted 发一次。年付订阅 = 一次发 12 个月的量;月付 = 一次发 1 个月; 一次性 = 永远只发一次。

多币种

payment.amount 用最小单位存(cents、分……),payment.currency 存 ISO 代码。 USD 和 CNY 都是一等公民。积分账本本身跟币种无关 —— 积分是纯整数,钱到积分的 换算发生在发放时,规则来自 siteConfig.demoPlans[*].creditsGranted

过期

creditTransaction.expirationDate 只在需要过期的 GRANT 行上设值(例如 register_giftsiteConfig.credits.registerGift 30 天后过期)。一个 cron 扫这些行并插对应的 EXPIRE 行。不部署 cron 就不会过期 —— 但积分也永远不会 变成负数。

用户侧 UI

积分中心在 /settings/credits
  • 当前余额(userCredit.currentCredits
  • 交易记录(分页的 creditTransaction 日志)
  • 购买更多 / 升级套餐入口(默认是 demo —— 看下一节)
订阅相关的开票和 portal 入口在 /settings/billing

从 demo 切换到真实 checkout

Vibestrap 把 /settings/credits 当成起步骨架 —— 演示 credits 和套餐购买页能长什么样。 开箱即用的 Buy 按钮只弹 toast(“仅演示”),不会真扣 Stripe。这样 vibestrap.dev 本身不会因为某个意外点击给买家创建无意义的订阅,买家也能在配置 Stripe key 之前安全地点着试。 要换成真实 checkout,改代码就行(不需要切换什么开关):
  1. siteConfig.demoPlans 里配上你自己 provider 的 price ID(以及 STRIPE_PRICE_* 等环境变量)
  2. 打开 src/app/[locale]/(app)/settings/credits/buy-plan-button.tsx,把里面的内容替换成 一个调 createCheckoutActionCheckoutButton/pricing 页面的 <CheckoutButton> (在 src/components/payment/checkout-button.tsx)就是接好真实 Stripe 的同款模式
  3. 删掉 src/app/[locale]/(app)/settings/credits/page.tsx 顶部的 demo banner
  4. 删掉 demo i18n 键:Settings.credits.demoNoticeSettings.credits.demoBanner.titleSettings.credits.demoBanner.body(en + zh 两份都删)
10 分钟机械操作。我们故意不做”demo mode”环境变量 —— 那样买家上线时会多一个忘记切换的开关; 直接删 demo 代码反而更清楚。

验证

  1. 注册一个新用户 —— 注册流程会触发 addRegisterGiftCredits
  2. 查 Postgres:
    select type, amount, source_type from credit_transaction
    where user_id = '<新用户 id>';
    
    应该能看到 GRANT 50 register_gift
  3. 在测试模式下买一个 demoPlans 档位,应该多一行 GRANTsource_type = 'subscription'(或 'one_time'),金额是该档的 creditsGranted
  4. 触发一次 AI 调用(或者写个脚本调 consumeCredits)—— 应该能看到 CONSUME 行,余额下降。

常见坑

  • 新增交易类型 —— 别。新增类型会破坏所有遍历这 4 种的统计和管理 UI。 细分用 sourceType
  • 绕过事务 —— 永远不要在 helper 之外直接 UPDATE userCredit.currentCredits。 只有 addCredits / consumeCredits / refundCredits 是安全的,因为它们把 UPDATE + INSERT 放在同一个 DB 事务里。
  • 先发积分再插 payment 行 —— webhook handler 是先插 payment 再发积分。 顺序反过来会导致重投时重复发积分,因为幂等检查是查 payment.invoiceId
  • siteConfig.demoPlans 引用过期 —— grantPlanCredits 按 id 查档位; 如果 config 里删了某档但还有付费订单引用它,helper 会静默 return。 删档前先确认没有在途订单。
  • 过期 cron 没部署 —— expirationDate 是数据,不是触发器。没 cron 就永远 不会插 EXPIRE 行。

官方文档