小飞侠的日志

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
+13
View File
@@ -19,6 +19,19 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
REFUNDED: '已退款',
};
/** 订单状态 Tag 颜色(Ant Design preset */
export const ORDER_STATUS_COLORS: Record<string, string> = {
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<string, string> = {
OPEN: '营业中',
PAUSED: '暂停',
+12 -4
View File
@@ -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) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
render: (s) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{
title: '配送',
@@ -293,7 +295,11 @@ export default function OrdersPage() {
<>
<Descriptions column={1} bordered size="small" title="基本信息">
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
<Descriptions.Item label="状态">{ORDER_STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
{ORDER_STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
@@ -435,7 +441,9 @@ export default function OrdersPage() {
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
render: (s) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
@@ -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<string, string> = {
@@ -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;
}
}