EveryInfra

Build in Public · LF-14

API 失败、退款与对账:如何核对一条请求的净扣款

把业务任务、调用尝试、交付状态与钱包事件分开,解释扣前拒绝、空结果和部分退款,避免重复减退款,并用整数离线示例核对单次请求。

“失败不收费”要成为可执行的产品约定,至少要说清两件事:什么算失败,怎样证明这次没有形成净扣款。只看 HTTP 状态会遗漏空结果与部分交付,只看余额变化又会混入其他并发请求、充值或调整。可靠对账需要回到一条请求及其相关事件。

本文讨论 API 接入方怎样组织核对记录,并结合 EveryInfra 当前代码解释几个容易混淆的字段。这里的离线账本是应用设计示例,不是客户真实账单,也不是会计收入确认规则。具体 action 的计费条件、金额单位与最终账单仍按对应产品契约核对。

  • 业务任务:用户真正要完成的目标,例如检查一组获准商品评论。一个任务可能包含多次 API 调用。
  • 调用尝试:一次实际请求,保存自己的时间、参数版本、模型或 action 以及可得 request ID。
  • 交付结果:成功、空结果、部分交付、失败或仍未知;这是业务状态,不等于钱包状态。
  • 钱包事件:与该尝试相关的扣款、返还或其他明确调整,用于解释净变化。

把这四个对象关联起来,而不是塞进同一个 success 字段。一次请求完成了部分目标,业务可以保留已交付内容,同时继续核对未完成部分的计费;一次超时也可能尚未确定交付与结算,不能被直接写成零费用。

业务任务 ID、请求 ID、异步 job_id 和账务 reference 也不是同一个概念。保存服务确实提供的关联关系;不能用相似字符串拼出不存在的账务 ID。查询任务时新产生的 HTTP 请求 ID,更不能覆盖最初提交任务的关联标识。

扣费前拒绝,与扣费后返还是两种路径

对照 2026-09-04 的已部署源码基线,EveryInfra 文本请求的身份、输入、模型与能力权限检查都在计费前;缺少 messages 时可先于鉴权返回输入错误,因此这里不声明所有错误之间统一的优先顺序。在 messages 有效且身份检查通过的路径上,未知模型在扣费前产生 422 unknown_model。这类失败是拒绝执行,而不是先收费再退款;旧的未知模型 503 叙事不应继续沿用。

对于已进入执行阶段、随后失败的请求,可能需要返还已扣部分。实现里存在返还分支,不代表客户端已经观察到该分支完成。若响应丢失,先按请求标识查询可用记录;没有证据时保留“结算待核对”,不要只凭错误文字修改自己的财务记录。

2026-09-02 的归档包含特定请求失败后返还的脱敏样本,也包含空结果和批量部分交付样本。它们说明这些情况值得分别验收,不是对今天每种能力或每种故障的统一保证。本轮只做源码与公开资料核对,没有新增真实扣费、退款或账户操作。

先读字段定义,再决定怎样相加

以同步数据响应为例,billing.charged 用于表示响应中的计费状态,billing.credits 是这份响应报告的扣款单位数量,billing.amount 用于带金额语义的展示。quota 的剩余值属于账户快照,不能当成单次费用;目录标价也不能替代实际请求记录。

最容易算错的是部分退款。当前数据实现先从原费用中减去已经计算的部分返还,再把剩余费用写入 billing.credits,同时单独返回 refunded_credits。因此,不能再做一次“billing.credits 减 refunded_credits”,否则会重复扣减退款。这个结论限定于这里核对的同步数据分支,不自动套用所有产品。

在一个纯合成例子中,原扣 30 单位、返还 10 单位,净扣是 20。若最终响应已报告 credits 为 20,refunded_credits 为 10,那么 20 已经是该响应表达的净数。要核对原始事件,另用 30 减 10;不要从 20 再减 10 得出 10。

同一个请求的每次轮询都可能携带重复状态,不要把每份响应里的 credits 相加。响应快照、计费事件与结算调整分别入库;先按它们各自稳定身份去重,再汇总。看到新一条 HTTP 日志,不代表发生了新一笔消费。

空结果与部分交付要按 action 解释

搜索结果为空时,先确认该工具的有效交付是否还可能出现在其他字段。当前普通搜索实现同时检查 results 与 answer;只盯着空 results,就可能把有答案的响应误判为完全空结果。客户端应依照工具契约,不在财务层猜内容是否有价值。

批量数据请求则需要核对目标数与实际交付。当前部分返还分支可给出 total_targets、billed_targets 和 refunded_credits,业务结果还可包含 partial 与 missed。这些字段帮助解释缺口,但具体值仍要来自实际响应;不能把评论条数当成成功目标数。

再次处理未完成目标前,先保留已交付对象与原请求,明确哪些目标仍未知。整批重发可能重复获得已有内容,也可能产生新的调用尝试。客户端对结果去重,与服务端防止重复计费,是两件不同的事情。

异步受理不等于最终结算

HTTP 202 与 job_id 表示进入任务处理流程,不是用户已经得到结果。保留原任务并查询其最终状态;任务查询成功只是查询本身完成,还要读取任务是否仍在运行或等待收尾。网络断开也不能自动解释为取消成功。

