import { Injectable } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.module'; import { CourierService } from '../integrations/courier/courier.service'; import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider'; import { logCourierCall } from '../integrations/courier/courier-log.util'; import { TradeService } from '../modules/trade/trade.service'; const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']); const RAW_BODY_LOG_LIMIT = 4000; export type TrackCallbackRequestMeta = { contentType?: string | null; rawBody?: string | null; query?: Record; }; @Injectable() export class DeliveryCallbackService { constructor( private readonly prisma: PrismaService, private readonly courier: CourierService, private readonly xiaofeixiaProvider: XiaofeixiaProvider, private readonly tradeService: TradeService, ) {} async handleTrackCallback( providerKey: string, body: unknown, requestUrl: string, meta?: TrackCallbackRequestMeta, ) { const normalized = providerKey.trim().toLowerCase(); const baseLog = { scene: 'TRACK_CALLBACK', requestUrl, requestBody: this.buildRequestBodyForLog(body, meta), }; if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') { await logCourierCall(this.prisma, { ...baseLog, status: 'FAILED', errorMessage: `不支持的承运商: ${providerKey}`, }); return this.courier.buildTrackCallbackResponse(false); } if (normalized === 'logistics') { await logCourierCall(this.prisma, { ...baseLog, status: 'SUCCESS', errorMessage: '跨城物流回调暂未接入,已记录', }); return this.courier.buildTrackCallbackResponse(true); } const payload = this.xiaofeixiaProvider.parseTrackCallback(body); if (!payload) { const response = this.courier.buildTrackCallbackResponse(false); await logCourierCall(this.prisma, { ...baseLog, responseBody: response, status: 'FAILED', errorMessage: '回调体解析失败', }); return response; } const order = payload.outNumber ? await this.prisma.order.findUnique({ where: { orderNo: payload.outNumber } }) : payload.trackingNumber ? await this.prisma.order.findFirst({ where: { delivery: { trackingNo: payload.trackingNumber } }, }) : null; if (!order) { const response = this.courier.buildTrackCallbackResponse(false); await logCourierCall(this.prisma, { ...baseLog, responseBody: response, status: 'FAILED', errorMessage: '订单不存在', externalNo: payload.outNumber || payload.trackingNumber, }); return response; } const targetStatus = this.xiaofeixiaProvider.mapTrackStatus( payload.status, payload.statusName, ); let applied = false; if (targetStatus && targetStatus !== order.status) { await this.tradeService.applyStatusTransition( order.id, order.status, targetStatus, 'COURIER_TRACK_CALLBACK', payload.trackInfo || payload.statusName, ); applied = 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; } /** 第三方日志:保留解析后字段,并附带 Content-Type / rawBody / query 便于排查空 body */ private buildRequestBodyForLog( body: unknown, meta?: TrackCallbackRequestMeta, ): Record { const parsed = body && typeof body === 'object' ? ({ ...(body as Record) } as Record) : body === undefined || body === null ? {} : { value: body }; const rawBody = meta?.rawBody != null ? this.redactSecrets(String(meta.rawBody)) : null; const query = meta?.query && Object.keys(meta.query).length > 0 ? this.redactSecretFields({ ...meta.query }) : undefined; return { ...this.redactSecretFields(parsed), _meta: { contentType: meta?.contentType ?? null, rawBody: rawBody && rawBody.length > RAW_BODY_LOG_LIMIT ? `${rawBody.slice(0, RAW_BODY_LOG_LIMIT)}…(truncated)` : rawBody, rawBodyLength: meta?.rawBody != null ? Buffer.byteLength(meta.rawBody, 'utf8') : 0, query, }, }; } private redactSecretFields(input: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(input)) { if (/^sign$/i.test(key) || /api[_-]?key/i.test(key)) { out[key] = '[REDACTED]'; } else if (key === 'mchId' && value != null) { const s = String(value); out[key] = s.length <= 4 ? '****' : `${s.slice(0, 4)}****`; } else { out[key] = value; } } return out; } private redactSecrets(raw: string): string { return raw .replace(/(sign=)[^&\s]*/gi, '$1[REDACTED]') .replace(/("sign"\s*:\s*")[^"]*/gi, '$1[REDACTED]') .replace(/(api[_-]?key=)[^&\s]*/gi, '$1[REDACTED]'); } }