diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 88d6ac5..faef146 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -19,6 +19,19 @@ export const ORDER_STATUS_LABELS: Record = { REFUNDED: '已退款', }; +/** 订单状态 Tag 颜色(Ant Design preset) */ +export const ORDER_STATUS_COLORS: Record = { + PENDING_PAY: 'red', + PENDING_SHIP: 'green', + OUT_WAREHOUSE: 'green', + SHIPPING: 'processing', + PENDING_RECEIVE: 'cyan', + COMPLETED: 'green', + CANCELLED: 'default', + REFUNDING: 'orange', + REFUNDED: 'default', +}; + export const STORE_STATUS_LABELS: Record = { OPEN: '营业中', PAUSED: '暂停', diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index ebc0c2f..b518c7f 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -18,7 +18,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type AdminOrderRow, type Paginated } from '../lib/api'; -import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; type ShipDefaults = { provider: string; @@ -172,7 +172,9 @@ export default function OrdersPage() { title: '状态', dataIndex: 'status', width: 100, - render: (s) => {ORDER_STATUS_LABELS[s] || s}, + render: (s) => ( + {ORDER_STATUS_LABELS[s] || s} + ), }, { title: '配送', @@ -293,7 +295,11 @@ export default function OrdersPage() { <> {detail.orderNo} - {ORDER_STATUS_LABELS[detail.status] || detail.status} + + + {ORDER_STATUS_LABELS[detail.status] || detail.status} + + {detail.user?.userNo} / {detail.user?.nickname} ¥{detail.productAmount} ¥{detail.payAmount} @@ -435,7 +441,9 @@ export default function OrdersPage() { title: '状态', dataIndex: 'status', width: 100, - render: (s) => {ORDER_STATUS_LABELS[s] || s}, + render: (s) => ( + {ORDER_STATUS_LABELS[s] || s} + ), }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '收货人', dataIndex: 'receiverName', width: 90 }, diff --git a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx index 7f2c77b..f158c3e 100644 --- a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx +++ b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx @@ -27,7 +27,8 @@ const PROVIDER_OPTIONS = [ { value: 'WECHAT_MAP', label: 'WECHAT_MAP' }, { value: 'ALIYUN_SMS', label: 'ALIYUN_SMS' }, { value: 'MOCK_SMS', label: 'MOCK_SMS' }, - { value: 'XIAOFEIXIA', label: 'XIAOFEIXIA' }, + { value: 'XFX', label: '小飞侠 (XFX)' }, + { value: 'LOGISTICS', label: 'LOGISTICS' }, ]; const STATUS_COLOR: Record = { diff --git a/server/dukang-api/src/callbacks/delivery-track.controller.ts b/server/dukang-api/src/callbacks/delivery-track.controller.ts index 1baf89d..db54c94 100644 --- a/server/dukang-api/src/callbacks/delivery-track.controller.ts +++ b/server/dukang-api/src/callbacks/delivery-track.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Post } from '@nestjs/common'; import { TradeService } from '../modules/trade/trade.service'; import { CourierService } from '../integrations/courier/courier.service'; import { PrismaService } from '../common/prisma/prisma.module'; +import { logCourierCall } from '../integrations/courier/courier-log.util'; @Controller('callbacks/delivery') export class DeliveryCallbackController { @@ -13,13 +14,34 @@ export class DeliveryCallbackController { @Post('track') async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) { + const baseLog = { + scene: 'TRACK_CALLBACK', + requestUrl: '/api/v1/callbacks/delivery/track', + requestBody: body as Record, + }; + if (!body.orderId && !body.orderNo) { + await logCourierCall(this.prisma, { + ...baseLog, + status: 'FAILED', + errorMessage: '缺少 orderId / orderNo', + }); return this.courier.buildTrackCallbackResponse(false); } + const order = body.orderId ? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } }) : await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } }); - if (!order) return this.courier.buildTrackCallbackResponse(false); + + if (!order) { + await logCourierCall(this.prisma, { + ...baseLog, + status: 'FAILED', + errorMessage: '订单不存在', + externalNo: body.orderNo, + }); + return this.courier.buildTrackCallbackResponse(false); + } const statusMap: Record = { SHIPPED: 'SHIPPING', @@ -28,9 +50,22 @@ export class DeliveryCallbackController { COMPLETED: 'COMPLETED', }; const target = statusMap[body.status ?? ''] ?? body.status; + let applied = false; if (target && target !== order.status) { await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK'); + applied = true; } - return this.courier.buildTrackCallbackResponse(true); + + const response = this.courier.buildTrackCallbackResponse(true); + await logCourierCall(this.prisma, { + ...baseLog, + responseBody: response, + status: 'SUCCESS', + externalNo: order.orderNo, + ref: { refType: 'ORDER', refId: order.id }, + errorMessage: applied ? undefined : '状态未变更', + }); + + return response; } } diff --git a/server/dukang-api/src/integrations/courier/courier-log.util.ts b/server/dukang-api/src/integrations/courier/courier-log.util.ts new file mode 100644 index 0000000..7c5a044 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier-log.util.ts @@ -0,0 +1,85 @@ +import type { PrismaService } from '../../common/prisma/prisma.module'; +import { XIAOFEIXIA_CMD } from './xiaofeixia/xiaofeixia.constants'; + +const CMD_SCENE: Record = { + [XIAOFEIXIA_CMD.CREATE_ORDER]: 'CREATE_SHIPMENT', + [XIAOFEIXIA_CMD.TRACK_ROUTE]: 'GET_TRACK', + [XIAOFEIXIA_CMD.CANCEL_ORDER]: 'CANCEL_SHIPMENT', + [XIAOFEIXIA_CMD.GET_ORDER]: 'GET_SHIPMENT', + [XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT', + [XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT', + [XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE', +}; + +export type CourierLogRef = { + refType?: string; + refId?: bigint; +}; + +export type LogCourierCallInput = { + scene: string; + requestUrl: string; + requestBody?: Record; + responseBody?: unknown; + externalNo?: string; + status: 'SUCCESS' | 'FAILED' | 'PENDING'; + errorMessage?: string; + ref?: CourierLogRef; +}; + +function maskMchId(mchId?: string) { + if (!mchId) return mchId; + if (mchId.length <= 4) return '****'; + return `${mchId.slice(0, 4)}****`; +} + +/** 请求体入库前脱敏(去掉 sign,商户号打码) */ +export function sanitizeXfxRequestBody(body: Record) { + const { sign: _sign, mchId, ...rest } = body; + return { + ...rest, + ...(mchId != null ? { mchId: maskMchId(String(mchId)) } : {}), + }; +} + +export function sceneForXfxCmd(cmd: string) { + return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`; +} + +export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) { + const responseBody = + input.responseBody === undefined + ? undefined + : typeof input.responseBody === 'object' && input.responseBody !== null + ? (input.responseBody as Record) + : { value: input.responseBody }; + + const row = await prisma.logThirdParty.create({ + data: { + provider: 'XFX', + scene: input.scene, + refType: input.ref?.refType, + refId: input.ref?.refId, + requestUrl: input.requestUrl.slice(0, 512), + requestBody: input.requestBody as never, + responseBody: responseBody as never, + externalNo: input.externalNo?.slice(0, 128), + status: input.status, + errorMessage: input.errorMessage?.slice(0, 512), + }, + }); + return row.id; +} + +export async function resolveOrderRefByOutNumber( + prisma: PrismaService, + outNumber?: string, +): Promise { + if (!outNumber?.trim()) return undefined; + const order = await prisma.order.findUnique({ + where: { orderNo: outNumber.trim() }, + select: { id: true }, + }); + if (!order) return undefined; + return { refType: 'ORDER', refId: order.id }; +} diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts index c633050..659f3bc 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts @@ -1,6 +1,13 @@ import { Injectable } from '@nestjs/common'; import { CourierApiError } from '../courier.error'; import { CourierConfigService } from '../courier.config'; +import { PrismaService } from '../../../common/prisma/prisma.module'; +import { + logCourierCall, + resolveOrderRefByOutNumber, + sanitizeXfxRequestBody, + sceneForXfxCmd, +} from '../courier-log.util'; import { buildXiaofeixiaSign } from './xiaofeixia.sign'; import { XIAOFEIXIA_SUCCESS_CODE } from './xiaofeixia.constants'; import type { XiaofeixiaApiResponse } from './xiaofeixia.types'; @@ -9,12 +16,25 @@ type RequestParams = Record; @Injectable() export class XiaofeixiaClient { - constructor(private readonly courierConfig: CourierConfigService) {} + constructor( + private readonly courierConfig: CourierConfigService, + private readonly prisma: PrismaService, + ) {} async request(cmd: string, bizParams: RequestParams): Promise { const cfg = this.courierConfig.load().xiaofeixia; + const scene = sceneForXfxCmd(cmd); + const externalNo = this.pickExternalNo(bizParams); if (!cfg.mchId || !cfg.apiKey) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl || '(未配置)', + requestBody: sanitizeXfxRequestBody({ cmd, ...bizParams, mchId: cfg.mchId }), + status: 'FAILED', + errorMessage: '小飞侠商户配置不完整', + externalNo, + }); throw new CourierApiError( '小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY', 'CONFIG_ERROR', @@ -42,6 +62,12 @@ export class XiaofeixiaClient { } } + const logRequestBody = sanitizeXfxRequestBody({ ...baseParams, sign: '[REDACTED]' }); + const orderRef = await resolveOrderRefByOutNumber( + this.prisma, + typeof bizParams.outNumber === 'string' ? bizParams.outNumber : undefined, + ); + let response: Response; try { response = await fetch(cfg.apiUrl, { @@ -50,15 +76,32 @@ export class XiaofeixiaClient { body: body.toString(), }); } catch (error) { - throw new CourierApiError( - '小飞侠接口网络异常', - '200000', - 'XIAOFEIXIA', - error, - ); + const message = error instanceof Error ? error.message : String(error); + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + status: 'FAILED', + errorMessage: `网络异常: ${message}`, + externalNo, + ref: orderRef, + }); + throw new CourierApiError('小飞侠接口网络异常', '200000', 'XIAOFEIXIA', error); } + const rawText = await response.text(); + if (!response.ok) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: { httpStatus: response.status, body: rawText.slice(0, 500) }, + status: 'FAILED', + errorMessage: `HTTP ${response.status}`, + externalNo, + ref: orderRef, + }); throw new CourierApiError( `小飞侠 HTTP 请求失败: ${response.status}`, '200000', @@ -66,11 +109,22 @@ export class XiaofeixiaClient { ); } - const rawText = await response.text(); let payload: XiaofeixiaApiResponse; try { - payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse) : (null as unknown as XiaofeixiaApiResponse); + payload = rawText + ? (JSON.parse(rawText) as XiaofeixiaApiResponse) + : (null as unknown as XiaofeixiaApiResponse); } catch { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: { raw: rawText.slice(0, 500) }, + status: 'FAILED', + errorMessage: '响应非 JSON', + externalNo, + ref: orderRef, + }); throw new CourierApiError( `小飞侠响应非 JSON(HTTP ${response.status}): ${rawText.slice(0, 200) || '(空)'}`, '200000', @@ -80,10 +134,29 @@ export class XiaofeixiaClient { } if (!payload) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + status: 'FAILED', + errorMessage: '小飞侠返回空响应', + externalNo, + ref: orderRef, + }); throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA'); } if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) { + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: payload as unknown as Record, + status: 'FAILED', + errorMessage: payload.message || '业务失败', + externalNo: externalNo || payload.data?.toString(), + ref: orderRef, + }); throw new CourierApiError( payload.message || '小飞侠接口业务失败', payload.code, @@ -92,6 +165,28 @@ export class XiaofeixiaClient { ); } + await logCourierCall(this.prisma, { + scene, + requestUrl: cfg.apiUrl, + requestBody: logRequestBody, + responseBody: payload as unknown as Record, + status: 'SUCCESS', + externalNo: externalNo || this.pickExternalNoFromData(payload.data), + ref: orderRef, + }); + return payload.data as T; } + + private pickExternalNo(params: RequestParams) { + const outNumber = params.outNumber != null ? String(params.outNumber) : undefined; + const number = params.number != null ? String(params.number) : undefined; + return outNumber || number; + } + + private pickExternalNoFromData(data: unknown) { + if (!data || typeof data !== 'object') return undefined; + const row = data as { number?: string; outNumber?: string; id?: string }; + return row.number || row.outNumber || row.id; + } }