Files
order_site/apps/backend/scripts/douyin-mashida-set-public-url.ts
T
yml2213 ccf8de97a9 chore(douyin-mashida): 联调脚本适配 /h5 路径与统一后台接口调用
- set-public-url 脚本将 certLink 与 claimLinkBase 统一设置为 <公网地址>/h5
- subscribe 脚本改走后台 HTTP 管理接口,避免在宿主机执行时直连数据库导致连接异常

验证方式:ngrok 隧道下端到端执行 subscribe 与 preflight 自动化校验全部通过
2026-10-10 20:12:33 +08:00

324 lines
12 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.
/**
* 抖音码速达「公网联调地址切换」——把兑换链接换成本机隧道地址,并自检隧道是否真的通到我们的路由。
*
* 为什么需要它:
* - **进站**方向(平台推送消息给我们)必须有一个公网可达的地址,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}/h5`
const claimLinkBase = `${publicUrl}/h5`
const messageUrl = `${publicUrl}${MESSAGE_PATH}`
console.log('抖音码速达 · 公网联调地址切换')
console.log('─'.repeat(72))
console.log(`后台地址 ${apiBase}`)
console.log(`公网地址 ${publicUrl}`)
console.log(`certLink ${certLink} (通用兜底 H5 领取链接)`)
console.log(`claimLinkBase ${claimLinkBase} (买家 H5 直充领取基址)`)
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,
claimLinkBase,
}
const saved = await api<JsonObject>(token, 'POST', CONFIG_PATH, payload)
const savedData = (saved.data ?? {}) as JsonObject
console.log(
`\n✅ 已保存:certLink = ${String(savedData.certLink || '')},` +
`claimLinkBase = ${String(savedData.claimLinkBase || '')},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'),
)
}