# 接入指南 把预付额度接到你的产品后端:用户先买额度,用了多少你报给 Billing,Billing 记账并告诉你余额还够不够。本指南按接入的先后顺序写,每一步都给 curl 和 TypeScript SDK 两种写法;做完最后一节的验收,就算接入完成。 把接入交给开发 Agent(Claude Code、Codex 等)时,只要对它说「请按 `你的服务地址/SKILL.md` 接入 Billing」,再告诉它产品需求和 `product.json` 的绝对路径。Agent 按同一套步骤做,本页是它和你共用的说明。 发布 0.4.0 / API v1。 ## 谁负责什么 | 你的产品 | Billing | | ---------------------------------------------- | --------------------------------------------------- | | 用户注册、登录,把当前用户的 ID 传给 Billing | 按(产品,用户 ID)记账,用户之间、产品之间互不可见 | | 自己的购买页:列出额度包与档位,用户确认后建单 | 订单、Stripe 收银台、确认付款、发放额度 | | 真实的服务,以及计量:每段工作用了多少 | 按产品的费率扣额度;余额不够时记为欠额,告诉你停止 | | 每次展示余额时读一次 | 余额、流水,每条带变动后的余额 | Billing 不会主动通知你的产品「付款到了」或「余额用完了」:你需要的时候读,报用量时看返回。 ## 拿到连接配置并核对 负责人在控制台为你的产品签发**产品密钥**,和服务地址一起交给你,存成后端私有的 `product.json`(权限 0600): ```json { "base_url": "https://billing.example.com", "api_key": "key_….…" } ``` 负责人在「开发接入」复制接入说明;验收用的 `demo_integration`(有测试额度)和 `demo_empty`(零额度),需另在「测试体验」中准备并检查就绪。复制说明不会改变测试数据。密钥只显示一次;不要把它粘贴进对话、命令行参数或日志。 用一次只读请求核对(密钥经 stdin 交给 curl,不出现在命令行里): ```sh jq -r '"header = \"Authorization: Bearer \(.api_key)\"\nurl = \"\(.base_url)/v1/capabilities\""' /absolute/path/product.json | curl -fsS -K - ``` 返回 200 即说明地址和密钥可用:`environment` 是 `test` 或 `live`,`actions` 含 `usage.record`、`balance.read`;产品配了 Stripe 时另含 `checkout.create`。这次调用成功后,控制台的「接入进度」会把「后端已连通」打勾。 下面的 curl 示例为了简短,把两项放进环境变量;生产代码从私有文件读取。 ```sh export BILLING=https://billing.example.com export KEY=$(jq -r .api_key /absolute/path/product.json) ``` TypeScript / JavaScript 后端用公开 SDK [`flyresearch/billing-sdk`](https://github.com/flyresearch/billing-sdk):零依赖,只用 `fetch`,Node 18+、Bun、Deno、Cloudflare Workers 都能用。其他语言直接按 [OpenAPI](/openapi.json) 调 HTTP。 ```sh bun add github:flyresearch/billing-sdk#v0.12.0 ``` ```ts import { createBillingClient } from "@flyresearch/billing-sdk"; const billing = createBillingClient({ baseUrl, apiKey }); // 来自 product.json ``` 读一次计量合同:`GET /v1/catalog` 的 `config` 说明你上报的单位(`metric`)和每单位扣多少额度(`credits_per_unit`)。 ## 核心循环:先读余额,用量发生后上报 这是接入的主体。规则只有三条: 1. **开始一次工作前读一次余额**,`allowed` 为 `false` 就不开始。 2. **工作中按固定间隔上报已经发生的用量**(例如每 5 秒),不需要事先冻结额度。 3. **上报的返回里 `allowed` 变成 `false`,就停止这次工作**。用量总会记下:余额不够时差额记为欠额,`balance` 变成负数,下一次充值先抵扣欠额。所以超用最多一个上报间隔。 ```sh curl -sS "$BILLING/v1/users/USER_ID/balance" -H "Authorization: Bearer $KEY" # → {"balance":"3000000","allowed":true, …} curl -sS -X POST "$BILLING/v1/users/USER_ID/usage" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"event_id":"session-42:1","units":"5000","metadata":{"session":"session-42"}}' # → {"event_id":"session-42:1","credits":"5000","balance":"2995000","allowed":true} ``` `event_id` 识别这条用量,是重试的依据: - 上报失败或没收到回复,就用**原 `event_id`、原内容**重发,返回第一次的结果,不会重复扣费。 - 同一个 `event_id` 换了内容,返回 409 `event_conflict`。所以每段用量用自己的 ID,比如「会话 ID:序号」。 - 每条扣 `units × credits_per_unit` 额度,精确相乘、不取整。`metadata` 只收字符串值,最多 1 KB,会随流水返回。账户关闭后上报返回 409 `account_closed`。 TypeScript 里 `createMeter` 已经实现了这个循环:按间隔批量上报、递增事件 ID、失败用原 ID 重试、`allowed` 变 `false` 时调用一次 `onStop`。 ```ts import { createMeter } from "@flyresearch/billing-sdk"; if (!(await billing.balance(userId)).allowed) return showPurchasePage(await billing.catalog()); // 你自己的购买页(见下文「在你自己的页面卖额度」) const meter = createMeter(billing, { customer: userId, eventPrefix: sessionId, // 每次工作唯一 intervalMs: 5000, onStop: () => endSession("insufficient_credits"), }); meter.add(measuredUnits); // 每测到一段用量就加 await meter.close(); // 结束时把剩下的报完 ``` 产品第一次用密钥上报用量后,控制台的「接入进度」会把「第一笔用量」打勾。控制台「测试体验」里记的用量不算:那是负责人在控制台里试,不是你的后端。 ## 在你自己的页面卖额度 购买页做在你自己的产品里,和其余界面保持一致。Billing 负责订单、Stripe 收银台、确认付款和发放额度。 1. 用 `GET /v1/catalog` 列出在售额度包:每条带 `sku`、`offer_version`、金额与额度。`kind` 为 `flexible` 的档允许用户在范围内自定金额,另有 `min_amount`、`max_amount`、`step`。金额都是最小币种单位的整数字符串。 2. 用户确认后,**先在自己的数据库里存一个新的 `purchase_ref`** 和所选的 SKU、`offer_version`,再建单: ```sh curl -sS -X POST "$BILLING/v1/users/USER_ID/checkouts" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"sku":"credits-10","offer_version":"v1","purchase_ref":"pr_7f3c…","return_path":"/billing/done"}' # → 201 {"order_id":"order_…","state":"awaiting_payment","checkout_url":"https://checkout.stripe.com/…"} ``` ```ts const order = await billing.checkout(userId, { sku, offer_version, purchase_ref, // 先存进你的库 return_path: "/billing/done", }); redirect(order.checkout_url); ``` 3. 把浏览器送到 `checkout_url`,用户在 Stripe 收银台付款。 **同一用户的同一 `purchase_ref` 就是同一笔购买**,所以重试很简单: - 建单失败或没收到回复,用同一个 `purchase_ref`、原 SKU 重发,得到同一个订单和收银台,不会建第二单。Stripe 暂时不可用时返回 503 `stripe_unavailable`,照此重试。 - 负责人调价或下架后,返回 HTTP 201 和 `{"state":"rejected","reason":"offer_changed"}`:这笔购买已终结,同一个 `purchase_ref` 重发也只得到这个拒绝。刷新额度包,用户重新确认后换一个新的 `purchase_ref`。 - 只有带 `order_id` 的结果才打开收银台。 **付完回到哪里**:站点(`return_origin`,如 `https://your-product.example`)由负责人在控制台「收款配置」里填一次;具体页面(`return_path`)每次建单时传。站点没配时不要传 `return_path`,否则返回 `return_origin_not_configured`。回跳地址带 `order` 和 `product` 两个参数。 **回跳不是付款凭证**。回到你的页面后,从后端读订单或余额,以服务端状态为准: ```sh curl -sS "$BILLING/v1/users/USER_ID/orders/ORDER_ID" -H "Authorization: Bearer $KEY" ``` 订单 `state` 为 `fulfilled` 表示额度已到账,`awaiting_payment` 表示还在等付款确认(支付宝、微信是异步的,几秒后再读),`exception` 不为空需要负责人处理。用户也可能付完就关掉收银台、根本不回跳,所以**每次展示余额都读一次**,不要只在回跳页刷新。 你的后端第一次用产品密钥成功建单(或开通订阅)后,控制台的「接入进度」会把「购买已接通」打勾。Billing 另有一张托管充值页,只供控制台「测试体验」使用,界面不属于你的产品,不要拿它做正式的购买入口。 谁可以购买由你的产品决定:不让某个用户买,就不替他建单。 ## 按月订阅(可选) 产品配了档位时,`catalog.plans` 按价格从低到高列出(价格为 0 的是免费档)。订阅同样在你自己的页面里做: | 用户要做什么 | HTTP | SDK | | -------------------- | -------------------------------------------------------------------------------- | ------------------------------ | | 看当前档位与本期额度 | `GET /v1/users/{user}/plan` | `plan()` | | 开通付费档 | `POST /v1/users/{user}/subscription-checkouts`,返回订阅收银台地址 | `subscriptionCheckout()` | | 升级或降级 | `POST /v1/users/{user}/plan` | `changePlan()` | | 到期取消、撤销取消 | `POST /v1/users/{user}/subscription/cancel`、`POST /v1/users/{user}/subscription/resume` | `cancelPlan()`、`resumePlan()` | | 换卡、看发票 | `POST /v1/users/{user}/billing-portal`,返回 Stripe 客户管理页 | `billingPortal()` | 档位的额度每期发放,先于额度包被消耗,期末作废。开通订阅也用 `purchase_ref` 识别,规则同建单。档位里的 `entitlements`(如并发数、密钥数)由你的产品自己执行,Billing 原样返回。各接口的状态与错误码见[接口与状态](/docs/api.md)。 ## 给用户看余额与账单 - 余额:`GET /v1/users/{user}/balance`(SDK `balance()`)。`balance` 可以为负(欠额)。 - 流水:`GET /v1/users/{user}/history`(SDK `history()`),从新到旧,每条带变动后的余额,购买条目附带订单。用 `has_more` 和 `next_cursor` 翻页;`?type=usage` 只看用量,`?grouped=true` 把同一 `group` 的用量合成一行。 - 订单:`GET /v1/users/{user}/orders`(SDK `orders()`)。 ## 验收 用负责人准备的两个测试用户,在产品的测试环境里跑一遍。每轮用新的 `event_id` 前缀,重试沿用旧的。下面 1–6 是必做项,不需要 Stripe。 1. **上报与重复**:读 `demo_integration` 的余额,上报一段用量,再用同一个 `event_id`、同样内容重发。两次返回相同,余额只减少一次,流水里只有一条用量。 2. **内容冲突**:同一个 `event_id` 换一个 `units` 上报,返回 409 `event_conflict`,余额不变。 3. **欠额与停止**:对 `demo_empty` 上报一段用量。照样记账:`balance` 为负、`allowed` 为 `false`。你的产品此时应停止服务。 4. **充值先抵扣欠额**:给 `demo_empty` 充值(或请负责人在控制台发测试额度)后,余额等于充值额度减去欠额。 5. **流水**:`history` 从新到旧列出充值、用量等,每条带变动后的余额,`metadata` 原样返回。 6. **用户隔离**:一个用户的用量不影响另一个用户的余额;用一个用户的订单 ID 在另一个用户的路径下查询,返回 404。你的后端也必须拒绝前端替换用户 ID。 配了 Stripe 时再做两项: 7. **购买**:从你自己的购买页为测试用户建单,在沙盒收银台用 [Stripe 测试卡](https://docs.stripe.com/testing) 付款。只有 Billing 核实付款后额度才到账;不能凭回跳页加额度。 8. **退款**:用一笔完全没用过的购买,请负责人在控制台退款;退款后这笔购买的额度被撤回,产品密钥本身不能退款。 验收时记下:服务的 `release_version`、capabilities 的结果、跑过哪些项、余额怎么变、哪些没验证。不要记录密钥或用户的原始数据。在控制台「接入进度」里,「后端已连通」「第一笔用量」以及(产品在售时)「购买已接通」都会打勾。 测试里不必连真服务:`@flyresearch/billing-sdk/testing` 的 `createTestBilling()` 是按同一套接口规则运行的内存服务,与真服务由同一套契约测试校验,适合写你产品里的计量、停工和购买测试。 ## 卡住时 | 看到什么 | 怎么办 | | ------------------------------ | ---------------------------------------------------------------------------------- | | capabilities 连不上 | 确认服务地址可达;地址是 `127.0.0.1` 时,后端必须在同一台机器上运行 | | HTTP 401 `unauthorized` | 密钥不属于这个服务,或已被撤销;请负责人在控制台「开发接入」核对或重新签发 | | `allowed: false` | 余额用完或有欠额:让用户充值,测试用户请负责人发测试额度(产品密钥不能自己加额度) | | 409 `event_conflict` | 同一个 `event_id` 报了不同内容;每段用量用自己的 ID,重试用原 ID 原内容 | | `stripe_not_configured` | 产品还没配 Stripe;可以先接完用量,购买等负责人配好再测 | | 503 `stripe_unavailable` | 订单已保存、收银台没建好;用同一个 `purchase_ref` 重试 | | 201 `offer_changed` | 额度包变了;刷新列表,用户重新确认后用新的 `purchase_ref` | | `return_origin_not_configured` | 传了 `return_path` 但负责人没配回跳站点;去掉 `return_path`,或请负责人配置 | ## 接下来读什么 - [接口与状态](/docs/api.md):每个接口的字段、精度、分页与重试规则。 - [错误与恢复](/docs/errors.md):每个错误码的含义与处理。 - [OpenAPI](/openapi.json):完整契约;`GET /v1` 不需要凭据,列出全部接口、各自要的凭据和全部错误码。