平台上要计费的资源有完全不同的"形状":文本对话按 token、网络搜索按次、OCR 按页数、视频按时长。用一个"积分余额"和一套计费逻辑没法准确体现这些成本,更没法承载营销活动。这篇讲两项基础改造:把钱包从单一永久池重做为多个桶(带有效期和可用范围),以及把计价从隐式约定改成后台可配的资源价表。
单一余额的局限性
现有账户模型是这样的:每个用户一条 Account 记录,字段就一个 Balance。所有充值、赠送、活动都加到这个数字上。问题在于这个模型无法表达——也无法强制——不同金钱的用途限制和有效期。
比如月卡产品要求:每月发放 1000 点,有效期 30 天,到期清零。如果这 1000 点也落在 Balance 里,那么"到期清零"这个约束就只能靠业务代码到处判断:扣费时检查来源是否过期、统计报表时提醒用户余额即将失效。一旦业务代码有一处漏判或遗忘,就会有用户在余额本该清零的那一刻发现账户还能扣费,或者收不到过期提醒。
类似的规则还有:赠送额度不能提现(充值时不计入),活动额度只能用于特定功能(订阅用户才能用),订阅档位要按周期重置。这些规则堆到业务代码里,复杂度爆炸。
真正的解决方案是把规则落在数据结构上:改用"多桶钱包"。每一笔发放都是一个桶,带自己的 Remaining(剩余点数)和 ExpiresAt(过期时间),以及预留的 AllowedModels(可用模型白名单,先不启用)。余额变成了多个桶的求和,扣费时按优先级顺序消耗——最早过期的先扣,确保快要失效的点不会被浪费。
多桶钱包的设计
核心数据结构是一张 PointBucket 表:
ID | UserID | Remaining | ExpiresAt | Source | AllowedModels
-----------|--------|-----------|----------------|--------------|---------------
1 | user-a | 500 | NULL | topup | (空,不限)
2 | user-a | 100 | 2026-07-27 | membership | (空)
3 | user-a | 50 | NULL | redeem | (空)
User-a 的总余额就是 500 + 100 + 50 = 650 点。当他发起一个消耗 200 点的回合时:
- 锁定该用户存活的桶(
ExpiresAt IS NULL OR ExpiresAt > now()),按ExpiresAt升序排列 - 第 2 行那个 100 点(7 天后过期)会先被消耗,变成 0;剩余还需 100 点
- 第 1 行那个 500 点(永久)消耗 100,变成 400
- 记录这次扣费从哪些桶各扣了多少,存在
UsageRecord.Allocations里
为什么要记录分配?因为结算时可能多退:模型只发出了 150 个 token,实际扣费少于预期 200 点。退费时要按逆序原路退回——最后扣的(永久的)先退,保证临时点不会被复活。而且如果某个原桶已经过期了,那笔退款就跳过(点本就该没了,不用还)。
每个桶本身没有"花费"的概念,只有"剩余"。所有的花费都是透过 UsageRecord 记录的。这样的好处是:
- 多退少补的逻辑精确而无歧义
- 每笔支出都能精确审计追溯到来源桶
- 临时点和永久点永远不串味
- 防止了"用完临时点还能从永久点倒流"这类漏洞
发放与消费的原语
平台所有入账路径(充值、兑换、活动赠送、订阅月度发放)都调同一个 GrantPoints 函数:
GrantPoints(tx, userID, amount, expiresAt, source)
amount > 0才会建桶;amount <= 0无效expiresAt = nil表示永久(所有现有充值、赠送都是永久)source标记来源:topup、redeem、adjust(管理员调整)、membership(订阅)
消费侧是 Reserve 和 Settle 的一对原语:
Reserve 是预扣。给定一个操作 ID、用户、估计消费点数,它会:
- 查存活桶、按最早过期先排序、加行级锁(
FOR UPDATE) - 贪心从前往后扣,直到凑满估计值或余额不足
- 写
UsageRecord记录status=reserved,包含Allocations分配 - 余额不足则报错,整个事务回滚、不写记录、不建空桶
Settle 是结算。给定操作 ID 和真实消费,它会:
- 幂等检查:如果记录已经 settled 或 refunded,直接返回
- 计算
delta = actual - reserved - 如果
delta < 0(多退),按Allocations逆序精确退回原桶 - 如果
delta > 0(少补),尽力从存活桶再扣;不足仍结算(不卡死) - 标记记录为
settled
为什么 settle 时"少补不足仍结算"?因为 reserve 已经用保守估计(input + maxOutputTokens)去预扣了,delta > 0 的缺口通常微小。而且结算不能卡死——已经发生的消费是既成事实,不因为后续余额不足而回滚。
并发与幂等的保证
同一用户的并发请求怎么防止双扣?方案很简单:事务内 FOR UPDATE 锁定存活桶。Postgres 会按行级锁串行化对同一用户的并发扣费。
幂等性靠 OperationID(对话回合 ID)作主键。同一操作重试 reserve,会查到已存的 UsageRecord,直接返回其 ReservedPoints,不重复扣。Settle 同理——已结的记录不再改。
过期的预留怎么处理?有一个后台对账任务,每日扫一遍超过 10 分钟还没 settle 的 reserved 记录,调 Settle(opID, 0) 让它全额退回原桶。这个任务即使失败也无碍——正确性不依赖它,它只是账务清理。
资源计价的统一框架
现在回到另一个问题:不同资源的单位不一样。对话要精确到 token 计数,网络搜索只能按次(一次搜索多少点),OCR 要按页数。长期来看,还会有 embedding 按千 token、视频按秒等等。
如果每个功能都自己定义一套计费规则,就会出现:
- 有些用"PriceRule + token 累加"的复杂方式(对话双倍率)
- 有些用"固定点数"(网搜 10 点/次)
- 有些用"浮点折算"(embedding 每 1000 token 多少点)
- 改价时无法审计,历史账单对不上
正确的做法是建一个通用资源价表 ResourcePrice,所有资源都必须先配价,消费时统一调 Charge() 接口。
表的结构是这样的:
ResourceKey | DisplayName | PricingType | Rate | PerUnits
-------------|--------------|-------------|------|----------
websearch | 网络搜索 | PER_CALL | 10 | 1
ocr | OCR识别 | PER_UNIT | 5 | 1
embedding | 文本嵌入 | PER_UNIT | 0.2 | 1000
video.gen | 视频生成 | PER_UNIT | 100 | 60
PER_CALL:每次调用固定点数。比如网搜一次 10 点,参数units无关PER_UNIT:按单位线性计费。比如 OCR 每页 5 点(perUnits=1),embedding 每 1000 token 0.2 点(perUnits=1000)
消费时调 Quote(resourceKey, units) 得到应扣点数,然后 Charge(opID, userID, resourceKey, units) 一次性扣费,包括:
- 查
ResourcePrice(必须 enabled) - 按公式计费:
PER_CALL时取ceil(rate);PER_UNIT时取ceil(rate * units / perUnits) - 用多桶钱包的
Reserve/Settle逻辑扣分配额 - 写
UsageRecord(status=settled)
这样的好处是:改价只需改 ResourcePrice 里一行。所有历史的 UsageRecord 还是记着原来的实际扣点,不会被改价影响。新的消费按新价表计算。审计时能看到每笔消费用的是什么价表。
为什么是现在
这两项改造之所以现在做,而不是等到实际需求出现,原因很直接:项目未上线、没有真实用户或活钱。这意味着:
- 没有历史数据需要迁移——全量重写钱包表无成本
- 没有活跃用户会在改造期间遇到不一致的余额计算
- 如果后续发现设计有漏洞,改数据结构的成本最低
一旦上线、产生真实用户和消费记录,再想重做多桶就会牵扯数据迁移、兼容旧账户、处理部分迁移失败等等复杂性。现在这两项都是"必须一次做对"的基础设施,做对的成本远小于上线后被迫改的成本。
■