diff --git a/apps/backend/src/services/order/webhook-service.js b/apps/backend/src/services/order/webhook-service.js index a7762a6b..637748e5 100644 --- a/apps/backend/src/services/order/webhook-service.js +++ b/apps/backend/src/services/order/webhook-service.js @@ -95,6 +95,48 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' }) } + const ignoreReason = resolveAgisoTradeIgnoreReason(parsed) + if (ignoreReason) { + await updateWebhookEvent(webhookEventId, { + processed: true, + process_error: `ignored_${ignoreReason}`, + related_order_id: null, + }) + + logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:当前通知不应再次触发履约发送', { + requestId, + webhookEventId, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + eventKey: parsed.eventKey, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + ignoreReason, + }) + + return { + accepted: true, + ignored: true, + ignoreReason, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + platformRaw: parsed.platformRaw, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + orderId: null, + totalAmountFen: Number(parsed.totalAmount || 0), + enriched: false, + enrichReason: 'skipped_ignored_event', + taskCount: 0, + messageDeliveries: [], + tasks: [], + } + } + const shouldProcess = await hasConfiguredOrderItems({ provider: parsed.provider, platform: parsed.platform, @@ -424,6 +466,10 @@ function verifyAgisoSignature({ rawJson, timestamp, sign }) { function resolveEventType(aopic, payload) { const normalized = String(aopic || '').trim() + if (isTradeFinishedPayload(payload)) { + return 'buyer_confirm_goods' + } + if (normalized === '1') { return 'payment_success' } @@ -436,7 +482,7 @@ function resolveEventType(aopic, payload) { return 'trade_create' } - if (normalized === '256') { + if (normalized === '16' || normalized === '256') { return 'buyer_confirm_goods' } @@ -452,6 +498,22 @@ function resolveEventType(aopic, payload) { return 'trade_event' } +export function resolveAgisoTradeIgnoreReason(parsed) { + if (!parsed || parsed.provider !== 'agiso') { + return '' + } + + if (parsed.eventType === 'buyer_confirm_goods') { + return 'buyer_confirm_goods' + } + + if (parsed.platform === 'xianyu' && isTradeFinishedPayload(parsed.rawPayload)) { + return 'buyer_confirm_goods' + } + + return '' +} + function resolveOrderStatus(eventType, payload) { const providerStatus = resolveProviderStatus(payload) @@ -770,6 +832,25 @@ function resolveProviderStatus(payload) { return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' } +function isTradeFinishedPayload(payload) { + const rawStatus = normalizeInteger( + pickFirstNonEmpty([payload?.order_status, payload?.orderStatus, payload?.status]), + ) + + if (rawStatus === 4) { + return true + } + + const statusText = pickFirstNonEmpty([ + payload?.Status, + payload?.status, + payload?.order_status_text, + payload?.orderStatusText, + ]).toUpperCase() + + return statusText === 'TRADE_FINISHED' +} + function normalizeProviderDateTime(value) { const normalized = String(value || '').trim() diff --git a/apps/backend/src/services/order/webhook-service.test.js b/apps/backend/src/services/order/webhook-service.test.js index 263ee0ff..ffd4a3d5 100644 --- a/apps/backend/src/services/order/webhook-service.test.js +++ b/apps/backend/src/services/order/webhook-service.test.js @@ -5,6 +5,7 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' import { parseAgisoTradeRequest, + resolveAgisoTradeIgnoreReason, shouldEnrichAgisoXianyuTrade, verifyAgisoSignatureWithSecret, } from './webhook-service.js' @@ -78,6 +79,48 @@ test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are in }), false) }) +test('parseAgisoTradeRequest maps buyer confirm goods webhook into ignored event type', () => { + const rawJson = '{"biz_order_id":2701836516013026052,"seller_id":"shop-9","seller_nick":"大锤商行","buyer_id":"buyer-1","buyer_name":"测试买家","order_status":4,"Status":"TRADE_FINISHED","total_fee":"95.88","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":1,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' + const timestamp = '1776096226' + const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim() + const sign = appSecret + ? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex') + : '' + + const parsed = parseAgisoTradeRequest({ + query: { + aopic: '16', + timestamp, + sign, + }, + body: { + json: rawJson, + fromPlatform: 'AldsIdle', + }, + }) + + assert.equal(parsed.platform, 'xianyu') + assert.equal(parsed.eventType, 'buyer_confirm_goods') + assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') +}) + +test('trade finished payload is still treated as buyer confirm goods when aopic drifts', () => { + const parsed = parseAgisoTradeRequest({ + query: { + aopic: '1', + timestamp: '1776096226', + sign: '', + }, + body: { + json: '{"biz_order_id":2701836516013026052,"order_status":4,"Status":"TRADE_FINISHED"}', + fromPlatform: 'xianyu', + }, + }) + + assert.equal(parsed.eventType, 'buyer_confirm_goods') + assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') +}) + test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => { const appSecret = 'unit-test-secret' const rawJson = '{"biz_order_id":"4502259793206016214"}' diff --git a/docs/xianyu_api/确认收货后通知.md b/docs/xianyu_api/确认收货后通知.md new file mode 100644 index 00000000..245ae94f --- /dev/null +++ b/docs/xianyu_api/确认收货后通知.md @@ -0,0 +1,43 @@ +确认收货后通知 +简要描述: 推送示例 + +确认收货后,推送对应消息。 +推送的签名请务必验证,以验证数据来源的合法性。验证方法参考以下说明。 +推送时,有可能消息重复推送。实际开发中,请一定要在使用消息前进行去重判断。主要依据是订单编号Tid和订单状态。 +推送方式,用jquery做示例:$.post('http://test.com/agiso?timestamp=11222212121&sign=f8aa165fc951f266667e0605d78b93af&aopic=32768', { json: '{"Tid":"592823138",......}' }) +推送参数: + +fromPlatform +string +Query/Get +平台,参数值:TbAcs,TbAlds,TbArs,Print,Acpr,PddAlds,AldsIdle,AldsJd,AldsDoudian,AldsKwai,AldsYouzan,AldsWeidian,AldsWxVideoShop,AldsXhs,Open +timestamp +Number +Query/Get +时间戳 +aopic +Number +Query/Get +推送类型,16:确认收货后; +sign +String +Query/Get +签名算法: +   将json和timestamp参数名和参数值组合起来(注意:json在前,timestamp在后),然后前后添加上AppSecret ,再进行Md5加密(加密算法参考接入指南-完整调用API示例代码中MD5算法)。 +例: +url:http://test.com/agiso?timestamp=11222212121&sign=f8aa165fc951f266667e0605d78b93af&aopic=256, +postData: { json: {"Tid":2067719225654838,"Status":"WAIT_BUYER_CONFIRM_GOODS",......,"TotalFee":"3.00"} }, +_appsecret: 9f8g9d78sg9d8f8ew9f89ds9f8ds9af8(开发者AppSecret), +连接后的串: 9f8g9d78sg9d8f8ew9f89ds9f8ds9af8json{"Tid":2067719225654838,"Status":"WAIT_BUYER_CONFIRM_GOODS",......,"TotalFee":"3.00"}timestamp112222121219f8g9d78sg9d8f8ew9f89ds9f8ds9af8 +再对连接后的字符串,进行MD5加密, +MD5结果: f8aa165fc951f266667e0605d78b93af(不区分大小写) +json +String +Form/Post +推送消息,如: +{ + "biz_order_id": 9112588, // 交易订单号 + "item_id": 1, // 商品Id + "order_status": 2,// 订单状态 0:未知状态、1:订单已创建、2:订单已付款、3:已发货、4:交易成功、5:已退款、6:交易关闭 + "seller_id": 1556356040640, // 闲鱼商家的Id +} \ No newline at end of file