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)。
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 抖音码速达「推送订阅 + 自测触发」—— 解决「下了单却什么都没收到」。
|
||||
*
|
||||
* 背景(实测踩过):填好 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'),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user