721 lines
17 KiB
JavaScript
721 lines
17 KiB
JavaScript
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 {}
|
|
}
|
|
}
|