feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,192 +0,0 @@
|
||||
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(
|
||||
`小飞侠响应非 JSON(HTTP ${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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/** 小飞侠接口 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';
|
||||
@@ -1,191 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user