第三方平台回调总说收不到?把本机 Webhook 收单页跑通,用 cpolar 固定二级子域名给对面看实时请求

第三方平台回调总说收不到?把本机 Webhook 收单页跑通,用 cpolar 固定二级子域名给对面看实时请求

说明:本文是一份「隔离环境中的可执行实验提纲」。所有命令、脚本、配置都按最小实验环境整理成可直接执行的形态,并在代码块里标注为骨架 ------骨架指的是「可以直接粘贴跑通的框架」,不是某次真实运行后导出的日志。本轮未实际安装、未实际启动、未做真实回调,因此文中不给出任何实测数值(到达时间、每秒请求数、耗时一律留白)。你照着跑完,就能在自己机器上得到真实结果。

对接第三方平台,最磨人的环节往往不是写业务代码,而是回调。

你把回调地址发过去,对面说「发了,你看下」。你回头翻日志,什么都没有。于是开始互相怀疑:对面到底发没发?发到哪个地址了?是不是你机器根本没在公网?

这类扯皮我遇到过好几次。问题的根子在于:本机跑的服务默认只有自己这台机器能访问,对接方在公网那头根本够不着。所以「收不到」要先排查的第一件事,不是代码写没写错,而是对方的消息到底有没有到达过你这台机器。

这篇不解决「你的回调处理逻辑对不对」,只解决一件事:如何把自己本机的一个只读收单页放到公网,让对接方真实发一条回调过来,你俩一起看着它到达。

先把这篇要解决的事讲清楚:

  • 本机怎么起一个最小 HTTP 服务,专门用来收回调
  • 收单页为什么必须是只读的,以及怎么在代码层面堵掉写操作
  • 怎么在本地先自己构造一次请求,确认页面真的能记录
  • 怎么用 cpolar 把收单页映射成公网地址,并且把地址固定下来
  • 两条边界:不落库敏感内容、不执行回调内容

图1:本地服务不在公网,第三方回调查无此人

1 什么是 Webhook 收单页?

先把概念分清。Webhook 是「事件主动推给你」的模式。第三方平台那边发生了某件事(订单状态变了、任务跑完了、审核通过了),它就按你事先登记的地址,往你这边发一个 HTTP 请求,把这件事告诉你。这个请求就叫回调。

而收单页,是这篇里我给它起的一个具体叫法:一个专门用来「接收并展示回调请求」的页面。它只做两件事:

  1. 把每次到达的请求记下来(方法、时间、脱敏后的请求头、JSON 体)
  2. 用一个只读列表页把记下来的内容展示出来

它不处理业务,不回写数据库,也不根据回调内容去触发任何动作。它就是个「收件箱」------信进来了,摆在那里给你看。

这里要提前说清楚一个取舍:收单页和生产环境的 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。

如果列表是空的,先按这个顺序查:

  1. node inbox.js 的那个终端有没有报错------服务没起来,POST 自然收不到
  2. curl 的返回是不是 405------那说明你发成了 GET 或者别的写法,收单入口只认 POST
  3. 端口对不对------服务绑的是 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 让对接方发一条真实回调

地址固定了,就可以把它交给对接方,让对方在平台的回调设置里填上这个地址。

真正联调时,让对面「发一条测试事件」,常见有两种情况,对应两种不同观察:

情况一:你的页面立刻多出一条记录。 那就成功了。对着这条记录,和对接方一起核对:请求体里的事件类型对不对、单号对不对、你需要的字段是不是都在。字段缺了、名字不一致,在这一步就能当场指出来,不用来回传截图。

情况二:页面半天没有新记录。 这时候别急着说「你没发」。按下面顺序排查:

  1. 先确认隧道还在线 :看 http://127.0.0.1:9200 的在线隧道列表,隧道是不是 online。隧道掉了,对面发什么都不会到。
  2. 确认对面填的地址和你的固定地址一字不差 :多了空格、少了斜杠、http 写成了 https,请求都会落到别处。固定子域名的意义就在这里------你俩对照的永远是同一个地址。
  3. 确认对面平台真的发出了请求:很多平台在回调设置里有个「发送测试」按钮,让对面点一下,看他那边有没有报错。

你可以有意让对接方发一条「字段不全」的事件,比如故意缺一个 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 收尾清理

联调完,按顺序关掉:

  1. 收单服务:在 node inbox.js 的终端按 Ctrl+C
  2. 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)、不落库敏感内容(内存存储 + 敏感头打码)、不执行回调内容(只展示不解析)

再强调一下这篇的边界:本文是一份隔离环境中的可执行实验提纲,本轮未实际安装、未实际启动、未做任何真实回调,文中没有任何实测数值。收单页保留多少条记录、敏感头清单要不要按你的实际情况增减、固定子域名取什么名字,都要你按自己的对接场景去定。

回到最开始那句「发了,你看下」------下次别再对着日志互相猜了。把收单页开成一个固定地址交给对面,他发一条,你们一起看着它落地,缺哪个字段当场就能说清楚。要比拼的从来不是谁的日志翻得快,而是让请求这件事变得可见。

相关推荐
Xxtaoaooo9 小时前
NocoDB在线表格数据库:fnOS部署、MySQL可视化与远程协作实操
数据库·mysql·docker·cpolar·nocodb
damoluomu9 小时前
Node.js CMS 的官网和仓库,22 个一次收齐(五)
node.js
承渊政道9 小时前
CentOS 7单机部署Elasticsearch 7.17.29:从启动报错、CRUD验证到固定公网地址的完整过程
linux·elasticsearch·centos·内网穿透·cpolar·单机部署
Flynt20 小时前
Deno 官宣一年后停更:我把它的招牌特性在 Node 26 和 Bun 里挨个找了一遍
node.js·deno·bun
孟陬1 天前
Node.js 中 `\b` 的作用
node.js·bun
小小荧1 天前
🚀 Node.js 之父离开 Deno,带着整个团队加入了 Cloudflare
前端·node.js
damoluomu1 天前
Directus 换掉 GPL 之后,Node.js CMS 到底怎么选?(四)
node.js
前端一小卒1 天前
搜索 API 额度用完返回空数组,Agent 就开始一本正经地胡说
前端·javascript·node.js