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 { nowIso } from '../utils/time.js' export async function processAgisoTradeWebhook(requestLike) { const parsed = parseAgisoTradeRequest(requestLike) const webhookEvent = createWebhookEvent(buildWebhookEventInput(requestLike, parsed)) return executeAgisoTradeWebhook(parsed, webhookEvent.id) } export async function replayAgisoTradeWebhookEvent(webhookEvent) { const requestLike = { headers: safeParseJson(webhookEvent.headers_json), query: safeParseJson(webhookEvent.query_json), body: safeParseJson(webhookEvent.body_json), } const parsed = parseAgisoTradeRequest(requestLike) return executeAgisoTradeWebhook(parsed, webhookEvent.id) } function buildWebhookEventInput(requestLike, parsed) { return { platform: 'agiso', 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) { 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, }) return { accepted: true, 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, }) 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 eventType = resolveEventType(query.aopic) const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign }) const platformOrderId = pickFirstNonEmpty([ payload.biz_order_id, payload.Tid, payload.tid, payload.order_id, payload.orderId, ]) return { platform: 'agiso', eventType, eventKey: `${platformOrderId || 'unknown'}:${eventType}:${timestamp || 'na'}`, signatureValid, platformOrderId, orderStatus: resolveOrderStatus(eventType, payload), payStatus: resolvePayStatus(eventType, payload), buyerId: pickFirstNonEmpty([payload.buyer_id, payload.buyerId, payload.openid]), buyerName: pickFirstNonEmpty([payload.buyer_name, payload.buyerName, payload.nick]), receiverContact: pickFirstNonEmpty([ payload.receiver_contact, payload.receiverContact, payload.mobile, payload.phone, ]), totalAmount: normalizeInteger( pickFirstNonEmpty([payload.total_fee, payload.totalFee, payload.pay_fee, payload.payFee]), ), 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) { const normalized = String(aopic || '').trim() if (normalized === '1') { return 'payment_success' } if (normalized === '32') { return 'trade_create' } if (normalized === '256') { return 'buyer_confirm_goods' } if (normalized === '128') { return 'trade_closed' } return 'trade_event' } function resolveOrderStatus(eventType, payload) { 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) { 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) { 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 = Array.isArray(payload.items) && payload.items.length > 0 ? payload.items : [payload] return items.map((item) => { const source = isPlainObject(item) ? item : {} const rawSkuKey = pickFirstNonEmpty([ source.sku_code, source.skuCode, source.goods_sku, source.item_id, source.itemId, source.goods_id, source.goodsId, source.num_iid, ]) const skuCode = resolveSkuCode(rawSkuKey) return { skuCode, skuName: pickFirstNonEmpty([ source.sku_name, source.skuName, source.goods_name, source.goodsName, skuCode, ]), quantity: Math.max( 1, normalizeInteger(pickFirstNonEmpty([source.quantity, source.num, source.buy_amount])) || 1, ), spec: source, } }) } function resolveSkuCode(rawKey) { const mappings = runtimeConfig.orders.skuMappings if (rawKey && mappings && typeof mappings === 'object') { const direct = mappings[String(rawKey)] if (typeof direct === 'string' && direct.trim()) { return direct.trim() } } if (typeof rawKey === 'string' && rawKey.trim()) { return rawKey.trim() } 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 {} } }