/** * 抖音码速达「推送订阅 + 自测触发」—— 解决「下了单却什么都没收到」。 * * 背景(实测踩过):填好 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 { 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 { 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(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 { 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 { const result = await api(token, 'GET', CONFIG_PATH) return (result.data ?? {}) as JsonObject } async function api( 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 { 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 { const parsed: Record = {} 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 公网地址(订阅用),会拼成 /api/v1/open/douyin-mashida/message', ' --message-url 直接指定推送地址(覆盖 --public-url 拼接结果)', ' --topics 订阅主题,逗号分隔(默认取后台配置的 handledTopics)', ' --disable 关闭推送(status=0)', ' --test 向平台 messagetest 触发一次测试推送', ' --test-topic 测试消息主题,默认 trade_TradePaid', ' --order-id 测试消息里带哪个订单号(建议用你的真单号)', ' --sub-order-id 子订单号,默认与订单号相同', ' --skip-subscribe 只做 --test,不改订阅', ' --help', ].join('\n'), ) }