init
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user