Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fe8b903a |
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -140,6 +141,14 @@ export default function OrderTrackDrawer({
|
|||||||
</Space>
|
</Space>
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</div>
|
</div>
|
||||||
|
) : nodes.some((n) => n.statusName?.includes('签收') || n.trackInfo?.includes('签收')) ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="暂无签收照片"
|
||||||
|
description="小飞侠 100108 未返回图片(可能是本人签收未拍照,或照片已过期)。可在「小飞侠联调」用运单号复测。"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
|
Alert, Button, Card, Col, Descriptions, Form, Image, Input, InputNumber, Row, Select, Space,
|
||||||
Tabs, Tag, Typography, message,
|
Tabs, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -53,6 +53,22 @@ const CREATE_DEFAULTS = {
|
|||||||
|
|
||||||
function ResultPanel({ result }: { result: ApiResult | null }) {
|
function ResultPanel({ result }: { result: ApiResult | null }) {
|
||||||
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
if (!result) return <Typography.Text type="secondary">点击「调用接口」后在此显示响应</Typography.Text>;
|
||||||
|
const display = (() => {
|
||||||
|
if (!result.data || typeof result.data !== 'object') return result;
|
||||||
|
const data = result.data as { count?: number; data?: string[] };
|
||||||
|
if (!Array.isArray(data.data)) return result;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
data: data.data.map((item) =>
|
||||||
|
typeof item === 'string' && item.length > 120
|
||||||
|
? `${item.slice(0, 80)}…(len=${item.length})`
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 8 }}>
|
<Space style={{ marginBottom: 8 }}>
|
||||||
@@ -64,7 +80,7 @@ function ResultPanel({ result }: { result: ApiResult | null }) {
|
|||||||
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
|
||||||
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
maxHeight: 360, overflow: 'auto', fontSize: 12,
|
||||||
}}>
|
}}>
|
||||||
{JSON.stringify(result, null, 2)}
|
{JSON.stringify(display, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -81,7 +97,9 @@ export default function XiaofeixiaTestPage() {
|
|||||||
const [queryForm] = Form.useForm();
|
const [queryForm] = Form.useForm();
|
||||||
const [batchForm] = Form.useForm();
|
const [batchForm] = Form.useForm();
|
||||||
const [trackForm] = Form.useForm();
|
const [trackForm] = Form.useForm();
|
||||||
|
const [signPhotoForm] = Form.useForm();
|
||||||
const [cancelForm] = Form.useForm();
|
const [cancelForm] = Form.useForm();
|
||||||
|
const [signPhotoPreview, setSignPhotoPreview] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
void request<XfxConfig>('/admin/courier/xiaofeixia/config').then(setConfig);
|
||||||
@@ -99,12 +117,19 @@ export default function XiaofeixiaTestPage() {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
setResult(res);
|
setResult(res);
|
||||||
|
if (path.endsWith('/get-sign-photos')) {
|
||||||
|
const data = (res.data as { data?: string[] } | undefined)?.data;
|
||||||
|
setSignPhotoPreview(Array.isArray(data) ? data.filter((u) => typeof u === 'string') : []);
|
||||||
|
} else {
|
||||||
|
setSignPhotoPreview([]);
|
||||||
|
}
|
||||||
if (res.ok) message.success('调用成功');
|
if (res.ok) message.success('调用成功');
|
||||||
else message.warning(res.error || '调用失败');
|
else message.warning(res.error || '调用失败');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e.message : String(e);
|
const err = e instanceof Error ? e.message : String(e);
|
||||||
message.error(err);
|
message.error(err);
|
||||||
setResult({ ok: false, elapsedMs: 0, error: err });
|
setResult({ ok: false, elapsedMs: 0, error: err });
|
||||||
|
setSignPhotoPreview([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -118,7 +143,7 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
<Typography.Title level={4}>小飞侠接口联调</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。凭证请在
|
通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301,含签收图片 100108)。凭证请在
|
||||||
<Link to="/fulfillment-providers">仓配管理</Link>
|
<Link to="/fulfillment-providers">仓配管理</Link>
|
||||||
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
中配置;联调会优先使用仓配管理里启用的小飞侠承运商。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -273,6 +298,25 @@ export default function XiaofeixiaTestPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'signPhotos',
|
||||||
|
label: '签收图片 (100108)',
|
||||||
|
children: (
|
||||||
|
<Form
|
||||||
|
form={signPhotoForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(v) => void invoke('/admin/courier/xiaofeixia/get-sign-photos', v)}
|
||||||
|
>
|
||||||
|
<Form.Item name="trackingNumber" label="运单号 number">
|
||||||
|
<Input placeholder="小飞侠运单号,与商家单号二选一" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="outNumber" label="商家单号 outNumber">
|
||||||
|
<Input placeholder="外部单号,与运单号二选一" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading}>调用接口</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'cancel',
|
key: 'cancel',
|
||||||
label: '取消订单 (100103)',
|
label: '取消订单 (100103)',
|
||||||
@@ -290,6 +334,24 @@ export default function XiaofeixiaTestPage() {
|
|||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<Card title="响应结果" size="small">
|
<Card title="响应结果" size="small">
|
||||||
<ResultPanel result={result} />
|
<ResultPanel result={result} />
|
||||||
|
{signPhotoPreview.length > 0 ? (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text strong>签收照片预览({signPhotoPreview.length})</Typography.Text>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap style={{ marginTop: 8 }}>
|
||||||
|
{signPhotoPreview.map((url, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${index}-${url.slice(0, 32)}`}
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface FulfillmentProviderDto {
|
|||||||
capabilities?: {
|
capabilities?: {
|
||||||
createShipment?: boolean;
|
createShipment?: boolean;
|
||||||
getTrack?: boolean;
|
getTrack?: boolean;
|
||||||
|
getSignPhotos?: boolean;
|
||||||
callback?: boolean;
|
callback?: boolean;
|
||||||
cancel?: boolean;
|
cancel?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
@@ -47,13 +47,27 @@ export function sceneForXfxCmd(cmd: string) {
|
|||||||
return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`;
|
return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 签收照等大字段入库前截断,避免撑爆 JSON / 影响后续业务返回 */
|
||||||
|
function sanitizeCourierResponseBody(scene: string, body: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (body === undefined) return undefined;
|
||||||
|
if (typeof body !== 'object' || body === null) {
|
||||||
|
return { value: body };
|
||||||
|
}
|
||||||
|
const root = { ...(body as Record<string, unknown>) };
|
||||||
|
if (scene === 'GET_SIGN_PHOTOS' && Array.isArray(root.data)) {
|
||||||
|
const photos = root.data as unknown[];
|
||||||
|
root.data = photos.map((item) => {
|
||||||
|
if (typeof item !== 'string') return item;
|
||||||
|
if (item.length <= 120) return item;
|
||||||
|
return `${item.slice(0, 80)}…(len=${item.length})`;
|
||||||
|
});
|
||||||
|
root.dataCount = photos.length;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) {
|
export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) {
|
||||||
const responseBody =
|
const responseBody = sanitizeCourierResponseBody(input.scene, input.responseBody);
|
||||||
input.responseBody === undefined
|
|
||||||
? undefined
|
|
||||||
: typeof input.responseBody === 'object' && input.responseBody !== null
|
|
||||||
? (input.responseBody as Record<string, unknown>)
|
|
||||||
: { value: input.responseBody };
|
|
||||||
|
|
||||||
const row = await prisma.logThirdParty.create({
|
const row = await prisma.logThirdParty.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export class FulfillmentProviderService {
|
|||||||
? JSON.stringify({
|
? JSON.stringify({
|
||||||
createShipment: true,
|
createShipment: true,
|
||||||
getTrack: true,
|
getTrack: true,
|
||||||
|
getSignPhotos: true,
|
||||||
callback: true,
|
callback: true,
|
||||||
cancel: true,
|
cancel: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -313,8 +313,9 @@ export class FulfillmentService {
|
|||||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||||
outNumber: order.orderNo,
|
outNumber: order.orderNo,
|
||||||
};
|
};
|
||||||
|
const cachedSignPhotoUrl = order.delivery.signPhotoResource?.url ?? null;
|
||||||
|
|
||||||
const [trackResult, signPhotoDataUris] = await Promise.all([
|
const [trackResult, signPhotoResult] = await Promise.all([
|
||||||
this.courier
|
this.courier
|
||||||
.getTrack(shipmentQuery, options)
|
.getTrack(shipmentQuery, options)
|
||||||
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
|
.then((nodes) => ({ nodes: Array.isArray(nodes) ? nodes : [], error: null as string | null }))
|
||||||
@@ -322,12 +323,30 @@ export class FulfillmentService {
|
|||||||
nodes: [] as TrackNode[],
|
nodes: [] as TrackNode[],
|
||||||
error: err instanceof Error ? err.message : '查询路由失败',
|
error: err instanceof Error ? err.message : '查询路由失败',
|
||||||
})),
|
})),
|
||||||
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
|
cachedSignPhotoUrl
|
||||||
|
? Promise.resolve({ dataUris: [] as string[], error: null as string | null })
|
||||||
|
: this.courier
|
||||||
|
.getSignPhotos(shipmentQuery, options)
|
||||||
|
.then((dataUris) => ({
|
||||||
|
dataUris: Array.isArray(dataUris) ? dataUris : [],
|
||||||
|
error: null as string | null,
|
||||||
|
}))
|
||||||
|
.catch((err: unknown) => ({
|
||||||
|
dataUris: [] as string[],
|
||||||
|
error: err instanceof Error ? err.message : '查询签收照片失败',
|
||||||
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes);
|
base.nodes = this.sortTrackNodesOldestFirst(trackResult.nodes);
|
||||||
base.queryError = trackResult.error;
|
base.queryError = trackResult.error;
|
||||||
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
|
if (cachedSignPhotoUrl) {
|
||||||
|
base.signPhotoUrls = [cachedSignPhotoUrl];
|
||||||
|
} else {
|
||||||
|
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoResult.dataUris);
|
||||||
|
if (base.signPhotoUrls.length === 0 && signPhotoResult.error && !base.queryError) {
|
||||||
|
base.queryError = signPhotoResult.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
|
||||||
const toAddress = this.buildReceiverAddress(order);
|
const toAddress = this.buildReceiverAddress(order);
|
||||||
@@ -431,43 +450,62 @@ export class FulfillmentService {
|
|||||||
},
|
},
|
||||||
dataUris: string[],
|
dataUris: string[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (!order.delivery || dataUris.length === 0) {
|
if (!order.delivery) return [];
|
||||||
return order.delivery?.signPhotoResource?.url ? [order.delivery.signPhotoResource.url] : [];
|
if (order.delivery.signPhotoResource?.url) {
|
||||||
|
return [order.delivery.signPhotoResource.url];
|
||||||
}
|
}
|
||||||
|
if (dataUris.length === 0) return [];
|
||||||
|
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
|
let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
|
||||||
|
|
||||||
for (let i = 0; i < dataUris.length; i += 1) {
|
for (let i = 0; i < dataUris.length; i += 1) {
|
||||||
const parsed = this.parseDataUri(dataUris[i]);
|
const raw = dataUris[i];
|
||||||
|
const parsed = this.parseDataUri(raw);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
|
|
||||||
const result = await this.oss.putObject({
|
try {
|
||||||
bizType: 'SIGN_PHOTO',
|
if (!this.oss.isEnabled()) {
|
||||||
mediaType: 'IMAGE',
|
throw new Error('OSS 未配置');
|
||||||
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
}
|
||||||
buffer: parsed.buffer,
|
const result = await this.oss.putObject({
|
||||||
mimeType: parsed.mimeType,
|
bizType: 'SIGN_PHOTO',
|
||||||
});
|
mediaType: 'IMAGE',
|
||||||
urls.push(result.url);
|
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
||||||
|
buffer: parsed.buffer,
|
||||||
if (!firstResourceId) {
|
mimeType: parsed.mimeType,
|
||||||
const resource = await this.prisma.commonResource.create({
|
|
||||||
data: {
|
|
||||||
ownerType: 'ORDER',
|
|
||||||
ownerId: order.id,
|
|
||||||
bizType: 'SIGN_PHOTO',
|
|
||||||
mediaType: 'IMAGE',
|
|
||||||
ossBucket: result.bucket,
|
|
||||||
ossKey: result.ossKey,
|
|
||||||
url: result.url,
|
|
||||||
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
|
||||||
fileSize: BigInt(parsed.buffer.length),
|
|
||||||
mimeType: parsed.mimeType,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
firstResourceId = resource.id;
|
urls.push(result.url);
|
||||||
|
|
||||||
|
if (!firstResourceId) {
|
||||||
|
const resource = await this.prisma.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'ORDER',
|
||||||
|
ownerId: order.id,
|
||||||
|
bizType: 'SIGN_PHOTO',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket: result.bucket,
|
||||||
|
ossKey: result.ossKey,
|
||||||
|
url: result.url,
|
||||||
|
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
|
||||||
|
fileSize: BigInt(parsed.buffer.length),
|
||||||
|
mimeType: parsed.mimeType,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
firstResourceId = resource.id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// OSS 失败不丢图:回退 data URI,保证 C 端 / HQ 仍可预览
|
||||||
|
this.logger.warn(
|
||||||
|
`签收照上传 OSS 失败,回退 dataURI:orderId=${order.id} err=${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
const dataUri = raw.startsWith('data:')
|
||||||
|
? raw
|
||||||
|
: `data:${parsed.mimeType};base64,${parsed.buffer.toString('base64')}`;
|
||||||
|
urls.push(dataUri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,4 +59,9 @@ export class AdminXiaofeixiaController {
|
|||||||
getTrack(@Body() body: XiaofeixiaShipmentQueryDto) {
|
getTrack(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||||
return this.service.getTrack(body);
|
return this.service.getTrack(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('get-sign-photos')
|
||||||
|
getSignPhotos(@Body() body: XiaofeixiaShipmentQueryDto) {
|
||||||
|
return this.service.getSignPhotos(body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ export class AdminXiaofeixiaService {
|
|||||||
return this.wrap(() => this.courier.getTrack(query, options));
|
return this.wrap(() => this.courier.getTrack(query, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSignPhotos(dto: XiaofeixiaShipmentQueryDto) {
|
||||||
|
const query = this.mapShipmentQuery(dto);
|
||||||
|
const options = await this.callOptions();
|
||||||
|
return this.wrap(async () => {
|
||||||
|
const dataUris = await this.courier.getSignPhotos(query, options);
|
||||||
|
return {
|
||||||
|
count: dataUris.length,
|
||||||
|
// 联调预览用:完整 dataURI;日志侧已截断
|
||||||
|
data: dataUris,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async callOptions() {
|
private async callOptions() {
|
||||||
const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig();
|
||||||
return fromDb ? { xiaofeixia: fromDb } : undefined;
|
return fromDb ? { xiaofeixia: fromDb } : undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user