userCredit),
加一份只追加的日志(creditTransaction),只允许 4 种交易类型。所有支付
Provider 最终都汇入同一组 addCredits / consumeCredits / refundCredits
原语,所以积分行为不管用户从 Stripe 还是微信付的钱都完全一样。
4 种交易类型
就这些。别再加 —— 细分语义放在sourceType 里。
| Type | 何时 | sourceType 示例 |
|---|---|---|
GRANT | 发放 | register_gift、subscription、credit_pack、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,改 config 就能调赠送量、月免费量、每个套餐的
发放量。
支付怎么发积分
src/payment/handlers/core.ts 里的 webhook handler 有个单一 dispatch 函数,
按 payload.type + payload.scene 选对应的 helper:
type + scene
自动识别。要新增模式,在这里加分支即可。
多币种
payment.amount 用最小单位存(cents、分……),payment.currency 存 ISO 代码。
USD 和 CNY 都是一等公民。积分账本本身跟币种无关 —— 积分是纯整数,钱到积分的
换算发生在发放时,规则来自 siteConfig.creditPacks /
siteConfig.credits.subscriptionMonthly。
过期
creditTransaction.expirationDate 只在需要过期的 GRANT 行上设值(例如
register_gift 按 siteConfig.credits.registerGift 30 天后过期)。一个 cron
扫这些行并插对应的 EXPIRE 行。不部署 cron 就不会过期 —— 但积分也永远不会
变成负数。
用户侧 UI
积分中心在/settings/credits:
- 当前余额(
userCredit.currentCredits) - 交易记录(分页的
creditTransaction日志) - 购买更多 / 升级套餐入口
/settings/billing。
验证
- 注册一个新用户 —— 注册流程会触发
addRegisterGiftCredits。 - 查 Postgres:
应该能看到
GRANT 50 register_gift。 - 在测试模式下买一个积分包,应该多一行
GRANT,source_type = 'credit_pack',金额是包对应的积分数。 - 触发一次 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行。
官方文档
- 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