- set-public-url 脚本将 certLink 与 claimLinkBase 统一设置为 <公网地址>/h5 - subscribe 脚本改走后台 HTTP 管理接口,避免在宿主机执行时直连数据库导致连接异常 验证方式:ngrok 隧道下端到端执行 subscribe 与 preflight 自动化校验全部通过
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
/**
|
||
* 抖音码速达「公网联调地址切换」——把兑换链接换成本机隧道地址,并自检隧道是否真的通到我们的路由。
|
||
*
|
||
* 为什么需要它:
|
||
* - **进站**方向(平台推送消息给我们)必须有一个公网可达的地址,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'),
|
||
)
|
||
}
|