小程序与 H5 调用第三方 API:密钥不上前端的中转方案
小程序和 H5 都不应该直连第三方 API。正确做法是把请求收敛到自有后端或云函数中转,密钥只留在服务端,域名白名单、超时重试、限流和监控都在中转层一次做完,前端只接收业务字段。
- 密钥放前端等于公开,小程序包可解、H5 源码明文
- 中转层统一设定连接 1 秒、总超时 3 秒、重试 2 次
- xx 不重试,只对网络错误、5xx 和 429 做指数退避
- 限流用 QPS 加并发连接双阈值,单靠带宽阈值挡不住刷配额
结论:小程序和 H5 都不该直连第三方 API。请求走自有后端或云函数中转,密钥只留服务端,前端只收业务字段,白名单、超时、重试、限流在中转层一次做完。
用大白话说:小程序像一个只能走指定门禁的房间,门外的事它自己办不了;H5 则像一个在别人地盘上说话的人,对方不点头你就拿不到数据。第三方 API 就是门外那家店,你得派自己的服务员去跑腿,而不是把店门钥匙塞给房间里的每个人。
为什么小程序不能直接调用第三方 API?#
三个硬约束,任何一个都能让直连方案在上线当天挂掉。
约束一:合法域名(request 合法域名)。微信要求所有 wx.request 走 HTTPS、域名已备案、TLS 1.2 及以上,不支持 IP 地址,也不支持 443 以外的端口。开发者工具里那个“不校验合法域名”的勾选项只在工具里有效,真机调试和线上都不会放行。我见过最常见的翻车场景是:接口域名是别人家的,你根本没权限把它加进自己的小程序后台——因为域名归属校验要求你把校验文件放到对方服务器的根目录。
约束二:跨域预检(CORS Preflight)。H5 侧不受小程序那套限制,但要面对浏览器的同源策略。只要你在请求里带了自定义头(比如 apikey),浏览器就会先发一个 OPTIONS 预检。第三方接口几乎不会为你的域名返回 Access-Control-Allow-Origin,预检直接失败,控制台报一条红字,response 为空。
约束三:密钥等于公开。小程序的 .wxapkg 包可以解包,H5 的 JS 是明文传输。任何写在前端的 apikey,等于写在公网上。
对比表:直连与后端中转的差异
| 项目 | 前端直连 | 后端中转 |
|---|---|---|
| 密钥存放 | 前端包内,可被解包提取 | 服务端环境变量或配置中心 |
| 域名白名单 | 需要第三方域名归属权 | 只需自己的域名 |
| CORS 预检 | 大概率被拦 | 不存在,服务端之间无同源限制 |
| 限流与配额保护 | 无法做,被刷只能干看着 | 令牌桶加并发连接双阈值 |
| 可观测性 | 只有前端埋点,看不到上游状态码 | 可记录上游 5xx、P95、缓存命中率 |
| 重复请求 | 每次穿透到上游,烧配额 | Redis 缓存,命中即返回 |
有人会问:那用云函数(Cloud Function)算不算中转?算。云函数跑在服务端,密钥放在环境变量里,出口 IP 可控,只是要额外注意冷启动带来的首包延迟,通常比常驻服务多 200~800ms。
如何设计一条稳定的中转链路?#
链路只有三段:小程序 / H5 → 自有 API(Nginx 1.24 + PHP-FPM 8.2 或 Node 20)→ 上游 API.PLUS。真正决定可用性的是中间那段的时间预算和失败处理。
参数取值(我线上用的这一套)
- 连接超时 1 秒:TCP 加 TLS 握手,超过 1 秒说明网络路径有问题,继续等没有意义。
- 总超时 3 秒:用户在小程序里点一下,3 秒没反馈就会怀疑卡死。
- 重试 2 次:指数退避(Exponential Backoff)100ms、300ms,叠加 0~50ms 抖动,避免同一批并发请求同时重试形成二次尖峰。
- 熔断(Circuit Breaker):连续 20 次失败后打开 30 秒,期间直接返回降级结果。
- 缓存:IP 归属地类结果 TTL 600 秒;随机类内容(比如壁纸)TTL 60 秒或不缓存。
可执行命令:上线前先用 curl 摸清上游延迟分布
# 运行环境:macOS / Linux 终端,curl 8.x
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
curl -sS -X GET \
--connect-timeout 1 --max-time 3 \
-H 'Accept: application/json' \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8' \
-w '\nHTTP=%{http_code} dns=%{time_namelookup}s connect=%{time_connect}s total=%{time_total}s\n'
参数拆解:--connect-timeout 1 只约束握手阶段,DNS 解析慢不会被它拦住;--max-time 3 是整条请求的总预算,DNS、连接、等待、下载都算在内;-sS 表示静默但保留错误输出;-w 用变量把分阶段耗时打出来,一眼能看出慢在 DNS 还是慢在上游。YOUR_KEY 换成你在服务端配置里读到的密钥,命令行里带密钥只适合本地验证,别写进 shell 历史。
返回片段(结构示意,字段名以文档为准)
{"code":200,"msg":"ok","data":{"ip":"8.8.8.8","location":"..."}}
HTTP=200 dns=0.012s connect=0.043s total=0.187s
这里的关键信息是 total=0.187s:上游在同一区域内通常 200ms 内返回。如果你的测得值是 1.2s 以上,先排查 DNS 和出口线路,别急着加超时阈值。接口字段说明见 GET 示例接口。
可执行脚本:PHP 8.2 中转函数
<?php
// 运行环境:PHP 8.2 + ext-curl
// 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
declare(strict_types=1);
function upstream_get(string $url, int $retries = 2): array
{
$lastError = 'unknown';
for ($i = 0; $i <= $retries; $i++) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 1000, // 握手 1s
CURLOPT_TIMEOUT_MS => 3000, // 总预算 3s
CURLOPT_NOSIGNAL => true, // 毫秒级超时必需,不加会被信号打断
CURLOPT_FOLLOWLOCATION => false, // 不跟随跳转,避免被带到未知主机
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'apiplus-relay/1.0',
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
$err = curl_error($ch);
curl_close($ch);
if ($errno !== 0) {
$lastError = "curl({$errno}): {$err}";
} elseif ($code === 429 || $code >= 500) {
$lastError = "http {$code}"; // 限流与服务端错误,可重试
} elseif ($code !== 200) {
throw new RuntimeException("upstream fatal http {$code}"); // 4xx 不重试
} else {
return json_decode((string) $body, true, 8, JSON_THROW_ON_ERROR);
}
// 100ms、300ms 退避 + 0~50ms 抖动
usleep((100 * (3 ** $i) + random_int(0, 50)) * 1000);
}
throw new RuntimeException("upstream failed: {$lastError}");
}
三个细节值得单独说。CURLOPT_NOSIGNAL 是 PHP 的老坑,不加它,TIMEOUT_MS 在部分场景下会退化成秒级精度;CURLOPT_FOLLOWLOCATION => false 是防御性的,跳转目标不可控,中转层不该帮上游做这件事;4xx 直接抛异常而不是重试,因为参数错误重试三次只会把错误放大三倍,还会白白消耗配额。
可执行脚本:Python 3.12 侧的等价实现
# 运行环境:Python 3.12 + requests 2.32 + urllib3 2.x
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retry = Retry(
total=2,
connect=1,
read=2,
backoff_factor=0.3, # 0.3s、0.6s 递增退避
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset(["GET"]), # 只重试幂等的 GET
raise_on_status=False,
)
session.mount("https://", HTTPAdapter(max_retries=retry, pool_maxsize=32))
session.headers.update({"Accept": "application/json"})
try:
r = session.get(
"https://www.api.plus/api/dongman",
params={"type": "json"},
timeout=(1.0, 2.0), # (连接超时, 读取超时) 秒
)
r.raise_for_status()
payload = r.json()
except requests.Timeout:
payload = {"code": 504, "msg": "upstream timeout", "data": None} # 降级占位
except requests.RequestException as exc:
payload = {"code": 502, "msg": f"upstream error: {type(exc).__name__}", "data": None}
timeout=(1.0, 2.0) 是元组,不是单个数字,写成单个数字只约束连接阶段,读取阶段会一直挂着。allowed_methods 必须显式限定为 GET,否则 POST 在连接中断时可能被重试,产生重复写入。pool_maxsize=32 和 Nginx 的 worker_connections 要对得上,不然高并发下连接池成了新瓶颈。这个接口的用途是拿一张随机图,返回体里有图片地址,缓存一秒的收益都不大,所以我用 60 秒 TTL 兜底;接口说明见 随机动漫壁纸接口。
密钥和限流怎么配合才挡得住刷量?#
密钥管理只做一件事:分级。前端一个密钥都不放,这是底线。
规范模板:密钥分级
- 服务端主密钥:只存在配置中心或环境变量,用于调用上游。
- 业务线子密钥:按业务拆成多把,方便单独吊销与配额核算。
- 前端凭据:只放短期令牌(Token),由后端在用户登录后签发,有效期 30 分钟,不携带上游
apikey的任何片段。
令牌要做防重放。签名里带时间戳和随机数(Nonce),时间窗 ±300 秒,Nonce 写进 Redis 7.2 并设 300 秒过期,重复即拒。这套组合能挡住重放攻击,成本只是每次请求多一次 Redis 读写。
限流分两层,缺一层都不完整。第一层在 Nginx,按客户端 IP 做令牌桶(Token Bucket);第二层在应用侧,按令牌身份做配额。
可执行配置:Nginx 1.24 限流片段
# 运行环境:Nginx 1.24,http 块
limit_req_zone $binary_remote_addr zone=relay:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=relayconn:10m;
# server / location 块
limit_req zone=relay burst=40 nodelay;
limit_conn relayconn 10;
limit_req_status 429;
limit_conn_status 429;
rate=20r/s 对应一个用户连点场景:中转层总超时 3 秒,用户 3 秒内最多点五六次,20r/s 已经给了很大余量。burst=40 nodelay 允许 40 个突发请求立即通过,超出直接 429。limit_conn 10 是并发连接上限,这一条比 QPS 更关键——慢连接攻击的特征就是 QPS 不高但连接数居高不下。Cloudflare 的季度报告里提到,2026 年第三季度其网络平均每小时处置 8,226 次网络层攻击,持续不足 10 分钟、带宽低于 1Gbps 的短促攻击环比增长 208%(Cloudflare DDoS 威胁报告 2026 Q3)。这类攻击打不动带宽,但足以把你的上游配额刷干净。所以阈值要按连接数和身份维度设,而不是只看流量。
上游返回 429 时要区分来源:是你自己超额,还是出口 IP 被共享调用方拖累。前者扩容,后者换独立出口或联系服务方。日志里至少记三段信息:上游状态码、本次耗时、当前并发数。
监控与告警清单#
监控表:指标、阈值与动作
| 指标 | 采集方式 | 建议阈值 | 告警动作 |
|---|---|---|---|
| 上游 5xx 率 | 中转层日志按分钟聚合 | > 2% 持续 5 分钟 | 企业微信值班群,附最近 10 条错误样本 |
| P95 总耗时 | curl 耗时或 OpenTelemetry | > 1500ms 持续 10 分钟 | 检查 DNS 与出口线路 |
| 超时率 | 超时计数 / 总请求 | > 3% | 自动触发熔断,返回缓存结果 |
| 429 次数 | 中转层按身份统计 | > 50 次 / 5 分钟 | 定位到具体 token,临时封禁 |
| 缓存命中率(Cache Hit Ratio) | Redis INFO stats | < 70% | 检查 TTL 与 key 设计 |
| 密钥剩余配额 | 定时任务探活 | < 20% | 提前通知扩容 |
阈值不是拍脑袋定的。P95 的 1500ms 来自上游实测 200ms 加两倍余量;超时率 3% 是经验值,超过这个数说明不是偶发抖动,而是链路真的有环节坏了。
风险清单#
风险表:影响与规避
| 风险 | 影响 | 规避方式 |
|---|---|---|
| 密钥写进前端包 | 被解包提取,额度被刷空 | 密钥只放服务端,前端用短期令牌 |
| 未设总超时 | PHP-FPM 进程被挂住,worker 耗尽 | 连接 1s、总 3s,加熔断 |
| 对 4xx 做重试 | 错误放大三倍,配额三倍消耗 | 只对网络错误、5xx、429 重试 |
| 无缓存 | 重复请求全部穿透,配额翻倍 | Redis 按业务设 60~600 秒 TTL |
| 上游不可用无降级 | 前端白屏或一直转圈 | 返回上次缓存或明确的错误码 |
| PHP 运行时未打补丁 | 已知漏洞被利用,机器被接管 | 跟随安全公告升级,参考 Krebs 关于 PHP 漏洞的报道 |
说到上游不可用,很多人把它当小概率事件。Cloudflare Radar 在 2026 年第三季度记录到 49 起互联网中断事件,其中电力故障 14 起、政府主导的关停 19 起(Cloudflare Q3 2026 断网汇总)。上游依赖一定有失效的时刻,降级路径不是可选项。中转层至少要保证一件事:即使上游全挂,前端拿到的也是一个结构合法的错误对象,而不是超时无响应。
总结#
小程序和 H5 调用第三方 API,工程上的分水岭只有一个:请求是不是从你自己的服务端发出去的。是,域名白名单、CORS、密钥、限流、缓存、监控全都有解;不是,前面几项全部无解,只剩祈祷。中转层本身不复杂,一百多行代码足够,真正花时间的是那几个阈值——1 秒连接、3 秒总超时、2 次重试、20r/s 加 10 并发。这些数不是标准答案,但都可以被观测数据推翻再调整。工程实践里更常被忽略的是错误码约定与文档沉淀,这块可以顺着 API 文章中心 里已有的调试类文章一起看。
适用范围
- 面向 C 端的小程序、公众号 H5、移动端 WebView 页面,需要调用收费或限额的第三方接口。
- 上游接口域名不归你所有,或者对方不提供 CORS 头。
- 需要按用户维度做配额、审计或计费的系统。
不适用范围
- 纯内网或本地脚本调用,没有前端,也没有跨域与域名白名单问题,直连更简单。
- 上游接口本身由你提供,且已经内置了鉴权、限流与缓存,再加一层中转只是增加一跳延迟。
- 对首包延迟极端敏感的场景(比如强交互的游戏内实时请求),多一次网络往返可能就是不可接受的成本,此时应优先考虑直连加域名报备,而不是中转。
本站实测数据(近 24 小时)#
以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 15:12,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。
- 接口调用总数:139 次;成功返回 137 次,鉴权/参数校验拦截 2 次
- 最活跃接口:随机动漫壁纸(93 次)、Menhera 动漫图片(41 次)、IP 地址查询(4 次)
- 近 7 天累计调用:895 次