47.7 开放平台与 API 生态 (OAuth 授权 / Webhook / API 计费)
一句话
开放平台的三件事——授权(别人怎么获得访问权)、推送(怎么通知别人)、计量(怎么收钱)——每一件都必须默认“调用方不可信、网络不可靠、账单必须可解释”。
三件套
| 能力 | 解决什么 | 关键设计 |
|---|---|---|
| OAuth 授权 | 第三方在最小范围内代表用户访问 | 授权码 + PKCE、scope 最小化、令牌短期 + 可撤销(49.2) |
| Webhook | 平台主动通知(订单、支付、审批) | 签名验证 + 重试 + 幂等 + 死信 |
| API 计费 | 按量/套餐收费 | 计量口径唯一、账单可对、配额与限流分开 |
代码:账单与重试时间表
package main
import "fmt"
const (
freeCalls = 1_000_000
tier2Cap = 10_000_000
tier2Rate = 0.20 / 1000 // 每千次 0.20
tier3Rate = 0.10 / 1000
)
func bill(calls int) float64 {
if calls <= freeCalls {
return 0
}
cost := 0.0
if calls <= tier2Cap {
return float64(calls-freeCalls) * tier2Rate
}
cost += float64(tier2Cap-freeCalls) * tier2Rate
cost += float64(calls-tier2Cap) * tier3Rate
return cost
}
func main() {
fmt.Println("=== 阶梯计费 ===")
fmt.Printf("%-12s %-12s %s\n", "调用量", "账单", "单价(每千次)")
for _, c := range []int{500_000, 2_000_000, 12_000_000, 50_000_000} {
b := bill(c)
unit := 0.0
if c > 0 {
unit = b / float64(c) * 1000
}
fmt.Printf("%-12d %-12.2f %.4f\n", c, b, unit)
}
fmt.Println("\n=== Webhook 重试时间表(指数退避 + 上限)===")
delays := []int{1, 5, 30, 120, 600} // 秒
cum := 0
for i, d := range delays {
cum += d
fmt.Printf("第 %d 次重试:+%-4ds(累计 %4ds ≈ %.1f 分钟)\n", i+1, d, cum, float64(cum)/60)
}
fmt.Println("超过最后一次仍失败 → 进死信 + 告警,不能无限重试")
fmt.Println("\n=== 配额与限流必须分开 ===")
fmt.Println("配额:本月还能调多少次(计费口径,超了要提前通知)")
fmt.Println("限流:每秒允许多少次(保护口径,超了返回 429 + Retry-After)")
}
本机实测(macOS,Apple M1,Go 1.27):
=== 阶梯计费 ===
调用量 账单 单价(每千次)
500000 0.00 0.0000
2000000 200.00 0.1000
12000000 2000.00 0.1667
50000000 5800.00 0.1160
=== Webhook 重试时间表(指数退避 + 上限)===
第 1 次重试:+1 s(累计 1s ≈ 0.0 分钟)
第 2 次重试:+5 s(累计 6s ≈ 0.1 分钟)
第 3 次重试:+30 s(累计 36s ≈ 0.6 分钟)
第 4 次重试:+120 s(累计 156s ≈ 2.6 分钟)
第 5 次重试:+600 s(累计 756s ≈ 12.6 分钟)
超过最后一次仍失败 → 进死信 + 告警,不能无限重试
=== 配额与限流必须分开 ===
配额:本月还能调多少次(计费口径,超了要提前通知)
限流:每秒允许多少次(保护口径,超了返回 429 + Retry-After)
读数:
| 调用量 | 账单 | 单价/千次 | 说明 |
|---|---|---|---|
| 50 万 | 0 | 0 | 免费额度内 |
| 200 万 | 200 | 0.10 | 全部按第二档(超出免费的部分) |
| 1200 万 | 2,000 | 0.167 | 前 900 万在第二档、后 200 万在第三档 |
| 5000 万 | 5,800 | 0.116 | 规模越大单价越低 |
🔑 计费的第一原则是可解释:客户要能自己算出账单(阶梯、口径、计费时间窗),否则每个月都要打客服。第二原则是计量唯一:以网关日志为准,不能有第二个统计口径。
Webhook 的四个必备
| 必备 | 做法 | 不做的后果 |
|---|---|---|
| 签名 | HMAC(secret, body + timestamp) | 任何人可伪造推送 |
| 重试 | 指数退避 + 上限 + 死信 | 网络抖动就丢事件 |
| 幂等 | 事件 ID 去重 | 重试导致重复处理 |
| 可见性 | 推送记录 + 手动重放 | 客户说没收到,无法举证 |
反模式
| 反模式 | 后果 |
|---|---|
| 用 access_token 当 API key 长期有效 | 泄露即长期接管 |
| Webhook 不签名 | 伪造订单事件 |
| 重试无上限 | 把客户系统打挂 |
| 配额与限流混为一谈 | 客户被 429 却不知道怎么超的 |
| 计费口径与限流口径不一致 | 账单与“被限流”的体验矛盾,投诉不断 |
| 开放平台没有沙箱 | 客户拿生产数据试错 |
自检
Q1 为什么开放平台的授权要用“授权码 + PKCE”,而不是直接把 token 发给第三方?
▸ 答案:因为第三方(尤其是前端/移动端)无法安全保存 client_secret,直接发长期 token 一旦泄露就是长期接管。授权码 + PKCE 让令牌只在后端换取、且绑定一次性 code_verifier,即使 code 被截获也无法兑换;令牌短期 + 可撤销,把泄露的影响面压到最小。
Q2 Webhook 为什么必须“签名 + 幂等”同时做?
▸ 答案:签名解决“是不是平台发的”,幂等解决“重试会不会重复执行”。只签名:攻击者不能伪造,但网络重试仍会让同一事件到达多次;只幂等:重复无害,但伪造事件也能通过。两者缺一不可,另外还要有“重放窗口”(timestamp 过期拒绝)防重放攻击。
Q3 计费为什么必须以网关日志为唯一口径?
▸ 答案:因为计费要能对账与举证:客户质疑账单时,要能拿出“哪个 key、哪个时间、哪次调用”;应用内部统计容易被重试、缓存、异步链路干扰,且各服务实现不一致。网关是所有流量的必经点,日志天然全量、可追溯。代价是日志量大,需要按 key 聚合与冷热分层(46.6)。