小飞侠接口
This commit is contained in:
@@ -13,6 +13,7 @@ import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
|||||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||||
import DeliveriesPage from './pages/DeliveriesPage';
|
import DeliveriesPage from './pages/DeliveriesPage';
|
||||||
|
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||||
import HqAccountsPage from './pages/HqAccountsPage';
|
import HqAccountsPage from './pages/HqAccountsPage';
|
||||||
import CitiesPage from './pages/CitiesPage';
|
import CitiesPage from './pages/CitiesPage';
|
||||||
import StoreMediaPage from './pages/StoreMediaPage';
|
import StoreMediaPage from './pages/StoreMediaPage';
|
||||||
@@ -60,6 +61,7 @@ export default function App() {
|
|||||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||||
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -67,7 +67,15 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/logs/third-party', label: '第三方日志' },
|
{ key: '/logs/third-party', label: '第三方日志' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
|
{
|
||||||
|
key: 'deliveries-group',
|
||||||
|
icon: <CarOutlined />,
|
||||||
|
label: '配送单',
|
||||||
|
children: [
|
||||||
|
{ key: '/deliveries', label: '配送单列表' },
|
||||||
|
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -95,7 +103,7 @@ export default function AdminLayout() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
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}
|
items={MENU_ITEMS}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
if (key.startsWith('/')) navigate(key);
|
if (key.startsWith('/')) navigate(key);
|
||||||
|
|||||||
@@ -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 <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Tag color={result.ok ? 'green' : 'red'}>{result.ok ? '成功' : '失败'}</Tag>
|
||||||
|
<span>{result.elapsedMs} ms</span>
|
||||||
|
{result.code && <span>code: {result.code}</span>}
|
||||||
|
</Space>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||||
|
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XiaofeixiaTestPage() {
|
||||||
|
const [config, setConfig] = useState<XfxConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<ApiResult | null>(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<XfxConfig>('/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<ApiResult>(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 (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。请确保服务器已配置
|
||||||
|
{' '}<code>XIAOFEIXIA_MCH_ID</code>、<code>XIAOFEIXIA_API_KEY</code>。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
{config && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions column={3} size="small">
|
||||||
|
<Descriptions.Item label="Provider">{config.activeProvider}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="API">{config.apiUrl}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商户号">{config.mchIdMasked || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="签名">{config.signType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="密钥">
|
||||||
|
{config.hasApiKey ? <Tag color="green">已配置</Tag> : <Tag color="red">未配置</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{config.ready ? <Tag color="green">可调用</Tag> : <Tag color="orange">配置不完整</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{!config.ready && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} lg={14}>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'freight',
|
||||||
|
label: '运费预估 (100105)',
|
||||||
|
children: (
|
||||||
|
<Form form={freightForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/estimate-freight', v)}>
|
||||||
|
<Form.Item name="weight" label="重量 (kg)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coverage',
|
||||||
|
label: '配送范围 (100301)',
|
||||||
|
children: (
|
||||||
|
<Form form={coverageForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/check-coverage', v)}>
|
||||||
|
<Form.Item name="toAddress" label="收件地址" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={2} placeholder="完整收件地址" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'create',
|
||||||
|
label: '创建订单 (100101)',
|
||||||
|
children: (
|
||||||
|
<Form form={createForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/create-shipment', v)}>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>寄件人</Typography.Text>
|
||||||
|
<Form.Item name="fromName" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromMobile" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromAddress" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="fromAddressDetail" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="fromLng" label="经度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="fromLat" label="纬度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Typography.Text strong>收件人</Typography.Text>
|
||||||
|
<Form.Item name="toName" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toMobile" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toAddress" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item name="toAddressDetail" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item name="toLng" label="经度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
<Form.Item name="toLat" label="纬度"><InputNumber style={{ width: 120 }} /></Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={8}><Form.Item name="goodsName" label="货品"><Input /></Form.Item></Col>
|
||||||
|
<Col span={8}><Form.Item name="goodsNum" label="件数"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item></Col>
|
||||||
|
<Col span={8}><Form.Item name="weight" label="重量 kg"><InputNumber min={0.01} style={{ width: '100%' }} /></Form.Item></Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
|
||||||
|
<Select options={[
|
||||||
|
{ value: '1', label: '寄付' },
|
||||||
|
{ value: '2', label: '到付' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注"><Input /></Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
<Button onClick={() => createForm.setFieldsValue({ ...CREATE_DEFAULTS, outNumber: `TEST-${Date.now()}` })}>
|
||||||
|
重置示例
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'query',
|
||||||
|
label: '查询订单 (100104)',
|
||||||
|
children: (
|
||||||
|
<Form form={queryForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-shipment', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号 number"><Input placeholder="小飞侠运单号" /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber"><Input /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'batch',
|
||||||
|
label: '批量查询 (100106)',
|
||||||
|
children: (
|
||||||
|
<Form form={batchForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/batch-get-shipments', {
|
||||||
|
trackingNumbers: parseList(v.trackingNumbersText),
|
||||||
|
outNumbers: parseList(v.outNumbersText),
|
||||||
|
})}>
|
||||||
|
<Form.Item name="trackingNumbersText" label="运单号(逗号分隔)">
|
||||||
|
<Input.TextArea rows={2} placeholder="NO001,NO002" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="outNumbersText" label="商家单号(逗号分隔)">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'track',
|
||||||
|
label: '路由查询 (100102)',
|
||||||
|
children: (
|
||||||
|
<Form form={trackForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-track', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号"><Input /></Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cancel',
|
||||||
|
label: '取消订单 (100103)',
|
||||||
|
children: (
|
||||||
|
<Form form={cancelForm} layout="vertical" onFinish={(v) => void invoke('/admin/courier/xiaofeixia/cancel-shipment', v)}>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号"><Input /></Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号"><Input /></Form.Item>
|
||||||
|
<Button type="primary" danger htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={10}>
|
||||||
|
<Card title="响应结果" size="small">
|
||||||
|
<ResultPanel result={result} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,5 +60,10 @@ OSS_UPLOAD_PREFIX=uploads
|
|||||||
OSS_UPLOAD_EXPIRE_SECONDS=900
|
OSS_UPLOAD_EXPIRE_SECONDS=900
|
||||||
# 单文件大小上限(字节,默认 10MB)
|
# 单文件大小上限(字节,默认 10MB)
|
||||||
OSS_MAX_UPLOAD_BYTES=10485760
|
OSS_MAX_UPLOAD_BYTES=10485760
|
||||||
# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
|
# 小飞侠同城配送(COURIER_PROVIDER=xiaofeixia)
|
||||||
# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
|
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=
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ import type { ICourierProvider } from './courier.types';
|
|||||||
},
|
},
|
||||||
CourierService,
|
CourierService,
|
||||||
],
|
],
|
||||||
exports: [CourierService, COURIER_PROVIDER],
|
exports: [CourierService, CourierConfigService, COURIER_PROVIDER],
|
||||||
})
|
})
|
||||||
export class CourierModule {}
|
export class CourierModule {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(fn: () => Promise<T>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,9 +28,12 @@ import { AdminTicketsService } from './admin-tickets.service';
|
|||||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { BenefitModule } from '../benefit/benefit.module';
|
import { BenefitModule } from '../benefit/benefit.module';
|
||||||
import { CommonModule } from '../common/common.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({
|
@Module({
|
||||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule],
|
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
AdminDashboardController,
|
AdminDashboardController,
|
||||||
AdminUsersController,
|
AdminUsersController,
|
||||||
@@ -49,6 +52,7 @@ import { CommonModule } from '../common/common.module';
|
|||||||
AdminProductsController,
|
AdminProductsController,
|
||||||
AdminUserLogsController,
|
AdminUserLogsController,
|
||||||
AdminTicketsController,
|
AdminTicketsController,
|
||||||
|
AdminXiaofeixiaController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminDashboardService,
|
AdminDashboardService,
|
||||||
@@ -64,6 +68,7 @@ import { CommonModule } from '../common/common.module';
|
|||||||
AdminProductsService,
|
AdminProductsService,
|
||||||
AdminUserLogsService,
|
AdminUserLogsService,
|
||||||
AdminTicketsService,
|
AdminTicketsService,
|
||||||
|
AdminXiaofeixiaService,
|
||||||
SuperAdminGuard,
|
SuperAdminGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user