From 006e68f7f63cf7ca627e3367150435633293caf9 Mon Sep 17 00:00:00 2001 From: yml Date: Tue, 14 Apr 2026 10:57:15 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=90=8E=E7=AB=AF=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E9=93=BE=E8=B7=AF=E8=87=AA=E5=8A=A8=E5=8C=96=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/package.json | 1 + .../src/services/order/inventory-service.js | 38 ++++++- .../services/order/inventory-service.test.js | 73 ++++++++++++ .../src/services/order/webhook-service.js | 21 ++-- .../services/order/webhook-service.test.js | 106 ++++++++++++++++++ .../agiso/xianyu/auto-delivery-service.js | 4 +- .../xianyu/auto-delivery-service.test.js | 30 +++++ apps/backend/tsconfig.json | 4 +- 8 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 apps/backend/src/services/order/inventory-service.test.js create mode 100644 apps/backend/src/services/order/webhook-service.test.js diff --git a/apps/backend/package.json b/apps/backend/package.json index 132fcd00..2ebad5f7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -10,6 +10,7 @@ "seed:dev-data": "node scripts/seed-dev-data.js", "db:migrate": "node src/db/migrate.js", "dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.js", + "test": "node --test", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node src/index.js" }, diff --git a/apps/backend/src/services/order/inventory-service.js b/apps/backend/src/services/order/inventory-service.js index 9839de30..8d270d5e 100644 --- a/apps/backend/src/services/order/inventory-service.js +++ b/apps/backend/src/services/order/inventory-service.js @@ -6,20 +6,52 @@ import { import { nowIso } from '../../utils/time.js' export async function reserveInventoryForTask({ skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }) { + return reserveInventoryForTaskWithDeps( + { skuCode, taskId, credentialType, roleKey }, + { + assignReservedInventoryItem, + findFirstAvailableInventoryItemBySkuCode, + nowIso, + }, + ) +} + +export async function reserveInventoryForTaskWithDeps( + { skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }, + { + assignReservedInventoryItem: assignReserved = assignReservedInventoryItem, + findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode, + nowIso: getNowIso = nowIso, + } = {}, +) { if (!skuCode) { return null } - const available = await findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType) + const available = await findAvailable(skuCode, credentialType) if (!available) { return null } - return assignReservedInventoryItem(available.id, taskId, nowIso(), roleKey) + return assignReserved(available.id, taskId, getNowIso(), roleKey) } export async function releaseReservedInventoryItems(inventoryItemIds = [], updatedAt = nowIso()) { + return releaseReservedInventoryItemsWithDeps( + inventoryItemIds, + updatedAt, + { releaseReservedInventoryItem }, + ) +} + +export async function releaseReservedInventoryItemsWithDeps( + inventoryItemIds = [], + updatedAt = nowIso(), + { + releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem, + } = {}, +) { const released = [] for (const inventoryItemId of inventoryItemIds) { @@ -27,7 +59,7 @@ export async function releaseReservedInventoryItems(inventoryItemIds = [], updat continue } - const item = await releaseReservedInventoryItem(inventoryItemId, updatedAt) + const item = await releaseReserved(inventoryItemId, updatedAt) if (item) { released.push(item) } diff --git a/apps/backend/src/services/order/inventory-service.test.js b/apps/backend/src/services/order/inventory-service.test.js new file mode 100644 index 00000000..80c62f10 --- /dev/null +++ b/apps/backend/src/services/order/inventory-service.test.js @@ -0,0 +1,73 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + releaseReservedInventoryItemsWithDeps, + reserveInventoryForTaskWithDeps, +} from './inventory-service.js' + +test('reserveInventoryForTaskWithDeps returns null when skuCode is missing', async () => { + let findCalled = false + + const result = await reserveInventoryForTaskWithDeps( + { skuCode: '', taskId: 12 }, + { + findFirstAvailableInventoryItemBySkuCode: async () => { + findCalled = true + return null + }, + }, + ) + + assert.equal(result, null) + assert.equal(findCalled, false) +}) + +test('reserveInventoryForTaskWithDeps reserves first available inventory item with defaults', async () => { + const calls = [] + const result = await reserveInventoryForTaskWithDeps( + { skuCode: 'dnf-cdk-a', taskId: 88 }, + { + findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType) => { + calls.push(['find', skuCode, credentialType]) + return { id: 321 } + }, + assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => { + calls.push(['assign', inventoryItemId, taskId, updatedAt, roleKey]) + return { id: inventoryItemId, reserved_by_task_id: taskId, role_key: roleKey, updated_at: updatedAt } + }, + nowIso: () => '2026-04-14T12:00:00.000Z', + }, + ) + + assert.deepEqual(calls, [ + ['find', 'dnf-cdk-a', 'tencent_code'], + ['assign', 321, 88, '2026-04-14T12:00:00.000Z', 'primary_code'], + ]) + assert.equal(result?.id, 321) + assert.equal(result?.reserved_by_task_id, 88) +}) + +test('releaseReservedInventoryItemsWithDeps skips empty ids and keeps successful releases only', async () => { + const calls = [] + const result = await releaseReservedInventoryItemsWithDeps( + [0, null, 11, 12, '', 13], + '2026-04-14T13:00:00.000Z', + { + releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => { + calls.push([inventoryItemId, updatedAt]) + return inventoryItemId === 12 ? null : { id: inventoryItemId } + }, + }, + ) + + assert.deepEqual(calls, [ + [11, '2026-04-14T13:00:00.000Z'], + [12, '2026-04-14T13:00:00.000Z'], + [13, '2026-04-14T13:00:00.000Z'], + ]) + assert.deepEqual(result, [ + { id: 11 }, + { id: 13 }, + ]) +}) diff --git a/apps/backend/src/services/order/webhook-service.js b/apps/backend/src/services/order/webhook-service.js index e37eb32b..a7762a6b 100644 --- a/apps/backend/src/services/order/webhook-service.js +++ b/apps/backend/src/services/order/webhook-service.js @@ -60,7 +60,7 @@ export async function replayAgisoTradeWebhookEvent(webhookEvent) { return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId }) } -function buildWebhookEventInput(requestLike, parsed) { +export function buildWebhookEventInput(requestLike, parsed) { return { provider: parsed.provider, platform: parsed.platform, @@ -258,7 +258,7 @@ async function enrichTradeBeforeUpsert(parsed, { requestId = '', webhookEventId } } -function shouldEnrichAgisoXianyuTrade(parsed) { +export function shouldEnrichAgisoXianyuTrade(parsed) { if (!parsed || parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { return false } @@ -306,7 +306,7 @@ function hasDetailedOrderItems(items) { }) } -function parseAgisoTradeRequest(requestLike) { +export function parseAgisoTradeRequest(requestLike) { const query = normalizeRecord(requestLike.query) const body = normalizeRecord(requestLike.body) const rawJson = String(body.json || '').trim() @@ -386,10 +386,10 @@ function parseAgisoTradeRequest(requestLike) { } } -function verifyAgisoSignature({ rawJson, timestamp, sign }) { - const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim() +export function verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) { + const normalizedSecret = String(appSecret || '').trim() - if (!appSecret) { + if (!normalizedSecret) { return true } @@ -399,7 +399,7 @@ function verifyAgisoSignature({ rawJson, timestamp, sign }) { const documentedDigest = crypto .createHash('md5') - .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8') + .update(`${normalizedSecret}json${rawJson}timestamp${timestamp}${normalizedSecret}`, 'utf8') .digest('hex') .toLowerCase() @@ -409,13 +409,18 @@ function verifyAgisoSignature({ rawJson, timestamp, sign }) { const legacyDigest = crypto .createHash('md5') - .update(`${appSecret}${rawJson}${timestamp}`, 'utf8') + .update(`${normalizedSecret}${rawJson}${timestamp}`, 'utf8') .digest('hex') .toLowerCase() return legacyDigest === sign } +function verifyAgisoSignature({ rawJson, timestamp, sign }) { + const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim() + return verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) +} + function resolveEventType(aopic, payload) { const normalized = String(aopic || '').trim() diff --git a/apps/backend/src/services/order/webhook-service.test.js b/apps/backend/src/services/order/webhook-service.test.js new file mode 100644 index 00000000..263ee0ff --- /dev/null +++ b/apps/backend/src/services/order/webhook-service.test.js @@ -0,0 +1,106 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import { + parseAgisoTradeRequest, + shouldEnrichAgisoXianyuTrade, + verifyAgisoSignatureWithSecret, +} from './webhook-service.js' + +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' + 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: '1', + timestamp, + sign, + }, + body: { + json: rawJson, + fromPlatform: 'xianyu', + }, + }) + + assert.equal(parsed.provider, 'agiso') + assert.equal(parsed.platform, 'xianyu') + assert.equal(parsed.eventType, 'payment_success') + assert.equal(parsed.signatureValid, true) + assert.equal(parsed.platformOrderId, '4502259793206016214') + assert.equal(parsed.shopId, 'shop-9') + assert.equal(parsed.shopName, '咸鱼店铺') + assert.equal(parsed.payStatus, 'paid') + assert.equal(parsed.orderStatus, 'paid') + assert.equal(parsed.totalAmount, 2550) + assert.equal(parsed.items.length, 1) + assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a') + assert.equal(parsed.items[0]?.quantity, 2) +}) + +test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => { + assert.equal(shouldEnrichAgisoXianyuTrade({ + provider: 'agiso', + platform: 'xianyu', + totalAmount: 0, + buyerName: '', + shopName: '', + items: [], + }), true) + + assert.equal(shouldEnrichAgisoXianyuTrade({ + provider: 'agiso', + platform: 'xianyu', + totalAmount: 2550, + buyerName: '测试买家', + shopName: '咸鱼店铺', + items: [ + { + skuCode: 'dnf-cdk-a', + spec: { + title: 'DNF礼包', + }, + }, + ], + }), false) + + assert.equal(shouldEnrichAgisoXianyuTrade({ + provider: 'agiso', + platform: 'taobao', + totalAmount: 0, + items: [], + }), false) +}) + +test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => { + const appSecret = 'unit-test-secret' + const rawJson = '{"biz_order_id":"4502259793206016214"}' + const timestamp = '1713097840' + const documentedSign = crypto + .createHash('md5') + .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8') + .digest('hex') + const legacySign = crypto + .createHash('md5') + .update(`${appSecret}${rawJson}${timestamp}`, 'utf8') + .digest('hex') + + assert.equal( + verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }), + true, + ) + assert.equal( + verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }), + true, + ) + assert.equal( + verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }), + false, + ) +}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js index d438b89d..958ff3a3 100644 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js +++ b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js @@ -262,7 +262,7 @@ function generateSign(params, appSecret) { return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() } -function isAgisoAutoDeliverySuccess(statusCode, payload) { +export function isAgisoAutoDeliverySuccess(statusCode, payload) { if (statusCode < 200 || statusCode >= 300) { return false } @@ -286,7 +286,7 @@ function isAgisoAutoDeliverySuccess(statusCode, payload) { return false } -function resolveAgisoAutoDeliveryErrorMessage(payload, rawText, statusCode) { +export function resolveAgisoAutoDeliveryErrorMessage(payload, rawText, statusCode) { if (payload && typeof payload === 'object') { for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) { const normalized = String(value || '').trim() diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.js b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.js index 10edc311..ad6d607d 100644 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.js +++ b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.js @@ -4,6 +4,8 @@ import assert from 'node:assert/strict' import { hasAgisoAutoDeliverySucceeded, isOrderReadyForAgisoAutoDelivery, + isAgisoAutoDeliverySuccess, + resolveAgisoAutoDeliveryErrorMessage, } from './auto-delivery-service.js' test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => { @@ -28,3 +30,31 @@ test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () { context_json: { agisoAutoDelivery: { status: 'failed' } } }, ]), false) }) + +test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => { + assert.equal(isAgisoAutoDeliverySuccess(200, {}), true) + assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true) + assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true) + assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false) + assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false) +}) + +test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => { + assert.equal( + resolveAgisoAutoDeliveryErrorMessage( + { Error_Msg: '库存不足' }, + 'raw body', + 400, + ), + '库存不足', + ) + + assert.equal( + resolveAgisoAutoDeliveryErrorMessage( + {}, + 'fallback raw body', + 400, + ), + 'fallback raw body', + ) +}) diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 6748b249..4039b1cc 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -19,9 +19,11 @@ "src/repositories/task-inventory-binding-repo.js", "src/repositories/task-repo.js", "src/repositories/webhook-event-repo.js", - "src/services/admin/admin-read-helpers.js", + "src/services/admin/admin-inventory-read-helpers.js", + "src/services/admin/admin-order-read-helpers.js", "src/services/admin/admin-read-shared-helpers.js", "src/services/admin/admin-task-read-helpers.js", + "src/services/admin/admin-webhook-read-helpers.js", "src/services/admin/admin-dashboard-service.js", "src/services/admin/admin-message-delivery-service.js", "src/services/admin/admin-platform-config-service.js",