Back
NimBuild AI

NimBuild AI

Next.js AI SaaS 积分账本设计

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_bonus
  • daily_grant
  • subscription_cycle
  • subscription_schedule
  • adjustment
  • refund
  • credit_expired
  • ai_generation
  • ai_generation_refund

新增产品工作流时,避免使用泛化的“消耗”。例如,文档中的文案生成扣费和未来的导出工作流,应该在用户历史和管理员审计中可区分。

积分过期应该很无聊

会过期的发放把 expiresAt 写入积分桶。/api/cron/credit-expiry 处理过期桶,从快速余额中扣除剩余数量,并写入 credit_expired 账本记录。

这样同时得到三个结果:

  1. 用户看到准确余额。
  2. 积分桶不再可消费。
  3. 消失原因可审计。

退款也是账本事件

对于 AI 工作,NimBuild 可能在调用提供商前先扣积分。如果外部工作失败,补偿流程会走退款路径,而不是原地修改余额,并生成关联失败操作引用的 ai_generation_refund 记录。

这个区分对信任很重要。用户能看到失败被补偿,运营者也能区分产品退款和提供商失败补偿。

给新工作流的设计规则

给新功能加入积分时:

  1. 外部工作前先检查余额。
  2. 事务内扣除。
  3. 使用具体的消耗原因。
  4. 传入稳定引用 ID。
  5. 失败时通过退款路径补偿。
  6. 持久化生成或工作流历史。
  7. 在管理员历史中可见。

可审计账本不是官僚流程。它让小团队也能安全处理账单问题、促销、数据修复和新定价方案,而不必靠猜测决策。

实用的关系型结构

这套实现模式适用于常见 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 更新。

管理员修复应记录:

  1. 执行变更的操作员。
  2. 用户和变更前余额。
  3. 精确数量和原因。
  4. 工单或事故引用。
  5. 变更后余额和账本记录。

这样就把一次有风险的支持操作变成可问责的运营事件,也能区分 Bug 修复、善意补偿和产品退款。

报表不应污染业务表

分析应读取 replica、物化视图或导出任务。不要在活跃余额行上增加报表字段,然后在另一个事务中更新它;这个字段最终一定和账本不一致。

有价值的报表包括:

  • 按来源和周期统计积分发放
  • 按产品工作流统计积分消耗
  • 按提供商和失败类型统计退款率
  • 按活动统计过期积分
  • 生成前后的平均余额

这些指标可以从账本历史计算,不需要修改事务表。

积分变更上线检查

发布新的积分消耗功能前,至少测试四个场景:

  1. 正常成功生成。
  2. 扣费前请求被拒绝。
  3. 扣费后提供商失败。
  4. 两个并发请求只能负担一次执行。

然后核对四类记录:快速余额、积分桶、账本记录、生成历史。只要其中一个不一致,事务边界就是错的。

这才是可审计积分账本的实际意义。它不是架构装饰,而是资金相关产品使用量的控制系统。