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,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 };
}
@@ -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')?.trim() ||
'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, CourierConfigService, COURIER_PROVIDER],
})
export class CourierModule {}
@@ -0,0 +1,59 @@
import { Inject, Injectable } from '@nestjs/common';
import { COURIER_PROVIDER } from './courier.constants';
import type {
BatchShipmentQuery,
CourierCallOptions,
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, options?: CourierCallOptions): Promise<CreateShipmentResult> {
return this.provider.createShipment(input, options);
}
cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
return this.provider.cancelShipment(query, options);
}
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
return this.provider.getShipment(query, options);
}
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
return this.provider.batchGetShipments(query, options);
}
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
return this.provider.getTrack(query, options);
}
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
return this.provider.checkDeliveryCoverage(toAddress, options);
}
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
return this.provider.estimateFreight(weight, options);
}
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
return this.provider.buildTrackCallbackResponse(success);
}
}
@@ -0,0 +1,123 @@
import { CourierProviderCode } from './courier.constants';
import type { XiaofeixiaConfig } from './courier.config';
/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */
export interface CourierCallOptions {
xiaofeixia?: XiaofeixiaConfig;
}
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, options?: CourierCallOptions): Promise<CreateShipmentResult>;
cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void>;
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>;
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number, options?: CourierCallOptions): 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,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;
}