小飞侠接口对接

This commit is contained in:
2026-06-30 23:44:31 +08:00
parent 6e047dc0a5
commit ed1845c0ba
14 changed files with 653 additions and 2 deletions
+10
View File
@@ -8,3 +8,13 @@ MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=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 { PayMockProvider } from './pay/pay.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 { DELIVERY_QUEUE } from '../jobs/jobs.constants';
@Module({
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
providers: [
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
{ provide: PAY_PROVIDER, useClass: PayMockProvider },
@@ -16,6 +17,6 @@ import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
PayMockProvider,
DeliveryMockProvider,
],
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER],
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, CourierModule],
})
export class IntegrationsModule {}