积分和按量计费

积分账本,积分从哪里来(套餐、积分包、管理员),如何在 D1 上安全地扣费,以及一个按量计费 API 的完整示例。

积分是 ShipKit 把钱换成用量的方式。订阅每月发一笔额度,积分包加一笔永不过期的固定数量,你的代码按实际做的工作扣积分。如果你的产品卖的是功能权限而不是用量,可以把整个模块删掉(见本页末尾)。

账本

所有数据都在一张表里:credit_ledger(src/features/credits/schema.ts)。没有余额字段,余额就是这个用户所有行的总和,所以它不可能和解释它的历史对不上。delta 为正是发放,为负是消费,reason 说明原因(monthly、plan-topup、purchase、refund、api-run,或者你的代码写入的任意字符串)。

写代码时要注意两点:

  • 行里存的是微积分(1 积分 = 1,000,000)。这样一次便宜的操作可以只扣五十分之一个积分,不必向上取整成一整个。src/features/credits/server/credits.ts 里的函数收发的都是整数积分,只有按量计费的辅助函数才接收微积分。
  • refId 唯一。 每笔来自外部事件的发放都带着订单 id,所以 webhook 重复投递也不会重复入账。

两个池子

余额分两部分,规则不同,界面上也分开显示:

池子来源过期时间
月度额度套餐的每月额度和升级补发下个月 1 日 00:00 UTC
永久余额积分包和管理员调整永不过期

消费总是先扣月度额度。反过来的话,用户花钱买断的积分会先被烧掉,而租来的那部分却白白过期。

积分从哪里来

每月额度。 每个账户每个自然月(UTC)都会拿到所在套餐的 monthlyCredits。定时任务每天都会跑一次发放,但行 id 由用户和月份推导而来,所以每月只有第一次真正写入,哪天漏跑了,第二天会补上。新账户在注册时就拿到当月额度,不用等定时任务。

支付事件本身不发放每月额度。它只记录一份权益(多少积分,到什么时候为止),由每月任务去读取。正因如此,年付套餐才会每个月都发,而不是一年只发一次。订阅不再续费后,权益自然过期,用户回落到免费套餐的额度,不需要任何取消逻辑。新开订阅或升级时,当月额度会立即补足。

积分包。 已付款的 order.paid 且 kind: 'credit_pack' 时,积分包的积分会进入永久余额。购买按钮在 /credits 页面和账户对话框里。

管理员调整。 见下文。

定价常量

src/config/plans.ts 顶部有三个数,其他数值都由它们推导:

  • CREDIT_LIST_CENTS:一个积分的标价(默认 1 美分)。积分包按标价卖,订阅低于标价。
  • CREDIT_COST_CENTS:一个积分对应的工作让你花多少钱。请实测,不要估。
  • CREDITS_PER_ACTION:你最便宜的主打操作消耗多少积分,让套餐卡片能写出“每月约 N 次”。

只要有套餐的单价低于成本的 1.15 倍,或者积分包低于成本,src/config/plans.test.ts 就会让测试失败。改这个下限是在决定你的利润率,所以要去改测试,而不是在调价时顺手越过。文件的其余部分见支付。

扣积分

D1 没有交互式事务,所以“先读余额再写扣款”是有竞态的:两个请求读到同一个余额,都会通过。这里的每个扣费函数都把检查放在写入的那条语句里(见数据库)。

按你什么时候知道成本来选函数:

何时知道成本用什么会透支吗?
工作开始前spendCredits(server/credits.ts)不会
工作结束后,但事先有上限holdCredits,再 settleHold 或 releaseHold(server/usage.ts)不会
工作结束后,且已经做完debitUsage(server/usage.ts)会,有意为之

最简单的情况,在服务端函数里:

import { spendCredits } from '@/features/credits/server/credits'

const ok = await spendCredits({
  userId: context.session.user.id,
  amount: 3,
  reason: 'report',
})
if (!ok) throw new Error('Not enough credits') // nothing was written

余额不够时 spendCredits 返回 false,什么也不写,你可以把它变成一个“去充值”的提示。

对于做完才知道成本的工作(按 token 计费的模型调用、按秒计费的渲染),用先冻结、再结算:

  1. holdCredits 按这次调用可能的最高成本扣款,带余额检查。余额覆盖不了最坏情况时,什么也不写,返回 false。
  2. 执行工作。
  3. settleHold 把冻结额缩小到实际成本。如果工作失败,用 releaseHold 撤掉冻结,同一个 key 就可以重试。

如果 Worker 在第 1 步和第 3 步之间挂掉,冻结额会停留在上限。这个误差对你有利;如果你的工作耗时长到值得在意,就定期扫一遍残留的冻结。

debitUsage 用来记录已经发生、前面没有任何检查的消耗。它可能把余额扣成负数,因为另一种选择是丢掉这笔你已经被收了钱的工作记录。

每种扣费都用自己的 reason,并在 src/features/credits/reasons.ts 里加一个标签,外加一条 credits_reason_* 消息,否则用户的历史记录里会显示原始的 slug。

按量计费的 API

POST /api/v1/me/run(src/routes/api/v1/me.run.ts)是完整示例:一个用用户 API key 认证、干活并收费的 endpoint。把 doTheWork 换成你真正的工作,其余保持不变。

  • 它先冻结调用方给的上限(maxUnits),执行工作,再按实际成本结算。每个单位 0.02 积分。
  • 付不起最坏情况的调用方会收到 402 insufficient_credits。
  • Idempotency-Key 请求头会成为冻结行的 refId,并限定在调用方范围内。用一个已经扣过费的 key 再次请求,会收到 409 idempotency_key_reused,而不是再跑一遍。失败后的重试可以通过,因为失败时冻结已被撤掉。
  • 响应里包含用掉的单位数、扣掉的积分和剩余余额。

用户 key 以及其他 /api/v1/me/* endpoint(包括 GET /api/v1/me/credits,返回余额和账本)见 API 密钥。

退款

积分包全额退款会收回它的积分,即使余额因此变成负数:花掉的就是花掉了,退款不应该让它们变成免费的。最近一笔订阅付款全额退款,本月额度回落到免费档;升级差价全额退款,收回补发的积分。部分退款什么也不收回;想扣回一部分,用管理员调整。详情见支付。

后台工具

  • 收入 → 积分(/admin/credits,权限 credits.read):所有用户的余额,可搜索、可排序。
  • 用户抽屉或用户详情页:余额和最近的账本记录,外加调整表单(权限 credits.write)。正数是发放,负数是扣除,可以扣到零以下。你填写的原因用户看得到,用户还会收到一条站内通知,这次调整会以 credits.adjusted 写入审计日志。
  • 设置 → 积分:credits.low_balance(默认 50)。余额低于它时,侧边栏底部的余额标签会变成充值提醒。
  • Admin API:/api/v1/admin/credits(账本)和 /api/v1/admin/credits/balances。

见管理后台和权限。

不用积分

如果你的套餐解锁的是功能而不是发放用量,把每个套餐的 monthlyCredits 设为 0(billing 代码仍然需要这个字段),然后运行 /delete-feature credits。账单和订阅照常工作,它们和积分之间除了支付事件流没有任何共享。见删除功能。