feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
@@ -0,0 +1,192 @@
import { Injectable } from '@nestjs/common';
import { CourierApiError } from '../courier.error';
import { CourierConfigService, type XiaofeixiaConfig } 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';
type RequestParams = Record<string, string | number | undefined>;
@Injectable()
export class XiaofeixiaClient {
constructor(
private readonly courierConfig: CourierConfigService,
private readonly prisma: PrismaService,
) {}
async request<T>(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise<T> {
const cfg = override ?? this.courierConfig.load().xiaofeixia;
const scene = sceneForXfxCmd(cmd);
const externalNo = this.pickExternalNo(bizParams);
if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) {
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl || '(未配置)',
requestBody: sanitizeXfxRequestBody({ cmd, ...bizParams, mchId: cfg.mchId }),
status: 'FAILED',
errorMessage: '小飞侠商户配置不完整',
externalNo,
});
throw new CourierApiError(
'小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key',
'CONFIG_ERROR',
'XIAOFEIXIA',
);
}
const baseParams: RequestParams = {
mchId: cfg.mchId,
cmd,
signType: cfg.signType,
...bizParams,
};
if (cfg.appId) {
baseParams.appId = cfg.appId;
}
const sign = buildXiaofeixiaSign(baseParams, cfg.apiKey, cfg.signType);
const body = new URLSearchParams();
for (const [key, value] of Object.entries({ ...baseParams, sign })) {
if (value !== undefined && value !== null && value !== '') {
body.append(key, String(value));
}
}
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, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
} catch (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',
'XIAOFEIXIA',
);
}
let payload: XiaofeixiaApiResponse<T>;
try {
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',
'XIAOFEIXIA',
rawText,
);
}
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,
'XIAOFEIXIA',
payload,
);
}
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;
}
}
@@ -0,0 +1,12 @@
/** 小飞侠接口 cmd 编号 */
export const XIAOFEIXIA_CMD = {
CREATE_ORDER: '100101',
TRACK_ROUTE: '100102',
CANCEL_ORDER: '100103',
GET_ORDER: '100104',
ESTIMATE_FREIGHT: '100105',
BATCH_GET_ORDER: '100106',
DELIVERY_COVERAGE: '100301',
} as const;
export const XIAOFEIXIA_SUCCESS_CODE = '100000';
@@ -0,0 +1,191 @@
import { Injectable } from '@nestjs/common';
import { CourierProviderCode } from '../courier.constants';
import { CourierApiError } from '../courier.error';
import type {
BatchShipmentQuery,
CourierCallOptions,
CreateShipmentInput,
CreateShipmentResult,
DeliveryCoverageResult,
FreightEstimateResult,
ICourierProvider,
ShipmentDetail,
ShipmentQuery,
TrackCallbackResponse,
TrackNode,
} from '../courier.types';
import { XiaofeixiaClient } from './xiaofeixia.client';
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
import type {
XiaofeixiaCreateOrderData,
XiaofeixiaDeliveryCoverageData,
XiaofeixiaFreightEstimateData,
XiaofeixiaOrderDetail,
XiaofeixiaTrackNode,
} from './xiaofeixia.types';
@Injectable()
export class XiaofeixiaProvider implements ICourierProvider {
readonly code = CourierProviderCode.XIAOFEIXIA;
constructor(private readonly client: XiaofeixiaClient) {}
async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
const data = await this.client.request<XiaofeixiaCreateOrderData>(
XIAOFEIXIA_CMD.CREATE_ORDER,
{
customerId: input.customerId,
outNumber: input.outNumber,
fromAddress: input.from.address,
fromAddressDetail: input.from.addressDetail,
fromCoordinate: this.formatCoordinate(input.from.coordinate),
fromMobile: input.from.mobile,
fromName: input.from.name,
toAddress: input.to.address,
toAddressDetail: input.to.addressDetail,
toCoordinate: this.formatCoordinate(input.to.coordinate),
toMobile: input.to.mobile,
toName: input.to.name,
goodsName: input.goodsName,
goodsNum: input.goodsNum,
weight: input.weight,
insuredSumPrice: input.insuredSumPrice,
collectionPrice: input.collectionPrice,
payMode: input.payMode,
remark: input.remark,
},
options?.xiaofeixia,
);
return {
providerShipmentId: data.id,
trackingNumber: data.number,
};
}
async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
this.assertShipmentQuery(query);
await this.client.request(
XIAOFEIXIA_CMD.CANCEL_ORDER,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
}
async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaOrderDetail>(
XIAOFEIXIA_CMD.GET_ORDER,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return this.mapOrderDetail(data);
}
async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
const number = query.trackingNumbers?.join(',');
const outNumber = query.outNumbers?.join(',');
if (!number && !outNumber) {
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
}
const data = await this.client.request<XiaofeixiaOrderDetail[]>(
XIAOFEIXIA_CMD.BATCH_GET_ORDER,
{
number,
outNumber,
},
options?.xiaofeixia,
);
return (data ?? []).map((item) => this.mapOrderDetail(item));
}
async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaTrackNode[]>(
XIAOFEIXIA_CMD.TRACK_ROUTE,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return data ?? [];
}
async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
{ toAddress },
options?.xiaofeixia,
);
return {
arriveTime: data.arriveTime,
siteName: data.name,
siteId: data.id,
};
}
async estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
const data = await this.client.request<XiaofeixiaFreightEstimateData>(
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
{ weight },
options?.xiaofeixia,
);
return { freightPrice: data.freightPrice };
}
buildTrackCallbackResponse(success = true): TrackCallbackResponse {
return {
code: success ? '100000' : '300000',
message: success ? 'success' : 'fail',
};
}
private assertShipmentQuery(query: ShipmentQuery): void {
if (!query.trackingNumber && !query.outNumber) {
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
}
}
private formatCoordinate(coordinate?: { lng: number; lat: number }): string | undefined {
if (!coordinate) return undefined;
return JSON.stringify({ lng: coordinate.lng, lat: coordinate.lat });
}
private mapOrderDetail(data: XiaofeixiaOrderDetail): ShipmentDetail {
return {
trackingNumber: data.number,
toCarrierName: data.toCarrierName,
toSiteName: data.toSiteName,
toName: data.toName,
toMobile: data.toMobile,
toAddress: data.toAddress,
toAddressDetail: data.toAddressDetail,
fromCarrierName: data.fromCarrierName,
fromSiteName: data.fromSiteName,
fromName: data.fromName,
fromMobile: data.fromMobile,
fromAddress: data.fromAddress,
fromAddressDetail: data.fromAddressDetail,
weight: data.weight,
payModeName: data.payModeName,
freightPrice: data.freightPrice,
insuredPrice: data.insuredPrice,
collectionPrice: data.collectionPrice,
sumPrice: data.sumPrice,
goodsName: data.goodsName,
remark: data.remark,
};
}
}
@@ -0,0 +1,28 @@
import { createHash, createHmac } from 'crypto';
import type { XiaofeixiaSignType } from '../courier.config';
type SignParams = Record<string, string | number | undefined | null>;
function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === '';
}
/** 按 ASCII 字典序拼接并生成签名 */
export function buildXiaofeixiaSign(
params: SignParams,
apiKey: string,
signType: XiaofeixiaSignType = 'MD5',
): string {
const sortedKeys = Object.keys(params)
.filter((key) => key !== 'sign' && !isEmpty(params[key]))
.sort();
const stringA = sortedKeys.map((key) => `${key}=${params[key]}`).join('&');
const stringSignTemp = `${stringA}&key=${apiKey}`;
if (signType === 'HMAC-SHA256') {
return createHmac('sha256', apiKey).update(stringSignTemp).digest('hex').toUpperCase();
}
return createHash('md5').update(stringSignTemp).digest('hex').toUpperCase();
}
@@ -0,0 +1,57 @@
export interface XiaofeixiaApiResponse<T> {
cmd: string;
code: string;
message: string;
bizCode?: string;
data?: T;
}
export interface XiaofeixiaCreateOrderData {
id: number;
number: string;
}
export interface XiaofeixiaTrackNode {
trackInfo: string;
createTime: string;
statusName: string;
}
export interface XiaofeixiaOrderDetail {
number: string;
toCarrierName?: string;
toSiteName?: string;
toName: string;
toMobile: string;
toAddress: string;
toAddressDetail?: string;
fromCarrierName?: string;
fromSiteName?: string;
fromName: string;
fromMobile: string;
fromAddress: string;
fromAddressDetail?: string;
weight?: number;
payModeName?: string;
freightPrice?: string;
insuredPrice?: number;
collectionPrice?: number;
sumPrice?: number;
goodsName?: string;
remark?: string;
}
export interface XiaofeixiaDeliveryCoverageData {
arriveTime: string;
name: string;
id: string;
}
export interface XiaofeixiaFreightEstimateData {
freightPrice: number;
}
export interface XiaofeixiaCancelOrderData {
id: number;
number: string;
}