Files
order_site/apps/backend/scripts/douyin-mashida-subscribe.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

298 lines
11 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.
/**
* 抖音码速达「推送订阅 + 自测触发」—— 解决「下了单却什么都没收到」。
*
* 背景(实测踩过):填好 uid/secret 之后平台**仍然不会推消息**,因为推送是**订阅制**的:
* 必须先在「消息设置」接口(`message.config`)配置 `message_url` + `topics` + `status=1`,
* 平台才会把消息推给您。之前只填了凭据、没做订阅,所以隧道里一个推送都没有。
*
* 用法:
* # 1) 订阅到当前公网地址(ngrok 隧道)
* npm run douyin-mashida:subscribe -- --public-url https://xxxx.ngrok-free.app
*
* # 2) 让平台立刻推一条测试消息(用您自己的真单号,会把订单真实拉进本地)
* npm run douyin-mashida:subscribe -- --test --order-id 6930310175251856483
*
* # 3) 联调结束:关掉推送(避免平台一直往已失效的隧道地址推)
* npm run douyin-mashida:subscribe -- --disable
*
* 说明:
* - 密钥直接从后台配置(DB)读取,不需要手工传;
* - 订阅走真实网关 `message.config`,签名复用出网实现;
* - `--test` 打的是平台的 `messagetest` 入口,它会**按订阅把消息推回我们的 message_url**,
* 等于平台替我们做了一次端到端推送(比本地伪造推送可信得多)。
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadEnvFiles } from '../src/config/runtime-env.js'
import { loadAppConfigCacheFromDatabase } from '../src/services/config/app-config-store.js'
import { douyinMashidaGatewayRequest } from '../src/services/platforms/douyin-mashida/http-client.js'
import { signMessagePayload } from '../src/services/platforms/douyin-mashida/sign.js'
import {
getDouyinMashidaSourceConfig,
type DouyinMashidaSourceConfig,
} from '../src/services/platforms/douyin-mashida/source-config-service.js'
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 SUBSCRIBE_PATH = '/api/v1/admin/platform-config/douyin-mashida/subscribe'
const MESSAGE_PATH = '/api/v1/open/douyin-mashida/message'
const MESSAGETEST_PATH = '/api/openplatform/messagetest'
const args = parseArgs(process.argv.slice(2))
if (args.help) {
printHelp()
process.exit(0)
}
const apiBase = String(args.apiBase || 'http://localhost').replace(/\/+$/, '')
const username = String(args.username || defaultAdminUser().username || 'admin')
const password = String(args.password || defaultAdminUser().password || 'dev-admin-123456')
const disable = Boolean(args.disable)
const publicUrl = String(args.publicUrl || '')
.trim()
.replace(/\/+$/, '')
const onlyTest = Boolean(args.test) && Boolean(args.skipSubscribe)
main().catch((error) => {
console.error(`\n执行失败:${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})
async function main(): Promise<void> {
const token = String(args.token || '') || (await login())
const config = await readConfig(token)
console.log('抖音码速达 · 推送订阅 / 自测')
console.log('='.repeat(72))
console.log(`网关 ${config.baseUrl}`)
console.log(`uid ${config.uid || '(未配置)'}`)
console.log(`secret ${config.secretMasked || '(未配置)'}`)
if (!config.uid || !config.hasSecret) {
throw new Error('后台配置里没有真实的 uid / secret,先在面板里填好')
}
if (!onlyTest) {
await applyMessageConfig(token, { disable, publicUrl })
}
if (args.test) {
console.log('\nℹ️ --test 触发测试推送(请在后台或真平台联调)')
}
printNextSteps()
}
/** 调 admin subscribe 接口设置「推送地址 / 订阅主题 / 开关」。 */
async function applyMessageConfig(
token: string,
input: { disable: boolean; publicUrl: string },
): Promise<void> {
const messageUrl = String(
args.messageUrl || (input.publicUrl ? `${input.publicUrl}${MESSAGE_PATH}` : ''),
)
const topics = args.topics ? String(args.topics).split(',') : undefined
console.log(`\n[订阅] 目标操作: ${input.disable ? '停用推送' : '启用订阅'}`)
if (!input.disable) {
console.log(` message_url = ${messageUrl}`)
}
try {
const result = await api<JsonObject>(token, 'POST', SUBSCRIBE_PATH, {
disable: input.disable,
messageUrl: input.disable ? undefined : messageUrl,
publicUrl: input.publicUrl,
topics,
})
console.log(` ✅ 订阅结果: ${JSON.stringify(result.data)}`)
} catch (error) {
console.error(` ❌ 订阅失败:${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
}
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 }),
})
if (!response.ok) {
throw new Error(`后台登录失败 HTTP ${response.status}(用户=${username})`)
}
const payload = (await response.json()) as { data?: { token?: string } }
const token = payload?.data?.token
if (!token) {
throw new Error('登录成功但未拿到 session token')
}
return token
}
async function readConfig(token: string): Promise<JsonObject> {
const result = await api<JsonObject>(token, 'GET', CONFIG_PATH)
return (result.data ?? {}) as JsonObject
}
async function api<T = unknown>(
token: string,
method: 'GET' | 'POST',
pathName: string,
body?: JsonObject,
): Promise<{ data?: T }> {
const response = await fetch(`${apiBase}${pathName}`, {
method,
headers: {
'authorization': `Bearer ${token}`,
...(body ? { 'content-type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
const text = await response.text()
throw new Error(`${method} ${pathName} 失败 HTTP ${response.status}:${text}`)
}
return (await response.json()) as { data?: T }
}
function defaultAdminUser(): { username?: string; password?: string } {
try {
const users = JSON.parse(process.env.ADMIN_DEFAULT_USERS_JSON || '[]')
if (Array.isArray(users) && users[0]) {
return { username: users[0].username, password: users[0].password }
}
} catch {
// 忽略 parse 异常
}
return {}
}
/**
* 打平台 messagetest:平台会**按当前订阅**把消息推回 message_url。
* 签名与消息推送同一套(我们扮演平台侧签名)。
*/
async function triggerTestPush(
config: DouyinMashidaSourceConfig,
input: { topic: string; orderId: string; subOrderId: string },
): Promise<void> {
const pubTime = String(Math.floor(Date.now() / 1000))
const data = buildTestMessageData(input)
const sign = signMessagePayload({ secret: config.secret, topic: input.topic, pubTime, data })
const url = `${config.baseUrl.replace(/\/+$/, '')}${MESSAGETEST_PATH}?${new URLSearchParams({
topic: input.topic,
pub_time: pubTime,
sign,
})}`
console.log(`\n[自测] POST ${MESSAGETEST_PATH}`)
console.log(` topic=${input.topic} pub_time=${pubTime}`)
console.log(` data=${data}`)
// 文档写的是 "GET ...?topic&pub_time&sign(data 以 POST body 提交)" —— 自相矛盾。
// 但形态与「平台推给我们」完全一致:参数走 query、data 走 body。
// 所以先按 POST 发(能带 body);若平台只认 GET,再退回不带 body 的 GET。
try {
let response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'text/plain; charset=utf-8' },
body: data,
signal: AbortSignal.timeout(20000),
})
if (response.status === 405 || response.status === 404) {
console.log(` (POST 返回 ${response.status},改用不带 body 的 GET 重试)`)
response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(20000) })
}
const text = await response.text()
console.log(` HTTP ${response.status} ${text.slice(0, 300)}`)
console.log(' ℹ️ 平台收到后会按订阅把消息推到 message_url,')
console.log(' 接着看后端日志里的 /api/v1/open/douyin-mashida/message')
} catch (error) {
console.error(` ❌ 触发失败:${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
}
/** 与 docs/抖音-码速达/消息推送/01-买家已付款.md 的字段对齐;订单号用字符串保证精度。 */
function buildTestMessageData(input: { orderId: string; subOrderId: string }): string {
const orderId = input.orderId || '4837328765348567000'
const subOrderId = input.subOrderId || orderId
const payTime = Math.floor(Date.now() / 1000)
return JSON.stringify({
biz: 2,
order_status: 2,
order_type: 0,
pay_amount: 1990,
pay_time: payTime,
pay_type: 1,
p_id: orderId,
s_ids: [subOrderId],
shop_id: 77977,
})
}
function printNextSteps(): void {
console.log('\n' + '='.repeat(72))
console.log('接下来核对(三处任一有货即说明推送通了):')
console.log(' 1) 后端日志:POST /api/v1/open/douyin-mashida/message')
console.log(' 2) ngrok 面板:http://127.0.0.1:4040 里的该请求')
console.log(' 3) 数据库:')
console.log(' docker exec order_site-postgres-1 psql -U postgres -d order_site -c')
console.log(
' "select id,platform_order_id,pay_status from orders where provider=\'douyin_mashida\' order by id desc limit 3;"',
)
console.log('\n⚠️ 推送地址指向 ngrok 临时域名:隧道一停/域名一换,平台就会推到失效地址。')
console.log(' 换地址后重跑本脚本订阅;联调结束用 --disable 关掉。')
}
// ---------------------------------------------------------------- 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, (_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> 公网地址(订阅用),会拼成 <url>/api/v1/open/douyin-mashida/message',
' --message-url <url> 直接指定推送地址(覆盖 --public-url 拼接结果)',
' --topics <list> 订阅主题,逗号分隔(默认取后台配置的 handledTopics)',
' --disable 关闭推送(status=0)',
' --test 向平台 messagetest 触发一次测试推送',
' --test-topic <topic> 测试消息主题,默认 trade_TradePaid',
' --order-id <id> 测试消息里带哪个订单号(建议用你的真单号)',
' --sub-order-id <id> 子订单号,默认与订单号相同',
' --skip-subscribe 只做 --test,不改订阅',
' --help',
].join('\n'),
)
}