diff --git a/apps/backend/package.json b/apps/backend/package.json index aeff0607..cd08c51e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -21,6 +21,7 @@ "mock:open91": "tsx scripts/mock-open91-order.ts", "douyin-mashida:set-public-url": "tsx scripts/douyin-mashida-set-public-url.ts", "douyin-mashida:preflight": "tsx scripts/douyin-mashida-preflight.ts", + "douyin-mashida:subscribe": "tsx scripts/douyin-mashida-subscribe.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "test:industry": "tsx scripts/test-kuaishou-industry.ts", "test:industry:http": "tsx scripts/test-kuaishou-industry-http.ts", diff --git a/apps/backend/scripts/douyin-mashida-subscribe.ts b/apps/backend/scripts/douyin-mashida-subscribe.ts new file mode 100644 index 00000000..a4ffbabc --- /dev/null +++ b/apps/backend/scripts/douyin-mashida-subscribe.ts @@ -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 { + // 配置在内存缓存里,脚本进程要先从 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 { + const messageUrl = String( + args.messageUrl || (input.publicUrl ? `${input.publicUrl}${MESSAGE_PATH}` : ''), + ) + const topics = String(args.topics || config.handledTopics.join(',')) + + const params: Record = { 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 { + 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'), + ) +}