12 KiB
抖音码速达 · 公网联调(ngrok)
目标:让真实码速达平台把消息推到你的机器,同时买家能打开你的
/redeem。 离线模拟(网关桩 / 推送模拟器)已按要求删除 —— 只走真实平台。 实现细节见 07-最小接入实现说明.md。
0. 为什么必须公网
抖音码速达是双向的:
| 方向 | 谁发起 | localhost |
|---|---|---|
出站 order.orderDetail / syncV2 / verifyV3 / order.message |
我们 | ✅ 行(baseUrl 指真平台且能出网即可) |
进站 trade_TradePaid / 退款推送 |
平台 | ❌ 平台必须能主动访问你 |
买家打开 cert_link(/redeem) |
买家手机 | ❌ 手机打不开你的 localhost |
隧道必须转发到 Caddy 的 80 端口(deploy/caddy/Caddyfile.dev 是 :80 {},任意 Host 都匹配,不用改 Caddy):
ngrok → Caddy:80 ─┬─ /api/* → backend:3000 ← 平台推消息打这里
├─ /health* → backend:3000
├─ /s/* → backend:3000
└─ 其余 → frontend:5173 ← 买家打开 /redeem 打这里
1. 起 ngrok
ngrok http 80
# 输出里的 https://xxxx.ngrok-free.app 就是公网地址
已验证可用(ngrok v3.39.10,authtoken 已配置)。
强烈建议固定域名与指定亚太区域(低延迟)
免费版每次重启域名都会变,一变更就要重配平台 message_url,而已发给买家的直充链接不可撤回。
# 1) 在 ngrok 后台 Domains 里保留一个免费静态域名(免费额度 1 个)
# 2) 用固定域名与亚太区域启动(国内联调延迟更低)
ngrok http 80 --domain=nonsecretly-posttracheal-amado.ngrok-free.dev --region=ap
⚠️ 免费版的浏览器警示页(影响买家)
实测:平台推送不受影响(服务端 POST,正常进我们的路由); 但浏览器访问会被插一页全英文 ngrok 警示页:
模拟 iPhone 浏览器访问 /redeem →
HTTP 200, 2781 字节, <html ... ngrok assets ...> ← 不是我们的页面
同一地址用 curl(Accept: */*)→ 200, 561 字节, 正常 vite 页面
也就是说:买家点开兑换链接会先看到 "You are about to visit ..." 并要求点一下,这对真实用户体验是硬伤。
对策(按推荐度):
- 联调期间接受:自己和测试同学点一下即可;平台回调完全不受影响。
- 换付费/带自定义域名的 ngrok:警示页会消失(生产环境本来也不该用隧道,应该用正式域名)。
- 我们的脚本自检时会带
ngrok-skip-browser-warning: 1头,不受影响。
备注:如果哪天回头用 Cloudflare Tunnel,它没有这个警示页;当时连不上的原因是本机代理拦了 QUIC (拨到 fake-IP
198.18.x.x一直 timeout),配置里加protocol: http2即可解决。
2. 必须做:订阅推送(只填 uid/secret 是不够的)★
真实踩坑:填好真实 uid/secret、隧道也通,但下了单本地什么都收不到。
根因是——平台推送是订阅制的:凭据只保证我们能调它,它不会自动往我们这推。
必须在「消息设置」接口(message.config)里配置 message_url + topics + status=1,
平台才会推消息过来。
cd apps/backend
# 订阅到当前公网地址(脚本自己从后台配置读真实 uid/secret)
npm run douyin-mashida:subscribe -- --public-url https://xxxx.ngrok-free.app
# 联调结束/隧道要停:关掉订阅,避免平台一直往失效地址推
npm run douyin-mashida:subscribe -- --disable
实测返回:
[订阅] method=message.config status=1
message_url = https://75fb-166-0-19-25.ngrok-free.app/api/v1/open/douyin-mashida/message
topics = trade_TradePaid,trade_TradeCanceled,refund_RefundSuccess
✅ 平台返回 code=10000 message=成功
⚠️ 订阅保存的是当时那个地址:ngrok 换域名后必须重新订阅,否则平台会一直推到旧地址。 另外平台不会补推订阅之前已支付的历史订单——
message.config之前下的单只能人工补。
3. 一条命令切换地址 + 自检
cd apps/backend
npm run douyin-mashida:set-public-url -- --public-url https://xxxx.ngrok-free.app
它会把 certLink / afterVerifyLink 改成 <公网地址>/redeem,
打印平台侧要配的三项,并自检隧道(假签名打公网地址,期望 401)。
4. 真实联调前置自检
npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app
# 有真实密钥时(这一步才能验签名):
npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app --secret <真实用户秘钥>
四步:① 后台配置就绪度 ② 真实网关连通性 ③ 公网回调可达性 ④ certLink 与公网域名是否一致。
实测输出(配置未填完时):
[1/4] 后台配置
enabled ✅ 开启
uid ❌ 未填
certLink ✅ https://xxxx.ngrok-free.app/redeem
baseUrl https://dy-openplatform-api.wanmahui.com
ready ❌ false
[2/4] 真实网关连通性
POST https://dy-openplatform-api.wanmahui.com/api/openplatform/gateway
HTTP 200
平台返回 code=10014 message=uid格式异常
✅ 网关连通、域名/路径/form 编码/参数形态都被接受
[3/4] 公网回调自检
HTTP 401 errorCode=douyin_mashida_message_invalid_signature
✅ 平台能推到你
[4/4] 域名一致性
✅ 一致
②的解读:平台先校 uid 再校签名。不带
--secret时只能证明"网络+路径+参数形态"没问题; 带上--secret后如果平台报的不是签名错误(而是"订单不存在"之类业务错),就说明签名/密钥也对了 —— 这一步能在正式推送前把密钥问题排掉,最值钱。
平台文档只给了
code=10000表示成功、"其余均表示错误",没有完整错误码表; 脚本不翻译码值,只原样打印,并对实测观测到的码加说明(如10014)。
5. 联调 checklist
1. 隧道起来 ngrok http 80 --domain=<静态域名>
2. 切公网地址 npm run douyin-mashida:set-public-url -- --public-url https://<域名>
3. 填真实凭据 面板:uid = 抖店商户号,secret = 真实用户秘钥
(baseUrl 保持 https://dy-openplatform-api.wanmahui.com)
4. 皮肤领取页两项配置 面板:
claimLinkBase = https://<域名>/claim 皮肤订单领取页基址
messageTemplate = 您的订单已发货,请点击领取:{link}
⚠️ claimLinkBase 必须是**路径式**(不要带 #):
#/claim 是打手领取页,配错会把买家带到打手流程
5. 前置自检 npm run douyin-mashida:preflight -- --public-url https://<域名> --secret <真密钥>
全 ✅ 才能开始
6. 订阅推送 ★ npm run douyin-mashida:subscribe -- --public-url https://<域名>
(不做这步平台一条消息都不会推,见 §2)
7. 平台侧下一笔小额真单 → 日志看:
POST /api/v1/open/douyin-mashida/message 收到推送
[douyin-mashida/certificate] 券码已同步 syncV2 成功
fulfillment_tasks / claim_tokens 已建任务与领取 token
8. 手机打开买家领取链接 https://<域名>/claim/<token>(首次要过 ngrok 警示页)
→ 看到物品图片 → 填游戏账号 → 领取 → 本地看到 verifyV3
9. 对账 orders / order_items / redeem_certificates / fulfillment_tasks 四张表
必须实测的 7 个点
message_type=2发出去会不会改变抖店订单的发货状态(我们只想发链接)- 平台期望的成功响应体(当前回
{"code":0,"message":"success"}) - 平台重推策略(我们失败时回 4xx,是否真会重推)
coupons.syncV2的order_id认子单号还是父单号(代码子单优先,日志里能看实际值)- 同步成功后买家在哪里看到卡券(决定他能不能走到
/redeem) verifyV3重复核销的返回、能否cancelVerify冲正order.orderDetail的p_id/order_id实际是字符串还是数字(数字会触发 19 位精度问题)
6. 坑
6.1 签名覆盖原始 body,中间层必须字节透传 ★
推送签名 = md5(secret + "data" + data + "pub_time" + pubTime + "topic" + topic + secret),
data 是 body 里的原始 JSON 字符串。任何重排/转义/压缩 body 的中间层都会让验签失败。
- ngrok 是透传,没问题;
- 中间若再加别的网关 / WAF / "统一 JSON 美化",就会 401。报 401 先怀疑中间层改过 body。
- 我们也因此把消息路由挂在
app.ts全局 body parser 之前(app.ts:42),用express.text收原文。
6.2 ngrok 免费版:域名会变 + 浏览器警示页
见 §1(域名会变 + 浏览器警示页)。域名一变,平台 message_url 和已发出的 cert_link 都会失效;浏览器访问会先过警示页。
6.3 Vite 的 allowedHosts 会拦隧道域名
隧道域名经 Caddy 转发到前端 dev server 时 Host 是隧道域名,不在白名单里 Vite 直接拒绝
(Blocked request. This host is not allowed.),表现为页面打不开、后端日志却完全正常。
已修:vite.config.ts 默认放行 .ngrok-free.app / .ngrok.app / .ngrok.io / .trycloudflare.com,
并真正接上了 VITE_ALLOWED_HOSTS(.env 里那个 221329.cc.cd 现在才真正生效 —— 之前白名单是硬编码的,变量从没被读过)。
6.4 配置是内存缓存,只有后台接口能立刻生效
readAppConfigEntry 读的是启动时从 DB 载入的内存缓存(app-config-store.ts:8)。
直接 UPDATE app_config_entries 不生效,必须走 POST /api/v1/admin/platform-config/douyin-mashida
(面板和两个脚本都走接口),否则要重启后端。
6.5 占位密钥会让"已配置"骗过你
早期本地联调存进库里的 dev_douyin_mashida_secret 会让 hasSecret=true,看起来配好了,
实际联调必然签名失败。preflight 会识别这个占位值并直接报阻塞。
6.6 certLink 域名容易漏改
certLink 是发给买家的链接,必须指向当前公网地址。preflight 第 ④ 步专门比对
certLink 与 --public-url 的 host,不一致会给提醒。
7. 排错表
| 现象 | 原因 / 处理 |
|---|---|
| 自检第 ③ 步 404 | 隧道没转发到 Caddy 80(可能指到了静态站点) |
| 自检第 ③ 步拿到 HTML | ngrok 浏览器警示页(自检脚本已带 skip 头);或隧道指错服务 |
页面能开但白屏 + Blocked request |
Vite allowedHosts 未放行隧道域名(§6.3) |
| 平台推送返回 401 验签失败 | secret 与平台不一致;或中间层改了 body(§6.1) |
| 平台推送后本地无日志 | ①没做订阅(见 §2,最常见)②隧道挂了 ③订阅的是旧域名 |
| 历史订单一直没进本地 | 平台不补推订阅之前的历史消息,只能人工补 |
| 收到推送但券同步失败 | 真实 uid/secret 不对;或 baseUrl 没指向真平台 |
| 买家点链接看到英文警示页 | ngrok 免费版特性(§6.2),点一下即可;正式环境用正式域名 |
| 域名突然全失效 | ngrok 重启换了域名(§6.2)→ 重跑 set-public-url 并改平台配置 |
| 改了配置行为没变 | 没走后台接口保存(§6.4) |
preflight 报"占位密钥" |
把面板里的 secret 换成真实用户秘钥(§6.5) |