Cloudflare Worker实现余额预留与Token结算

上一篇讨论了长文本请求跨过计费阈值时,为什么网关要在转发前做成本预估。这一篇继续往下走:预估出来的金额,怎样在 Cloudflare Worker + D1 中先锁住,等上游返回实际 Token 用量后再结算?

本文给的是一套可核对的参考设计,不声称是某个线上平台的现有实现。示例省略鉴权、价格计算和上游协议适配;这三项必须在接入真实模型前补齐。尤其不要把示例中的"余额单位"直接当成人民币或美元浮点数。

先定义三个不变量

假设账户可用余额为 available_units,一次请求的预留为 reserved_units,实际费用为 actual_units,三者都是整数记账单位

  1. 同一 request_id 最多预留一次。客户端重试不能再次扣住余额,也不能重复调用上游。
  2. 只有拿到可信的上游用量后才能结算:0 <= actual_units <= reserved_units。若超出预留上限,进入人工/自动对账,不能悄悄把余额扣成负数。
  3. 结算或释放只能发生一次。网络超时不等于"上游没有执行";结果不明时保留预留并标记 unknown,等待查询或对账。

因此,最小状态流转是:

text 复制代码
reserved ──(准备转发)──> sent ──(有可信用量)──> settled
    │                       │
    └──(确定未转发)──> released ├──(确定未计费)──> released
                            └──(结果不明)──> unknown ──(对账)──> settled/released

releasedsettled 是终态。不要设置一个"超时 30 秒就自动退款"的定时器:请求可能已经到达上游,响应却丢在网络上。

D1 表结构:余额和请求流水分开

sql 复制代码
CREATE TABLE accounts (
  account_id TEXT PRIMARY KEY,
  available_units INTEGER NOT NULL CHECK (available_units >= 0)
);

CREATE TABLE requests (
  request_id TEXT PRIMARY KEY,
  account_id TEXT NOT NULL REFERENCES accounts(account_id),
  payload_hash TEXT NOT NULL,
  state TEXT NOT NULL CHECK (state IN ('reserved','sent','unknown','settled','released')),
  reserved_units INTEGER NOT NULL CHECK (reserved_units > 0),
  actual_units INTEGER,
  price_version TEXT NOT NULL,
  provider_request_id TEXT,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  CHECK (actual_units IS NULL OR (actual_units >= 0 AND actual_units <= reserved_units)),
  CHECK (state != 'settled' OR actual_units IS NOT NULL)
);

CREATE INDEX idx_requests_state_time ON requests(state, updated_at);

payload_hash 用于发现"同一个幂等键却换了请求内容";price_version 固定当次计价规则,后续价格变动不应重写历史账单。账户充值也应单独记流水;这里聚焦调用扣费。

关键点是把预留与扣余额放在同一条数据库写操作里 。下面的触发器在插入请求流水后有条件扣余额;若余额不足,整个插入语句回滚。request_id 的主键冲突同样不会产生第二次预留。

sql 复制代码
CREATE TRIGGER reserve_after_insert
AFTER INSERT ON requests
BEGIN
  UPDATE accounts
     SET available_units = available_units - NEW.reserved_units
   WHERE account_id = NEW.account_id
     AND available_units >= NEW.reserved_units;
  SELECT CASE WHEN changes() != 1
    THEN RAISE(ABORT, 'insufficient_balance') END;
END;

完成请求时,状态更新与"退回未使用预留"也必须是同一条原子写操作。比如预留 100 单位、实际 37 单位,结算时退回 63;若确定未计费,退回全部 100。

sql 复制代码
CREATE TRIGGER release_or_settle_after_update
AFTER UPDATE OF state ON requests
WHEN OLD.state IN ('reserved','sent','unknown')
 AND NEW.state IN ('settled','released')
BEGIN
  UPDATE accounts
     SET available_units = available_units + OLD.reserved_units
       - CASE WHEN NEW.state = 'settled' THEN NEW.actual_units ELSE 0 END
   WHERE account_id = NEW.account_id;
END;

生产迁移还应增加状态转移约束:不允许终态改回中间态,不允许 reserved 直接假装已收到上游用量。本文先用 Worker 的 WHERE state IN (...) 控制更新,同时把状态字段和请求流水保留用于审计。任何手工修数都应走单独的管理流程,不能绕过这套规则。

Worker 的三个写入动作

下面只展示最重要的 D1 调用;accountId 必须来自已验证的身份,requestId 应由服务端生成或与用户身份绑定,不能信任客户端随意指定其他人的账户。

ts 复制代码
type Env = { DB: D1Database };

async function reserve(env: Env, input: {
  requestId: string; accountId: string; payloadHash: string;
  upperBoundUnits: number; priceVersion: string;
}) {
  const now = Date.now();
  if (!Number.isSafeInteger(input.upperBoundUnits) || input.upperBoundUnits <= 0)
    throw new Error('invalid reserve amount');

  await env.DB.prepare(`
    INSERT INTO requests
      (request_id, account_id, payload_hash, state, reserved_units,
       actual_units, price_version, created_at, updated_at)
    VALUES (?, ?, ?, 'reserved', ?, NULL, ?, ?, ?)
  `).bind(input.requestId, input.accountId, input.payloadHash,
          input.upperBoundUnits, input.priceVersion, now, now).run();
}

