/** * 抖音码速达「真实联调前置自检」——跟真平台对接前,先把能自动验的都验掉。 * * 用法: * 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 claimLinkBase = String(config.claimLinkBase || '') 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( ` 领取链接 ${claimLinkBase ? `✅ ${claimLinkBase}` : '⚠️ 未填(皮肤订单会退回 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})——买家手机多半打不开,平台也可能不认`) } if (claimLinkBase) { if (claimLinkBase.includes('#')) { problems.push( `领取链接基址带了 #(${claimLinkBase})——\`#/claim\` 是**打手**领取页,` + '皮肤买家会被带进绑定游戏 UID 的流程;请改成路径式,例如 https://<域名>/claim', ) } if (!/^https:/i.test(claimLinkBase)) { problems.push(`领取链接基址不是 https(${claimLinkBase})——买家手机多半打不开`) } } } // ---------------------------------------------------------------- 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'), ) }