diff --git a/apps/backend/src/db/migrations/006_amounts_to_fen.sql b/apps/backend/src/db/migrations/006_amounts_to_fen.sql new file mode 100644 index 00000000..27aaa30f --- /dev/null +++ b/apps/backend/src/db/migrations/006_amounts_to_fen.sql @@ -0,0 +1,3 @@ +UPDATE orders +SET total_amount = total_amount * 100 +WHERE total_amount != 0; diff --git a/apps/backend/src/db/migrations/007_normalize_aldsidle_to_xianyu.sql b/apps/backend/src/db/migrations/007_normalize_aldsidle_to_xianyu.sql new file mode 100644 index 00000000..024c25ae --- /dev/null +++ b/apps/backend/src/db/migrations/007_normalize_aldsidle_to_xianyu.sql @@ -0,0 +1,11 @@ +UPDATE orders +SET platform = 'xianyu' +WHERE lower(trim(platform)) = 'aldsidle'; + +UPDATE webhook_events +SET platform = 'xianyu' +WHERE lower(trim(platform)) = 'aldsidle'; + +UPDATE message_deliveries +SET platform = 'xianyu' +WHERE lower(trim(platform)) = 'aldsidle'; diff --git a/apps/backend/src/services/admin-service.js b/apps/backend/src/services/admin-service.js index adbb046b..d3b2e707 100644 --- a/apps/backend/src/services/admin-service.js +++ b/apps/backend/src/services/admin-service.js @@ -7,6 +7,7 @@ import { getOrderById, listOrders } from '../repositories/order-repo.js' import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../repositories/task-repo.js' import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../repositories/webhook-event-repo.js' import { createHttpError } from '../utils/http.js' +import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../utils/money.js' import { nowIso } from '../utils/time.js' import { reserveCdkForTask } from './cdk-service.js' import { replayAgisoTradeWebhookEvent } from './webhook-service.js' @@ -80,7 +81,8 @@ export function getAdminOrderDetail(orderId) { buyerId: order.buyer_id, buyerName: order.buyer_name, receiverContact: order.receiver_contact, - totalAmount: order.total_amount, + totalAmount: formatFenToAmount(order.total_amount), + totalAmountFen: normalizeFen(order.total_amount), currency: order.currency, paidAt: order.paid_at, createdAt: order.created_at, @@ -356,20 +358,7 @@ export function getAdminWebhookEvents(query = {}) { }) return { - items: items.map((item) => ({ - eventId: item.id, - provider: item.provider || 'agiso', - platform: item.platform, - shopId: item.shop_id || '', - shopName: item.shop_name || '', - eventType: item.event_type, - eventKey: item.event_key, - signatureValid: Boolean(item.signature_valid), - processed: Boolean(item.processed), - processError: item.process_error, - relatedOrderId: item.related_order_id, - createdAt: item.created_at, - })), + items: items.map((item) => mapAdminWebhookEvent(item)), pagination: { page, pageSize, total }, } } @@ -384,23 +373,7 @@ export function getAdminWebhookEventDetail(eventId) { }) } - return { - eventId: event.id, - provider: event.provider || 'agiso', - platform: event.platform, - shopId: event.shop_id || '', - shopName: event.shop_name || '', - eventType: event.event_type, - eventKey: event.event_key, - signatureValid: Boolean(event.signature_valid), - processed: Boolean(event.processed), - processError: event.process_error, - relatedOrderId: event.related_order_id, - createdAt: event.created_at, - headers: safeParseJson(event.headers_json), - query: safeParseJson(event.query_json), - body: safeParseJson(event.body_json), - } + return mapAdminWebhookEvent(event, { includeRaw: true }) } export async function replayAdminWebhookEvent(eventId) { @@ -607,6 +580,130 @@ function mapAdminTaskSummary(task) { } } +function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { + const headers = normalizeRecord(safeParseJson(item.headers_json)) + const query = normalizeRecord(safeParseJson(item.query_json)) + const body = normalizeRecord(safeParseJson(item.body_json)) + const payload = extractWebhookPayload(body) + const itemSources = extractWebhookItemSources(payload) + const firstItem = itemSources[0] || {} + const rawAmount = pickFirstNonEmpty([ + payload.total_fee, + payload.totalFee, + payload.TotalFee, + payload.pay_fee, + payload.payFee, + payload.Payment, + payload.payment, + payload.buy_amount, + payload.buyAmount, + ]) + const totalAmountFen = parseAmountToFen(rawAmount) + const taskCount = item.related_order_id ? listTasksByOrderId(item.related_order_id).length : 0 + + const mapped = { + eventId: item.id, + provider: item.provider || 'agiso', + platform: item.platform, + platformRaw: pickFirstNonEmpty([ + query.fromPlatform, + query.from_platform, + body.fromPlatform, + body.from_platform, + payload.fromPlatform, + payload.FromPlatform, + payload.platform, + payload.Platform, + ]), + shopId: item.shop_id || pickFirstNonEmpty([ + payload.PlatformUserId, + payload.platformUserId, + payload.platform_user_id, + payload.seller_id, + payload.sellerId, + payload.SellerId, + payload.seller_nick, + payload.sellerNick, + payload.SellerNick, + ]), + shopName: item.shop_name || pickFirstNonEmpty([ + payload.shopName, + payload.shop_name, + payload.ShopName, + payload.seller_nick, + payload.sellerNick, + payload.SellerNick, + ]), + eventType: item.event_type, + eventKey: item.event_key, + signatureValid: Boolean(item.signature_valid), + processed: Boolean(item.processed), + processError: item.process_error, + relatedOrderId: item.related_order_id, + createdAt: item.created_at, + platformOrderId: pickFirstNonEmpty([ + payload.biz_order_id, + payload.bizOrderId, + payload.Tid, + payload.tid, + payload.Oid, + payload.oid, + payload.order_id, + payload.orderId, + firstItem.Oid, + firstItem.oid, + ]), + 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, + ]), + totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '', + totalAmountFen, + currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY', + itemCount: itemSources.length, + taskCount, + messageText: pickFirstNonEmpty([ + payload.MsgTypeDes, + payload.msgTypeDes, + payload.msg_type_desc, + payload.message, + payload.msg, + ]), + requestTimestamp: pickFirstNonEmpty([query.timestamp]), + requestSign: pickFirstNonEmpty([query.sign]), + aopic: pickFirstNonEmpty([query.aopic]), + sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]), + sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]), + userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]), + } + + if (!includeRaw) { + return mapped + } + + return { + ...mapped, + headers, + query, + body, + payload, + } +} + function mapAdminCdkListItem(item) { const task = item.reserved_by_task_id ? getTaskById(item.reserved_by_task_id) : null const order = task?.order_id ? getOrderById(task.order_id) : null @@ -711,7 +808,8 @@ function mapAdminOrderListItem(item) { orderStatus: item.order_status, payStatus: item.pay_status, buyerName: item.buyer_name, - totalAmount: item.total_amount, + totalAmount: formatFenToAmount(item.total_amount), + totalAmountFen: normalizeFen(item.total_amount), currency: item.currency, createdAt: item.created_at, updatedAt: item.updated_at, @@ -932,6 +1030,67 @@ function safeParseJson(rawText) { } } +function normalizeRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {} +} + +function extractWebhookPayload(body) { + const normalizedBody = normalizeRecord(body) + const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim() + + if (rawJson) { + return normalizeRecord(safeParseJson(rawJson)) + } + + return normalizedBody +} + +function extractWebhookItemSources(payload) { + const normalizedPayload = normalizeRecord(payload) + const candidates = [ + normalizedPayload.orders, + normalizedPayload.Orders, + normalizedPayload.items, + normalizedPayload.Items, + normalizedPayload.order_items, + ] + + for (const candidate of candidates) { + if (Array.isArray(candidate) && candidate.length > 0) { + return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0) + } + } + + if ( + pickFirstNonEmpty([ + normalizedPayload.item_id, + normalizedPayload.itemId, + normalizedPayload.ItemId, + normalizedPayload.num_iid, + normalizedPayload.numIid, + normalizedPayload.NumIid, + ]) + ) { + return [normalizedPayload] + } + + return [] +} + +function pickFirstNonEmpty(values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + } + + return '' +} + function maskCode(value) { const text = String(value || '').trim() if (!text) { diff --git a/apps/backend/src/services/claim-session-service.js b/apps/backend/src/services/claim-session-service.js index 5c1412b5..c33d85e9 100644 --- a/apps/backend/src/services/claim-session-service.js +++ b/apps/backend/src/services/claim-session-service.js @@ -11,6 +11,7 @@ import { findTaskByClaimTokenId, updateTask } from '../repositories/task-repo.js import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../repositories/cdk-repo.js' import { buildClaimUrl } from './claim-service.js' import { createHttpError } from '../utils/http.js' +import { formatFenToAmount, normalizeFen } from '../utils/money.js' import { nowIso } from '../utils/time.js' const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) @@ -385,7 +386,8 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session } platformOrderId: order.platform_order_id, payStatus: order.pay_status, orderStatus: order.order_status, - totalAmount: order.total_amount, + totalAmount: formatFenToAmount(order.total_amount), + totalAmountFen: normalizeFen(order.total_amount), currency: order.currency, }, orderItem: { diff --git a/apps/backend/src/services/webhook-service.js b/apps/backend/src/services/webhook-service.js index b5e74a01..8153f78b 100644 --- a/apps/backend/src/services/webhook-service.js +++ b/apps/backend/src/services/webhook-service.js @@ -5,6 +5,7 @@ import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook- import { upsertOrderFromWebhook } from './order-service.js' import { createHttpError } from '../utils/http.js' import { logWebhook } from '../utils/logger.js' +import { parseAmountToFen } from '../utils/money.js' import { nowIso } from '../utils/time.js' export async function processAgisoTradeWebhook(requestLike) { @@ -229,7 +230,7 @@ function parseAgisoTradeRequest(requestLike) { payload.receiver_mobile, payload.receiverMobile, ]), - totalAmount: normalizeInteger( + totalAmount: parseAmountToFen( pickFirstNonEmpty([ payload.total_fee, payload.totalFee, @@ -524,6 +525,7 @@ function resolveBusinessPlatform(rawPlatform) { xianyu: 'xianyu', idlefish: 'xianyu', idle: 'xianyu', + aldsidle: 'xianyu', taobaoidle: 'xianyu', taobaoxianyu: 'xianyu', taobao: 'taobao', diff --git a/apps/backend/src/utils/money.js b/apps/backend/src/utils/money.js new file mode 100644 index 00000000..5f8125d0 --- /dev/null +++ b/apps/backend/src/utils/money.js @@ -0,0 +1,57 @@ +export function parseAmountToFen(value) { + const normalized = normalizeAmountInput(value) + + if (!normalized) { + return 0 + } + + const sign = normalized.startsWith('-') ? -1 : 1 + const unsigned = sign < 0 ? normalized.slice(1) : normalized + const match = unsigned.match(/^(\d+)(?:\.(\d+))?$/) + + if (!match) { + return 0 + } + + const integerPart = Number(match[1] || '0') + + if (!Number.isSafeInteger(integerPart)) { + return 0 + } + + const fractionRaw = String(match[2] || '') + const fractionForRound = `${fractionRaw}000` + let cents = integerPart * 100 + Number(fractionForRound.slice(0, 2)) + + if (Number(fractionForRound[2] || '0') >= 5) { + cents += 1 + } + + return cents * sign +} + +export function formatFenToAmount(value) { + const normalizedFen = normalizeFen(value) + const sign = normalizedFen < 0 ? '-' : '' + const absoluteFen = Math.abs(normalizedFen) + const integerPart = Math.floor(absoluteFen / 100) + const fractionPart = String(absoluteFen % 100).padStart(2, '0') + return `${sign}${integerPart}.${fractionPart}` +} + +export function normalizeFen(value) { + const parsed = Number(value) + return Number.isFinite(parsed) ? Math.round(parsed) : 0 +} + +function normalizeAmountInput(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + if (typeof value !== 'string') { + return '' + } + + return value.trim().replace(/,/g, '') +} diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index a0e686a7..d81bc65a 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -64,7 +64,8 @@ export interface AdminOrderListItem { orderStatus: string payStatus: string buyerName: string - totalAmount: number + totalAmount: string + totalAmountFen: number currency: string createdAt: string updatedAt: string @@ -124,7 +125,8 @@ export interface AdminOrderDetail { buyerId: string buyerName: string receiverContact: string - totalAmount: number + totalAmount: string + totalAmountFen: number currency: string paidAt: string | null createdAt: string @@ -225,13 +227,32 @@ export interface AdminCdkListItem { export interface AdminWebhookEventListItem { eventId: number + provider: string platform: string + platformRaw: string + shopId: string + shopName: string eventType: string eventKey: string signatureValid: boolean processed: boolean processError: string relatedOrderId: number | null + platformOrderId: string + buyerId: string + buyerName: string + totalAmount: string + totalAmountFen: number + currency: string + itemCount: number + taskCount: number + messageText: string + requestTimestamp: string + requestSign: string + aopic: string + sourceHost: string + sourceIp: string + userAgent: string createdAt: string } @@ -239,6 +260,7 @@ export interface AdminWebhookEventDetail extends AdminWebhookEventListItem { headers: Record query: Record body: Record + payload: Record } export interface AdminWebhookReplayResponse { @@ -246,6 +268,11 @@ export interface AdminWebhookReplayResponse { replayed: boolean result: { accepted: boolean + provider: string + platform: string + platformRaw: string + shopId: string + shopName: string eventType: string platformOrderId: string orderId: number diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 6f3dbc1b..b8fb1394 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -37,7 +37,8 @@ export interface ClaimOrderInfo { platformOrderId: string payStatus: string orderStatus: string - totalAmount: number + totalAmount: string + totalAmountFen: number currency: string } diff --git a/apps/frontend/src/views/admin/AdminWebhookEventDetailView.vue b/apps/frontend/src/views/admin/AdminWebhookEventDetailView.vue index 0b8271f3..fad176cf 100644 --- a/apps/frontend/src/views/admin/AdminWebhookEventDetailView.vue +++ b/apps/frontend/src/views/admin/AdminWebhookEventDetailView.vue @@ -7,6 +7,7 @@ import { fetchAdminWebhookEventDetail, replayAdminWebhookEvent } from '@/service import AdminStatusTag from '@/components/admin/AdminStatusTag.vue' import type { AdminWebhookEventDetail, AdminWebhookReplayResponse } from '@/types/admin' import { hasAdminRole } from '@/utils/admin-auth' +import { formatAdminDateTime } from '@/utils/admin-time' import { stringifyDisplayJson } from '@/utils/date-time' import { formatAdminWebhookEventType } from '@/utils/admin-webhook' @@ -68,6 +69,21 @@ function prettyJson(value: Record) { return stringifyDisplayJson(value, 2) } +function formatRequestTimestamp(value: string) { + const normalized = String(value || '').trim() + + if (!/^\d{10,13}$/.test(normalized)) { + return normalized || '-' + } + + const epochMs = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000 + return formatAdminDateTime(new Date(epochMs).toISOString()) +} + +function formatAmount(detail: AdminWebhookEventDetail) { + return detail.totalAmount ? `${detail.totalAmount} ${detail.currency}` : '-' +} + onMounted(loadDetail) @@ -86,22 +102,86 @@ onMounted(loadDetail)