Menu

积分系统

积分系统是 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' })

两条硬性约定:

  1. 绝不要把它包成客户端可调的 server action。 用户能随意触发退回,就等于每个成功的任务都免费
  2. 退回按消费行记录的分桶增量原样返还。如果中间发生过月度重置,退回可能让订阅桶短暂超出套餐额度 —— 这是刻意的,宁可偏向用户。反过来退进购买桶的话,用户可以靠故意失败的任务把会过期的积分洗成永久积分

退回是幂等的:余额行锁串行化,加上 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:

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 处截断 —— 已经花掉的积分不会让用户余额变成负数。详见订单与订阅管理。