From e9a87194bdb3a2be30c4d71175d08aac5d1804e3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 12:01:42 +0800 Subject: [PATCH] =?UTF-8?q?chore(douyin-mashida):=20=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E8=81=94=E8=B0=83=E5=B7=A5=E5=85=B7=E9=93=BE=EF=BC=88=E5=88=87?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=20+=20=E5=89=8D=E7=BD=AE=E8=87=AA=E6=A3=80?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实联调必须让平台能主动推到本机,所以需要隧道;这两个脚本把「配置切换」和 「联调前该验的东西」自动化,避免手工漏配(尤其 certLink 漏改、占位密钥没换): - `douyin-mashida:set-public-url`:把 certLink/afterVerifyLink 切成隧道地址, 打印平台侧要配的三项(message_url / topics / 卡券链接),并自检隧道: 用错误签名打公网地址,期望 401 —— 证明「隧道 → Caddy → 后端 → 验签」通了, 不需要密钥、也不产生真实订单 - `douyin-mashida:preflight`:四步自检 ① 配置就绪度(含识别早期联调留下的占位密钥,它会让 hasSecret=true 骗过你) ② 真实网关连通性:平台是先校 uid 再校签名,带 --secret 时若平台报的不是 签名错误即证明签名/密钥正确(正式推送前就能排掉密钥问题) ③ 公网回调可达性 ④ certLink 与公网域名是否一致 - 平台文档只给了 code=10000 表示成功,无完整错误码表,故脚本不翻译码值、 只原样打印,并对实测观测到的码(如 10014)加说明 同时修掉 vite dev server 的一个潜在坑:`VITE_ALLOWED_HOSTS` 早已在 .env 与 compose 中配置,但 vite.config.ts 的白名单是硬编码的、从未读取该变量, 换隧道域名必然被 Vite 拦(Blocked request);现已真正接上并默认放行 常见隧道域名后缀。 --- apps/backend/package.json | 2 + .../scripts/douyin-mashida-preflight.ts | 432 ++++++++++++++++++ .../scripts/douyin-mashida-set-public-url.ts | 319 +++++++++++++ apps/frontend/vite.config.ts | 30 +- 4 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 apps/backend/scripts/douyin-mashida-preflight.ts create mode 100644 apps/backend/scripts/douyin-mashida-set-public-url.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 7b7a553f..aeff0607 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,6 +19,8 @@ "lint:check": "npm run lint -- --quiet", "lint:fix": "npm run lint -- --fix", "mock:open91": "tsx scripts/mock-open91-order.ts", + "douyin-mashida:set-public-url": "tsx scripts/douyin-mashida-set-public-url.ts", + "douyin-mashida:preflight": "tsx scripts/douyin-mashida-preflight.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "test:industry": "tsx scripts/test-kuaishou-industry.ts", "test:industry:http": "tsx scripts/test-kuaishou-industry-http.ts", diff --git a/apps/backend/scripts/douyin-mashida-preflight.ts b/apps/backend/scripts/douyin-mashida-preflight.ts new file mode 100644 index 00000000..d82b8328 --- /dev/null +++ b/apps/backend/scripts/douyin-mashida-preflight.ts @@ -0,0 +1,432 @@ +/** + * 抖音码速达「真实联调前置自检」——跟真平台对接前,先把能自动验的都验掉。 + * + * 用法: + * npm run douyin-mashida:preflight -- --public-url https://221329.cc.cd + * npm run douyin-mashida:preflight -- --public-url https://221329.cc.cd --secret <真实用户秘钥> + * + * 会检查四件事: + * 1) 后台配置是否就绪(enabled / uid / secret / certLink / baseUrl 是否是本地桩) + * 2) **真实网关连通性**:拿 uid+secret 发一次探测请求,看平台怎么回 + * —— 平台先校 uid 再校签名,所以不带真凭据时只能证明"网络+路径+参数形态"没问题; + * 带上 --secret 才能验证签名/密钥是否正确(这一步最值钱,省得联调时抓瞎) + * 3) 公网回调自检:故意用错签名打公网地址,期望 401 —— 证明平台能推到你 + * 4) certLink 与公网地址是否同一个域名(发给买家的链接很容易漏改) + * + * ⚠️ 平台文档只给了 `code=10000` 表示成功、"其余均表示错误",**没有完整错误码表**。 + * 所以这里不做码值翻译,只把原始 code/message 打出来,并对**实测观测到**的码加一句说明。 + */ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { loadEnvFiles } from '../src/config/runtime-env.js' +import { + DOUYIN_MASHIDA_GATEWAY_PATH, + buildGatewayRequestParams, +} from '../src/services/platforms/douyin-mashida/sign.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 DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com' +const CONFIG_PATH = '/api/v1/admin/platform-config/douyin-mashida' +const MESSAGE_PATH = '/api/v1/open/douyin-mashida/message' + +/** + * 早期本地联调留下的占位密钥。 + * 它会让 `hasSecret` 显示为 true,从而**看起来配置好了**,实际联调必然签名失败—— + * 这里显式识别出来提醒替换(掩码来自后端 maskSecret,格式为 前6****后6)。 + */ +const LEGACY_PLACEHOLDER_SECRET_MASK = 'dev_do****secret' + +/** 实测观测到的网关返回码(不是文档承诺的,仅用于给提示)。 */ +const OBSERVED_CODES: Record = { + '10000': '成功', + '10014': 'uid格式异常(实测:用非法 uid 探测时会返回它,说明网关可达且参数形态已被接受)', +} + +const args = parseArgs(process.argv.slice(2)) + +if (args.help) { + printHelp() + process.exit(0) +} + +const apiBase = String(args.apiBase || 'http://localhost').replace(/\/+$/, '') +const publicUrl = String(args.publicUrl || '') + .trim() + .replace(/\/+$/, '') +const username = String(args.username || defaultAdminUser().username || 'admin') +const password = String(args.password || defaultAdminUser().password || 'dev-admin-123456') + +const problems: string[] = [] +const warnings: string[] = [] + +main().catch((error) => { + console.error(`\n执行失败:${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) + +async function main(): Promise { + console.log('抖音码速达 · 真实联调前置自检') + console.log('='.repeat(72)) + + const token = String(args.token || '') || (await login()) + const config = await readConfig(token) + + step1Config(config) + await step2Gateway(config) + if (publicUrl) { + await step3PublicCallback(publicUrl) + step4CertLinkHost(config, publicUrl) + } else { + console.log('\n[3/4] 公网回调自检 —— 跳过(没给 --public-url)') + warnings.push('没做公网回调自检:建议加 --public-url https://<隧道域名> 再跑一次') + } + + printVerdict() +} + +// ---------------------------------------------------------------- 1) 配置 + +function step1Config(config: JsonObject): void { + console.log('\n[1/4] 后台配置') + const enabled = config.enabled !== false + const uid = String(config.uid || '') + const certLink = String(config.certLink || '') + const baseUrl = String(config.baseUrl || '') + const hasSecret = Boolean(config.hasSecret) + const ready = Boolean(config.ready) + + console.log(` enabled ${enabled ? '✅ 开启' : '❌ 关闭'}`) + console.log(` uid ${uid ? `✅ ${uid}` : '❌ 未填'}`) + console.log( + ` secret ${hasSecret ? `✅ 已配置(${String(config.secretMasked || '')})` : '❌ 未配置'}`, + ) + console.log(` certLink ${certLink ? `✅ ${certLink}` : '❌ 未填'}`) + console.log(` baseUrl ${baseUrl || '❌ 未填'}`) + console.log(` ready ${ready ? '✅ true' : '❌ false'}`) + + if (!enabled) problems.push('接入开关未开启(enabled=false)') + if (!uid) problems.push('未填 uid(抖店商户号)') + if (!hasSecret) { + problems.push('未填用户秘钥') + } else if (String(config.secretMasked || '') === LEGACY_PLACEHOLDER_SECRET_MASK) { + problems.push( + `当前存的是本地联调用的**占位密钥**(${LEGACY_PLACEHOLDER_SECRET_MASK}),不是真实用户秘钥——` + + '请在面板里重新填入真实 secret(不换必然签名失败)', + ) + } + if (!certLink) problems.push('未填 certLink(买家要打开的兑换链接)') + + if (isLocalAddress(baseUrl)) { + problems.push( + `baseUrl 指向本地/内网(${baseUrl})——真平台收不到任何请求,改成 ${DEFAULT_BASE_URL}`, + ) + } + if (certLink && !/^https:/i.test(certLink)) { + problems.push(`certLink 不是 https(${certLink})——买家手机多半打不开,平台也可能不认`) + } +} + +// ---------------------------------------------------------------- 2) 网关 + +async function step2Gateway(config: JsonObject): Promise { + console.log('\n[2/4] 真实网关连通性') + + const configuredUid = String(config.uid || '') + const realUid = String(args.uid || configuredUid) + const secret = String(args.secret || '') + const usingRealCredentials = Boolean(secret && realUid) + + const uid = usingRealCredentials ? realUid : realUid || 'dummy_uid' + const signSecret = usingRealCredentials ? secret : 'dummy_secret' + const baseUrl = normalizeBaseUrl(String(config.baseUrl || DEFAULT_BASE_URL)) + + console.log(` POST ${baseUrl}${DOUYIN_MASHIDA_GATEWAY_PATH}`) + console.log( + ` method=order.orderDetail uid=${uid} secret=${usingRealCredentials ? '(真实)' : '(占位)'}`, + ) + + const params = buildGatewayRequestParams({ + secret: signSecret, + uid, + method: 'order.orderDetail', + // 用不存在的订单号探测,不会碰到真实业务数据 + params: { shop_order_id: '1' }, + }) + + let code = '' + let message = '' + try { + const response = await fetch(`${baseUrl}${DOUYIN_MASHIDA_GATEWAY_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params).toString(), + signal: AbortSignal.timeout(15000), + }) + const text = await response.text() + const parsed = safeJson(text) + code = String(parsed?.code ?? '') + message = String(parsed?.message ?? '') + + console.log(` HTTP ${response.status}`) + console.log(` 平台返回 code=${code || '(缺)'} message=${message || '(空)'}`) + if (!parsed) { + console.log(` 原始响应:${text.slice(0, 300)}`) + } + } catch (error) { + console.log(` ❌ 请求异常:${error instanceof Error ? error.message : String(error)}`) + problems.push('真实网关请求失败:检查出网(DNS/代理/防火墙)与 baseUrl') + return + } + + if (OBSERVED_CODES[code]) { + console.log(` ℹ️ code ${code}:${OBSERVED_CODES[code]}`) + } + + // 签名类错误是「密钥/签名规则不对」的强信号 + if (/签名|sign/i.test(message)) { + problems.push( + `平台报签名相关错误(code=${code} message=${message})——检查 secret 是否正确、参数值是否按原始值参与签名`, + ) + return + } + + if (code === '10000') { + console.log(' ✅ 网关连通且调用成功') + return + } + + if (!usingRealCredentials) { + console.log(' ✅ 网关连通、域名/路径/form 编码/参数形态都被接受') + console.log(' ℹ️ 平台是先校 uid 再校签名,所以**签名正确性还没验证**;') + console.log(' 加上 --secret <真实用户秘钥> 再跑一次就能验到。') + warnings.push('未验证签名(没给 --secret)') + return + } + + // 带了真实凭据,且不是签名错误 → 签名通过了,报的是业务错(订单号不存在之类) + console.log(' ✅ 网关连通 + **签名验证通过**(平台报的是业务错误而不是签名错误)') + console.log(' ℹ️ 这个业务错误是拿不存在的订单号探测导致的,正常。') +} + +// ---------------------------------------------------------------- 3) 公网回调 + +async function step3PublicCallback(url: string): Promise { + console.log('\n[3/4] 公网回调自检') + const messageUrl = `${url}${MESSAGE_PATH}` + console.log(` POST ${messageUrl}`) + + try { + const response = await fetch( + `${messageUrl}?${new URLSearchParams({ + topic: 'trade_TradePaid', + pub_time: '1', + sign: 'deadbeefdeadbeefdeadbeefdeadbeef', + })}`, + { + method: 'POST', + headers: { + 'content-type': 'text/plain; charset=utf-8', + // ngrok 免费版对浏览器类请求会插警示页,带上这个头跳过 + 'ngrok-skip-browser-warning': '1', + }, + body: '{"p_id":1}', + signal: AbortSignal.timeout(15000), + }, + ) + const text = await response.text() + const parsed = safeJson(text) + const errorCode = String(parsed?.errorCode || '') + + console.log(` HTTP ${response.status} errorCode=${errorCode || '(无)'}`) + + if (response.status === 401 && errorCode === 'douyin_mashida_message_invalid_signature') { + console.log(' ✅ 平台能推到你(返回 401 验签失败,正是预期:请求确实走进了我们的路由)') + return + } + if (response.status === 404) { + problems.push('公网地址 404:隧道没转发到 Caddy 80(或路径被改)') + return + } + if (/ 0) { + console.log(`\n⚠️ ${warnings.length} 项提醒:`) + for (const line of warnings) { + console.log(` - ${line}`) + } + } + console.log('\n下一步(平台侧):') + console.log( + ` 消息推送地址 message_url = ${publicUrl ? `${publicUrl}${MESSAGE_PATH}` : 'https://<公网域名>/api/v1/open/douyin-mashida/message'}`, + ) + console.log( + ' 订阅 topics = trade_TradePaid(建议加 trade_TradeCanceled、refund_RefundSuccess)', + ) + + if (problems.length > 0) { + process.exitCode = 1 + } +} + +// ---------------------------------------------------------------- 工具 + +function safeJson(text: string): JsonObject | null { + try { + const parsed: unknown = JSON.parse(text) + return Object.prototype.toString.call(parsed) === '[object Object]' + ? (parsed as JsonObject) + : null + } catch { + return null + } +} + +function safeHost(value: string): string { + try { + return new URL(value).host + } catch { + return '' + } +} + +function normalizeBaseUrl(value: string): string { + const text = String(value || '').replace(/\/+$/, '') + return text || DEFAULT_BASE_URL +} + +function isLocalAddress(value: string): boolean { + const text = String(value || '') + if (!text) { + return false + } + return ( + /host\.docker\.internal/i.test(text) || + /127\.0\.0\.1/.test(text) || + /localhost/i.test(text) || + /192\.168\./.test(text) || + /10\.\d+\./.test(text) + ) +} + +async function readConfig(token: string): Promise { + const response = await fetch(`${apiBase}${CONFIG_PATH}`, { + headers: { authorization: `Bearer ${token}` }, + }) + const body = (await response.json().catch(() => ({}))) as JsonObject + if (response.status < 200 || response.status >= 300) { + throw new Error(`读配置失败:HTTP ${response.status} ${String(body.msg || '')}`) + } + return (body.data ?? {}) as JsonObject +} + +function defaultAdminUser(): { username: string; password: string } { + try { + const users = JSON.parse(String(process.env.ADMIN_DEFAULT_USERS_JSON || '[]')) as Array<{ + username?: string + password?: string + role?: string + }> + 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 || '')})`) + } + console.log(`已登录后台(${username})`) + return token +} + +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, (_m, 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 公网地址(隧道域名),给了才做回调自检', + ' --secret <密钥> 真实用户秘钥,给了才能验证签名/密钥是否正确', + ' --uid 覆盖配置里的 uid(默认用后台配置的)', + ' --api-base 后台地址,默认 http://localhost', + ' --token 直接给后台 token', + ' --username/--password 后台账号', + ' --help', + ].join('\n'), + ) +} diff --git a/apps/backend/scripts/douyin-mashida-set-public-url.ts b/apps/backend/scripts/douyin-mashida-set-public-url.ts new file mode 100644 index 00000000..96d5e8f8 --- /dev/null +++ b/apps/backend/scripts/douyin-mashida-set-public-url.ts @@ -0,0 +1,319 @@ +/** + * 抖音码速达「公网联调地址切换」——把兑换链接换成本机隧道地址,并自检隧道是否真的通到我们的路由。 + * + * 为什么需要它: + * - **进站**方向(平台推送消息给我们)必须有一个公网可达的地址,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}/redeem` + const messageUrl = `${publicUrl}${MESSAGE_PATH}` + + console.log('抖音码速达 · 公网联调地址切换') + console.log('─'.repeat(72)) + console.log(`后台地址 ${apiBase}`) + console.log(`公网地址 ${publicUrl}`) + console.log(`certLink ${certLink}`) + + 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, + } + + const saved = await api(token, 'POST', CONFIG_PATH, payload) + const savedData = (saved.data ?? {}) as JsonObject + console.log( + `\n✅ 已保存:certLink = ${String(savedData.certLink || '')},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'), + ) +} diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 3a2d6171..8a9a2ba1 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -7,6 +7,34 @@ import { defineConfig } from 'vite' const currentDir = path.dirname(fileURLToPath(import.meta.url)) const apiTarget = process.env.VITE_API_TARGET || 'http://127.0.0.1' +/** + * dev server 的 Host 白名单。 + * + * 用隧道(ngrok / cloudflared)做公网联调时,请求经隧道打到 Caddy、再转发到本 dev server, + * Host 头是**隧道域名**;不在白名单里 Vite 会直接拒绝 + * ("Blocked request. This host is not allowed."),表现为页面打不开、后端日志却一片正常。 + * + * 所以:常见隧道域名后缀默认放行,`VITE_ALLOWED_HOSTS`(compose 里已透传)可再追加, + * 逗号分隔、允许 `.example.com` 这种后缀写法。只影响 dev server,不影响构建产物。 + */ +const tunnelHosts = ['.ngrok-free.app', '.ngrok.app', '.ngrok.io', '.trycloudflare.com'] +const extraAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '') + .split(',') + .map((host) => host.trim()) + .filter(Boolean) + +const allowedHosts = [ + ...new Set([ + 'localhost', + '.localhost', + '127.0.0.1', + '221329.cc.cd', + '.cc.cd', + ...tunnelHosts, + ...extraAllowedHosts, + ]), +] + export default defineConfig({ plugins: [react()], resolve: { @@ -18,7 +46,7 @@ export default defineConfig({ host: '0.0.0.0', port: 5173, // 隧道/自定义域名访问 dev server 时需加入白名单,否则 Vite 会拦截 Host - allowedHosts: ['localhost', '.localhost', '127.0.0.1', '221329.cc.cd', '.cc.cd'], + allowedHosts, proxy: { '/api': { target: apiTarget,