diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index 040e2aa..669356b 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -13,6 +13,7 @@ import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage';
import RedeemRecordsPage from './pages/RedeemRecordsPage';
import DeliveriesPage from './pages/DeliveriesPage';
+import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import StoreMediaPage from './pages/StoreMediaPage';
@@ -60,6 +61,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index c2ae20c..ee61bec 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -67,7 +67,15 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/logs/third-party', label: '第三方日志' },
],
},
- { key: '/deliveries', icon: , label: '配送单' },
+ {
+ key: 'deliveries-group',
+ icon: ,
+ label: '配送单',
+ children: [
+ { key: '/deliveries', label: '配送单列表' },
+ { key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
+ ],
+ },
{ key: '/hq-accounts', icon: , label: 'HQ账户' },
];
@@ -95,7 +103,7 @@ export default function AdminLayout() {
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
- defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group']}
+ defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']}
items={MENU_ITEMS}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
diff --git a/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx
new file mode 100644
index 0000000..7da077e
--- /dev/null
+++ b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx
@@ -0,0 +1,290 @@
+import { useEffect, useState } from 'react';
+import {
+ Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
+ Tabs, Tag, Typography, message,
+} from 'antd';
+import { request } from '../lib/api';
+
+type XfxConfig = {
+ provider: string;
+ activeProvider: string;
+ apiUrl: string;
+ appId: string | null;
+ mchId: string | null;
+ mchIdMasked: string | null;
+ hasApiKey: boolean;
+ signType: string;
+ ready: boolean;
+};
+
+type ApiResult = {
+ ok: boolean;
+ elapsedMs: number;
+ data?: unknown;
+ error?: string;
+ code?: string;
+ provider?: string;
+ raw?: unknown;
+};
+
+const CREATE_DEFAULTS = {
+ outNumber: `TEST-${Date.now()}`,
+ fromName: '杜康仓库',
+ fromMobile: '13800000000',
+ fromAddress: '河南省郑州市金水区',
+ fromAddressDetail: '杜康酒业仓',
+ fromLng: 113.665,
+ fromLat: 34.757,
+ toName: '测试收货人',
+ toMobile: '13800000001',
+ toAddress: '河南省郑州市二七区',
+ toAddressDetail: '测试路 1 号',
+ toLng: 113.640,
+ toLat: 34.720,
+ goodsName: '杜康酒',
+ goodsNum: 2,
+ weight: 2,
+ payMode: '1',
+ remark: 'Admin 联调测试',
+};
+
+function ResultPanel({ result }: { result: ApiResult | null }) {
+ if (!result) return 点击「调用接口」后在此显示响应;
+ return (
+
+
+ {result.ok ? '成功' : '失败'}
+ {result.elapsedMs} ms
+ {result.code && code: {result.code}}
+
+
+ {JSON.stringify(result, null, 2)}
+
+
+ );
+}
+
+export default function XiaofeixiaTestPage() {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+
+ const [freightForm] = Form.useForm();
+ const [coverageForm] = Form.useForm();
+ const [createForm] = Form.useForm();
+ const [queryForm] = Form.useForm();
+ const [batchForm] = Form.useForm();
+ const [trackForm] = Form.useForm();
+ const [cancelForm] = Form.useForm();
+
+ useEffect(() => {
+ void request('/admin/courier/xiaofeixia/config').then(setConfig);
+ createForm.setFieldsValue(CREATE_DEFAULTS);
+ freightForm.setFieldsValue({ weight: 2 });
+ coverageForm.setFieldsValue({ toAddress: '河南省郑州市二七区测试路1号' });
+ }, [createForm, freightForm, coverageForm]);
+
+ async function invoke(path: string, body: unknown) {
+ setLoading(true);
+ setResult(null);
+ try {
+ const res = await request(path, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+ setResult(res);
+ if (res.ok) message.success('调用成功');
+ else message.warning(res.error || '调用失败');
+ } catch (e) {
+ const err = e instanceof Error ? e.message : String(e);
+ message.error(err);
+ setResult({ ok: false, elapsedMs: 0, error: err });
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function parseList(text?: string) {
+ return (text ?? '').split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean);
+ }
+
+ return (
+
+
小飞侠接口联调
+
+ 通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。请确保服务器已配置
+ {' '}XIAOFEIXIA_MCH_ID、XIAOFEIXIA_API_KEY。
+
+
+ {config && (
+
+
+ {config.activeProvider}
+ {config.apiUrl}
+ {config.mchIdMasked || '—'}
+ {config.signType}
+
+ {config.hasApiKey ? 已配置 : 未配置}
+
+
+ {config.ready ? 可调用 : 配置不完整}
+
+
+ {!config.ready && (
+
+ )}
+
+ )}
+
+
+
+ void invoke('/admin/courier/xiaofeixia/estimate-freight', v)}>
+
+
+
+
+
+ ),
+ },
+ {
+ key: 'coverage',
+ label: '配送范围 (100301)',
+ children: (
+
+
+
+
+
+ ),
+ },
+ {
+ key: 'create',
+ label: '创建订单 (100101)',
+ children: (
+
+
+
+
+
+ 寄件人
+
+
+
+
+
+
+
+
+
+
+ 收件人
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ key: 'query',
+ label: '查询订单 (100104)',
+ children: (
+
+
+
+
+ ),
+ },
+ {
+ key: 'batch',
+ label: '批量查询 (100106)',
+ children: (
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ key: 'track',
+ label: '路由查询 (100102)',
+ children: (
+
+
+
+
+ ),
+ },
+ {
+ key: 'cancel',
+ label: '取消订单 (100103)',
+ children: (
+
+
+
+
+ ),
+ },
+ ]}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index a2d2b85..86061b8 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -60,5 +60,10 @@ OSS_UPLOAD_PREFIX=uploads
OSS_UPLOAD_EXPIRE_SECONDS=900
# 单文件大小上限(字节,默认 10MB)
OSS_MAX_UPLOAD_BYTES=10485760
-# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
-# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
+# 小飞侠同城配送(COURIER_PROVIDER=xiaofeixia)
+COURIER_PROVIDER=xiaofeixia
+XIAOFEIXIA_API_URL=https://beta.51xiaoju.cn/app/api/interface.do
+XIAOFEIXIA_MCH_ID=
+XIAOFEIXIA_API_KEY=
+XIAOFEIXIA_SIGN_TYPE=MD5
+# XIAOFEIXIA_APP_ID=
diff --git a/server/dukang-api/src/integrations/courier/courier.module.ts b/server/dukang-api/src/integrations/courier/courier.module.ts
index c512c02..d6d88dc 100644
--- a/server/dukang-api/src/integrations/courier/courier.module.ts
+++ b/server/dukang-api/src/integrations/courier/courier.module.ts
@@ -29,6 +29,6 @@ import type { ICourierProvider } from './courier.types';
},
CourierService,
],
- exports: [CourierService, COURIER_PROVIDER],
+ exports: [CourierService, CourierConfigService, COURIER_PROVIDER],
})
export class CourierModule {}
diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts
new file mode 100644
index 0000000..6aa1e0a
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts
@@ -0,0 +1,57 @@
+import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
+import {
+ XiaofeixiaBatchShipmentQueryDto,
+ XiaofeixiaCheckCoverageDto,
+ XiaofeixiaCreateShipmentDto,
+ XiaofeixiaEstimateFreightDto,
+ XiaofeixiaShipmentQueryDto,
+} from './dto/admin-courier.dto';
+
+/** HQ 小飞侠接口联调(仅管理端,勿对 C 端暴露) */
+@Controller('admin/courier/xiaofeixia')
+@UseGuards(HqAuthGuard)
+export class AdminXiaofeixiaController {
+ constructor(private readonly service: AdminXiaofeixiaService) {}
+
+ @Get('config')
+ getConfig() {
+ return this.service.getConfig();
+ }
+
+ @Post('estimate-freight')
+ estimateFreight(@Body() body: XiaofeixiaEstimateFreightDto) {
+ return this.service.estimateFreight(body);
+ }
+
+ @Post('check-coverage')
+ checkCoverage(@Body() body: XiaofeixiaCheckCoverageDto) {
+ return this.service.checkCoverage(body);
+ }
+
+ @Post('create-shipment')
+ createShipment(@Body() body: XiaofeixiaCreateShipmentDto) {
+ return this.service.createShipment(body);
+ }
+
+ @Post('cancel-shipment')
+ cancelShipment(@Body() body: XiaofeixiaShipmentQueryDto) {
+ return this.service.cancelShipment(body);
+ }
+
+ @Post('get-shipment')
+ getShipment(@Body() body: XiaofeixiaShipmentQueryDto) {
+ return this.service.getShipment(body);
+ }
+
+ @Post('batch-get-shipments')
+ batchGetShipments(@Body() body: XiaofeixiaBatchShipmentQueryDto) {
+ return this.service.batchGetShipments(body);
+ }
+
+ @Post('get-track')
+ getTrack(@Body() body: XiaofeixiaShipmentQueryDto) {
+ return this.service.getTrack(body);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts
new file mode 100644
index 0000000..489cadf
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts
@@ -0,0 +1,151 @@
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { CourierConfigService } 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';
+import type {
+ BatchShipmentQuery,
+ CreateShipmentInput,
+ ShipmentQuery,
+} from '../../integrations/courier/courier.types';
+import type {
+ XiaofeixiaBatchShipmentQueryDto,
+ XiaofeixiaCheckCoverageDto,
+ XiaofeixiaCreateShipmentDto,
+ XiaofeixiaEstimateFreightDto,
+ XiaofeixiaShipmentQueryDto,
+} from './dto/admin-courier.dto';
+
+function maskSecret(value: string, visible = 4) {
+ if (!value) return '';
+ if (value.length <= visible) return '*'.repeat(value.length);
+ return `${value.slice(0, visible)}${'*'.repeat(Math.min(8, value.length - visible))}`;
+}
+
+@Injectable()
+export class AdminXiaofeixiaService {
+ constructor(
+ private readonly courier: CourierService,
+ private readonly courierConfig: CourierConfigService,
+ ) {}
+
+ getConfig() {
+ const cfg = this.courierConfig.load();
+ const xfx = cfg.xiaofeixia;
+ return {
+ provider: cfg.provider,
+ activeProvider: this.courier.activeProvider,
+ 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),
+ };
+ }
+
+ async estimateFreight(dto: XiaofeixiaEstimateFreightDto) {
+ return this.wrap(() => this.courier.estimateFreight(dto.weight));
+ }
+
+ async checkCoverage(dto: XiaofeixiaCheckCoverageDto) {
+ return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress));
+ }
+
+ async createShipment(dto: XiaofeixiaCreateShipmentDto) {
+ const input = this.mapCreateInput(dto);
+ return this.wrap(() => this.courier.createShipment(input));
+ }
+
+ async cancelShipment(dto: XiaofeixiaShipmentQueryDto) {
+ const query = this.mapShipmentQuery(dto);
+ return this.wrap(async () => {
+ await this.courier.cancelShipment(query);
+ return { cancelled: true };
+ });
+ }
+
+ async getShipment(dto: XiaofeixiaShipmentQueryDto) {
+ const query = this.mapShipmentQuery(dto);
+ return this.wrap(() => this.courier.getShipment(query));
+ }
+
+ async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) {
+ const query: BatchShipmentQuery = {
+ trackingNumbers: dto.trackingNumbers?.filter(Boolean),
+ outNumbers: dto.outNumbers?.filter(Boolean),
+ };
+ return this.wrap(() => this.courier.batchGetShipments(query));
+ }
+
+ async getTrack(dto: XiaofeixiaShipmentQueryDto) {
+ const query = this.mapShipmentQuery(dto);
+ return this.wrap(() => this.courier.getTrack(query));
+ }
+
+ private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery {
+ if (!dto.trackingNumber && !dto.outNumber) {
+ throw new BadRequestException('运单号与商家单号至少填一个');
+ }
+ return {
+ trackingNumber: dto.trackingNumber,
+ outNumber: dto.outNumber,
+ };
+ }
+
+ private mapCreateInput(dto: XiaofeixiaCreateShipmentDto): CreateShipmentInput {
+ const coord = (lng?: number, lat?: number) =>
+ lng != null && lat != null ? { lng, lat } : undefined;
+
+ return {
+ outNumber: dto.outNumber,
+ customerId: dto.customerId,
+ from: {
+ name: dto.fromName,
+ mobile: dto.fromMobile,
+ address: dto.fromAddress,
+ addressDetail: dto.fromAddressDetail,
+ coordinate: coord(dto.fromLng, dto.fromLat),
+ },
+ to: {
+ name: dto.toName,
+ mobile: dto.toMobile,
+ address: dto.toAddress,
+ addressDetail: dto.toAddressDetail,
+ coordinate: coord(dto.toLng, dto.toLat),
+ },
+ goodsName: dto.goodsName,
+ goodsNum: dto.goodsNum,
+ weight: dto.weight,
+ insuredSumPrice: dto.insuredSumPrice,
+ collectionPrice: dto.collectionPrice,
+ payMode: dto.payMode as CourierPayMode,
+ remark: dto.remark,
+ };
+ }
+
+ private async wrap(fn: () => Promise) {
+ const startedAt = Date.now();
+ try {
+ const data = await fn();
+ return {
+ ok: true,
+ elapsedMs: Date.now() - startedAt,
+ data,
+ };
+ } catch (err) {
+ if (err instanceof CourierApiError) {
+ return {
+ ok: false,
+ elapsedMs: Date.now() - startedAt,
+ error: err.message,
+ code: err.code,
+ provider: err.providerCode,
+ raw: err.raw,
+ };
+ }
+ throw err;
+ }
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts
new file mode 100644
index 0000000..a5cd347
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts
@@ -0,0 +1,128 @@
+import {
+ IsArray,
+ IsIn,
+ IsNotEmpty,
+ IsNumber,
+ IsOptional,
+ IsString,
+ Min,
+} from 'class-validator';
+
+export class XiaofeixiaEstimateFreightDto {
+ @IsNumber()
+ @Min(0.01)
+ weight: number;
+}
+
+export class XiaofeixiaCheckCoverageDto {
+ @IsString()
+ @IsNotEmpty()
+ toAddress: string;
+}
+
+export class XiaofeixiaShipmentQueryDto {
+ @IsOptional()
+ @IsString()
+ trackingNumber?: string;
+
+ @IsOptional()
+ @IsString()
+ outNumber?: string;
+}
+
+export class XiaofeixiaBatchShipmentQueryDto {
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ trackingNumbers?: string[];
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ outNumbers?: string[];
+}
+
+export class XiaofeixiaCreateShipmentDto {
+ @IsString()
+ @IsNotEmpty()
+ outNumber: string;
+
+ @IsOptional()
+ @IsString()
+ customerId?: string;
+
+ @IsString()
+ @IsNotEmpty()
+ fromName: string;
+
+ @IsString()
+ @IsNotEmpty()
+ fromMobile: string;
+
+ @IsString()
+ @IsNotEmpty()
+ fromAddress: string;
+
+ @IsString()
+ @IsNotEmpty()
+ fromAddressDetail: string;
+
+ @IsOptional()
+ @IsNumber()
+ fromLng?: number;
+
+ @IsOptional()
+ @IsNumber()
+ fromLat?: number;
+
+ @IsString()
+ @IsNotEmpty()
+ toName: string;
+
+ @IsString()
+ @IsNotEmpty()
+ toMobile: string;
+
+ @IsString()
+ @IsNotEmpty()
+ toAddress: string;
+
+ @IsString()
+ @IsNotEmpty()
+ toAddressDetail: string;
+
+ @IsOptional()
+ @IsNumber()
+ toLng?: number;
+
+ @IsOptional()
+ @IsNumber()
+ toLat?: number;
+
+ @IsOptional()
+ @IsString()
+ goodsName?: string;
+
+ @IsOptional()
+ @IsNumber()
+ goodsNum?: number;
+
+ @IsOptional()
+ @IsNumber()
+ weight?: number;
+
+ @IsOptional()
+ @IsNumber()
+ insuredSumPrice?: number;
+
+ @IsOptional()
+ @IsNumber()
+ collectionPrice?: number;
+
+ @IsIn(['1', '2'])
+ payMode: string;
+
+ @IsOptional()
+ @IsString()
+ remark?: string;
+}
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 81c4932..c12c74d 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -28,9 +28,12 @@ import { AdminTicketsService } from './admin-tickets.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
+import { IntegrationsModule } from '../../integrations/integrations.module';
+import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
+import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
@Module({
- imports: [IamModule, TradeModule, BenefitModule, CommonModule],
+ imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -49,6 +52,7 @@ import { CommonModule } from '../common/common.module';
AdminProductsController,
AdminUserLogsController,
AdminTicketsController,
+ AdminXiaofeixiaController,
],
providers: [
AdminDashboardService,
@@ -64,6 +68,7 @@ import { CommonModule } from '../common/common.module';
AdminProductsService,
AdminUserLogsService,
AdminTicketsService,
+ AdminXiaofeixiaService,
SuperAdminGuard,
],
})