小飞侠的日志
This commit is contained in:
@@ -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<string, unknown>,
|
||||
};
|
||||
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { XIAOFEIXIA_CMD } from './xiaofeixia/xiaofeixia.constants';
|
||||
|
||||
const CMD_SCENE: Record<string, string> = {
|
||||
[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<string, unknown>;
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>)
|
||||
: { 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<CourierLogRef | undefined> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, string | number | undefined>;
|
||||
|
||||
@Injectable()
|
||||
export class XiaofeixiaClient {
|
||||
constructor(private readonly courierConfig: CourierConfigService) {}
|
||||
constructor(
|
||||
private readonly courierConfig: CourierConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async request<T>(cmd: string, bizParams: RequestParams): Promise<T> {
|
||||
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<T>;
|
||||
try {
|
||||
payload = rawText ? (JSON.parse(rawText) as XiaofeixiaApiResponse<T>) : (null as unknown as XiaofeixiaApiResponse<T>);
|
||||
payload = rawText
|
||||
? (JSON.parse(rawText) as XiaofeixiaApiResponse<T>)
|
||||
: (null as unknown as XiaofeixiaApiResponse<T>);
|
||||
} 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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user