当前数据任务查询主要返回任务状态、结果或失败信息,并非每次都提供完整 billing 快照。缺少账务字段时应到获准的账单入口核对,不能填入零,也不能把创建时与结束时两个不同快照都当作新扣款。本文不声称所有异步能力采用同一结算时点。

用整数核对单次费用,不用显示小数反推

对账先固定钱包、计量单位和请求范围。不要把多个钱包、不同币种或展示汇率下的金额直接相加。若接口使用整数 credits,就优先用原单位核对;在边界处验证安全整数,超出 JavaScript 精确整数范围时使用明确的整数表示。

下面演示自定义事件结构:每条都属于同一已确认的请求范围,credits 使用正整数字符串,kind 说明扣款或返还。它不读取真实账单、不连接数据库,也不改变余额。完整事件集合与账务授权是调用者需要先确认的前提。

离线 JavaScript:区分扣款总额、返还总额和净扣款
function reconcileAttempt(entries) {
  const seen = new Map();
  let debited = 0n, refunded = 0n;
  for (const e of entries) {
    if (typeof e.id !== "string" || !e.id.trim()
        || !["debit", "refund"].includes(e.kind)
        || typeof e.credits !== "string" || !/^[1-9][0-9]*$/.test(e.credits)) {
      throw new Error("invalid synthetic ledger entry");
    }
    const fingerprint = JSON.stringify([e.kind, e.credits]);
    if (seen.has(e.id)) {
      if (seen.get(e.id) !== fingerprint) throw new Error("conflicting entry");
      continue; // 同一事件的重复快照不再计入。
    }
    seen.set(e.id, fingerprint);
    if (e.kind === "debit") debited += BigInt(e.credits);
    else refunded += BigInt(e.credits);
  }
  if (refunded > debited) throw new Error("check scope or missing debit evidence");
  return {
    debited: debited.toString(), refunded: refunded.toString(),
    netCharged: (debited - refunded).toString()
  };
}

console.log(reconcileAttempt([
  {id: "synthetic-debit", kind: "debit", credits: "30"},
  {id: "synthetic-refund", kind: "refund", credits: "10"},
  {id: "synthetic-refund", kind: "refund", credits: "10"}
]));

结果是扣款 30、返还 10、净扣 20;重复出现的同一返还事件不会再计入。如果同一事件 ID 对应不同内容,示例停止而不静默覆盖。返还大于已取得扣款时也停止核对,原因可能是范围或时间窗口不完整,不能直接认定服务多退了钱。

这不是完整钱包算法。充值、赠送、提现、冻结、冲正与其他调整未在示例中建模;真实系统要按各自事件定义处理。空列表算出的零也只代表没有输入事件,不能证明一条未知请求没有扣款。

JavaScript 的 Number 不是任意精度整数。RFC 8259 对 JSON 数字互操作范围的说明,是这里保持整数字符串、再显式转为 BigInt 的背景;这只能减少计算表示误差,不能补齐缺失的账务事件。

按时间窗口核对时,保留期初与跨期事件

请求可能在一天内开始、下一天完成;退款还可能晚于原扣款入账。按请求创建时间筛选和按钱包事件发生时间筛选,会得到不同集合。报表先写明采用哪种时间,再保存期初、期末与跨期关联,避免当天只见返还、不见前一天扣款而误报异常。

余额恒等式要求把该钱包窗口内全部入账与出账计入,而不是只统计本文的两种事件。当前余额与某次调用前的余额之差,还可能包含并发任务或其他调整;它适合作为交叉核对,不适合独自证明一条请求的费用。

钱包消费、客户充值现金、赠送额度使用和会计收入各有不同口径。本文只讨论请求与钱包证据的对应,不提供收入确认或税务结论。对外财务指标应由负责的产品与财务人员明确口径后再公布。

Stripe 的幂等请求文档提供了一个对照:安全重发需要服务明确支持相应幂等机制。它不能证明 EveryInfra 接受相同的幂等键。本文示例按事件 ID 去重的是已取得记录,不是在服务端阻止再次扣款;这两种去重不能混为一谈。

差异先进入待核对清单,不自动改账

  • 有扣款记录、无可关联交付:先确认是否异步未结束、结果未保存或关联丢失。
  • 失败结果、账务仍未明确:核对原请求与后续事件,不自行标记已退款。
  • 同一事件内容冲突:保存原始版本与获取时间,停止静默覆盖。
  • 退款重复展示:先判断是否同一事件被多次读取,不能重复计入。
  • 金额单位或窗口不一致:先统一比较范围,再判断差额。

提交支持请求时,只提供必要的 request ID、时间、能力、状态与脱敏账单引用。不要发送完整 Key、客户正文或整个账户导出。差异排查与账务修改需要不同权限;一个自动生成的异常标记,不构成冲正或退款授权。

一条可复核的请求,应能说明它交付了什么、费用依据是什么、返还是否已经计入净额,以及还有哪些事件尚未确认。先把这些关系做清楚,“失败不收费”才不只是口号,也不会因客户端重复计算退款而制造新的账单差异。