小飞侠的日志

This commit is contained in:
2026-07-06 21:36:20 +08:00
parent d5fdf626fe
commit 047cf879f8
6 changed files with 253 additions and 16 deletions
@@ -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(
`小飞侠响应非 JSONHTTP ${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;
}
}