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)。

关联