From bde85a7b828e9e34488281113d8ddb0d8c090d8b Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 16 Jul 2026 21:40:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=93=E5=BA=93=E7=AE=A1=E7=90=86=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20=E5=B0=8F=E9=A3=9E=E4=BE=A0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/FulfillmentProvidersPage.tsx | 138 ++++++++++++---- .../src/pages/XiaofeixiaTestPage.tsx | 14 +- .../shared-types/src/fulfillment-provider.ts | 29 ++++ server/dukang-api/prisma/seed-v31.ts | 6 + .../system-config/system-config.registry.ts | 15 -- .../integrations/courier/courier.service.ts | 29 ++-- .../src/integrations/courier/courier.types.ts | 20 ++- .../courier/xiaofeixia/xiaofeixia.client.ts | 10 +- .../courier/xiaofeixia/xiaofeixia.provider.ts | 111 ++++++++----- .../fulfillment-provider.service.ts | 152 +++++++++++++++++- .../fulfillment/fulfillment.service.ts | 97 ++++++----- .../admin-fulfillment-providers.controller.ts | 7 + .../src/modules/ops/admin-orders.service.ts | 81 +++++++--- .../modules/ops/admin-xiaofeixia.service.ts | 48 ++++-- .../src/modules/ops/dto/admin-mutate.dto.ts | 20 +++ 15 files changed, 580 insertions(+), 197 deletions(-) diff --git a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx index 6533f96..588196c 100644 --- a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx +++ b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { + Alert, Button, Form, Input, @@ -17,6 +18,7 @@ import { FULFILLMENT_PROVIDER_TYPE_LABELS, FulfillmentProviderStatus, FulfillmentProviderType, + isXfxProviderCode, type FulfillmentProviderDto, } from '@dukang/shared-types'; import { request } from '../lib/api'; @@ -30,6 +32,12 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([ value, label, })); +const SIGN_OPTIONS = [ + { value: 'MD5', label: 'MD5' }, + { value: 'HMAC-SHA256', label: 'HMAC-SHA256' }, +]; + +const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do'; export default function FulfillmentProvidersPage() { const [rows, setRows] = useState([]); @@ -37,6 +45,13 @@ export default function FulfillmentProvidersPage() { const [open, setOpen] = useState(false); const [editRow, setEditRow] = useState(null); const [form] = Form.useForm(); + const watchedCode = Form.useWatch('code', form); + const watchedType = Form.useWatch('type', form); + + const showXfxFields = useMemo( + () => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API, + [watchedCode, watchedType], + ); const load = useCallback(async () => { setLoading(true); @@ -56,48 +71,64 @@ export default function FulfillmentProvidersPage() { setEditRow(null); form.resetFields(); form.setFieldsValue({ + code: 'XFX', + name: '小飞侠', type: FulfillmentProviderType.API, status: FulfillmentProviderStatus.ACTIVE, - capabilitiesJson: JSON.stringify( - { createShipment: true, getTrack: true, callback: true }, - null, - 2, - ), + apiUrl: DEFAULT_XFX_API_URL, + signType: 'MD5', }); setOpen(true); } function openEdit(row: FulfillmentProviderDto) { setEditRow(row); + const xfx = row.xiaofeixiaConfig; form.setFieldsValue({ code: row.code, name: row.name, type: row.type, status: row.status, - capabilitiesJson: row.capabilities ? JSON.stringify(row.capabilities, null, 2) : '', - configJson: '', + apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL, + mchId: xfx?.mchId || '', + apiKey: '', + signType: xfx?.signType || 'MD5', + appId: xfx?.appId || '', }); setOpen(true); } async function submit() { const v = await form.validateFields(); + const payload: Record = { + name: v.name, + type: v.type, + status: v.status, + }; + + if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) { + payload.xiaofeixiaConfig = { + apiUrl: v.apiUrl, + mchId: v.mchId, + apiKey: v.apiKey || undefined, + signType: v.signType, + appId: v.appId || undefined, + }; + } + if (editRow) { await request(`/admin/fulfillment-providers/${editRow.id}`, { method: 'PUT', - body: JSON.stringify({ - name: v.name, - type: v.type, - status: v.status, - configJson: v.configJson || undefined, - capabilitiesJson: v.capabilitiesJson || undefined, - }), + body: JSON.stringify(payload), }); message.success('已更新'); } else { await request('/admin/fulfillment-providers', { method: 'POST', - body: JSON.stringify(v), + body: JSON.stringify({ + code: v.code, + ...payload, + }), }); message.success('已创建'); } @@ -123,14 +154,18 @@ export default function FulfillmentProvidersPage() { ), }, { - title: '能力', + title: '接口配置', render: (_, row) => { - const caps = row.capabilities; - if (!caps) return '—'; - return Object.entries(caps) - .filter(([, on]) => on) - .map(([k]) => k) - .join('、') || '—'; + if (isXfxProviderCode(row.code)) { + const cfg = row.xiaofeixiaConfig; + if (!cfg?.apiUrl) return 未配置; + return ( + + {cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'} + + ); + } + return row.hasConfig ? '已配置' : '—'; }, }, { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime }, @@ -153,7 +188,7 @@ export default function FulfillmentProvidersPage() { 仓配管理 - 注册第三方履约接口后,仓库设置中方可选择对应承运商 + 注册第三方履约接口并填写凭证;仓库绑定后,推单将使用此处配置的 API 地址与密钥 + + setOpen(false)} onOk={() => void submit()} width={560} + destroyOnClose >
@@ -183,12 +226,47 @@ export default function FulfillmentProvidersPage() { + + + + + + + + + + + + )}
diff --git a/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx index 7da077e..de92dd3 100644 --- a/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx +++ b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space, Tabs, Tag, Typography, message, @@ -15,6 +16,8 @@ type XfxConfig = { hasApiKey: boolean; signType: string; ready: boolean; + source?: string; + hint?: string; }; type ApiResult = { @@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
小飞侠接口联调 - 通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。请确保服务器已配置 - {' '}XIAOFEIXIA_MCH_IDXIAOFEIXIA_API_KEY。 + 通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。凭证请在 + 仓配管理 + 中配置;联调会优先使用仓配管理里启用的小飞侠承运商。 {config && ( @@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() { {config.ready ? 可调用 : 配置不完整} + + {config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'} + {config.hint ? ` · ${config.hint}` : ''} + {!config.ready && ( )} diff --git a/packages/shared-types/src/fulfillment-provider.ts b/packages/shared-types/src/fulfillment-provider.ts index 2883eab..8976be1 100644 --- a/packages/shared-types/src/fulfillment-provider.ts +++ b/packages/shared-types/src/fulfillment-provider.ts @@ -1,6 +1,24 @@ import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums'; import type { WarehouseFulfillmentMode } from './enums'; +/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson) */ +export interface XiaofeixiaProviderConfig { + apiUrl: string; + mchId: string; + apiKey: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; +} + +/** 回显用(不含明文 apiKey) */ +export interface XiaofeixiaProviderConfigPublic { + apiUrl: string; + mchId: string; + signType: 'MD5' | 'HMAC-SHA256'; + appId?: string; + hasApiKey: boolean; +} + export interface FulfillmentProviderDto { id: string; code: string; @@ -14,6 +32,8 @@ export interface FulfillmentProviderDto { cancel?: boolean; } | null; hasConfig: boolean; + /** 小飞侠等承运商结构化配置(脱敏) */ + xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null; createdAt: string; updatedAt: string; } @@ -25,6 +45,8 @@ export interface CreateFulfillmentProviderInput { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + /** 结构化小飞侠配置;有则覆盖写入 configJson */ + xiaofeixiaConfig?: Partial; } export interface UpdateFulfillmentProviderInput { @@ -33,6 +55,7 @@ export interface UpdateFulfillmentProviderInput { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + xiaofeixiaConfig?: Partial; } export interface ManualShipOrderInput { @@ -49,3 +72,9 @@ export interface WarehouseFulfillmentConfig { lng?: number | null; lat?: number | null; } + +export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const; + +export function isXfxProviderCode(code: string): boolean { + return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase()); +} diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index f798a86..e88b2eb 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -189,6 +189,12 @@ async function main() { callback: true, cancel: true, }), + configJson: JSON.stringify({ + apiUrl: 'https://beta.51xiaoju.cn/app/api/interface.do', + mchId: '', + apiKey: '', + signType: 'MD5', + }), }, }); diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index b78d739..701fa2f 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -6,7 +6,6 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [ { key: 'sms', label: '短信' }, { key: 'wechat', label: '微信' }, { key: 'oss', label: '对象存储 OSS' }, - { key: 'courier', label: '同城配送' }, { key: 'app', label: '应用链接' }, { key: 'deploy', label: '发布部署' }, ]; @@ -16,7 +15,6 @@ const G = { sms: 'sms', wechat: 'wechat', oss: 'oss', - courier: 'courier', app: 'app', deploy: 'deploy', } as const; @@ -79,19 +77,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ { key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, - { key: 'COURIER_PROVIDER', label: '配送服务商', group: G.courier, type: 'string', requiresRestart: true, placeholder: 'xiaofeixia' }, - { key: 'XIAOFEIXIA_API_URL', label: '小飞侠 API 地址', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'XIAOFEIXIA_MCH_ID', label: '小飞侠商户号', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'XIAOFEIXIA_API_KEY', label: '小飞侠 API Key', group: G.courier, type: 'password', secret: true, requiresRestart: false }, - { key: 'XIAOFEIXIA_SIGN_TYPE', label: '小飞侠签名类型', group: G.courier, type: 'string', requiresRestart: false, placeholder: 'MD5' }, - { key: 'XIAOFEIXIA_APP_ID', label: '小飞侠 AppID', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_NAME', label: '默认寄件人', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_MOBILE', label: '默认寄件手机', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_ADDRESS', label: '默认寄件地址', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_ADDRESS_DETAIL', label: '默认寄件门牌', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_LNG', label: '默认寄件经度', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_LAT', label: '默认寄件纬度', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false }, diff --git a/server/dukang-api/src/integrations/courier/courier.service.ts b/server/dukang-api/src/integrations/courier/courier.service.ts index e01acb3..d0cc76d 100644 --- a/server/dukang-api/src/integrations/courier/courier.service.ts +++ b/server/dukang-api/src/integrations/courier/courier.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { COURIER_PROVIDER } from './courier.constants'; import type { BatchShipmentQuery, + CourierCallOptions, CreateShipmentInput, CreateShipmentResult, DeliveryCoverageResult, @@ -24,32 +25,32 @@ export class CourierService { return this.provider.code; } - createShipment(input: CreateShipmentInput): Promise { - return this.provider.createShipment(input); + createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise { + return this.provider.createShipment(input, options); } - cancelShipment(query: ShipmentQuery): Promise { - return this.provider.cancelShipment(query); + cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.cancelShipment(query, options); } - getShipment(query: ShipmentQuery): Promise { - return this.provider.getShipment(query); + getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.getShipment(query, options); } - batchGetShipments(query: BatchShipmentQuery): Promise { - return this.provider.batchGetShipments(query); + batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.batchGetShipments(query, options); } - getTrack(query: ShipmentQuery): Promise { - return this.provider.getTrack(query); + getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.getTrack(query, options); } - checkDeliveryCoverage(toAddress: string): Promise { - return this.provider.checkDeliveryCoverage(toAddress); + checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise { + return this.provider.checkDeliveryCoverage(toAddress, options); } - estimateFreight(weight: number): Promise { - return this.provider.estimateFreight(weight); + estimateFreight(weight: number, options?: CourierCallOptions): Promise { + return this.provider.estimateFreight(weight, options); } buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse { diff --git a/server/dukang-api/src/integrations/courier/courier.types.ts b/server/dukang-api/src/integrations/courier/courier.types.ts index 4d52d15..58cfdda 100644 --- a/server/dukang-api/src/integrations/courier/courier.types.ts +++ b/server/dukang-api/src/integrations/courier/courier.types.ts @@ -1,4 +1,10 @@ import { CourierProviderCode } from './courier.constants'; +import type { XiaofeixiaConfig } from './courier.config'; + +/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */ +export interface CourierCallOptions { + xiaofeixia?: XiaofeixiaConfig; +} export interface CourierCoordinate { lng: number; @@ -106,12 +112,12 @@ export interface TrackCallbackResponse { 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; + createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise; + cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise; + getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise; + batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise; + getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise; + checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise; + estimateFreight(weight: number, options?: CourierCallOptions): Promise; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; } diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts index 659f3bc..7dd7e35 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CourierApiError } from '../courier.error'; -import { CourierConfigService } from '../courier.config'; +import { CourierConfigService, type XiaofeixiaConfig } from '../courier.config'; import { PrismaService } from '../../../common/prisma/prisma.module'; import { logCourierCall, @@ -21,12 +21,12 @@ export class XiaofeixiaClient { private readonly prisma: PrismaService, ) {} - async request(cmd: string, bizParams: RequestParams): Promise { - const cfg = this.courierConfig.load().xiaofeixia; + async request(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise { + const cfg = override ?? this.courierConfig.load().xiaofeixia; const scene = sceneForXfxCmd(cmd); const externalNo = this.pickExternalNo(bizParams); - if (!cfg.mchId || !cfg.apiKey) { + if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) { await logCourierCall(this.prisma, { scene, requestUrl: cfg.apiUrl || '(未配置)', @@ -36,7 +36,7 @@ export class XiaofeixiaClient { externalNo, }); throw new CourierApiError( - '小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY', + '小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key', 'CONFIG_ERROR', 'XIAOFEIXIA', ); diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts index ea67094..4de6b96 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts @@ -3,6 +3,7 @@ import { CourierProviderCode } from '../courier.constants'; import { CourierApiError } from '../courier.error'; import type { BatchShipmentQuery, + CourierCallOptions, CreateShipmentInput, CreateShipmentResult, DeliveryCoverageResult, @@ -29,28 +30,32 @@ export class XiaofeixiaProvider implements ICourierProvider { 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, - }); + async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): 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, + }, + options?.xiaofeixia, + ); return { providerShipmentId: data.id, @@ -58,24 +63,32 @@ export class XiaofeixiaProvider implements ICourierProvider { }; } - async cancelShipment(query: ShipmentQuery): Promise { + async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + await this.client.request( + XIAOFEIXIA_CMD.CANCEL_ORDER, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); } - async getShipment(query: ShipmentQuery): Promise { + async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - const data = await this.client.request(XIAOFEIXIA_CMD.GET_ORDER, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.GET_ORDER, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); return this.mapOrderDetail(data); } - async batchGetShipments(query: BatchShipmentQuery): Promise { + async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise { const number = query.trackingNumbers?.join(','); const outNumber = query.outNumbers?.join(','); @@ -83,27 +96,36 @@ export class XiaofeixiaProvider implements ICourierProvider { throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code); } - const data = await this.client.request(XIAOFEIXIA_CMD.BATCH_GET_ORDER, { - number, - outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.BATCH_GET_ORDER, + { + number, + outNumber, + }, + options?.xiaofeixia, + ); return (data ?? []).map((item) => this.mapOrderDetail(item)); } - async getTrack(query: ShipmentQuery): Promise { + async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - const data = await this.client.request(XIAOFEIXIA_CMD.TRACK_ROUTE, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.TRACK_ROUTE, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); return data ?? []; } - async checkDeliveryCoverage(toAddress: string): Promise { + async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise { const data = await this.client.request( XIAOFEIXIA_CMD.DELIVERY_COVERAGE, { toAddress }, + options?.xiaofeixia, ); return { @@ -113,10 +135,11 @@ export class XiaofeixiaProvider implements ICourierProvider { }; } - async estimateFreight(weight: number): Promise { + async estimateFreight(weight: number, options?: CourierCallOptions): Promise { const data = await this.client.request( XIAOFEIXIA_CMD.ESTIMATE_FREIGHT, { weight }, + options?.xiaofeixia, ); return { freightPrice: data.freightPrice }; diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts index a4b4042..cb82e16 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts @@ -1,7 +1,13 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; +import { + isXfxProviderCode, + type XiaofeixiaProviderConfig, + type XiaofeixiaProviderConfigPublic, +} from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config'; export type CreateFulfillmentProviderInput = { code: string; @@ -10,6 +16,7 @@ export type CreateFulfillmentProviderInput = { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + xiaofeixiaConfig?: Partial; }; export type UpdateFulfillmentProviderInput = Partial; @@ -21,6 +28,8 @@ type Capabilities = { cancel?: boolean; }; +const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do'; + @Injectable() export class FulfillmentProviderService { constructor(private readonly prisma: PrismaService) {} @@ -46,6 +55,44 @@ export class FulfillmentProviderService { return this.toDto(row); } + /** 供推单使用:解析完整小飞侠凭证(含 apiKey) */ + async resolveXiaofeixiaConfig(providerId: bigint): Promise { + const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } }); + if (!row) throw new NotFoundException('仓配承运商不存在'); + if (!isXfxProviderCode(row.code)) { + throw new BadRequestException('该承运商不是小飞侠'); + } + const cfg = this.parseXiaofeixiaConfig(row.configJson); + if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) { + throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key'); + } + return { + apiUrl: cfg.apiUrl, + mchId: cfg.mchId, + apiKey: cfg.apiKey, + signType: this.resolveSignType(cfg.signType), + appId: cfg.appId || undefined, + }; + } + + /** 取第一个启用的小飞侠承运商配置(联调/兼容) */ + async resolveDefaultXiaofeixiaConfig(): Promise { + const row = await this.prisma.fulfillmentProvider.findFirst({ + where: { + status: 'ACTIVE', + type: 'API', + code: { in: ['XFX', 'XIAOFEIXIA'] }, + }, + orderBy: { updatedAt: 'desc' }, + }); + if (!row?.configJson) return null; + try { + return await this.resolveXiaofeixiaConfig(row.id); + } catch { + return null; + } + } + async create(input: CreateFulfillmentProviderInput) { const code = input.code.trim().toUpperCase(); if (!/^[A-Z0-9_]+$/.test(code)) { @@ -54,28 +101,54 @@ export class FulfillmentProviderService { const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } }); if (existing) throw new BadRequestException('承运商编码已存在'); + const configJson = this.resolveConfigJsonForWrite(code, null, input); + if (isXfxProviderCode(code) && input.type === 'API') { + this.assertXiaofeixiaConfigComplete(configJson, true); + } + const row = await this.prisma.fulfillmentProvider.create({ data: { code, name: input.name.trim(), type: input.type, status: input.status ?? 'ACTIVE', - configJson: input.configJson?.trim() || null, - capabilitiesJson: input.capabilitiesJson?.trim() || null, + configJson, + capabilitiesJson: + input.capabilitiesJson?.trim() || + (isXfxProviderCode(code) + ? JSON.stringify({ + createShipment: true, + getTrack: true, + callback: true, + cancel: true, + }) + : null), }, }); return this.toDto(row); } async update(id: bigint, input: UpdateFulfillmentProviderInput) { - await this.getById(id); + const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } }); + if (!current) throw new NotFoundException('仓配承运商不存在'); + + const code = current.code; + const configJson = + input.xiaofeixiaConfig !== undefined || input.configJson !== undefined + ? this.resolveConfigJsonForWrite(code, current.configJson, input) + : undefined; + + if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') { + this.assertXiaofeixiaConfigComplete(configJson, false); + } + const row = await this.prisma.fulfillmentProvider.update({ where: { id }, data: { ...(input.name !== undefined ? { name: input.name.trim() } : {}), ...(input.type !== undefined ? { type: input.type } : {}), ...(input.status !== undefined ? { status: input.status } : {}), - ...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}), + ...(configJson !== undefined ? { configJson } : {}), ...(input.capabilitiesJson !== undefined ? { capabilitiesJson: input.capabilitiesJson?.trim() || null } : {}), @@ -93,6 +166,74 @@ export class FulfillmentProviderService { } } + parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== 'object') return null; + return { + apiUrl: String(parsed.apiUrl ?? '').trim(), + mchId: String(parsed.mchId ?? '').trim(), + apiKey: String(parsed.apiKey ?? '').trim(), + signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5', + appId: parsed.appId ? String(parsed.appId).trim() : undefined, + }; + } catch { + return null; + } + } + + private resolveConfigJsonForWrite( + code: string, + existingRaw: string | null, + input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput, + ): string | null { + if (isXfxProviderCode(code) && input.xiaofeixiaConfig) { + const existing = this.parseXiaofeixiaConfig(existingRaw); + const next: XiaofeixiaProviderConfig = { + apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(), + mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(), + apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(), + signType: + input.xiaofeixiaConfig.signType === 'HMAC-SHA256' + ? 'HMAC-SHA256' + : input.xiaofeixiaConfig.signType === 'MD5' + ? 'MD5' + : existing?.signType ?? 'MD5', + appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined, + }; + return JSON.stringify(next); + } + if (input.configJson !== undefined) { + return input.configJson?.trim() || null; + } + return existingRaw; + } + + private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) { + const cfg = this.parseXiaofeixiaConfig(configJson); + if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址'); + if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号'); + if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key'); + if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写'); + } + + private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null { + const cfg = this.parseXiaofeixiaConfig(raw); + if (!cfg) return null; + return { + apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL, + mchId: cfg.mchId, + signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5', + appId: cfg.appId, + hasApiKey: Boolean(cfg.apiKey), + }; + } + + private resolveSignType(raw?: string): XiaofeixiaSignType { + return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5'; + } + private toDto(row: { id: bigint; code: string; @@ -112,6 +253,9 @@ export class FulfillmentProviderService { status: row.status, capabilities: this.parseCapabilities(row.capabilitiesJson), hasConfig: Boolean(row.configJson), + xiaofeixiaConfig: isXfxProviderCode(row.code) + ? this.toPublicXiaofeixiaConfig(row.configJson) + : null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts index 90ce54f..df0a52c 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -1,10 +1,12 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; +import { isXfxProviderCode } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { CourierService } from '../../integrations/courier/courier.service'; import { CourierPayMode } from '../../integrations/courier/courier.types'; +import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config'; import { TradeService } from '../trade/trade.service'; +import { FulfillmentProviderService } from './fulfillment-provider.service'; export type ManualShipInput = { logisticsCompany: string; @@ -14,16 +16,14 @@ export type ManualShipInput = { export type HqLogisticsShipInput = ManualShipInput; -const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']); - @Injectable() export class FulfillmentService { private readonly logger = new Logger(FulfillmentService.name); constructor( private readonly prisma: PrismaService, - private readonly config: ConfigService, private readonly courier: CourierService, + private readonly fulfillmentProviderService: FulfillmentProviderService, @Inject(forwardRef(() => TradeService)) private readonly tradeService: TradeService, ) {} @@ -69,38 +69,50 @@ export class FulfillmentService { } async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { - if (!XFX_CODES.has(provider.code)) { + if (!isXfxProviderCode(provider.code)) { this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); return; } - const defaults = this.getShipDefaults(); - const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng; - const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat; + let xfxConfig: XiaofeixiaConfig; + try { + xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.logDispatchFailure(order, provider, message); + await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); + return; + } + + const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665; + const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757; try { - const result = await this.courier.createShipment({ - outNumber: order.orderNo, - from: { - name: warehouse.contactName, - mobile: warehouse.contactPhone, - address: warehouse.address, - addressDetail: warehouse.name, - coordinate: { lng: fromLng, lat: fromLat }, + const result = await this.courier.createShipment( + { + outNumber: order.orderNo, + from: { + name: warehouse.contactName, + mobile: warehouse.contactPhone, + address: warehouse.address, + addressDetail: warehouse.name, + coordinate: { lng: fromLng, lat: fromLat }, + }, + to: { + name: order.receiverName, + mobile: order.receiverPhone, + address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, + addressDetail: order.receiverAddress, + }, + goodsName: order.productName, + goodsNum: order.quantity, + weight: 2, + payMode: CourierPayMode.SENDER, + remark: `仓配自动发货 ${order.orderNo}`, }, - to: { - name: order.receiverName, - mobile: order.receiverPhone, - address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, - addressDetail: order.receiverAddress, - }, - goodsName: order.productName, - goodsNum: order.quantity, - weight: defaults.weight, - payMode: defaults.payMode, - remark: `仓配自动发货 ${order.orderNo}`, - }); + { xiaofeixia: xfxConfig }, + ); const now = new Date(); await this.prisma.$transaction(async (tx) => { @@ -196,10 +208,20 @@ export class FulfillmentService { if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) { try { - const nodes = await this.courier.getTrack({ - trackingNumber: order.delivery.trackingNo ?? undefined, - outNumber: order.orderNo, - }); + const options = order.delivery.fulfillmentProviderId + ? { + xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig( + order.delivery.fulfillmentProviderId, + ), + } + : undefined; + const nodes = await this.courier.getTrack( + { + trackingNumber: order.delivery.trackingNo ?? undefined, + outNumber: order.orderNo, + }, + options, + ); return { nodes, manualQueryUrl: order.delivery.manualQueryUrl, @@ -208,7 +230,7 @@ export class FulfillmentService { logisticsCompany: order.delivery.logisticsCompany, }; } catch { - // fall through to manual fields + // fall through } } @@ -294,13 +316,4 @@ export class FulfillmentService { if (!tpl) return undefined; return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo)); } - - private getShipDefaults() { - return { - fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), - fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), - weight: 2, - payMode: CourierPayMode.SENDER, - }; - } } diff --git a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts index 98fd5d2..c79005b 100644 --- a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts @@ -39,9 +39,15 @@ export class AdminFulfillmentProvidersController { status: dto.status as FulfillmentProviderStatus | undefined, configJson: dto.configJson, capabilitiesJson: dto.capabilitiesJson, + xiaofeixiaConfig: dto.xiaofeixiaConfig, }); } + @Get(':id') + detail(@Param('id') id: string) { + return this.service.getById(BigInt(id)); + } + @Put(':id') @HqOperation({ action: HqOperationAction.WAREHOUSE_UPDATE, @@ -56,6 +62,7 @@ export class AdminFulfillmentProvidersController { status: dto.status as FulfillmentProviderStatus | undefined, configJson: dto.configJson, capabilitiesJson: dto.capabilitiesJson, + xiaofeixiaConfig: dto.xiaofeixiaConfig, }); } } diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index f36e2ab..6fa57d0 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -11,6 +10,7 @@ import type { AdminOrdersQueryDto } from './dto/admin-query.dto'; import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto'; import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; @Injectable() export class AdminOrdersService { @@ -19,7 +19,7 @@ export class AdminOrdersService { private readonly tradeService: TradeService, private readonly xiaofeixiaService: AdminXiaofeixiaService, private readonly fulfillmentService: FulfillmentService, - private readonly config: ConfigService, + private readonly fulfillmentProviderService: FulfillmentProviderService, ) {} async list(query: AdminOrdersQueryDto) { @@ -96,21 +96,6 @@ export class AdminOrdersService { return this.detail(id); } - getShipDefaults() { - return { - provider: 'XFX', - providerLabel: '小飞侠', - fromName: this.config.get('SHIP_FROM_NAME') || '杜康仓库', - fromMobile: this.config.get('SHIP_FROM_MOBILE') || '13800000000', - fromAddress: this.config.get('SHIP_FROM_ADDRESS') || '河南省郑州市金水区', - fromAddressDetail: this.config.get('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓', - fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), - fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), - weight: 2, - payMode: '1', - }; - } - async shipOrder(id: bigint, dto: AdminShipOrderDto) { if (dto.provider !== 'XFX') { throw new BadRequestException('暂仅支持小飞侠配送'); @@ -118,7 +103,10 @@ export class AdminOrdersService { const order = await this.prisma.order.findUnique({ where: { id }, - include: { delivery: true }, + include: { + delivery: true, + fulfillmentWarehouse: true, + }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -129,7 +117,23 @@ export class AdminOrdersService { throw new BadRequestException('该订单已有运单号,请勿重复发货'); } - const defaults = this.getShipDefaults(); + const warehouse = order.fulfillmentWarehouse; + const providerId = + order.delivery?.fulfillmentProviderId ?? + warehouse?.fulfillmentProviderId ?? + null; + + let xfxConfig; + if (providerId) { + xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId); + } else { + xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + if (!xfxConfig) { + throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商'); + } + } + + const defaults = this.getShipDefaults(warehouse); const shipmentDto: XiaofeixiaCreateShipmentDto = { outNumber: order.orderNo, fromName: dto.fromName || defaults.fromName, @@ -149,13 +153,14 @@ export class AdminOrdersService { remark: dto.remark || `HQ发货 ${order.orderNo}`, }; - const result = await this.xiaofeixiaService.createShipment(shipmentDto); + const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig); if (!result.ok || !result.data) { throw new BadRequestException(result.error || '小飞侠创建运单失败'); } const { providerShipmentId, trackingNumber } = result.data; const now = new Date(); + const resolvedProviderId = providerId; await this.prisma.$transaction(async (tx) => { if (order.delivery) { @@ -163,6 +168,7 @@ export class AdminOrdersService { where: { orderId: id }, data: { provider: 'XFX', + fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, @@ -173,6 +179,7 @@ export class AdminOrdersService { data: { orderId: id, provider: 'XFX', + fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, @@ -185,6 +192,40 @@ export class AdminOrdersService { return this.detail(id); } + getShipDefaults(warehouse?: { + contactName: string; + contactPhone: string; + address: string; + name: string; + lng: { toNumber?: () => number } | number | null; + lat: { toNumber?: () => number } | number | null; + } | null) { + const lng = + warehouse?.lng != null + ? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng + ? Number(warehouse.lng) + : Number(warehouse.lng) + : 113.665; + const lat = + warehouse?.lat != null + ? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat + ? Number(warehouse.lat) + : Number(warehouse.lat) + : 34.757; + return { + provider: 'XFX', + providerLabel: '小飞侠', + fromName: warehouse?.contactName || '杜康仓库', + fromMobile: warehouse?.contactPhone || '13800000000', + fromAddress: warehouse?.address || '河南省郑州市金水区', + fromAddressDetail: warehouse?.name || '杜康酒业仓', + fromLng: lng, + fromLat: lat, + weight: 2, + payMode: '1', + }; + } + /** 总部传统快递填单(同城无仓 / 跨城) */ async shipLogistics(id: bigint, dto: HqLogisticsShipDto) { await this.fulfillmentService.shipHqLogistics(id, dto); diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts index 4f9bef2..4c2bc46 100644 --- a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts +++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { CourierConfigService } from '../../integrations/courier/courier.config'; +import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config'; import { CourierApiError } from '../../integrations/courier/courier.error'; import { CourierService } from '../../integrations/courier/courier.service'; import { CourierPayMode } from '../../integrations/courier/courier.types'; @@ -8,6 +9,7 @@ import type { CreateShipmentInput, ShipmentQuery, } from '../../integrations/courier/courier.types'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; import type { XiaofeixiaBatchShipmentQueryDto, XiaofeixiaCheckCoverageDto, @@ -27,48 +29,61 @@ export class AdminXiaofeixiaService { constructor( private readonly courier: CourierService, private readonly courierConfig: CourierConfigService, + private readonly fulfillmentProviderService: FulfillmentProviderService, ) {} - getConfig() { - const cfg = this.courierConfig.load(); - const xfx = cfg.xiaofeixia; + async getConfig() { + const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + const envCfg = this.courierConfig.load().xiaofeixia; + const xfx = fromDb ?? envCfg; return { - provider: cfg.provider, + provider: this.courierConfig.load().provider, activeProvider: this.courier.activeProvider, + source: fromDb ? 'fulfillment_provider' : 'env', apiUrl: xfx.apiUrl, appId: xfx.appId ?? null, mchId: xfx.mchId || null, mchIdMasked: xfx.mchId ? maskSecret(xfx.mchId) : null, hasApiKey: Boolean(xfx.apiKey), signType: xfx.signType, - ready: Boolean(xfx.mchId && xfx.apiKey), + ready: Boolean(xfx.mchId && xfx.apiKey && xfx.apiUrl), + hint: fromDb + ? '凭证来自仓配管理中启用的小飞侠承运商' + : '未在仓配管理配置,回退到环境变量(请迁移至仓配管理)', }; } async estimateFreight(dto: XiaofeixiaEstimateFreightDto) { - return this.wrap(() => this.courier.estimateFreight(dto.weight)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.estimateFreight(dto.weight, options)); } async checkCoverage(dto: XiaofeixiaCheckCoverageDto) { - return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress, options)); } - async createShipment(dto: XiaofeixiaCreateShipmentDto) { + async createShipment(dto: XiaofeixiaCreateShipmentDto, xfxOverride?: XiaofeixiaConfig) { const input = this.mapCreateInput(dto); - return this.wrap(() => this.courier.createShipment(input)); + const options = xfxOverride + ? { xiaofeixia: xfxOverride } + : await this.callOptions(); + return this.wrap(() => this.courier.createShipment(input, options)); } async cancelShipment(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); + const options = await this.callOptions(); return this.wrap(async () => { - await this.courier.cancelShipment(query); + await this.courier.cancelShipment(query, options); return { cancelled: true }; }); } async getShipment(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); - return this.wrap(() => this.courier.getShipment(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.getShipment(query, options)); } async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) { @@ -76,12 +91,19 @@ export class AdminXiaofeixiaService { trackingNumbers: dto.trackingNumbers?.filter(Boolean), outNumbers: dto.outNumbers?.filter(Boolean), }; - return this.wrap(() => this.courier.batchGetShipments(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.batchGetShipments(query, options)); } async getTrack(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); - return this.wrap(() => this.courier.getTrack(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.getTrack(query, options)); + } + + private async callOptions() { + const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + return fromDb ? { xiaofeixia: fromDb } : undefined; } private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery { diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index c9d3e30..a1ede94 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -583,6 +583,16 @@ export class CreateFulfillmentProviderDto { @IsOptional() @IsString() capabilitiesJson?: string; + + @IsOptional() + @IsObject() + xiaofeixiaConfig?: { + apiUrl?: string; + mchId?: string; + apiKey?: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; + }; } export class UpdateFulfillmentProviderDto { @@ -605,6 +615,16 @@ export class UpdateFulfillmentProviderDto { @IsOptional() @IsString() capabilitiesJson?: string; + + @IsOptional() + @IsObject() + xiaofeixiaConfig?: { + apiUrl?: string; + mchId?: string; + apiKey?: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; + }; } export class ManualShipOrderDto {