Files
order_site/apps/backend/scripts/douyin-mashida-subscribe.ts
T
yml2213 bce1168bbb chore(douyin-mashida): 新增推送订阅与自测脚本
排查「下了单但本地什么都没收到」时定位到根因:**平台推送是订阅制的**,
只填 uid/secret 不会收到任何消息,必须调「消息设置」接口 `message.config`
配置 `message_url` + `topics` + `status=1`。此脚本把这一步自动化:

- 订阅:从后台配置读真实 uid/secret → 调 message.config(status=1)
  → 打印平台返回;`--disable` 可关闭推送(隧道失效时避免平台一直推空地址)
- 自测:打平台 `messagetest` 入口尝试触发一次推送
  (注:该入口对 data 的解析要求未探明,实测各种编码/字段形态都返回
   `20006 data参数内容解析异常`,故不把它当作必过项;真实下单才是权威验证)
- 顺带修复 lint:`Boolean(args.test)` 属多余的布尔转换

实测:订阅返回 `code=10000 成功`;随后用真密钥按平台形态签名推送自有订单,
后端成功调真实 orderDetail 建单并完成 coupons.syncV2(sync_status=success)。
2026-10-09 12:43:34 +08:00

253 lines
9.7 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 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 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> {
// 配置在内存缓存里,脚本进程要先从 DB 载入,否则拿到的是默认值(没有真实密钥)
await loadAppConfigCacheFromDatabase()
const config = getDouyinMashidaSourceConfig()
console.log('抖音码速达 · 推送订阅 / 自测')
console.log('='.repeat(72))
console.log(`网关 ${config.baseUrl}`)
console.log(`uid ${config.uid || '(未配置)'}`)
console.log(
`secret ${config.secret ? `${config.secret.slice(0, 6)}****${config.secret.slice(-6)}` : '(未配置)'}`,
)
if (!config.uid || !config.secret) {
throw new Error('后台配置里没有真实的 uid / secret,先在面板里填好')
}
if (!onlyTest) {
await applyMessageConfig(config, { disable, publicUrl })
}
if (args.test) {
await triggerTestPush(config, {
topic: String(args.testTopic || 'trade_TradePaid'),
orderId: String(args.orderId || ''),
subOrderId: String(args.subOrderId || ''),
})
}
printNextSteps()
}
/** 调 message.config 设置「推送地址 / 订阅主题 / 开关」。 */
async function applyMessageConfig(
config: DouyinMashidaSourceConfig,
input: { disable: boolean; publicUrl: string },
): Promise<void> {
const messageUrl = String(
args.messageUrl || (input.publicUrl ? `${input.publicUrl}${MESSAGE_PATH}` : ''),
)
const topics = String(args.topics || config.handledTopics.join(','))
const params: Record<string, string> = { status: input.disable ? '0' : '1' }
if (!input.disable) {
if (!messageUrl) {
throw new Error('缺少 --public-url(或 --message-url):订阅必须告诉平台往哪推')
}
params.message_url = messageUrl
params.topics = topics
}
console.log(`\n[订阅] method=message.config status=${params.status}`)
if (!input.disable) {
console.log(` message_url = ${messageUrl}`)
console.log(` topics = ${topics}`)
}
try {
const response = await douyinMashidaGatewayRequest({
method: 'message.config',
params,
config,
})
console.log(` ✅ 平台返回 code=${response.code} message=${response.message}`)
} catch (error) {
console.error(` ❌ 订阅失败:${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
}
/**
* 打平台 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'),
)
}