import crypto from 'node:crypto' import { runtimeConfig } from '../config/runtime.js' import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook-event-repo.js' import { upsertOrderFromWebhook } from './order-service.js' import { createHttpError } from '../utils/http.js' import { logWebhook } from '../utils/logger.js' import { nowIso } from '../utils/time.js' export async function processAgisoTradeWebhook(requestLike) { const parsed = parseAgisoTradeRequest(requestLike) const webhookEvent = 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 }) } 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 result = await upsertOrderFromWebhook(parsed) updateWebhookEvent(webhookEventId, { processed: true, process_error: '', related_order_id: result.order.id, }) 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, orderId: result.order.id, taskCount: result.tasks.length, messageDeliveryCount: Array.isArray(result.messageDeliveries) ? result.messageDeliveries.length : 0, }) return { accepted: true, provider: parsed.provider, platform: parsed.platform, shopId: parsed.shopId, shopName: parsed.shopName, platformRaw: parsed.platformRaw, eventType: parsed.eventType, platformOrderId: parsed.platformOrderId, orderId: result.order.id, 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) { 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 } } function parseAgisoTradeRequest(requestLike) { const query = normalizeRecord(requestLike.query) const body = normalizeRecord(requestLike.body) const rawJson = String(body.json || '').trim() const payload = rawJson ? parsePayloadJson(rawJson) : 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 itemSources = extractOrderItemSources(payload) const firstItem = itemSources[0] || {} const platformOrderId = pickFirstNonEmpty([ payload.biz_order_id, payload.Tid, payload.tid, payload.Oid, payload.oid, payload.order_id, payload.orderId, firstItem.Oid, firstItem.oid, ]) 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: normalizeInteger( 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), } } function verifyAgisoSignature({ rawJson, timestamp, sign }) { const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim() if (!appSecret) { return true } if (!rawJson || !timestamp || !sign) { return false } const documentedDigest = crypto .createHash('md5') .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8') .digest('hex') .toLowerCase() if (documentedDigest === sign) { return true } const legacyDigest = crypto .createHash('md5') .update(`${appSecret}${rawJson}${timestamp}`, 'utf8') .digest('hex') .toLowerCase() return legacyDigest === sign } function resolveEventType(aopic, payload) { const normalized = String(aopic || '').trim() if (normalized === '1') { return 'payment_success' } if (normalized === '21') { return 'trade_create' } if (normalized === '32') { return 'trade_create' } if (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' } 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 {} } try { const parsed = JSON.parse(rawJson) return isPlainObject(parsed) ? parsed : {} } catch { throw createHttpError('json 参数不是合法 JSON', { statusCode: 400, errorCode: 'invalid_json_payload', }) } } function normalizeOrderItems(payload) { const items = extractOrderItemSources(payload) return items.map((item) => { const source = isPlainObject(item) ? item : {} const rawSkuCandidates = [ 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) return { skuCode, skuName: pickFirstNonEmpty([ source.Title, source.title, source.sku_name, source.skuName, source.goods_name, source.goodsName, skuCode, ]), quantity: Math.max( 1, normalizeInteger( pickFirstNonEmpty([ source.quantity, source.num, source.Num, source.buy_amount, payload.quantity, payload.num, payload.Num, ]), ) || 1, ), spec: source, } }) } function extractOrderItemSources(payload) { const candidates = [ payload.items, payload.Items, payload.orders, payload.Orders, payload.order_list, payload.OrderList, ] for (const current of candidates) { if (Array.isArray(current) && current.length > 0) { return current } } return [payload] } function resolveBusinessPlatform(rawPlatform) { const normalized = normalizePlatformKey(rawPlatform) if (!normalized) { return 'unknown' } const aliases = { xianyu: 'xianyu', idlefish: 'xianyu', idle: '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 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 mappings = runtimeConfig.orders.skuMappings const candidates = Array.isArray(rawKey) ? rawKey : [rawKey] if (mappings && typeof mappings === 'object') { for (const candidate of candidates) { const normalizedCandidate = String(candidate || '').trim() if (!normalizedCandidate) { continue } const direct = mappings[normalizedCandidate] if (typeof direct === 'string' && direct.trim()) { return direct.trim() } } } 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 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) { const normalized = String(rawValue || '').trim() if (!normalized) { return {} } try { const parsed = JSON.parse(normalized) return isPlainObject(parsed) ? parsed : {} } catch { return {} } }