小飞侠接口对接
This commit is contained in:
@@ -8,3 +8,13 @@ MOCK_SMS_CODE=123456
|
|||||||
MOCK_PAY=true
|
MOCK_PAY=true
|
||||||
MOCK_DELIVERY_AUTO=true
|
MOCK_DELIVERY_AUTO=true
|
||||||
AUTO_APPROVE_STORE=true
|
AUTO_APPROVE_STORE=true
|
||||||
|
|
||||||
|
# 快递中间层(xiaofeixia | sf | jd,目前仅实现 xiaofeixia)
|
||||||
|
COURIER_PROVIDER=xiaofeixia
|
||||||
|
# 小飞侠同城快递
|
||||||
|
XIAOFEIXIA_API_URL=https://beta.51xiaoju.cn/app/api/interface.do
|
||||||
|
XIAOFEIXIA_APP_ID=
|
||||||
|
XIAOFEIXIA_MCH_ID=
|
||||||
|
XIAOFEIXIA_API_KEY=
|
||||||
|
# 签名类型:MD5(默认)或 HMAC-SHA256
|
||||||
|
XIAOFEIXIA_SIGN_TYPE=MD5
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CourierProviderCode } from './courier.constants';
|
||||||
|
|
||||||
|
export type XiaofeixiaSignType = 'MD5' | 'HMAC-SHA256';
|
||||||
|
|
||||||
|
export interface XiaofeixiaConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
appId?: string;
|
||||||
|
mchId: string;
|
||||||
|
apiKey: string;
|
||||||
|
signType: XiaofeixiaSignType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourierIntegrationConfig {
|
||||||
|
provider: CourierProviderCode;
|
||||||
|
xiaofeixia: XiaofeixiaConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CourierConfigService {
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
load(): CourierIntegrationConfig {
|
||||||
|
const providerRaw = (this.config.get<string>('COURIER_PROVIDER') ?? 'xiaofeixia').toLowerCase();
|
||||||
|
const provider = this.resolveProvider(providerRaw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
xiaofeixia: {
|
||||||
|
apiUrl:
|
||||||
|
this.config.get<string>('XIAOFEIXIA_API_URL') ??
|
||||||
|
'https://beta.51xiaoju.cn/app/api/interface.do',
|
||||||
|
appId: this.config.get<string>('XIAOFEIXIA_APP_ID') || undefined,
|
||||||
|
mchId: this.config.get<string>('XIAOFEIXIA_MCH_ID') ?? '',
|
||||||
|
apiKey: this.config.get<string>('XIAOFEIXIA_API_KEY') ?? '',
|
||||||
|
signType: this.resolveSignType(this.config.get<string>('XIAOFEIXIA_SIGN_TYPE')),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProvider(raw: string): CourierProviderCode {
|
||||||
|
switch (raw) {
|
||||||
|
case 'xiaofeixia':
|
||||||
|
return CourierProviderCode.XIAOFEIXIA;
|
||||||
|
case 'sf':
|
||||||
|
return CourierProviderCode.SF;
|
||||||
|
case 'jd':
|
||||||
|
return CourierProviderCode.JD;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported COURIER_PROVIDER: ${raw}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSignType(raw?: string): XiaofeixiaSignType {
|
||||||
|
if (raw?.toUpperCase() === 'HMAC-SHA256') {
|
||||||
|
return 'HMAC-SHA256';
|
||||||
|
}
|
||||||
|
return 'MD5';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const COURIER_PROVIDER = 'COURIER_PROVIDER';
|
||||||
|
|
||||||
|
export enum CourierProviderCode {
|
||||||
|
XIAOFEIXIA = 'XIAOFEIXIA',
|
||||||
|
SF = 'SF',
|
||||||
|
JD = 'JD',
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export class CourierApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly code: string,
|
||||||
|
readonly providerCode?: string,
|
||||||
|
readonly raw?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CourierApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { COURIER_PROVIDER, CourierProviderCode } from './courier.constants';
|
||||||
|
import { CourierConfigService } from './courier.config';
|
||||||
|
import { CourierService } from './courier.service';
|
||||||
|
import { XiaofeixiaClient } from './xiaofeixia/xiaofeixia.client';
|
||||||
|
import { XiaofeixiaProvider } from './xiaofeixia/xiaofeixia.provider';
|
||||||
|
import type { ICourierProvider } from './courier.types';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
CourierConfigService,
|
||||||
|
XiaofeixiaClient,
|
||||||
|
XiaofeixiaProvider,
|
||||||
|
{
|
||||||
|
provide: COURIER_PROVIDER,
|
||||||
|
useFactory: (
|
||||||
|
configService: CourierConfigService,
|
||||||
|
xiaofeixia: XiaofeixiaProvider,
|
||||||
|
): ICourierProvider => {
|
||||||
|
const { provider } = configService.load();
|
||||||
|
switch (provider) {
|
||||||
|
case CourierProviderCode.XIAOFEIXIA:
|
||||||
|
return xiaofeixia;
|
||||||
|
default:
|
||||||
|
throw new Error(`Courier provider not implemented: ${provider}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inject: [CourierConfigService, XiaofeixiaProvider],
|
||||||
|
},
|
||||||
|
CourierService,
|
||||||
|
],
|
||||||
|
exports: [CourierService, COURIER_PROVIDER],
|
||||||
|
})
|
||||||
|
export class CourierModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { COURIER_PROVIDER } from './courier.constants';
|
||||||
|
import type {
|
||||||
|
BatchShipmentQuery,
|
||||||
|
CreateShipmentInput,
|
||||||
|
CreateShipmentResult,
|
||||||
|
DeliveryCoverageResult,
|
||||||
|
FreightEstimateResult,
|
||||||
|
ICourierProvider,
|
||||||
|
ShipmentDetail,
|
||||||
|
ShipmentQuery,
|
||||||
|
TrackCallbackResponse,
|
||||||
|
TrackNode,
|
||||||
|
} from './courier.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快递统一门面:业务层只依赖本 Service,不感知具体快递商实现。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CourierService {
|
||||||
|
constructor(@Inject(COURIER_PROVIDER) private readonly provider: ICourierProvider) {}
|
||||||
|
|
||||||
|
get activeProvider() {
|
||||||
|
return this.provider.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult> {
|
||||||
|
return this.provider.createShipment(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelShipment(query: ShipmentQuery): Promise<void> {
|
||||||
|
return this.provider.cancelShipment(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
getShipment(query: ShipmentQuery): Promise<ShipmentDetail> {
|
||||||
|
return this.provider.getShipment(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]> {
|
||||||
|
return this.provider.batchGetShipments(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrack(query: ShipmentQuery): Promise<TrackNode[]> {
|
||||||
|
return this.provider.getTrack(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> {
|
||||||
|
return this.provider.checkDeliveryCoverage(toAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
estimateFreight(weight: number): Promise<FreightEstimateResult> {
|
||||||
|
return this.provider.estimateFreight(weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
|
||||||
|
return this.provider.buildTrackCallbackResponse(success);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { CourierProviderCode } from './courier.constants';
|
||||||
|
|
||||||
|
export interface CourierCoordinate {
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourierContact {
|
||||||
|
name: string;
|
||||||
|
mobile: string;
|
||||||
|
address: string;
|
||||||
|
addressDetail: string;
|
||||||
|
coordinate?: CourierCoordinate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 付费对象:寄付 / 到付 */
|
||||||
|
export enum CourierPayMode {
|
||||||
|
SENDER = '1',
|
||||||
|
RECEIVER = '2',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateShipmentInput {
|
||||||
|
outNumber: string;
|
||||||
|
customerId?: string;
|
||||||
|
from: CourierContact;
|
||||||
|
to: CourierContact;
|
||||||
|
goodsName?: string;
|
||||||
|
goodsNum?: number;
|
||||||
|
weight?: number;
|
||||||
|
insuredSumPrice?: number;
|
||||||
|
collectionPrice?: number;
|
||||||
|
payMode: CourierPayMode;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateShipmentResult {
|
||||||
|
providerShipmentId: number;
|
||||||
|
trackingNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShipmentQuery {
|
||||||
|
trackingNumber?: string;
|
||||||
|
outNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchShipmentQuery {
|
||||||
|
trackingNumbers?: string[];
|
||||||
|
outNumbers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackNode {
|
||||||
|
trackInfo: string;
|
||||||
|
createTime: string;
|
||||||
|
statusName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShipmentDetail {
|
||||||
|
trackingNumber: 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 DeliveryCoverageResult {
|
||||||
|
arriveTime: string;
|
||||||
|
siteName: string;
|
||||||
|
siteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreightEstimateResult {
|
||||||
|
freightPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 路由变化回调(各快递商 POST 到业务方) */
|
||||||
|
export interface TrackCallbackPayload {
|
||||||
|
outNumber: string;
|
||||||
|
trackingNumber: string;
|
||||||
|
status: string;
|
||||||
|
statusName: string;
|
||||||
|
trackInfo: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackCallbackResponse {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICourierProvider {
|
||||||
|
readonly code: CourierProviderCode;
|
||||||
|
|
||||||
|
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult>;
|
||||||
|
cancelShipment(query: ShipmentQuery): Promise<void>;
|
||||||
|
getShipment(query: ShipmentQuery): Promise<ShipmentDetail>;
|
||||||
|
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]>;
|
||||||
|
getTrack(query: ShipmentQuery): Promise<TrackNode[]>;
|
||||||
|
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult>;
|
||||||
|
estimateFreight(weight: number): Promise<FreightEstimateResult>;
|
||||||
|
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './courier.constants';
|
||||||
|
export * from './courier.types';
|
||||||
|
export * from './courier.error';
|
||||||
|
export * from './courier.service';
|
||||||
|
export * from './courier.module';
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CourierApiError } from '../courier.error';
|
||||||
|
import { CourierConfigService } from '../courier.config';
|
||||||
|
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) {}
|
||||||
|
|
||||||
|
async request<T>(cmd: string, bizParams: RequestParams): Promise<T> {
|
||||||
|
const cfg = this.courierConfig.load().xiaofeixia;
|
||||||
|
|
||||||
|
if (!cfg.mchId || !cfg.apiKey) {
|
||||||
|
throw new CourierApiError(
|
||||||
|
'小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(cfg.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new CourierApiError(
|
||||||
|
'小飞侠接口网络异常',
|
||||||
|
'200000',
|
||||||
|
'XIAOFEIXIA',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new CourierApiError(
|
||||||
|
`小飞侠 HTTP 请求失败: ${response.status}`,
|
||||||
|
'200000',
|
||||||
|
'XIAOFEIXIA',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as XiaofeixiaApiResponse<T>;
|
||||||
|
|
||||||
|
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
|
||||||
|
throw new CourierApiError(
|
||||||
|
payload.message || '小飞侠接口业务失败',
|
||||||
|
payload.code,
|
||||||
|
'XIAOFEIXIA',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,168 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CourierProviderCode } from '../courier.constants';
|
||||||
|
import { CourierApiError } from '../courier.error';
|
||||||
|
import type {
|
||||||
|
BatchShipmentQuery,
|
||||||
|
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): 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerShipmentId: data.id,
|
||||||
|
trackingNumber: data.number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelShipment(query: ShipmentQuery): Promise<void> {
|
||||||
|
this.assertShipmentQuery(query);
|
||||||
|
await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, {
|
||||||
|
number: query.trackingNumber,
|
||||||
|
outNumber: query.outNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getShipment(query: ShipmentQuery): Promise<ShipmentDetail> {
|
||||||
|
this.assertShipmentQuery(query);
|
||||||
|
const data = await this.client.request<XiaofeixiaOrderDetail>(XIAOFEIXIA_CMD.GET_ORDER, {
|
||||||
|
number: query.trackingNumber,
|
||||||
|
outNumber: query.outNumber,
|
||||||
|
});
|
||||||
|
return this.mapOrderDetail(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchGetShipments(query: BatchShipmentQuery): 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (data ?? []).map((item) => this.mapOrderDetail(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrack(query: ShipmentQuery): Promise<TrackNode[]> {
|
||||||
|
this.assertShipmentQuery(query);
|
||||||
|
const data = await this.client.request<XiaofeixiaTrackNode[]>(XIAOFEIXIA_CMD.TRACK_ROUTE, {
|
||||||
|
number: query.trackingNumber,
|
||||||
|
outNumber: query.outNumber,
|
||||||
|
});
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> {
|
||||||
|
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
|
||||||
|
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
|
||||||
|
{ toAddress },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
arriveTime: data.arriveTime,
|
||||||
|
siteName: data.name,
|
||||||
|
siteId: data.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async estimateFreight(weight: number): Promise<FreightEstimateResult> {
|
||||||
|
const data = await this.client.request<XiaofeixiaFreightEstimateData>(
|
||||||
|
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
|
||||||
|
{ weight },
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||||
|
import { CourierModule } from './courier/courier.module';
|
||||||
import { SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER } from './integrations.constants';
|
import { SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER } from './integrations.constants';
|
||||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
|
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
||||||
{ provide: PAY_PROVIDER, useClass: PayMockProvider },
|
{ provide: PAY_PROVIDER, useClass: PayMockProvider },
|
||||||
@@ -16,6 +17,6 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
|||||||
PayMockProvider,
|
PayMockProvider,
|
||||||
DeliveryMockProvider,
|
DeliveryMockProvider,
|
||||||
],
|
],
|
||||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER],
|
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, CourierModule],
|
||||||
})
|
})
|
||||||
export class IntegrationsModule {}
|
export class IntegrationsModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user