Compare commits

..

1 Commits

Author SHA1 Message Date
jacy e5fe8b903a feat(courier): 小飞侠签收照联调与 OSS 失败回退
CI / verify (push) Waiting to run
补齐 admin 100108 联调入口;track 优先用缓存签收照,上传失败回退 dataURI,日志截断大图。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 17:57:10 +08:00
8 changed files with 183 additions and 40 deletions
@@ -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 APIcmd 100101~100301 HQ APIcmd 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 失败,回退 dataURIorderId=${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;