From 26739235e408695e64adb2f8df47331f0339a95f Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 21 May 2026 15:04:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=A1=A5=E5=85=85Webhook?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/order/webhook-service.js | 33 ++- .../services/order/webhook-service.test.js | 205 ++++++++++++++++++ docs/backend-typescript-migration-plan.md | 8 + 3 files changed, 237 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/services/order/webhook-service.js b/apps/backend/src/services/order/webhook-service.js index 1bdc3713..2fc7a1ba 100644 --- a/apps/backend/src/services/order/webhook-service.js +++ b/apps/backend/src/services/order/webhook-service.js @@ -80,6 +80,19 @@ export function buildWebhookEventInput(requestLike, parsed) { } async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } = {}) { + return executeAgisoTradeWebhookWithDeps(parsed, webhookEventId, { requestId }) +} + +export async function executeAgisoTradeWebhookWithDeps( + parsed, + webhookEventId, + { requestId = '' } = {}, + deps = {}, +) { + const updateEvent = deps.updateWebhookEvent || updateWebhookEvent + const hasConfiguredItems = deps.hasConfiguredOrderItems || hasConfiguredOrderItems + const upsertOrder = deps.upsertOrderFromWebhook || upsertOrderFromWebhook + try { if (!parsed.signatureValid) { throw createHttpError('验签失败', { @@ -97,7 +110,7 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' const ignoreReason = resolveAgisoTradeIgnoreReason(parsed) if (ignoreReason) { - await updateWebhookEvent(webhookEventId, { + await updateEvent(webhookEventId, { processed: true, process_error: `ignored_${ignoreReason}`, related_order_id: null, @@ -137,7 +150,7 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } } - const shouldProcess = await hasConfiguredOrderItems({ + const shouldProcess = await hasConfiguredItems({ provider: parsed.provider, platform: parsed.platform, shopId: parsed.shopId, @@ -146,7 +159,7 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' }) if (!shouldProcess) { - await updateWebhookEvent(webhookEventId, { + await updateEvent(webhookEventId, { processed: true, process_error: 'ignored_unconfigured_product', related_order_id: null, @@ -187,9 +200,9 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' const enriched = await enrichTradeBeforeUpsert(parsed, { requestId, webhookEventId, - }) - const result = await upsertOrderFromWebhook(enriched.parsed) - await updateWebhookEvent(webhookEventId, { + }, deps) + const result = await upsertOrder(enriched.parsed) + await updateEvent(webhookEventId, { processed: true, process_error: result.ignored ? 'ignored_unconfigured_product' : '', related_order_id: result.order?.id || null, @@ -239,7 +252,7 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' })), } } catch (error) { - await updateWebhookEvent(webhookEventId, { + await updateEvent(webhookEventId, { processed: false, process_error: error instanceof Error ? error.message : String(error || ''), related_order_id: null, @@ -267,7 +280,9 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } } -async function enrichTradeBeforeUpsert(parsed, { requestId = '', webhookEventId = null } = {}) { +async function enrichTradeBeforeUpsert(parsed, { requestId = '', webhookEventId = null } = {}, deps = {}) { + const enrichOrder = deps.enrichAgisoXianyuTradeOrder || enrichAgisoXianyuTradeOrder + if (parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { return { parsed, enriched: false, reason: 'not_supported' } } @@ -276,7 +291,7 @@ async function enrichTradeBeforeUpsert(parsed, { requestId = '', webhookEventId return { parsed, enriched: false, reason: 'enough_fields_present' } } - const detailResult = await enrichAgisoXianyuTradeOrder(parsed, { requestId }) + const detailResult = await enrichOrder(parsed, { requestId }) const nextParsed = detailResult?.parsed || parsed logWebhook('[webhook-service/agiso]', 'Agiso webhook 订单补查结束', { diff --git a/apps/backend/src/services/order/webhook-service.test.js b/apps/backend/src/services/order/webhook-service.test.js index 7b557857..8bbf2b9a 100644 --- a/apps/backend/src/services/order/webhook-service.test.js +++ b/apps/backend/src/services/order/webhook-service.test.js @@ -5,12 +5,46 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' import { buildWebhookEventInput, + executeAgisoTradeWebhookWithDeps, parseAgisoTradeRequest, resolveAgisoTradeIgnoreReason, shouldEnrichAgisoXianyuTrade, verifyAgisoSignatureWithSecret, } from './webhook-service.js' +function createParsedTrade(patch = {}) { + return { + provider: 'agiso', + platform: 'xianyu', + platformRaw: 'xianyu', + eventType: 'payment_success', + eventKey: 'agiso:xianyu:shop-9:4502259793206016214:payment_success:1713097840', + signatureValid: true, + platformOrderId: '4502259793206016214', + shopId: 'shop-9', + shopName: '咸鱼店铺', + shopIdAliases: ['shop-9'], + orderStatus: 'paid', + payStatus: 'paid', + buyerId: 'buyer-1', + buyerName: '测试买家', + receiverContact: '', + totalAmount: 2550, + currency: 'CNY', + paidAt: '2026-04-14T12:30:40.000Z', + rawPayload: {}, + items: [ + { + skuCode: 'dnf-cdk-a', + skuName: 'DNF礼包', + quantity: 1, + spec: { title: 'DNF礼包' }, + }, + ], + ...patch, + } +} + test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => { const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' const timestamp = '1713097840' @@ -180,3 +214,174 @@ test('buildWebhookEventInput preserves raw request payload for later replay', () assert.deepEqual(JSON.parse(input.bodyJson), { json: '{"biz_order_id":"4502259793206016214"}' }) assert.match(input.createdAt, /^\d{4}-\d{2}-\d{2}T/) }) + +test('executeAgisoTradeWebhookWithDeps records invalid signature failure', async () => { + const updates = [] + + await assert.rejects( + () => executeAgisoTradeWebhookWithDeps( + createParsedTrade({ signatureValid: false }), + 701, + { requestId: 'req-invalid-signature' }, + { + updateWebhookEvent: async (webhookEventId, patch) => { + updates.push({ webhookEventId, patch }) + return { id: webhookEventId, ...patch } + }, + hasConfiguredOrderItems: async () => { + throw new Error('should not check configured items when signature is invalid') + }, + }, + ), + /验签失败/, + ) + + assert.deepEqual(updates, [ + { + webhookEventId: 701, + patch: { + processed: false, + process_error: '验签失败', + related_order_id: null, + }, + }, + ]) +}) + +test('executeAgisoTradeWebhookWithDeps ignores unconfigured products before upsert', async () => { + const updates = [] + let upsertCalled = false + + const result = await executeAgisoTradeWebhookWithDeps( + createParsedTrade(), + 702, + { requestId: 'req-unconfigured' }, + { + hasConfiguredOrderItems: async (input) => { + assert.equal(input.provider, 'agiso') + assert.equal(input.platform, 'xianyu') + assert.equal(input.shopId, 'shop-9') + assert.equal(input.items.length, 1) + return false + }, + updateWebhookEvent: async (webhookEventId, patch) => { + updates.push({ webhookEventId, patch }) + return { id: webhookEventId, ...patch } + }, + upsertOrderFromWebhook: async () => { + upsertCalled = true + return {} + }, + }, + ) + + assert.equal(result.accepted, true) + assert.equal(result.ignored, true) + assert.equal(result.ignoreReason, 'unconfigured_product') + assert.equal(result.orderId, null) + assert.equal(upsertCalled, false) + assert.deepEqual(updates, [ + { + webhookEventId: 702, + patch: { + processed: true, + process_error: 'ignored_unconfigured_product', + related_order_id: null, + }, + }, + ]) +}) + +test('executeAgisoTradeWebhookWithDeps enriches incomplete xianyu trade and upserts order', async () => { + const updates = [] + const enrichCalls = [] + const upsertCalls = [] + const parsed = createParsedTrade({ + totalAmount: 0, + buyerName: '', + shopName: '', + items: [ + { + skuCode: 'dnf-cdk-a', + skuName: 'dnf-cdk-a', + quantity: 1, + spec: {}, + }, + ], + }) + + const result = await executeAgisoTradeWebhookWithDeps( + parsed, + 703, + { requestId: 'req-success' }, + { + hasConfiguredOrderItems: async () => true, + enrichAgisoXianyuTradeOrder: async (input, options) => { + enrichCalls.push({ input, options }) + return { + parsed: { + ...input, + totalAmount: 1990, + buyerName: '补查买家', + shopName: '补查店铺', + items: [ + { + skuCode: 'dnf-cdk-a', + skuName: 'DNF礼包', + quantity: 1, + spec: { title: 'DNF礼包' }, + }, + ], + }, + enriched: true, + reason: '', + } + }, + upsertOrderFromWebhook: async (input) => { + upsertCalls.push(input) + return { + ignored: false, + ignoreReason: '', + order: { id: 801 }, + tasks: [ + { id: 901, task_no: 'DT901', task_status: 'paid' }, + ], + messageDeliveries: [{ id: 1001 }], + } + }, + updateWebhookEvent: async (webhookEventId, patch) => { + updates.push({ webhookEventId, patch }) + return { id: webhookEventId, ...patch } + }, + }, + ) + + assert.equal(enrichCalls.length, 1) + assert.equal(enrichCalls[0].options.requestId, 'req-success') + assert.equal(upsertCalls.length, 1) + assert.equal(upsertCalls[0].totalAmount, 1990) + assert.equal(upsertCalls[0].buyerName, '补查买家') + assert.equal(result.accepted, true) + assert.equal(result.ignored, false) + assert.equal(result.enriched, true) + assert.equal(result.orderId, 801) + assert.equal(result.totalAmountFen, 1990) + assert.equal(result.taskCount, 1) + assert.deepEqual(result.tasks, [ + { + taskId: 901, + taskNo: 'DT901', + status: 'paid', + }, + ]) + assert.deepEqual(updates, [ + { + webhookEventId: 703, + patch: { + processed: true, + process_error: '', + related_order_id: 801, + }, + }, + ]) +}) diff --git a/docs/backend-typescript-migration-plan.md b/docs/backend-typescript-migration-plan.md index 5a4f4bb7..90f81aa0 100644 --- a/docs/backend-typescript-migration-plan.md +++ b/docs/backend-typescript-migration-plan.md @@ -365,6 +365,14 @@ - `npm run typecheck` - `npm run build` - `npm test` 共 136 个用例通过 +63. webhook service 迁移前补编排测试: + - 新增 `executeAgisoTradeWebhookWithDeps`,便于隔离 webhook event repository、商品匹配、订单 upsert 与订单详情补查依赖 + - 覆盖验签失败落错误、未配置商品提前忽略、咸鱼订单补查后 upsert 三个关键分支 +64. Docker 内验证通过: + - `src/services/order/webhook-service.test.js` 共 9 个用例通过 + - `npm run typecheck` + - `npm run build` + - `npm test` 共 139 个用例通过 ## 下一步建议