Files
order_site/apps/backend/scripts/douyin-mashida-preflight.ts
T
yml2213 215a612b06 chore(douyin-mashida): 联调脚本支持皮肤领取链接
- `set-public-url`:切公网地址时**同时**写 `certLink` 与新的 `claimLinkBase`
  (只切前者会让皮肤订单仍指向旧域名的领取页),并打印两者
- `preflight`:新增领取链接自检 —— 未填给 ⚠️(皮肤订单会退回固定兑换链接、
  买家又要手输订单号);带 `#` 直接判为阻塞(会把人带到打手领取页);非 https 阻塞
2026-10-09 15:07:03 +08:00

450 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 抖音码速达「真实联调前置自检」——跟真平台对接前,先把能自动验的都验掉。
*
* 用法:
* 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'),
)
}