
第三方平台回调总说收不到?把本机 Webhook 收单页跑通,用 cpolar 固定二级子域名给对面看实时请求
说明:本文是一份「隔离环境中的可执行实验提纲」。所有命令、脚本、配置都按最小实验环境整理成可直接执行的形态,并在代码块里标注为骨架 ------骨架指的是「可以直接粘贴跑通的框架」,不是某次真实运行后导出的日志。本轮未实际安装、未实际启动、未做真实回调,因此文中不给出任何实测数值(到达时间、每秒请求数、耗时一律留白)。你照着跑完,就能在自己机器上得到真实结果。
对接第三方平台,最磨人的环节往往不是写业务代码,而是回调。
你把回调地址发过去,对面说「发了,你看下」。你回头翻日志,什么都没有。于是开始互相怀疑:对面到底发没发?发到哪个地址了?是不是你机器根本没在公网?
这类扯皮我遇到过好几次。问题的根子在于:本机跑的服务默认只有自己这台机器能访问,对接方在公网那头根本够不着。所以「收不到」要先排查的第一件事,不是代码写没写错,而是对方的消息到底有没有到达过你这台机器。
这篇不解决「你的回调处理逻辑对不对」,只解决一件事:如何把自己本机的一个只读收单页放到公网,让对接方真实发一条回调过来,你俩一起看着它到达。
先把这篇要解决的事讲清楚:
- 本机怎么起一个最小 HTTP 服务,专门用来收回调
- 收单页为什么必须是只读的,以及怎么在代码层面堵掉写操作
- 怎么在本地先自己构造一次请求,确认页面真的能记录
- 怎么用 cpolar 把收单页映射成公网地址,并且把地址固定下来
- 两条边界:不落库敏感内容、不执行回调内容
图1:本地服务不在公网,第三方回调查无此人
1 什么是 Webhook 收单页?
先把概念分清。Webhook 是「事件主动推给你」的模式。第三方平台那边发生了某件事(订单状态变了、任务跑完了、审核通过了),它就按你事先登记的地址,往你这边发一个 HTTP 请求,把这件事告诉你。这个请求就叫回调。
而收单页,是这篇里我给它起的一个具体叫法:一个专门用来「接收并展示回调请求」的页面。它只做两件事:
- 把每次到达的请求记下来(方法、时间、脱敏后的请求头、JSON 体)
- 用一个只读列表页把记下来的内容展示出来
它不处理业务,不回写数据库,也不根据回调内容去触发任何动作。它就是个「收件箱」------信进来了,摆在那里给你看。
这里要提前说清楚一个取舍:收单页和生产环境的 Webhook 网关不是一回事。
生产级网关要考虑签名校验、幂等、重试、异步队列、失败告警,那是另一套工程。这篇要的是「最快的可见性」:先确认请求能不能到、字段全不全、值对不对。链路先通,再谈健壮性。
图2:收单页只负责「收到并展示」,不做业务处理
2 环境准备:确认 Node 与 Python3
实验环境很轻,不需要数据库,不需要容器:
bash
# 两个都查一下,用哪个都行
node -v
python3 -V
收单服务和收单页我都用 Node 写,因为一个进程就能把「接收 + 渲染只读页」都覆盖掉,起停都方便。你顺手用 Python3 也可以,逻辑是一样的。
敲黑板:这篇全程在本机跑,收单服务只绑 127.0.0.1,不对局域网开放。真正让它对外可见的动作只有一个,就是后面那步 cpolar 隧道。除了这一步,不会有别的口子开出去。
3 起一个最小的 Webhook 收单服务
先写收单服务。它要接收 POST 进来的回调,把内容记住,同时提供一个只读的列表页。
新建 inbox.js,下面是骨架:
javascript
const http = require("http");
// 内存里存最近的回调记录;进程重启即清空,这是刻意的:
// 本页只做即时核对,不做持久化存储。
const records = [];
const MAX_RECORDS = 20;
// 被认为是敏感的请求头,展示时一律打码
const SENSITIVE_HEADERS = [
"authorization",
"cookie",
"x-api-key",
"x-signature",
"token",
];
function maskHeaders(headers) {
const out = {};
for (const [k, v] of Object.entries(headers)) {
out[k] = SENSITIVE_HEADERS.includes(k.toLowerCase()) ? "***REDACTED***" : v;
}
return out;
}
function render() {
const rows = records
.map((r) => {
const t = new Date(r.at).toLocaleString();
return `<tr><td>${t}</td><td>${r.method}</td><td><pre>${JSON.stringify(
r.headers,
null,
2
)}</pre></td><td><pre>${r.body}</pre></td></tr>`;
})
.join("");
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
<title>Webhook Inbox (read-only)</title>
<style>body{font-family:system-ui,sans-serif;margin:2rem}
table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:6px 10px;
vertical-align:top}pre{margin:0;white-space:pre-wrap;max-width:420px}</style>
</head><body><h1>Webhook 收单页(只读)</h1>
<p>共 ${records.length} 条记录,最多保留 ${MAX_RECORDS} 条。</p>
<table><tr><th>到达时间</th><th>方法</th><th>请求头(已脱敏)</th><th>请求体</th></tr>
${rows}</table></body></html>`;
}
const server = http.createServer((req, res) => {
// 只读页:只提供 GET
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(render());
return;
}
// 收单入口:只收 POST,收到就记账
if (req.method === "POST") {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
});
req.on("end", () => {
records.unshift({
at: Date.now(),
method: req.method,
headers: maskHeaders(req.headers),
// 只原样展示,不解析、不执行、不落库
body: raw.slice(0, 2000),
});
if (records.length > MAX_RECORDS) records.pop();
// 只回一个确认,不回传任何内部状态
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ received: true }));
});
return;
}
// 其余方法一律拒绝,保证入口没有写/改/删能力
res.writeHead(405, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "method not allowed" }));
});
// 只绑回环,不对局域网开放
server.listen(8101, "127.0.0.1", () => {
console.log("webhook inbox on http://127.0.0.1:8101");
});
这段服务里有三处是刻意设计的,值得单独点出来:
- 请求头脱敏 :
authorization、cookie、x-api-key、x-signature、token这些头在展示前会被替换成***REDACTED***。回调里经常带着签名和令牌,这些东西不该出现在一个要给别人看的页面上。 - 请求体只展示、不解析 :代码里对
raw只做截断和展示,没有JSON.parse,更没有根据内容去调用任何东西。这是「不执行回调内容」在代码层面的落实。 - 只绑
127.0.0.1:本机之外谁都连不上,唯一的对外入口留给 cpolar。
启动:
bash
node inbox.js
看到 webhook inbox on http://127.0.0.1:8101 就说明起来了。
图3:收单服务 + 只读列表页结构
4 本地先自己构造一次回调
别急着上 cpolar。先用本机自己发一条,确认收单页真的能记录------这一步不是为了测试而测试,是为了把「服务正常」和「隧道正常」这两个变量分开。隧道不通的时候你至少知道跟服务无关。
另开一个终端:
bash
curl -s -X POST http://127.0.0.1:8101 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer should-not-be-visible" \
-d '{"event":"order.paid","order_id":"A1024","amount":99}'
预期返回:
json
{"received":true}
然后浏览器打开 http://127.0.0.1:8101,你应该看到列表里多了一条记录:到达时间是刚才那一刻,方法是 POST,请求头那一列里 authorization 显示的是 ***REDACTED***,请求体是那段 JSON。
如果列表是空的,先按这个顺序查:
node inbox.js的那个终端有没有报错------服务没起来,POST 自然收不到curl的返回是不是405------那说明你发成了 GET 或者别的写法,收单入口只认 POST- 端口对不对------服务绑的是
8101,curl也得打8101
这一步跑通,你手里就有了一个「本地确定能收」的基线。后面经 cpolar 收到的回调如果没出现,问题就锁定在隧道这一段,不用再回头怀疑服务。
图4:本地 POST 一次后,只读页出现带脱敏头的记录
5 用 cpolar 把收单页暴露成公网地址
现在本机能收了,但对接方在外网,够不着 127.0.0.1:8101。这一步用 cpolar 把它映射成一个公网地址。
cpolar 官方文档:https://www.cpolar.com/docs
5.1 安装并登录 cpolar
macOS 上的安装与登录:
bash
# 安装(macOS 走 Homebrew)
brew tap probezy/core && brew install cpolar
# 安装并启动后台服务
sudo cpolar service install
sudo cpolar service start
也可以命令行手动写入 authtoken。token 在后台的「验证」页面取,地址是 https://dashboard.cpolar.com/auth :
bash
cpolar authtoken <你的 authtoken>
装完先确认本地面板能打开:浏览器访问 http://127.0.0.1:9200。能打开 9200 面板,说明 cpolar 本体是正常的,后面隧道不通就先回到这里查。
Linux / 树莓派可以用官方一键脚本:
bash
curl -L https://www.cpolar.com/static/downloads/install-release-cpolar.sh | sudo bash
5.2 先开一条临时 HTTP 隧道验证链路
收单页是 HTTP 服务,用 HTTP 隧道:
bash
cpolar http 8101
命令跑起来后,终端会打印出公网地址,形如 https://xxxxxxxx.r3.cpolar.cn。
注意这里的分工:隧道指向的是 8101(收单页),不是别的端口。 这个服务本身就是为「被外部访问」设计的只读收单入口,所以暴露它是符合预期的;但如果你本机还跑着别的服务,千万别图省事一起穿透出去。
拿到地址后,先自己用这个公网地址再发一条 POST,确认链路通了:
bash
curl -s -X POST https://xxxxxxxx.r3.cpolar.cn \
-H "Content-Type: application/json" \
-d '{"event":"link.check","from":"self"}'
刷一下 http://127.0.0.1:8101,如果多出一条 link.check 的记录,说明公网 → 隧道 → 本机服务这条链路已经打通。这一步一定要自己先跑,再发给对接方------不然对面发过来没反应,你连是自己这侧不通还是对方没发都分不清。
需要提醒的一点:免费套餐生成的公网地址是随机地址,24 小时内会变化。给自己验一次链路完全够用,但下面这种场景就不行了。
图5:cpolar HTTP 隧道把本地 8101 映射成公网地址
5.3 为什么对接方需要的是固定地址
想一下对接方的实际操作:他们要在自己的后台把一个「回调地址」填进去,然后保存。这个地址一旦保存,平台后续每次事件都会往它发。
如果地址是随机且 24 小时就变的,那意味着:今天填的地址,明天对面平台发的消息就全打空了,两边又得重新对一次地址。更麻烦的是,如果你和对接方约好了「明天上午联调」,随机地址根本撑不到那时候。
所以这里的真实诉求是:给对面一个今天填一次、明天还能用的地址。
按 cpolar 当前规则,固定二级子域名需要基础套餐或以上。这一点要讲清楚,不然容易踩坑:免费套餐给的是随机临时地址,地址不会固定;固定二级子域名不是免费套餐自带的能力,需要升级到基础套餐及以上才能配置。
5.4 配置固定二级子域名
在后台保留一个二级子域名,然后把它写进隧道配置即可。Linux 上的配置路径是 /usr/local/etc/cpolar/cpolar.yml,下面是骨架:
yaml
authtoken: xxxxxxxxxxxx
tunnels:
webhook-inbox:
addr: 8101
proto: http
region: cn_vip
subdomain: your-inbox
说明几点:
subdomain就是你要固定的那个二级子域名,配置成功后公网地址会稳定在这个名字下,不随会话变化- YAML 缩进敏感,别用 TAB
- 改完重启服务:
bash
sudo systemctl restart cpolar
重启后到 http://127.0.0.1:9200 的「状态 → 在线隧道列表」里确认这条隧道在线、地址已经是固定的那个。
如果地址没变成你设的子域名,先查两件事:这个子域名是不是已经被占用 ,以及当前账号套餐是否满足固定二级子域名的条件(基础套餐或以上)。这两条是配不上的最常见原因。
图6:固定二级子域名配置后的稳定回调地址
6 让对接方发一条真实回调
地址固定了,就可以把它交给对接方,让对方在平台的回调设置里填上这个地址。
真正联调时,让对面「发一条测试事件」,常见有两种情况,对应两种不同观察:
情况一:你的页面立刻多出一条记录。 那就成功了。对着这条记录,和对接方一起核对:请求体里的事件类型对不对、单号对不对、你需要的字段是不是都在。字段缺了、名字不一致,在这一步就能当场指出来,不用来回传截图。
情况二:页面半天没有新记录。 这时候别急着说「你没发」。按下面顺序排查:
- 先确认隧道还在线 :看
http://127.0.0.1:9200的在线隧道列表,隧道是不是 online。隧道掉了,对面发什么都不会到。 - 确认对面填的地址和你的固定地址一字不差 :多了空格、少了斜杠、
http写成了https,请求都会落到别处。固定子域名的意义就在这里------你俩对照的永远是同一个地址。 - 确认对面平台真的发出了请求:很多平台在回调设置里有个「发送测试」按钮,让对面点一下,看他那边有没有报错。
你可以有意让对接方发一条「字段不全」的事件,比如故意缺一个 order_id。这样你俩能提前看见:字段缺失时,页面上显示的请求体长什么样。验证字段完整性,最好的方式就是看一条真的缺字段的请求,而不是靠文档想象。
图7:一条真实的第三方回调出现在只读收单页
7 收单页的边界:只读、不落库敏感、不执行
这一节是这篇最需要守住的线。收单页要开给外部看,就必须把边界定死。
7.1 对外只读,没有写/改/删
代码层面已经处理了:收单服务只实现 GET / 和 POST(收单入口),其余方法一律 405。页面上没有任何「重放 / 改写 / 删除」按钮,因为它压根没有对应的接口。你可以自己构造一次越界写请求验证一下:
bash
# 尝试用 PUT 改点东西,预期被拒绝
curl -s -o /dev/null -w "%{http_code}\n" -X PUT http://127.0.0.1:8101
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE http://127.0.0.1:8101
两条都应该返回 405。返回别的码,说明有方法没被兜住,得回去补 405 分支。
7.2 不落库敏感内容
记录只存在内存里(records 数组),进程重启即清空。这样做有两个好处:一是没有任何凭据会被写到磁盘上,二是这个实验天然是一次性的,不会留下需要清理的数据库。
展示层面,敏感请求头统一打码成 ***REDACTED***。给对接方看的时候,他需要看到的是「有没有这个头、值对不对得上格式」,而不是签名的原文------签名原文根本不该经由一个公网页面流转。
7.3 不执行回调内容
收单页对请求体只做「截断 + 展示」,不解析、不据此触发动作。这是故意的:回调是外部发来的内容,把它当数据看,别当指令执行。
一句话概括这三条:收单页是展示器,不是执行器。
图8:收单页三条边界------只读、不落库敏感、不执行
8 收尾清理
联调完,按顺序关掉:
- 收单服务:在
node inbox.js的终端按 Ctrl+C - cpolar 隧道:临时隧道在
cpolar http 8101的终端按 Ctrl+C;如果是配置文件方式常驻的,改回配置后执行sudo systemctl restart cpolar
确认 http://127.0.0.1:9200 的在线隧道列表里已经没有这条隧道。清理完可以再 curl 一次公网地址,确认已经连不上------这就说明对外的入口确实已经关掉了。
如果你只是临时联调,用临时隧道(cpolar http 8101)就够,用完即关,不用长期挂着。只有像「对接方这两天要反复测」这种需要稳定地址的场景,才值得上固定二级子域名。
9 总结
到这里,整条链路是:本机起一个只读的 Webhook 收单页并绑在 127.0.0.1:8101,先在本地自己发一条确认收单正常,再用 cpolar 的 HTTP 隧道把它映射成公网地址------需要稳定地址时用固定二级子域名------最后让对接方发一条真实回调,两边一起看请求到达和字段完整性。
把关键步骤收一下:
- 收单页只做两件事:接收回调、只读展示;不处理业务,是展示器不是执行器
- 先本地自测、再经公网自测,把「服务正常」和「隧道正常」分开验证,排错时才能定位
- 固定二级子域名属于基础套餐或以上的能力,免费套餐给的是 24 小时内会变的随机地址,不适合让对接方「填一次长期用」
- 边界三条:对外只读(越界方法返回 405)、不落库敏感内容(内存存储 + 敏感头打码)、不执行回调内容(只展示不解析)
再强调一下这篇的边界:本文是一份隔离环境中的可执行实验提纲,本轮未实际安装、未实际启动、未做任何真实回调,文中没有任何实测数值。收单页保留多少条记录、敏感头清单要不要按你的实际情况增减、固定子域名取什么名字,都要你按自己的对接场景去定。
回到最开始那句「发了,你看下」------下次别再对着日志互相猜了。把收单页开成一个固定地址交给对面,他发一条,你们一起看着它落地,缺哪个字段当场就能说清楚。要比拼的从来不是谁的日志翻得快,而是让请求这件事变得可见。