From 80d967f2cf05701273c0bd98592548262d481efe Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Mon, 6 Jul 2026 14:12:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E9=A3=9E=E4=BE=A0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 12 +- .../src/pages/XiaofeixiaTestPage.tsx | 290 ++++++++++++++++++ server/dukang-api/.env.example | 9 +- .../integrations/courier/courier.module.ts | 2 +- .../ops/admin-xiaofeixia.controller.ts | 57 ++++ .../modules/ops/admin-xiaofeixia.service.ts | 151 +++++++++ .../src/modules/ops/dto/admin-courier.dto.ts | 128 ++++++++ .../dukang-api/src/modules/ops/ops.module.ts | 7 +- 9 files changed, 652 insertions(+), 6 deletions(-) create mode 100644 apps/admin-web/src/pages/XiaofeixiaTestPage.tsx create mode 100644 server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts create mode 100644 server/dukang-api/src/modules/ops/dto/admin-courier.dto.ts 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_IDXIAOFEIXIA_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: ( +
void invoke('/admin/courier/xiaofeixia/check-coverage', v)}> + + + + +
+ ), + }, + { + key: 'create', + label: '创建订单 (100101)', + children: ( +
void invoke('/admin/courier/xiaofeixia/create-shipment', v)}> + + + + + + 寄件人 + + + + + + + + + + + 收件人 + + + + + + + + + + + + + + + + + + + + + +
+ ), + }, + { + key: 'query', + label: '查询订单 (100104)', + children: ( +
void invoke('/admin/courier/xiaofeixia/get-shipment', v)}> + + + +
+ ), + }, + { + key: 'batch', + label: '批量查询 (100106)', + children: ( +
void invoke('/admin/courier/xiaofeixia/batch-get-shipments', { + trackingNumbers: parseList(v.trackingNumbersText), + outNumbers: parseList(v.outNumbersText), + })}> + + + + + + + +
+ ), + }, + { + key: 'track', + label: '路由查询 (100102)', + children: ( +
void invoke('/admin/courier/xiaofeixia/get-track', v)}> + + + +
+ ), + }, + { + key: 'cancel', + label: '取消订单 (100103)', + children: ( +
void invoke('/admin/courier/xiaofeixia/cancel-shipment', v)}> + + + +
+ ), + }, + ]} + /> + + + + + + +
+
+ ); +} 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, ], })