import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' import { createWebhookEvent, updateWebhookEvent } from '../../repositories/webhook-event-repo.js' import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' import { extractAgisoTradeOrderItemSources, extractAgisoTradePayload, resolveAgisoTradePlatformOrderId, } from './agiso-trade-parsing.js' import { upsertOrderFromWebhook } from './order-service.js' import { hasConfiguredOrderItems } from './product-match-service.js' import { createHttpError } from '../../utils/http.js' import { parseJsonObject } from '../../utils/json.js' import { logWebhook } from '../../utils/logger.js' import { parseAmountToFen } from '../../utils/money.js' import { nowIso } from '../../utils/time.js' export async function processAgisoTradeWebhook(requestLike) { const parsed = parseAgisoTradeRequest(requestLike) const webhookEvent = await createWebhookEvent(buildWebhookEventInput(requestLike, parsed)) logWebhook('[webhook-service/agiso]', 'Webhook 已解析并写入 webhook_events', { requestId: requestLike.requestId || '', webhookEventId: webhookEvent.id, provider: parsed.provider, platform: parsed.platform, shopId: parsed.shopId, shopName: parsed.shopName, eventKey: parsed.eventKey, eventType: parsed.eventType, platformOrderId: parsed.platformOrderId, signatureValid: parsed.signatureValid, }) return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId || '' }) } export async function replayAgisoTradeWebhookEvent(webhookEvent) { const requestLike = { requestId: `replay-${webhookEvent.id}`, headers: safeParseJson(webhookEvent.headers_json), query: safeParseJson(webhookEvent.query_json), body: safeParseJson(webhookEvent.body_json), } const parsed = parseAgisoTradeRequest(requestLike) logWebhook('[webhook-service/agiso]', '开始重放 webhook 事件', { requestId: requestLike.requestId, webhookEventId: webhookEvent.id, provider: parsed.provider, platform: parsed.platform, shopId: parsed.shopId, shopName: parsed.shopName, eventKey: parsed.eventKey, eventType: parsed.eventType, platformOrderId: parsed.platformOrderId, }) return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId }) } export function buildWebhookEventInput(requestLike, parsed) { return { provider: parsed.provider, platform: parsed.platform, shopId: parsed.shopId, shopName: parsed.shopName, eventType: parsed.eventType, eventKey: parsed.eventKey, signatureValid: parsed.signatureValid, headersJson: JSON.stringify(requestLike.headers || {}), queryJson: JSON.stringify(requestLike.query || {}), bodyJson: JSON.stringify(requestLike.body || {}), processed: false, processError: '', relatedOrderId: null, createdAt: nowIso(), } } async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } = {}) { try { if (!parsed.signatureValid) { throw createHttpError('验签失败', { statusCode: 400, errorCode: 'invalid_signature', }) } if (!parsed.platformOrderId) { throw createHttpError('缺少平台订单号', { statusCode: 400, errorCode: 'missing_platform_order_id', }) } 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, shopId: parsed.shopId, items: parsed.items, }) if (!shouldProcess) { await updateWebhookEvent(webhookEventId, { processed: true, process_error: 'ignored_unconfigured_product', related_order_id: null, }) logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:未命中任何已配置履约商品', { requestId, webhookEventId, provider: parsed.provider, platform: parsed.platform, shopId: parsed.shopId, shopName: parsed.shopName, platformOrderId: parsed.platformOrderId, rawItemCount: Array.isArray(parsed.items) ? parsed.items.length : 0, }) return { accepted: true, ignored: true, ignoreReason: 'unconfigured_product', 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_unconfigured_product', taskCount: 0, messageDeliveries: [], tasks: [], } } const enriched = await enrichTradeBeforeUpsert(parsed, { requestId, webhookEventId, }) const result = await upsertOrderFromWebhook(enriched.parsed) await updateWebhookEvent(webhookEventId, { processed: true, process_error: result.ignored ? 'ignored_unconfigured_product' : '', related_order_id: result.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: enriched.parsed.platformOrderId, orderId: result.order?.id || null, ignored: Boolean(result.ignored), ignoreReason: String(result.ignoreReason || ''), enriched: enriched.enriched, enrichReason: enriched.reason || '', totalAmountFen: Number(enriched.parsed.totalAmount || 0), taskCount: result.tasks.length, messageDeliveryCount: Array.isArray(result.messageDeliveries) ? result.messageDeliveries.length : 0, }) return { accepted: true, provider: enriched.parsed.provider, platform: enriched.parsed.platform, shopId: enriched.parsed.shopId, shopName: enriched.parsed.shopName, platformRaw: enriched.parsed.platformRaw, eventType: enriched.parsed.eventType, platformOrderId: enriched.parsed.platformOrderId, orderId: result.order?.id || null, totalAmountFen: Number(enriched.parsed.totalAmount || 0), ignored: Boolean(result.ignored), ignoreReason: String(result.ignoreReason || ''), enriched: enriched.enriched, enrichReason: enriched.reason || '', taskCount: result.tasks.length, messageDeliveries: result.messageDeliveries || [], tasks: result.tasks.map((task) => ({ taskId: task.id, taskNo: task.task_no, status: task.task_status, })), } } catch (error) { await updateWebhookEvent(webhookEventId, { processed: false, process_error: error instanceof Error ? error.message : String(error || ''), 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, signatureValid: parsed.signatureValid, error, }, { level: 'error' }, ) throw error } } async function enrichTradeBeforeUpsert(parsed, { requestId = '', webhookEventId = null } = {}) { if (parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { return { parsed, enriched: false, reason: 'not_supported' } } if (!shouldEnrichAgisoXianyuTrade(parsed)) { return { parsed, enriched: false, reason: 'enough_fields_present' } } const detailResult = await enrichAgisoXianyuTradeOrder(parsed, { requestId }) const nextParsed = detailResult?.parsed || parsed logWebhook('[webhook-service/agiso]', 'Agiso webhook 订单补查结束', { requestId, webhookEventId, provider: nextParsed.provider, platform: nextParsed.platform, shopId: nextParsed.shopId, platformOrderId: nextParsed.platformOrderId, enriched: Boolean(detailResult?.enriched), reason: String(detailResult?.reason || ''), totalAmountFen: Number(nextParsed.totalAmount || 0), paidAt: nextParsed.paidAt || null, errorMessage: String(detailResult?.errorMessage || ''), }) return { parsed: nextParsed, enriched: Boolean(detailResult?.enriched), reason: String(detailResult?.reason || ''), errorMessage: String(detailResult?.errorMessage || ''), } } export function shouldEnrichAgisoXianyuTrade(parsed) { if (!parsed || parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { return false } if (Number(parsed.totalAmount || 0) <= 0) { return true } if (!String(parsed.buyerName || '').trim()) { return true } if (!String(parsed.shopName || '').trim()) { return true } if (!hasDetailedOrderItems(parsed.items)) { return true } return false } function hasDetailedOrderItems(items) { const normalizedItems = Array.isArray(items) ? items : [] if (normalizedItems.length === 0) { return false } return normalizedItems.some((item) => { const spec = isPlainObject(item?.spec) ? item.spec : {} const title = pickFirstNonEmpty([ spec.title, spec.Title, spec.itemTitle, spec.item_title, spec.goods_name, spec.goodsName, item?.skuName, ]) const skuCode = String(item?.skuCode || '').trim() return Boolean(title && title !== skuCode) }) } export function parseAgisoTradeRequest(requestLike) { const query = normalizeRecord(requestLike.query) const body = normalizeRecord(requestLike.body) const rawJson = String(body.json || '').trim() const payload = rawJson ? parsePayloadJson(rawJson) : extractAgisoTradePayload(body) const timestamp = String(query.timestamp || '').trim() const sign = String(query.sign || '').trim().toLowerCase() const provider = 'agiso' const platformRaw = pickFirstNonEmpty([ query.fromPlatform, query.from_platform, body.fromPlatform, body.from_platform, payload.fromPlatform, payload.FromPlatform, payload.platform, payload.Platform, ]) const platform = resolveBusinessPlatform(platformRaw) const shop = resolveShop(payload) const eventType = resolveEventType(query.aopic, payload) const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign }) const platformOrderId = resolveAgisoTradePlatformOrderId(payload) return { provider, platform, platformRaw, eventType, eventKey: [provider, platform || 'unknown', shop.shopId || 'unknown', platformOrderId || 'unknown', eventType, timestamp || 'na'].join(':'), signatureValid, platformOrderId, shopId: shop.shopId, shopName: shop.shopName, orderStatus: resolveOrderStatus(eventType, payload), payStatus: resolvePayStatus(eventType, payload), buyerId: pickFirstNonEmpty([ payload.buyer_id, payload.buyerId, payload.BuyerId, payload.openid, payload.BuyerOpenUid, payload.buyer_open_uid, payload.buyerOpenUid, ]), buyerName: pickFirstNonEmpty([ payload.buyer_name, payload.buyerName, payload.BuyerName, payload.nick, payload.BuyerNick, payload.buyer_nick, payload.buyerNick, ]), receiverContact: pickFirstNonEmpty([ payload.receiver_contact, payload.receiverContact, payload.mobile, payload.phone, payload.receiver_mobile, payload.receiverMobile, ]), totalAmount: parseAmountToFen( pickFirstNonEmpty([ payload.total_fee, payload.totalFee, payload.TotalFee, payload.pay_fee, payload.payFee, payload.Payment, payload.payment, ]), ), currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY', paidAt: resolvePaidAt(eventType, payload), rawPayload: payload, items: normalizeOrderItems(payload), } } export function verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) { const normalizedSecret = String(appSecret || '').trim() if (!normalizedSecret) { return true } if (!rawJson || !timestamp || !sign) { return false } const documentedDigest = crypto .createHash('md5') .update(`${normalizedSecret}json${rawJson}timestamp${timestamp}${normalizedSecret}`, 'utf8') .digest('hex') .toLowerCase() if (documentedDigest === sign) { return true } const legacyDigest = crypto .createHash('md5') .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() if (isTradeFinishedPayload(payload)) { return 'buyer_confirm_goods' } if (normalized === '1') { return 'payment_success' } if (normalized === '21') { return 'trade_create' } if (normalized === '32') { return 'trade_create' } if (normalized === '16' || normalized === '256') { return 'buyer_confirm_goods' } if (normalized === '128') { return 'trade_closed' } const status = resolveProviderStatus(payload) if (status === 'closed') { return 'trade_closed' } 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) if (providerStatus === 'paid') { return 'paid' } if (providerStatus === 'closed') { return 'closed' } if (providerStatus === 'refunded') { return 'refunded' } if (eventType === 'payment_success') { return 'paid' } if (eventType === 'trade_closed') { return 'closed' } const rawStatus = normalizeInteger( pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), ) if (rawStatus === 3 || rawStatus === 4) { return 'paid' } if (rawStatus === 6) { return 'closed' } if (rawStatus === 5) { return 'refunded' } return 'created' } function resolvePayStatus(eventType, payload) { const providerStatus = resolveProviderStatus(payload) if (providerStatus === 'paid') { return 'paid' } if (providerStatus === 'closed') { return 'unpaid' } if (providerStatus === 'refunded') { return 'refunded' } if (eventType === 'payment_success') { return 'paid' } if (eventType === 'trade_closed') { return 'unpaid' } const rawStatus = normalizeInteger( pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), ) if (rawStatus === 3 || rawStatus === 4) { return 'paid' } if (rawStatus === 5) { return 'refunded' } return 'unpaid' } function resolvePaidAt(eventType, payload) { const providerPaidAt = normalizeProviderDateTime( pickFirstNonEmpty([ payload.paid_at, payload.paidAt, payload.pay_time, payload.payTime, payload.PayTime, ]), ) if (providerPaidAt) { return providerPaidAt } if (resolveProviderStatus(payload) === 'paid') { return nowIso() } if (eventType === 'payment_success') { return nowIso() } const rawStatus = normalizeInteger( pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), ) if (rawStatus === 3 || rawStatus === 4) { return nowIso() } return null } function parsePayloadJson(rawJson) { if (!rawJson) { return {} } return parseJsonObject(rawJson, { preserveLargeIntegers: true, throwOnError: true, }) } function normalizeOrderItems(payload) { const items = extractAgisoTradeOrderItemSources(payload) return items.map((item) => { const source = isPlainObject(item) ? item : {} const skuDescriptor = parseExternalSkuDescriptor( pickFirstNonEmpty([ source.sku, source.Sku, payload.sku, payload.Sku, payload._agisoTradeDetail?.sku, ]), ) const rawSkuCandidates = [ skuDescriptor.externalSkuCode, source.OuterSkuId, source.outerSkuId, source.outer_sku_id, source.OuterIid, source.outerIid, source.outer_iid, source.sku_code, source.skuCode, source.goods_sku, source.item_id, source.itemId, source.goods_id, source.goodsId, source.NumIid, source.num_iid, payload.item_id, payload.itemId, ] const skuCode = resolveSkuCode(rawSkuCandidates) const externalItemId = pickFirstNonEmpty([ source.item_id, source.itemId, source.goods_id, source.goodsId, payload.item_id, payload.itemId, ]) const externalSkuName = pickFirstNonEmpty([ skuDescriptor.externalSkuName, source.Title, source.title, source.sku_name, source.skuName, source.goods_name, source.goodsName, skuCode, ]) return { skuCode, skuName: externalSkuName, externalItemId, externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), externalSkuName, quantity: Math.max( 1, normalizeInteger( pickFirstNonEmpty([ source.quantity, source.num, source.Num, source.buy_amount, payload.quantity, payload.num, payload.Num, ]), ) || 1, ), spec: source, snapshot: { externalItemId, externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), externalSkuName, }, } }) } function resolveBusinessPlatform(rawPlatform) { const normalized = normalizePlatformKey(rawPlatform) if (!normalized) { return 'unknown' } const aliases = { xianyu: 'xianyu', idlefish: 'xianyu', idle: 'xianyu', aldsidle: 'xianyu', taobaoidle: 'xianyu', taobaoxianyu: 'xianyu', taobao: 'taobao', tb: 'taobao', pdd: 'pdd', pinduoduo: 'pdd', douyin: 'douyin', weidian: 'weidian', jd: 'jd', jingdong: 'jd', } return aliases[normalized] || normalized } function normalizePlatformKey(value) { return String(value || '') .trim() .toLowerCase() .replace(/[\s_-]+/g, '') } function resolveShop(payload) { const shopName = pickFirstNonEmpty([ payload.shop_name, payload.shopName, payload.ShopName, payload.seller_name, payload.sellerName, payload.seller_nick, payload.sellerNick, payload.SellerNick, ]) return { shopId: pickFirstNonEmpty([ payload.shop_id, payload.shopId, payload.ShopId, payload.seller_id, payload.sellerId, payload.SellerId, payload.PlatformUserId, payload.platformUserId, shopName, ]), shopName, } } function resolveProviderStatus(payload) { const rawStatus = normalizeInteger( pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), ) if (rawStatus === 3 || rawStatus === 4) { return 'paid' } if (rawStatus === 6) { return 'closed' } if (rawStatus === 5) { return 'refunded' } const statusText = pickFirstNonEmpty([ payload.Status, payload.status, payload.order_status_text, payload.orderStatusText, ]).toUpperCase() if (!statusText) { return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' } if (['WAIT_SELLER_SEND_GOODS', 'WAIT_BUYER_CONFIRM_GOODS', 'TRADE_FINISHED', 'SUCCESS', 'PAID'].includes(statusText)) { return 'paid' } if (['TRADE_CLOSED', 'TRADE_CLOSED_BY_TAOBAO', 'CLOSED'].includes(statusText)) { return 'closed' } if (['REFUND_SUCCESS', 'TRADE_REFUND', 'REFUNDED'].includes(statusText)) { return 'refunded' } if (['WAIT_BUYER_PAY', 'CREATED', 'NEW'].includes(statusText)) { return 'unpaid' } 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() if (!normalized) { return null } const isoText = normalized.replace(' ', 'T') const utcMatch = isoText.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/) if (utcMatch) { const [, year, month, day, hours, minutes, seconds] = utcMatch return new Date( Date.UTC( Number(year), Number(month) - 1, Number(day), Number(hours) - 8, Number(minutes), Number(seconds), ), ).toISOString() } const date = new Date(normalized) return Number.isNaN(date.getTime()) ? null : date.toISOString() } function resolveSkuCode(rawKey) { const candidates = Array.isArray(rawKey) ? rawKey : [rawKey] for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim()) { return candidate.trim() } if (typeof candidate === 'number' && Number.isFinite(candidate)) { return String(candidate) } } return '' } function parseExternalSkuDescriptor(rawValue) { const raw = String(rawValue || '').trim() if (!raw) { return { raw, externalSkuCode: '', externalSkuName: '', } } const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean) let externalSkuCode = '' let externalSkuName = '' for (const part of parts) { if (!externalSkuCode && !part.includes(':') && !part.includes(':')) { externalSkuCode = part continue } const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) if (separatorIndex >= 0) { const label = part.slice(0, separatorIndex).trim() const value = part.slice(separatorIndex + 1).trim() if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) { externalSkuName = value } } } if (!externalSkuCode && parts[0]) { externalSkuCode = parts[0] } if (!externalSkuName) { externalSkuName = parts .map((part) => { const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : '' }) .find(Boolean) || '' } return { raw, externalSkuCode, externalSkuName, } } function normalizeRecord(value) { return isPlainObject(value) ? value : {} } function normalizeInteger(value) { const parsed = Number(value) return Number.isFinite(parsed) ? Math.round(parsed) : 0 } function pickFirstNonEmpty(values) { for (const value of values) { const normalized = String(value || '').trim() if (normalized) { return normalized } } return '' } function isPlainObject(value) { return Object.prototype.toString.call(value) === '[object Object]' } function safeParseJson(rawValue) { if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) { return rawValue } const normalized = String(rawValue || '').trim() if (!normalized) { return {} } try { const parsed = JSON.parse(normalized) return isPlainObject(parsed) ? parsed : {} } catch { return {} } }