async function markSent(env: Env, requestId: string) {
  const r = await env.DB.prepare(`
    UPDATE requests SET state='sent', updated_at=?
    WHERE request_id=? AND state='reserved'
  `).bind(Date.now(), requestId).run();
  return r.meta.changes === 1;
}

async function settle(env: Env, requestId: string, actualUnits: number) {
  if (!Number.isSafeInteger(actualUnits) || actualUnits < 0)
    throw new Error('invalid actual amount');
  const r = await env.DB.prepare(`
    UPDATE requests SET state='settled', actual_units=?, updated_at=?
    WHERE request_id=? AND state IN ('sent','unknown')
      AND reserved_units >= ?
  `).bind(actualUnits, Date.now(), requestId, actualUnits).run();
  return r.meta.changes === 1;
}

async function release(env: Env, requestId: string) {
  const r = await env.DB.prepare(`
    UPDATE requests SET state='released', updated_at=?
    WHERE request_id=? AND state IN ('reserved','sent','unknown')
  `).bind(Date.now(), requestId).run();
  return r.meta.changes === 1;
}

release() 只能在证实没有上游费用时调用 :例如预留后尚未转发,或上游提供明确的未执行/未计费结果。若 fetch() 抛出超时或连接中断,不能仅凭这个异常调用 release()。此时将 sent 改成 unknown,保留预留并进入对账队列。

正常请求顺序是 reserve → markSent → 调用上游 → 读取用量 → 计算实际费用 → settle。如果 reserve 因主键冲突失败,先读取已有流水,核对 account_idpayload_hash,然后返回已有状态;不要再转发一次 。如果 markSent 返回 false,也不能继续转发。上述示例未包含上游本身的幂等机制,若上游不支持请求幂等,Worker 在"已发送但未收到响应"时无法凭本地数据库判断真实账单,必须对账。

实际用量与预留上限怎么算

预留不是随便写一个"大概够用"的数字。它需要考虑:输入 Token、max_output_tokens、选定模型的价格版本、缓存最坏情况、长文本跨档,以及可能产生的工具费用。上游成功后,应以可信的响应 usage 字段 和当次锁定的价格版本计算 actual_units,而不是让客户端回传"我用了多少 Token"。

为了防止 JavaScript 浮点误差,金额先换算为足够细的整数记账单位,并在预留时向上取整;同时检查整数不超过 Number.MAX_SAFE_INTEGER。D1 的底层 SQLite 使用 64 位整数,但 Worker 绑定传参不宜假设 JavaScript BigInt 可直接写入。

四组必须跑的测试

场景 期待结果
余额 100,两笔并发请求都要预留 80 只有一笔成功,余额不为负
同一 request_id 重试两次 只保留一条流水、只调用一次上游
预留 80,实际 30,重复执行结算 第一次退回 50;第二次不再动余额
上游超时,无法确认是否计费 状态转 unknown,余额暂不释放,等待对账

若 D1 开启读副本,不要拿可能滞后的普通读结果做"可用余额足够"的最终裁决;预留动作必须由主库写入时的条件和约束决定。Cloudflare 的 Sessions API 能处理后续读的一致性要求,但不能替代原子写入。D1 文档对 batch() 的事务和回滚语义有明确说明,不过把余额判断放在 Worker 的两次独立数据库调用里,仍然不是同一事务。

这套设计解决的是"预留与结算不重复、不透支、可追溯",不是"任何异常都立即退款"。下一篇我会故意构造三类上游失败:请求还没发出、上游明确拒绝、响应超时但账单未知,逐一验证什么时候该释放,什么时候必须对账。

参考:Cloudflare D1 Worker APID1 SQL 与 SQLite 兼容性D1 读副本一致性

相关推荐
回眸&啤酒鸭1 小时前
【回眸】学习力重建与卡牌游戏融合应用指南
人工智能
万物智能信息科技1 小时前
PWM散热风扇设置—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
人工智能·华为·开源·harmonyos·鸿蒙
catcatuncle1 小时前
读了一个开源 AI Agent 的源码后,我重新思考了"文件验收"这件事
人工智能
二川bro1 小时前
Agent安全执行命令:命令分级+Hook+读写锁
人工智能
AI天行健1 小时前
AI视频热缓存命中率89%:无限画布Vera1.1调参方案与数据验证
人工智能·ai
Fang_YuanAI1 小时前
AIGC原生IP到底应该怎么做?
人工智能·ai·aigc·mcn·ai短剧·ip孵化·aigc创作
IamZJT_1 小时前
01|先跑起来:用大模型和一个查询工具处理文字工单
人工智能
星火10241 小时前
【从 0 到 1 动手造 Agent】(组件篇)06、向量库:让 Agent 按「意思」找东西
人工智能·agent
IamZJT_1 小时前
Agent 系统工程 03|工具执行成功但回执丢了,重试还是不重试?
人工智能