diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index abeb435..b96e32d 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -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 diff --git a/server/dukang-api/src/integrations/courier/courier.config.ts b/server/dukang-api/src/integrations/courier/courier.config.ts new file mode 100644 index 0000000..eab15d3 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier.config.ts @@ -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('COURIER_PROVIDER') ?? 'xiaofeixia').toLowerCase(); + const provider = this.resolveProvider(providerRaw); + + return { + provider, + xiaofeixia: { + apiUrl: + this.config.get('XIAOFEIXIA_API_URL') ?? + 'https://beta.51xiaoju.cn/app/api/interface.do', + appId: this.config.get('XIAOFEIXIA_APP_ID') || undefined, + mchId: this.config.get('XIAOFEIXIA_MCH_ID') ?? '', + apiKey: this.config.get('XIAOFEIXIA_API_KEY') ?? '', + signType: this.resolveSignType(this.config.get('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'; + } +} diff --git a/server/dukang-api/src/integrations/courier/courier.constants.ts b/server/dukang-api/src/integrations/courier/courier.constants.ts new file mode 100644 index 0000000..79d3ecc --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier.constants.ts @@ -0,0 +1,7 @@ +export const COURIER_PROVIDER = 'COURIER_PROVIDER'; + +export enum CourierProviderCode { + XIAOFEIXIA = 'XIAOFEIXIA', + SF = 'SF', + JD = 'JD', +} diff --git a/server/dukang-api/src/integrations/courier/courier.error.ts b/server/dukang-api/src/integrations/courier/courier.error.ts new file mode 100644 index 0000000..a37aad0 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier.error.ts @@ -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'; + } +} diff --git a/server/dukang-api/src/integrations/courier/courier.module.ts b/server/dukang-api/src/integrations/courier/courier.module.ts new file mode 100644 index 0000000..c512c02 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier.module.ts @@ -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 {} diff --git a/server/dukang-api/src/integrations/courier/courier.service.ts b/server/dukang-api/src/integrations/courier/courier.service.ts new file mode 100644 index 0000000..e01acb3 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier.service.ts @@ -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 { + return this.provider.createShipment(input); + } + + cancelShipment(query: ShipmentQuery): Promise { + return this.provider.cancelShipment(query); + } + + getShipment(query: ShipmentQuery): Promise { + return this.provider.getShipment(query); + } + + batchGetShipments(query: BatchShipmentQuery): Promise { + return this.provider.batchGetShipments(query); + } + + getTrack(query: ShipmentQuery): Promise { + return this.provider.getTrack(query); + } + + checkDeliveryCoverage(toAddress: string): Promise { + return this.provider.checkDeliveryCoverage(toAddress); + } + + estimateFreight(weight: number): Promise { + return this.provider.estimateFreight(weight); + } + + buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse { + return this.provider.buildTrackCallbackResponse(success); + } +} diff --git a/server/dukang-api/src/integrations/courier/courier.types.ts b/server/dukang-api/src/integrations/courier/courier.types.ts new file mode 100644 index 0000000..4d52d15 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/courier.types.ts @@ -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; + cancelShipment(query: ShipmentQuery): Promise; + getShipment(query: ShipmentQuery): Promise; + batchGetShipments(query: BatchShipmentQuery): Promise; + getTrack(query: ShipmentQuery): Promise; + checkDeliveryCoverage(toAddress: string): Promise; + estimateFreight(weight: number): Promise; + buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; +} diff --git a/server/dukang-api/src/integrations/courier/index.ts b/server/dukang-api/src/integrations/courier/index.ts new file mode 100644 index 0000000..5aa4130 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/index.ts @@ -0,0 +1,5 @@ +export * from './courier.constants'; +export * from './courier.types'; +export * from './courier.error'; +export * from './courier.service'; +export * from './courier.module'; diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts new file mode 100644 index 0000000..d3c7417 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts @@ -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; + +@Injectable() +export class XiaofeixiaClient { + constructor(private readonly courierConfig: CourierConfigService) {} + + async request(cmd: string, bizParams: RequestParams): Promise { + 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; + + if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) { + throw new CourierApiError( + payload.message || '小飞侠接口业务失败', + payload.code, + 'XIAOFEIXIA', + payload, + ); + } + + return payload.data as T; + } +} diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.constants.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.constants.ts new file mode 100644 index 0000000..0687343 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.constants.ts @@ -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'; diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts new file mode 100644 index 0000000..ea67094 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts @@ -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 { + const data = await this.client.request(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 { + this.assertShipmentQuery(query); + await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, { + number: query.trackingNumber, + outNumber: query.outNumber, + }); + } + + async getShipment(query: ShipmentQuery): Promise { + this.assertShipmentQuery(query); + const data = await this.client.request(XIAOFEIXIA_CMD.GET_ORDER, { + number: query.trackingNumber, + outNumber: query.outNumber, + }); + return this.mapOrderDetail(data); + } + + async batchGetShipments(query: BatchShipmentQuery): Promise { + 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(XIAOFEIXIA_CMD.BATCH_GET_ORDER, { + number, + outNumber, + }); + + return (data ?? []).map((item) => this.mapOrderDetail(item)); + } + + async getTrack(query: ShipmentQuery): Promise { + this.assertShipmentQuery(query); + const data = await this.client.request(XIAOFEIXIA_CMD.TRACK_ROUTE, { + number: query.trackingNumber, + outNumber: query.outNumber, + }); + return data ?? []; + } + + async checkDeliveryCoverage(toAddress: string): Promise { + const data = await this.client.request( + XIAOFEIXIA_CMD.DELIVERY_COVERAGE, + { toAddress }, + ); + + return { + arriveTime: data.arriveTime, + siteName: data.name, + siteId: data.id, + }; + } + + async estimateFreight(weight: number): Promise { + const data = await this.client.request( + 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, + }; + } +} diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.sign.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.sign.ts new file mode 100644 index 0000000..2166378 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.sign.ts @@ -0,0 +1,28 @@ +import { createHash, createHmac } from 'crypto'; +import type { XiaofeixiaSignType } from '../courier.config'; + +type SignParams = Record; + +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(); +} diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.types.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.types.ts new file mode 100644 index 0000000..cae088e --- /dev/null +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.types.ts @@ -0,0 +1,57 @@ +export interface XiaofeixiaApiResponse { + 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; +} diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 0e3e27c..481f73f 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -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 {}