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:
yml2213
2026-10-09 12:01:42 +08:00
parent 8c33ff62ec
commit e9a87194bd
4 changed files with 782 additions and 1 deletions
+2
View File
@@ -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'),
)
}
+29 -1
View File
@@ -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,