155426b669
Capture raw request for courier track callbacks regardless of Content-Type and persist contentType/rawBody/query into log_third_party for empty-body diagnosis. Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
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<string, unknown>;
|
|
};
|
|
|
|
@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<string, unknown> {
|
|
const parsed =
|
|
body && typeof body === 'object'
|
|
? ({ ...(body as Record<string, unknown>) } as Record<string, unknown>)
|
|
: 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<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
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]');
|
|
}
|
|
}
|