积分系统
积分系统是 lib/credits/。它只有两个文件:ledger.ts(写)和 index.ts(读 + 年付定投结算)。
双桶模型
每个计费主体有两个桶:
| 桶 | 字段 | 语义 |
|---|---|---|
| 订阅积分 | subscription_credits | 每次发放时重置为套餐月度额度,不累积。订阅结束时清零 |
| 购买积分 | purchased_credits | 积分包购买、注册赠送、管理员发放进这里。永不过期,只会被花掉或因退款回收 |
消费优先扣订阅桶,因为它会过期 —— 先花会消失的,留下不会消失的。
为什么订阅积分是重置而不是累加
累加会让积分无限堆积,失去「按月供给」的意义,也让退款和降级的计算变成噩梦。重置语义下,一个订阅周期内用户能用的量是确定的。
这条语义还解释了支付流程里的「单订阅守卫」:两份并发订阅会互相重置对方的积分,所以同一主体只允许一份有效订阅。
重置写两行流水,不是一行净额
发放时账本会写两行:
subscription_cycle_expire 未用完的余额过期(桶为空时不写这行)
subscription_grant 全额发放如果只写一行净额,用户留了 60 分、套餐给 100 分,账本上会显示「+40」,读起来像发少了。两行结构让「过期多少」和「发放多少」各自说得清楚。
只追加账本
余额表不允许被直接 UPDATE。所有变动都经过 lib/credits/ledger.ts 的单一原语 mutateCredits,它在一个事务里完成:
按主体分发 → 行锁 → upsert 余额 → 计算分桶增量 → 写一行流水(带变动后快照)credit_transactions 每行的关键字段:
| 字段 | 说明 |
|---|---|
seq | 单调递增的插入序号。created_at 排不了同一事务内写的多行(Postgres 的 now() 在事务内是固定的),历史查询靠它兜底排序 |
amount | 有符号总额:正数发放,负数消费/回收 |
subscription_delta / purchased_delta | 分桶增量,两者之和等于 amount |
subscription_credits_after / purchased_credits_after | 变动后快照,审计时不需要重放 |
refunded_transaction_id | 仅 usage_refund 行有值,指向被冲正的消费行。UNIQUE —— 一笔消费最多退回一次 |
流水类型
| 类型 | 触发场景 |
|---|---|
purchase | 积分包购买 |
subscription_grant | 订阅周期发放(含年付定投、升级补差) |
subscription_cycle_expire | 新发放重置订阅桶时,未用完的部分过期 |
usage | 业务功能消费 |
usage_refund | 任务失败,把消费按原分桶退回 |
refund_revoke | 退款后回收积分 |
subscription_end_revoke | 订阅结束,清空订阅桶剩余 |
admin_adjustment | 管理员手工增减 |
signup_bonus | 注册赠送 |
不变量
- 余额永不为负。 回收类操作(
refund_revoke、admin_adjustment的负数)在 0 处截断 - 空操作不写流水。 转换函数返回
null时(比如订阅桶本来就是 0 还要清零)既不改余额也不留噪音行
年付订阅的月度定投
年付套餐不会一次性发放 12 个月的积分,而是:
付款时 立即发放第 1 个月 + 把剩余 11 次写进订阅行
之后每月 定时任务到点结算一次调度状态是 subscriptions 表上的一等字段,不是 jsonb 里的黑盒:
| 字段 | 含义 |
|---|---|
monthly_credits | 每次发放的额度 |
remaining_grants | 还剩几次 |
next_grant_at | 下次发放时间(UTC) |
数据库就是调度队列,next_grant_at 上有条件索引,定时任务直接扫它。
发放日期怎么算
第 k 次发放 = 年付周期起点 + k 个月(k = 12 - remaining),用 addMonthsClamped 做月末钳制。
钳制是必要的:朴素的 setMonth 会溢出(1/31 + 1 个月 = 3/3)。而且必须每次都从周期起点算,不能逐月链式累加 —— 链式累加会把 31 号一路磨成 28 号,再也回不来。
在 31 号购买的年付订阅,发放日是 2/28、3/31、4/30……始终对齐周年日。
两个触发器,一个入口
| 触发器 | 时机 |
|---|---|
定时任务 settleDueDripGrantsBatch | /api/cron/credits 每分钟一轮,每轮最多 100 条,按到期时间从早到晚 |
惰性结算 settleDueDripGrants | 读余额或消费之前顺手结算,覆盖「刚到点但定时任务还没跑到」和「定时任务挂了」 |
两者都汇入同一个原语 settleDripForSubscription:行锁 + 锁内复检,并发触发折叠成一次发放。
积压多个月时,因为发放是重置语义,多个逾期月份会折叠成一次重置,不会伪造出每月一条的假历史。
必须配置定时任务
惰性结算只覆盖有人来读/花的情况。年付用户如果一个月不登录,靠惰性结算就拿不到当月积分。
/api/cron/credits的配置见定时任务。
把业务功能接进积分系统
用户端入口是 actions/credits/index.ts 的 consumeCredits:
import { consumeCredits } from '@/actions/credits'
const result = await consumeCredits({
amount: 10,
note: 'AI image generation', // 必填,会写进流水,用户在账单页能看到
})
if (!result.success) {
// 余额不足等情况
return
}
const { subscriptionCredits, purchasedCredits, totalCredits, txId } = result.data它做了三件事:会话校验、按当前工作区决定扣哪个池(团队工作区扣组织共享池)、内部先结算到期的年付定投再扣款。
任务失败要退回
拿到 txId 之后,如果任务最终失败了,用 refundSpentCredits 把积分按原分桶退回:
import { refundSpentCredits } from '@/lib/credits'
await refundSpentCredits({ userId, spendTxId: txId, note: 'Generation failed' })两条硬性约定:
- 绝不要把它包成客户端可调的 server action。 用户能随意触发退回,就等于每个成功的任务都免费
- 退回按消费行记录的分桶增量原样返还。如果中间发生过月度重置,退回可能让订阅桶短暂超出套餐额度 —— 这是刻意的,宁可偏向用户。反过来退进购买桶的话,用户可以靠故意失败的任务把会过期的积分洗成永久积分
退回是幂等的:余额行锁串行化,加上 refunded_transaction_id 的 UNIQUE 约束,一笔消费最多退一次,重放直接返回当前余额。
完整示例
模板内置了一个交互式参考页(仅开发模式可见):
/dashboard/credit-usage-example三个用例:成功扣款、模拟任务失败后退回并重放、余额不足的升级引导。对应代码在 app/[locale]/(protected)/dashboard/credit-usage-example/。
读取余额
| 方法 | 用途 |
|---|---|
getUserCredits(userId) | 个人两个桶的余额 |
getOrganizationCredits(orgId) | 组织共享池余额 |
getUserBillingSummary(userId) | 余额 + 当前订阅(套餐、状态、周期结束时间、是否期末取消、下次定投时间) |
getOrganizationBillingSummary(orgId) | 同上,组织版 |
hasEntitledSubscription(userId) | 是否持有有效订阅 |
所有读取都会先结算到期的年付定投,所以读到的永远是应得余额。
用户端对应的 server action 在 actions/credits/index.ts:getMyCredits、getMyBillingSummary、getMyCreditHistory。
「有效订阅」的口径
ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due', 'unpaid']催款期(past_due / unpaid)仍然算持有订阅,权益不立即断。这与结账守卫、团队席位计算、存储保留策略共用同一口径 —— 一处定义,全局一致。
而能发放积分的状态更窄,只有 active 和 trialing。
团队共享池
团队套餐的积分进 organization_credit_balances:
- 计费主体是组织,
user_id降级为「操作人」 - 桶结构和幂等机制与个人完全一致,只是余额表不同
- 消费时按当前工作区自动选池,业务代码不需要分支
- 积分包是个人资产,团队工作区禁止购买
流水表用同一张 credit_transactions,靠 organization_id 是否为空区分主体。席位、邀请流程、组织删除守卫等见团队与组织。
注册赠送积分
配置在 config/credits.ts:
export const creditsConfig = {
/** 注册时一次性赠送。设为 0 即关闭 */
signupBonusCredits: 30,
} as const进购买桶,这样它不会被后续的订阅重置抹掉。
幂等由「每个用户最多一行 signup_bonus 流水」保证,判重和发放在同一事务内,重放无害。
管理员操作
| 入口 | 能力 |
|---|---|
/dashboard/admin/credits | 全局积分流水审计(按类型筛选、按邮箱/备注搜索)+ 按邮箱批量发放 |
/dashboard/admin/users/[userId] | 单用户积分余额与历史,支持手工调整 |
批量发放去重、单次上限 200 个邮箱,未匹配到的邮箱会原样回报。
手工调整(adjustCredits)的语义:正数进购买桶(免得下次订阅重置时消失),负数先扣购买桶再扣订阅桶,整体在 0 处截断。
退款时的积分回收
退款由 webhook 驱动(管理后台的退款按钮只负责调渠道 API,本地状态和积分回收都等 webhook 回来写)。
applyRefund 会按退款比例回收积分,回收在 0 处截断 —— 已经花掉的积分不会让用户余额变成负数。详见订单与订阅管理。