userCredit),
加一份只追加的日志(creditTransaction),只允许 4 种交易类型。所有支付
Provider 最终都汇入同一组 addCredits / consumeCredits / refundCredits
原语,所以积分行为不管用户从 Stripe 还是微信付的钱都完全一样。
4 种交易类型
就这些。别再加 —— 细分语义放在sourceType 里。
| Type | 何时 | sourceType 示例 |
|---|---|---|
GRANT | 发放 | register_gift、subscription、one_time、manual |
CONSUME | 消耗 | ai_call、image_generation |
EXPIRE | 到期(cron) | expiration_cron |
REFUND | 退还 | failed_call、manual_reversal |
src/db/app.schema.ts;原语在 src/credits/server.ts;业务封装在
src/credits/index.ts。
原子原语
三个原语都把 UPDATE + INSERT 放在同一个事务里,余额和日志永远不会漂移:consumeCredits 在事务内检查余额,不够时返回
{ ok: false, reason: 'INSUFFICIENT' },不会抛错。refundCredits 跟
addCredits 对称,只是 type: 'REFUND'。
业务封装
多数调用方不直接addCredits,而是用 src/credits/index.ts 里的高层 helper:
siteConfig.credits.registerGift;套餐发放数来自
siteConfig.demoPlans 每一档的 creditsGranted。改 demoPlans 就能改整个
发放矩阵。
支付怎么发积分
src/payment/handlers/core.ts 里的 webhook handler 只有一句 dispatch:
grantPlanCredits 按 id 查 siteConfig.demoPlans,每次成功扣款都按
creditsGranted 发一次。年付订阅 = 一次发 12 个月的量;月付 = 一次发 1 个月;
一次性 = 永远只发一次。
多币种
payment.amount 用最小单位存(cents、分……),payment.currency 存 ISO 代码。
USD 和 CNY 都是一等公民。积分账本本身跟币种无关 —— 积分是纯整数,钱到积分的
换算发生在发放时,规则来自 siteConfig.demoPlans[*].creditsGranted。
过期
creditTransaction.expirationDate 只在需要过期的 GRANT 行上设值(例如
register_gift 按 siteConfig.credits.registerGift 30 天后过期)。一个 cron
扫这些行并插对应的 EXPIRE 行。不部署 cron 就不会过期 —— 但积分也永远不会
变成负数。
用户侧 UI
积分中心在/settings/credits:
- 当前余额(
userCredit.currentCredits) - 交易记录(分页的
creditTransaction日志) - 购买更多 / 升级套餐入口(默认是 demo —— 看下一节)
/settings/billing。
从 demo 切换到真实 checkout
Vibestrap 把/settings/credits 当成起步骨架 —— 演示 credits 和套餐购买页能长什么样。
开箱即用的 Buy 按钮只弹 toast(“仅演示”),不会真扣 Stripe。这样 vibestrap.dev
本身不会因为某个意外点击给买家创建无意义的订阅,买家也能在配置 Stripe key 之前安全地点着试。
要换成真实 checkout,改代码就行(不需要切换什么开关):
- 在
siteConfig.demoPlans里配上你自己 provider 的 price ID(以及STRIPE_PRICE_*等环境变量) - 打开
src/app/[locale]/(app)/settings/credits/buy-plan-button.tsx,把里面的内容替换成 一个调createCheckoutAction的CheckoutButton。/pricing页面的<CheckoutButton>(在src/components/payment/checkout-button.tsx)就是接好真实 Stripe 的同款模式 - 删掉
src/app/[locale]/(app)/settings/credits/page.tsx顶部的 demo banner - 删掉 demo i18n 键:
Settings.credits.demoNotice、Settings.credits.demoBanner.title、Settings.credits.demoBanner.body(en + zh 两份都删)
验证
- 注册一个新用户 —— 注册流程会触发
addRegisterGiftCredits。 - 查 Postgres:
应该能看到
GRANT 50 register_gift。 - 在测试模式下买一个
demoPlans档位,应该多一行GRANT,source_type = 'subscription'(或'one_time'),金额是该档的creditsGranted。 - 触发一次 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行。
官方文档
- Drizzle ORM 事务:orm.drizzle.team/docs/transactions
- PostgreSQL
SELECT … FOR UPDATE(Drizzle 隐式使用):postgresql.org/docs/current/explicit-locking.html - Vibestrap 源码:
src/credits/server.ts、src/credits/index.ts、src/db/app.schema.ts