- `set-public-url`:切公网地址时**同时**写 `certLink` 与新的 `claimLinkBase`
(只切前者会让皮肤订单仍指向旧域名的领取页),并打印两者
- `preflight`:新增领取链接自检 —— 未填给 ⚠️(皮肤订单会退回固定兑换链接、
买家又要手输订单号);带 `#` 直接判为阻塞(会把人带到打手领取页);非 https 阻塞
450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
/**
|
||
* 抖音码速达「真实联调前置自检」——跟真平台对接前,先把能自动验的都验掉。
|
||
*
|
||
* 用法:
|
||
* 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<string, unknown>
|
||
|
||
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<string, string> = {
|
||
'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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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 (/<html/i.test(text) || /ngrok/i.test(text)) {
|
||
problems.push('公网地址返回网页而非 JSON:多半是 ngrok 浏览器警示页,或隧道指到了静态站点')
|
||
return
|
||
}
|
||
problems.push(`公网回调自检结果异常:HTTP ${response.status} ${text.slice(0, 160)}`)
|
||
} catch (error) {
|
||
problems.push(`公网地址连不上:${error instanceof Error ? error.message : String(error)}`)
|
||
}
|
||
}
|
||
|
||
function step4CertLinkHost(config: JsonObject, url: string): void {
|
||
console.log('\n[4/4] 域名一致性')
|
||
const certLink = String(config.certLink || '')
|
||
if (!certLink) {
|
||
return
|
||
}
|
||
const certHost = safeHost(certLink)
|
||
const publicHost = safeHost(url)
|
||
console.log(` certLink ${certHost}`)
|
||
console.log(` 公网地址 ${publicHost}`)
|
||
|
||
if (certHost && publicHost && certHost !== publicHost) {
|
||
warnings.push(
|
||
`certLink 域名(${certHost})与当前公网地址(${publicHost})不一致——买家会跳到另一个域名,确认是有意的(例如正式域名),否则用 douyin-mashida:set-public-url 重设`,
|
||
)
|
||
} else if (certHost) {
|
||
console.log(' ✅ 一致')
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 汇总
|
||
|
||
function printVerdict(): void {
|
||
console.log('\n' + '='.repeat(72))
|
||
if (problems.length === 0) {
|
||
console.log('✅ 自检全部通过,可以做真实联调了')
|
||
} else {
|
||
console.log(`❌ 有 ${problems.length} 项阻塞:`)
|
||
for (const line of problems) {
|
||
console.log(` - ${line}`)
|
||
}
|
||
}
|
||
if (warnings.length > 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<JsonObject> {
|
||
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<string> {
|
||
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<string, string | boolean> {
|
||
const parsed: Record<string, string | boolean> = {}
|
||
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 <url> 公网地址(隧道域名),给了才做回调自检',
|
||
' --secret <密钥> 真实用户秘钥,给了才能验证签名/密钥是否正确',
|
||
' --uid <uid> 覆盖配置里的 uid(默认用后台配置的)',
|
||
' --api-base <url> 后台地址,默认 http://localhost',
|
||
' --token <token> 直接给后台 token',
|
||
' --username/--password 后台账号',
|
||
' --help',
|
||
].join('\n'),
|
||
)
|
||
}
|