/** * 抖音码速达「公网联调地址切换」——把兑换链接换成本机隧道地址,并自检隧道是否真的通到我们的路由。 * * 为什么需要它: * - **进站**方向(平台推送消息给我们)必须有一个公网可达的地址,localhost 无解,要挂隧道; * - **出站**方向(我们调 order.orderDetail / syncV2 / verifyV3)走的是 baseUrl,要指向**真实平台**; * - 买家点开的 `cert_link` 也必须是公网地址(否则手机打不开)。 * 这三处地址一变就要同步改配置,手工点面板容易漏,所以做成一条命令。 * * 用法: * # 1) 先在本机挂好隧道(另开终端) * ngrok http 80 * # 或(你们已有 cc.cd 域名时) * cloudflared tunnel --url http://localhost:80 * * # 2) 把配置切到隧道地址 + 自检隧道 * npm run douyin-mashida:set-public-url -- --public-url https://xxxx.ngrok-free.app --verify * * # 3) 收尾:切回真实平台(把 baseUrl 改回去) * npm run douyin-mashida:set-public-url -- --public-url https://<你的正式域名> --no-verify * * 说明:隧道必须转发到 **Caddy 的 80 端口**(`docker compose ... ps` 里的 gateway), * Caddy 会把 `/api/*` 送到后端、其余送到前端 dev server, * 所以一个地址同时满足「平台推送」和「买家打开 /redeem」。 */ import path from 'node:path' import { fileURLToPath } from 'node:url' import { loadEnvFiles } from '../src/config/runtime-env.js' type JsonObject = Record const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..') const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..') loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')]) const CONFIG_PATH = '/api/v1/admin/platform-config/douyin-mashida' const MESSAGE_PATH = '/api/v1/open/douyin-mashida/message' const args = parseArgs(process.argv.slice(2)) if (args.help) { printHelp() process.exit(0) } const apiBase = String( args.apiBase || process.env.DOUYIN_MASHIDA_API_BASE || 'http://localhost', ).replace(/\/+$/, '') const publicUrl = String(args.publicUrl || '') .trim() .replace(/\/+$/, '') const verify = args.verify !== false && args.noVerify !== true if (!publicUrl) { console.error('缺少 --public-url(例如 https://xxxx.ngrok-free.app)') console.error('先另开终端跑:ngrok http 80') process.exit(1) } if (!/^https?:\/\//i.test(publicUrl)) { console.error(`--public-url 必须以 http(s):// 开头,收到:${publicUrl}`) process.exit(1) } const username = String(args.username || defaultAdminUser().username || 'admin') const password = String(args.password || defaultAdminUser().password || 'dev-admin-123456') main().catch((error) => { console.error(`\n执行失败:${error instanceof Error ? error.message : String(error)}`) process.exit(1) }) async function main(): Promise { const certLink = `${publicUrl}/h5` const claimLinkBase = `${publicUrl}/h5` const messageUrl = `${publicUrl}${MESSAGE_PATH}` console.log('抖音码速达 · 公网联调地址切换') console.log('─'.repeat(72)) console.log(`后台地址 ${apiBase}`) console.log(`公网地址 ${publicUrl}`) console.log(`certLink ${certLink} (通用兜底 H5 领取链接)`) console.log(`claimLinkBase ${claimLinkBase} (买家 H5 直充领取基址)`) const token = String(args.token || '') || (await login()) const current = await api(token, 'GET', CONFIG_PATH) const data = (current.data ?? {}) as JsonObject console.log(`当前 baseUrl ${String(data.baseUrl || '')}`) const payload: JsonObject = { ...stripReadonly(data), certLink, afterVerifyLink: certLink, claimLinkBase, } const saved = await api(token, 'POST', CONFIG_PATH, payload) const savedData = (saved.data ?? {}) as JsonObject console.log( `\n✅ 已保存:certLink = ${String(savedData.certLink || '')},` + `claimLinkBase = ${String(savedData.claimLinkBase || '')},ready = ${String(savedData.ready)}`, ) printPlatformChecklist(messageUrl, certLink) printWarnings(String(data.baseUrl || ''), publicUrl) if (verify) { await verifyTunnel(publicUrl, messageUrl) } } /** 平台侧要配的东西,一次打全,避免漏配。 */ function printPlatformChecklist(messageUrl: string, certLink: string): void { console.log('\n平台侧(码速达后台)要配:') console.log(` 消息推送地址 message_url = ${messageUrl}`) console.log( ' 订阅 topics = trade_TradePaid(建议加 trade_TradeCanceled、refund_RefundSuccess)', ) console.log(` 买家看到的卡券链接 = ${certLink}`) } function printWarnings(baseUrl: string, url: string): void { const warnings: string[] = [] if (!/^https:/i.test(url)) { warnings.push( '公网地址不是 https —— 平台多半只接受 https 回调,ngrok/cloudflared 默认给的就是 https。', ) } if (isLocalStubUrl(baseUrl)) { warnings.push( `baseUrl 指向本地/内网(${baseUrl})—— 真平台不会收到任何请求。` + '真实联调请在面板或接口里把 baseUrl 改成 https://dy-openplatform-api.wanmahui.com', ) } if (/ngrok-free\.app$/i.test(new URL(url).hostname)) { warnings.push( 'ngrok 免费版每次重启域名都会变,平台侧要重配。建议固定域名:' + 'ngrok http 80 --domain=<你的固定域名>(免费账号可保留 1 个)。', ) } if (warnings.length > 0) { console.log('\n⚠️ 注意:') for (const line of warnings) { console.log(` - ${line}`) } } } /** * 自检隧道:故意用错误的 sign 打公网地址。 * 期望返回 401 + douyin_mashida_message_invalid_signature —— * 说明「隧道 → Caddy → 后端 → 验签逻辑」整条路通了(且我们确实在校验签名)。 * 不需要密钥,也不会产生真实订单。 */ async function verifyTunnel(url: string, messageUrl: string): Promise { console.log('\n自检隧道…') console.log(` POST ${messageUrl}`) const probeUrl = `${messageUrl}?${new URLSearchParams({ topic: 'trade_TradePaid', pub_time: '1', sign: 'deadbeefdeadbeefdeadbeefdeadbeef', })}` let response: Response try { response = await fetch(probeUrl, { method: 'POST', headers: { 'content-type': 'text/plain; charset=utf-8', // ngrok 免费版对浏览器类请求会插警示页;带上这个头可以跳过 'ngrok-skip-browser-warning': '1', }, body: '{"p_id":1}', }) } catch (error) { console.error(` ❌ 连不上公网地址:${error instanceof Error ? error.message : String(error)}`) console.error(' 检查隧道是否在跑、是否转发到 Caddy 的 80 端口。') process.exitCode = 1 return } const text = await response.text() let parsed: JsonObject = {} try { parsed = JSON.parse(text) as JsonObject } catch { // 非 JSON(例如 ngrok 的警示页) } const errorCode = String(parsed.errorCode || '') if (response.status === 401 && errorCode === 'douyin_mashida_message_invalid_signature') { console.log(' ✅ 隧道通到后端了(返回 401 验签失败,正是预期:证明请求真的走到了我们的路由)') console.log( ' 下一步:让平台推一条真实消息,或本地用 mock:douyin-mashida-push --base-url ' + url, ) return } if (response.status === 404) { console.log( ' ⚠️ 返回 404:隧道通了但没路由到后端。确认隧道转发到 Caddy 80(不是某个静态站点)。', ) } else if (/ngrok/i.test(text) || / const admin = users.find((user) => user.role === 'admin') || users[0] return { username: String(admin?.username || ''), password: String(admin?.password || '') } } catch { return { username: '', password: '' } } } async function login(): Promise { const response = await fetch(`${apiBase}/api/v1/admin/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username, password }), }) const body = (await response.json().catch(() => ({}))) as JsonObject const token = String(((body.data ?? {}) as JsonObject).token || '') if (!token) { throw new Error( `后台登录失败(HTTP ${response.status},${String(body.msg || '')})。用 --token 直接给 token,或检查 --username/--password。`, ) } console.log(`已登录后台(${username})`) return token } async function api( token: string, method: 'GET' | 'POST', route: string, body?: JsonObject, ): Promise { const response = await fetch(`${apiBase}${route}`, { method, headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }) const parsed = (await response.json().catch(() => ({}))) as JsonObject if (response.status < 200 || response.status >= 300) { throw new Error(`${method} ${route} 失败:HTTP ${response.status} ${String(parsed.msg || '')}`) } return parsed as T } // ---------------------------------------------------------------- CLI function parseArgs(argv: string[]): Record { const parsed: Record = {} for (let i = 0; i < argv.length; i += 1) { const token = argv[i] if (!token.startsWith('--')) { continue } const key = token.slice(2).replace(/-([a-z0-9])/g, (_match, char: string) => char.toUpperCase()) const next = argv[i + 1] if (!next || next.startsWith('--')) { parsed[key] = true } else { parsed[key] = next i += 1 } } return parsed } function printHelp(): void { console.log( [ '抖音码速达公网联调地址切换', '', ' --public-url 必填,隧道公网地址,如 https://xxxx.ngrok-free.app', ' --api-base 后台地址,默认 http://localhost', ' --token 直接给后台 token(跳过登录)', ' --username/--password 后台账号,默认取 .env 的 ADMIN_DEFAULT_USERS_JSON 里第一个 admin', ' --no-verify 跳过隧道自检', ' --help', ].join('\n'), ) }