Next.js AI SaaS 积分账本设计
按量计费的 AI 产品同时需要两件事:能快速读取的余额,以及能解释每次变化的审计轨迹。一个数字无法同时承担这两个职责。
NimBuild 的积分系统采用双写模式,核心是三层记录。
一个余额背后的三类记录
快速余额
user.credits 保存当前可消费余额,用于快速访问和简单 UI 读取。
可消费积分桶
credit_balance_bucket 保存一次发放的来源、剩余数量、优先级和可选过期时间。订阅周期、注册奖励、每日登录和管理员调整可以并存,且不丢失各自身份。
扣除时优先消费更早过期的未过期桶,让促销积分自然过期,又不会混淆所有已购买积分。
不可变账本
credit_ledger 记录每次发放、扣除、退款、调整和过期。管理员和客服用它还原事件链。
相关记录在同一个数据库事务中更新。只应用了一半的积分变更不算成功。
来源为什么重要
文档中的 starter 积分来源包括:
| 来源 | 触发条件 | 数量 |
|---|---|---|
| 注册奖励 | 首次 Firebase 用户同步 | 2,000 积分 |
| 每日登录 | Asia/Shanghai 每日首次成功登录 | 200 积分,次日零点过期 |
| 订阅 | Webhook 或定时发放 | 按套餐配置 |
| 管理员调整 | 手动操作 | 自定义 |
不同来源有不同业务含义。注册奖励可能是免费的,订阅积分是已购买的,管理员调整可能是数据修复。如果它们都直接改一个不可解释的数字,后续决策只能靠猜。
使用显式账本原因
每条账本记录都有 reason。NimBuild 使用这些名称:
registration_bonusdaily_grantsubscription_cyclesubscription_scheduleadjustmentrefundcredit_expiredai_generationai_generation_refund
新增产品工作流时,避免使用泛化的“消耗”。例如,文档中的文案生成扣费和未来的导出工作流,应该在用户历史和管理员审计中可区分。
积分过期应该很无聊
会过期的发放把 expiresAt 写入积分桶。/api/cron/credit-expiry 处理过期桶,从快速余额中扣除剩余数量,并写入 credit_expired 账本记录。
这样同时得到三个结果:
- 用户看到准确余额。
- 积分桶不再可消费。
- 消失原因可审计。
退款也是账本事件
对于 AI 工作,NimBuild 可能在调用提供商前先扣积分。如果外部工作失败,补偿流程会走退款路径,而不是原地修改余额,并生成关联失败操作引用的 ai_generation_refund 记录。
这个区分对信任很重要。用户能看到失败被补偿,运营者也能区分产品退款和提供商失败补偿。
给新工作流的设计规则
给新功能加入积分时:
- 外部工作前先检查余额。
- 事务内扣除。
- 使用具体的消耗原因。
- 传入稳定引用 ID。
- 失败时通过退款路径补偿。
- 持久化生成或工作流历史。
- 在管理员历史中可见。
可审计账本不是官僚流程。它让小团队也能安全处理账单问题、促销、数据修复和新定价方案,而不必靠猜测决策。
实用的关系型结构
这套实现模式适用于常见 PostgreSQL 部署。关键规则是:余额、积分桶和账本写入必须处于同一个数据库事务。一个简化后的结构如下:
create table user_credit_state (
user_id uuid primary key references "user"(id),
credits integer not null,
updated_at timestamptz not null default now()
);
create table credit_balance_bucket (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references "user"(id),
source_type text not null,
remaining_amount integer not null check (remaining_amount >= 0),
priority integer not null default 100,
expires_at timestamptz
);
create table credit_ledger (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references "user"(id),
amount integer not null,
reason text not null,
reference_id text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index credit_balance_bucket_spend_idx
on credit_balance_bucket (user_id, expires_at nulls last, priority);
create index credit_ledger_user_created_idx
on credit_ledger (user_id, created_at desc);
生产项目的表名和 Drizzle 定义以 starter 文档为准,但这些不变量是通用的:负向账本记录必须匹配消耗的积分桶;退款必须指向被补偿的操作;过期任务必须同时减少积分桶和快速余额。
扣费伪代码
下面的例子比产品 UI 更清楚地展示事务边界:
begin;
select credits
from user_credit_state
where user_id = $userId
for update;
-- 余额不足时停止。
-- 按过期时间和优先级选择积分桶。
-- 更新每个桶的 remaining_amount。
-- 写入一条总扣费负账本记录。
-- 用相同数量更新 user_credit_state.credits。
commit;
for update 很重要。两个并发生成请求不能同时读到相同的 20 积分并都启动外部工作。这个行锁只串行化单个用户的可支付判断,不会锁住整个积分表。
starter 暴露的 TypeScript API 已经封装这些不变量:
const credits = await getUserCredits(userId);
const canAfford = await canUserAfford(userId, creditsNeeded);
if (!canAfford) {
throw new Error("INSUFFICIENT_CREDITS");
}
await deductCredits(userId, creditsNeeded, "ai_generation", generationId);
引用 ID 必须跨重试保持稳定。如果工作流先写入生成记录,就使用该记录 ID;如果下一步是提供商调用,同一个 ID 可以把扣费、历史和退款关联起来。
管理员修复也必须走同一规则
人工调整有时不可避免。支付可能只完成了一半,客服也可能决定为故障恢复积分。但修复仍然应使用 adjustment 这类账本原因,而不是临时执行 SQL 更新。
管理员修复应记录:
- 执行变更的操作员。
- 用户和变更前余额。
- 精确数量和原因。
- 工单或事故引用。
- 变更后余额和账本记录。
这样就把一次有风险的支持操作变成可问责的运营事件,也能区分 Bug 修复、善意补偿和产品退款。
报表不应污染业务表
分析应读取 replica、物化视图或导出任务。不要在活跃余额行上增加报表字段,然后在另一个事务中更新它;这个字段最终一定和账本不一致。
有价值的报表包括:
- 按来源和周期统计积分发放
- 按产品工作流统计积分消耗
- 按提供商和失败类型统计退款率
- 按活动统计过期积分
- 生成前后的平均余额
这些指标可以从账本历史计算,不需要修改事务表。
积分变更上线检查
发布新的积分消耗功能前,至少测试四个场景:
- 正常成功生成。
- 扣费前请求被拒绝。
- 扣费后提供商失败。
- 两个并发请求只能负担一次执行。
然后核对四类记录:快速余额、积分桶、账本记录、生成历史。只要其中一个不一致,事务边界就是错的。
这才是可审计积分账本的实际意义。它不是架构装饰,而是资金相关产品使用量的控制系统。
