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

4 种交易类型

就这些。别再加 —— 细分语义放在 sourceType 里。
Type何时sourceType 示例
GRANT发放register_giftsubscriptioncredit_packmanual
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);                       // 注册赠送
addSubscriptionCredits({ userId, plan, paymentId });  // 订阅续费
addCreditPackCredits({ userId, packId, paymentId });  // 积分包购买
数额来自 siteConfig.credits,改 config 就能调赠送量、月免费量、每个套餐的 发放量。

支付怎么发积分

src/payment/handlers/core.ts 里的 webhook handler 有个单一 dispatch 函数, 按 payload.type + payload.scene 选对应的 helper:
if (p.type === 'credit_pack') {
  const packId = p.scene.replace(/^credits_/, '');
  await addCreditPackCredits({ userId: p.userId, packId, paymentId });
}
if (p.type === 'subscription' || p.type === 'one_time') {
  if (p.scene === 'pro_monthly' || p.scene === 'pro_yearly') {
    await addSubscriptionCredits({ userId: p.userId, plan: 'pro', paymentId });
  } else if (p.scene === 'lifetime' || p.scene === 'vibestrap-lifetime') {
    await addSubscriptionCredits({ userId: p.userId, plan: 'lifetime', paymentId });
  }
}
订阅、终身、积分包三种定价模式都靠创建 checkout 时设的 type + scene 自动识别。要新增模式,在这里加分支即可。

多币种

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

过期

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

用户侧 UI

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

验证

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

常见坑

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

官方文档