Files
order_site/apps/backend/src/services/order/webhook-service.js
T
2026-04-12 16:10:21 +08:00

926 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 })
}
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 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 || ''),
}
}
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)
})
}
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: 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),
}
}
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 {}
}
return parseJsonObject(rawJson, {
preserveLargeIntegers: true,
throwOnError: true,
})
}
function normalizeOrderItems(payload) {
const items = extractOrderItemSources(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 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',
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 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) {
const normalized = String(rawValue || '').trim()
if (!normalized) {
return {}
}
try {
const parsed = JSON.parse(normalized)
return isPlainObject(parsed) ? parsed : {}
} catch {
return {}
}
}