chore(douyin-mashida): 公网联调工具链(切地址 + 前置自检)
真实联调必须让平台能主动推到本机,所以需要隧道;这两个脚本把「配置切换」和
「联调前该验的东西」自动化,避免手工漏配(尤其 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);现已真正接上并默认放行
常见隧道域名后缀。
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<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 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<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'),
|
||||
)
|
||||
}
|
||||
@@ -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<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 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<void> {
|
||||
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<JsonObject>(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<JsonObject>(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<void> {
|
||||
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) || /<html/i.test(text)) {
|
||||
console.log(' ⚠️ 拿到的是网页而不是 JSON:ngrok 免费版插了警示页。')
|
||||
console.log(' 浏览器首次访问需点一下「Visit Site」;平台回调是服务端请求,一般不受影响。')
|
||||
} else {
|
||||
console.log(` ⚠️ 预期 401,实际 HTTP ${response.status}:${text.slice(0, 200)}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
function isLocalStubUrl(value: string): boolean {
|
||||
const text = String(value || '')
|
||||
return (
|
||||
/host\.docker\.internal/i.test(text) ||
|
||||
/127\.0\.0\.1/.test(text) ||
|
||||
/localhost/i.test(text) ||
|
||||
/:\d{4,5}\//.test(`${text}/`)
|
||||
)
|
||||
}
|
||||
|
||||
/** GET 回来的字段里,有些是只读的,回传会被后端忽略;这里显式剔除以示明确。 */
|
||||
function stripReadonly(data: JsonObject): JsonObject {
|
||||
const payload: JsonObject = { ...data }
|
||||
delete payload.secretMasked
|
||||
delete payload.hasSecret
|
||||
delete payload.ready
|
||||
return payload
|
||||
}
|
||||
|
||||
function defaultAdminUser(): { username: string; password: string } {
|
||||
const raw = String(process.env.ADMIN_DEFAULT_USERS_JSON || '')
|
||||
if (!raw) {
|
||||
return { username: '', password: '' }
|
||||
}
|
||||
try {
|
||||
const users = JSON.parse(raw) 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 || '')})。用 --token 直接给 token,或检查 --username/--password。`,
|
||||
)
|
||||
}
|
||||
console.log(`已登录后台(${username})`)
|
||||
return token
|
||||
}
|
||||
|
||||
async function api<T>(
|
||||
token: string,
|
||||
method: 'GET' | 'POST',
|
||||
route: string,
|
||||
body?: JsonObject,
|
||||
): Promise<T> {
|
||||
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<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, (_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 <url> 必填,隧道公网地址,如 https://xxxx.ngrok-free.app',
|
||||
' --api-base <url> 后台地址,默认 http://localhost',
|
||||
' --token <token> 直接给后台 token(跳过登录)',
|
||||
' --username/--password 后台账号,默认取 .env 的 ADMIN_DEFAULT_USERS_JSON 里第一个 admin',
|
||||
' --no-verify 跳过隧道自检',
|
||||
' --help',
|
||||
].join('\n'),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user