diff --git a/apps/mini-user/src/components/LogisticsRichText.tsx b/apps/mini-user/src/components/LogisticsRichText.tsx
new file mode 100644
index 0000000..3854a2b
--- /dev/null
+++ b/apps/mini-user/src/components/LogisticsRichText.tsx
@@ -0,0 +1,44 @@
+import { Text } from '@tarojs/components';
+import Taro from '@tarojs/taro';
+import { splitLogisticsText } from '../lib/order-logistics';
+import { toast } from '../lib/api';
+
+type LogisticsRichTextProps = {
+ text: string;
+ className?: string;
+ phoneClassName?: string;
+};
+
+export default function LogisticsRichText({
+ text,
+ className = '',
+ phoneClassName = 'order-logistics-phone-link',
+}: LogisticsRichTextProps) {
+ const segments = splitLogisticsText(text);
+
+ function dial(phone: string) {
+ const normalized = phone.replace(/-/g, '');
+ Taro.makePhoneCall({ phoneNumber: normalized }).catch(() => toast('无法拨打电话'));
+ }
+
+ return (
+
+ {segments.map((segment, index) =>
+ segment.type === 'phone' ? (
+ {
+ e.stopPropagation();
+ dial(segment.value);
+ }}
+ >
+ {segment.value}
+
+ ) : (
+ {segment.value}
+ ),
+ )}
+
+ );
+}
diff --git a/apps/mini-user/src/lib/order-logistics.ts b/apps/mini-user/src/lib/order-logistics.ts
index af0c9dd..361fa54 100644
--- a/apps/mini-user/src/lib/order-logistics.ts
+++ b/apps/mini-user/src/lib/order-logistics.ts
@@ -1,3 +1,4 @@
+import type { OrderTrackDto } from '@dukang/shared-types';
import { isXfxProviderCode } from '@dukang/shared-types';
import { formatShanghaiDateTime } from './datetime';
import { request } from './api';
@@ -16,17 +17,18 @@ export type OrderTrackNode = {
statusName?: string;
};
-export type OrderTrackResponse = {
- nodes?: OrderTrackNode[];
- manualQueryUrl?: string | null;
- provider?: string;
- trackingNo?: string;
- logisticsCompany?: string;
+export type OrderTrackEstimatedArrival = {
+ arriveTime: string;
+ siteName?: string;
};
+export type OrderTrackResponse = OrderTrackDto;
+
/** 订单详情页展示物流摘要的状态(配送中) */
export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const;
+const PHONE_PATTERN = /(1[3-9]\d{9}|\d{3,4}-\d{7,8})/g;
+
export function deliveryProviderLabel(provider?: string, company?: string) {
if (company) return company;
if (!provider || provider === 'MOCK' || isXfxProviderCode(provider)) {
@@ -46,7 +48,7 @@ export function formatTrackNodeTime(node: OrderTrackNode) {
return formatShanghaiDateTime(node.createTime || node.createdAt);
}
-export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] {
+export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]) {
return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime || a.createdAt || 0).getTime();
const tb = new Date(b.createTime || b.createdAt || 0).getTime();
@@ -57,11 +59,60 @@ export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]): OrderTrackNo
});
}
+export function sortTrackNodesOldestFirst(nodes: OrderTrackNode[]) {
+ return [...nodes].sort((a, b) => {
+ const ta = new Date(a.createTime || a.createdAt || 0).getTime();
+ const tb = new Date(b.createTime || b.createdAt || 0).getTime();
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
+ if (Number.isNaN(ta)) return 1;
+ if (Number.isNaN(tb)) return -1;
+ return ta - tb;
+ });
+}
+
export function latestTrackNode(nodes: OrderTrackNode[]) {
const sorted = sortTrackNodesNewestFirst(nodes);
return sorted[0] ?? null;
}
+export function isLogisticsNotArrived(nodes: OrderTrackNode[], orderStatus?: string) {
+ if (['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(orderStatus || '')) {
+ return false;
+ }
+ if (nodes.length === 0) return true;
+ return !nodes.some(
+ (node) => node.statusName?.includes('签收') || node.trackInfo?.includes('签收'),
+ );
+}
+
+export function formatEstimatedArrival(eta?: OrderTrackEstimatedArrival | null) {
+ if (!eta?.arriveTime) return '';
+ const time = formatShanghaiDateTime(eta.arriveTime);
+ if (eta.siteName) return `预计送达:${time}(${eta.siteName})`;
+ return `预计送达:${time}`;
+}
+
+export type LogisticsTextSegment = { type: 'text' | 'phone'; value: string };
+
+export function splitLogisticsText(text: string): LogisticsTextSegment[] {
+ if (!text) return [];
+ const segments: LogisticsTextSegment[] = [];
+ let lastIndex = 0;
+ const regex = new RegExp(PHONE_PATTERN.source, 'g');
+ let match: RegExpExecArray | null;
+ while ((match = regex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
+ }
+ segments.push({ type: 'phone', value: match[0] });
+ lastIndex = match.index + match[0].length;
+ }
+ if (lastIndex < text.length) {
+ segments.push({ type: 'text', value: text.slice(lastIndex) });
+ }
+ return segments.length ? segments : [{ type: 'text', value: text }];
+}
+
export async function fetchOrderTrack(orderId: string) {
return request(`/trade/orders/${orderId}/track`);
}
diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx
index 5e26fed..4e7185d 100644
--- a/apps/mini-user/src/pages/order-detail/index.tsx
+++ b/apps/mini-user/src/pages/order-detail/index.tsx
@@ -6,13 +6,17 @@ import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
+import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import {
fetchOrderTrack,
+ formatEstimatedArrival,
+ isLogisticsNotArrived,
latestTrackNode,
ORDER_LOGISTICS_PREVIEW_STATUSES,
shouldLoadOrderTrack,
+ type OrderTrackEstimatedArrival,
type OrderTrackNode,
} from '../../lib/order-logistics';
import { maskPhone } from '../../lib/phone';
@@ -93,19 +97,28 @@ export default function OrderDetailPage() {
const [order, setOrder] = useState(null);
const [confirming, setConfirming] = useState(false);
const [latestTrack, setLatestTrack] = useState(null);
+ const [estimatedArrival, setEstimatedArrival] = useState(null);
const [trackLoading, setTrackLoading] = useState(false);
- async function loadOrderTrack(delivery: OrderDetail['delivery']) {
+ async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
if (!orderId || !shouldLoadOrderTrack(delivery)) {
setLatestTrack(null);
+ setEstimatedArrival(null);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
- setLatestTrack(latestTrackNode(track.nodes ?? []));
+ const nodes = track.nodes ?? [];
+ setLatestTrack(latestTrackNode(nodes));
+ setEstimatedArrival(
+ isLogisticsNotArrived(nodes, orderStatus) && track.estimatedArrival
+ ? track.estimatedArrival
+ : null,
+ );
} catch {
setLatestTrack(null);
+ setEstimatedArrival(null);
} finally {
setTrackLoading(false);
}
@@ -116,7 +129,7 @@ export default function OrderDetailPage() {
request(`/trade/orders/${orderId}`)
.then((data) => {
setOrder(data);
- void loadOrderTrack(data.delivery);
+ void loadOrderTrack(data.delivery, data.status);
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]);
@@ -127,7 +140,7 @@ export default function OrderDetailPage() {
request(`/trade/orders/${orderId}`)
.then((data) => {
setOrder(data);
- void loadOrderTrack(data.delivery);
+ void loadOrderTrack(data.delivery, data.status);
})
.catch(() => {});
});
@@ -267,19 +280,27 @@ export default function OrderDetailPage() {
由合伙人 {order.proxyPartnerName} 代下
) : null}
{showLogisticsPreview ? (
-
-
- {trackLoading ? (
- 物流信息加载中…
- ) : latestTrack?.trackInfo ? (
- {latestTrack.trackInfo}
- ) : order.delivery?.manualQueryUrl ? (
- 物流已更新,点击查看详情
- ) : (
- 物流信息待更新,请稍后查看
- )}
+
+
+
+ {trackLoading ? (
+ 物流信息加载中…
+ ) : latestTrack?.trackInfo ? (
+
+ ) : order.delivery?.manualQueryUrl ? (
+ 物流已更新,点击查看详情
+ ) : (
+ 物流信息待更新,请稍后查看
+ )}
+
+ 物流详情
- 物流详情
+ {estimatedArrival ? (
+ {formatEstimatedArrival(estimatedArrival)}
+ ) : null}
) : canViewLogistics ? (
diff --git a/apps/mini-user/src/pages/order-logistics/index.tsx b/apps/mini-user/src/pages/order-logistics/index.tsx
index 3c07e0a..1f68298 100644
--- a/apps/mini-user/src/pages/order-logistics/index.tsx
+++ b/apps/mini-user/src/pages/order-logistics/index.tsx
@@ -1,15 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
-import { View, Text } from '@tarojs/components';
+import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
+import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api';
import {
deliveryProviderLabel,
fetchOrderTrack,
formatTrackNodeTime,
shouldLoadOrderTrack,
- sortTrackNodesNewestFirst,
+ sortTrackNodesOldestFirst,
type OrderDelivery,
type OrderTrackNode,
} from '../../lib/order-logistics';
@@ -28,9 +29,13 @@ export default function OrderLogisticsPage() {
usePageView('order_logistics_view', orderId ? { orderId } : undefined);
const [order, setOrder] = useState(null);
const [trackNodes, setTrackNodes] = useState([]);
+ const [signPhotoUrls, setSignPhotoUrls] = useState([]);
const [trackLoading, setTrackLoading] = useState(false);
- const sortedTrackNodes = useMemo(() => sortTrackNodesNewestFirst(trackNodes), [trackNodes]);
+ const sortedTrackNodes = useMemo(() => sortTrackNodesOldestFirst(trackNodes), [trackNodes]);
+ const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
+ order?.status || '',
+ );
useEffect(() => {
if (!orderId) return;
@@ -42,14 +47,21 @@ export default function OrderLogisticsPage() {
setOrder(data);
if (!shouldLoadOrderTrack(data.delivery)) {
setTrackNodes([]);
+ setSignPhotoUrls([]);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
- if (!cancelled) setTrackNodes(track.nodes ?? []);
+ if (!cancelled) {
+ setTrackNodes(track.nodes ?? []);
+ setSignPhotoUrls(track.signPhotoUrls ?? []);
+ }
} catch {
- if (!cancelled) setTrackNodes([]);
+ if (!cancelled) {
+ setTrackNodes([]);
+ setSignPhotoUrls([]);
+ }
} finally {
if (!cancelled) setTrackLoading(false);
}
@@ -77,6 +89,13 @@ export default function OrderLogisticsPage() {
.catch(() => toast('无法打开物流查询'));
}
+ function previewSignPhotos(index: number) {
+ if (signPhotoUrls.length === 0) return;
+ Taro.previewImage({ current: signPhotoUrls[index], urls: signPhotoUrls }).catch(() =>
+ toast('无法预览图片'),
+ );
+ }
+
const delivery = order?.delivery;
return (
@@ -114,6 +133,23 @@ export default function OrderLogisticsPage() {
) : null}
+ {signPhotoUrls.length > 0 ? (
+
+ 签收照片
+
+ {signPhotoUrls.map((url, index) => (
+ previewSignPhotos(index)}
+ />
+ ))}
+
+
+ ) : null}
+
{trackLoading ? (
物流动态
@@ -122,22 +158,38 @@ export default function OrderLogisticsPage() {
) : sortedTrackNodes.length > 0 ? (
物流动态
-
- {sortedTrackNodes.map((node, index) => (
-
-
-
- {node.statusName ? (
- {node.statusName}
- ) : null}
- {node.trackInfo || '—'}
- {formatTrackNodeTime(node)}
+
+ {sortedTrackNodes.map((node, index) => {
+ const isLatest = index === sortedTrackNodes.length - 1;
+ return (
+
+
+
+ {node.statusName ? (
+ {node.statusName}
+ ) : null}
+
+ {formatTrackNodeTime(node)}
+
-
- ))}
+ );
+ })}
) : shouldLoadOrderTrack(delivery) ? (
diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css
index f93a05d..9080c46 100644
--- a/apps/mini-user/src/styles/order.css
+++ b/apps/mini-user/src/styles/order.css
@@ -542,6 +542,35 @@
color: var(--color-heritage-red);
}
+.order-logistics-preview-block {
+ margin-top: 12px;
+}
+
+.order-logistics-eta {
+ display: block;
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--color-on-surface-variant);
+ line-height: 1.5;
+}
+
+.order-logistics-phone-link {
+ color: var(--color-heritage-red);
+}
+
+.order-logistics-sign-photos {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.order-logistics-sign-photo {
+ width: 96px;
+ height: 96px;
+ border-radius: 8px;
+ background: var(--color-surface-container);
+}
+
.order-row-value--link {
color: var(--color-heritage-red);
}
@@ -550,6 +579,10 @@
padding-top: 4px;
}
+.order-logistics-timeline--complete .order-logistics-timeline-item:not(:last-child)::before {
+ background: rgba(166, 29, 36, 0.35);
+}
+
.order-logistics-timeline-item {
display: flex;
gap: 12px;
@@ -571,6 +604,15 @@
background: rgba(0, 0, 0, 0.08);
}
+.order-logistics-timeline-item--done .order-logistics-timeline-dot {
+ background: var(--color-heritage-red);
+ box-shadow: 0 0 0 1px var(--color-heritage-red);
+}
+
+.order-logistics-timeline-item--done:not(:last-child)::before {
+ background: rgba(166, 29, 36, 0.35);
+}
+
.order-logistics-timeline-dot {
flex-shrink: 0;
width: 11px;
@@ -584,7 +626,7 @@
.order-logistics-timeline-item--latest .order-logistics-timeline-dot {
background: var(--color-heritage-red);
- box-shadow: 0 0 0 1px var(--color-heritage-red);
+ box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.25);
}
.order-logistics-timeline-body {
@@ -600,8 +642,13 @@
margin-bottom: 4px;
}
+.order-logistics-timeline-item--done .order-logistics-timeline-status {
+ color: var(--color-heritage-red);
+}
+
.order-logistics-timeline-item--latest .order-logistics-timeline-status {
color: var(--color-heritage-red);
+ font-weight: 700;
}
.order-logistics-node {
diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts
index 463a067..073ab14 100644
--- a/packages/shared-types/src/trade.ts
+++ b/packages/shared-types/src/trade.ts
@@ -70,6 +70,30 @@ export interface DeliveryDto {
deliveredAt?: string;
}
+/** 物流路由节点(C 端 track API) */
+export interface OrderTrackNodeDto {
+ trackInfo: string;
+ createTime: string;
+ statusName?: string;
+}
+
+/** 小飞侠 cmd=100301 预估送达 */
+export interface OrderTrackEstimatedArrivalDto {
+ arriveTime: string;
+ siteName?: string;
+}
+
+/** GET /trade/orders/:id/track 响应 */
+export interface OrderTrackDto {
+ nodes: OrderTrackNodeDto[];
+ signPhotoUrls: string[];
+ estimatedArrival?: OrderTrackEstimatedArrivalDto | null;
+ manualQueryUrl?: string | null;
+ provider?: string;
+ trackingNo?: string | null;
+ logisticsCompany?: string | null;
+}
+
/** 大单拦截原因:≥10 箱不自动推小飞侠 */
export const FULFILLMENT_HOLD_REASON_LABELS: Record = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
diff --git a/server/dukang-api/src/callbacks/callbacks.module.ts b/server/dukang-api/src/callbacks/callbacks.module.ts
index 536b7e0..4052a56 100644
--- a/server/dukang-api/src/callbacks/callbacks.module.ts
+++ b/server/dukang-api/src/callbacks/callbacks.module.ts
@@ -6,9 +6,12 @@ import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller';
import { WechatMessageCallbackController } from './wechat-message.controller';
import { DeliveryCallbackController } from './delivery-track.controller';
+import { DeliveryCallbackService } from './delivery-callback.service';
+import { CourierModule } from '../integrations/courier/courier.module';
@Module({
- imports: [IntegrationsModule, TradeModule, PrismaModule],
+ imports: [IntegrationsModule, TradeModule, PrismaModule, CourierModule],
+ providers: [DeliveryCallbackService],
controllers: [
WechatPayCallbackController,
WechatRefundCallbackController,
diff --git a/server/dukang-api/src/callbacks/delivery-callback.service.ts b/server/dukang-api/src/callbacks/delivery-callback.service.ts
new file mode 100644
index 0000000..acfc6a1
--- /dev/null
+++ b/server/dukang-api/src/callbacks/delivery-callback.service.ts
@@ -0,0 +1,101 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaService } from '../common/prisma/prisma.module';
+import { CourierService } from '../integrations/courier/courier.service';
+import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider';
+import { logCourierCall } from '../integrations/courier/courier-log.util';
+import { TradeService } from '../modules/trade/trade.service';
+
+const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']);
+
+@Injectable()
+export class DeliveryCallbackService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly courier: CourierService,
+ private readonly xiaofeixiaProvider: XiaofeixiaProvider,
+ private readonly tradeService: TradeService,
+ ) {}
+
+ async handleTrackCallback(providerKey: string, body: unknown, requestUrl: string) {
+ const normalized = providerKey.trim().toLowerCase();
+ const baseLog = {
+ scene: 'TRACK_CALLBACK',
+ requestUrl,
+ requestBody: (body && typeof body === 'object' ? body : { value: body }) as Record<
+ string,
+ unknown
+ >,
+ };
+
+ if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') {
+ await logCourierCall(this.prisma, {
+ ...baseLog,
+ status: 'FAILED',
+ errorMessage: `不支持的承运商: ${providerKey}`,
+ });
+ return this.courier.buildTrackCallbackResponse(false);
+ }
+
+ if (normalized === 'logistics') {
+ await logCourierCall(this.prisma, {
+ ...baseLog,
+ status: 'SUCCESS',
+ errorMessage: '跨城物流回调暂未接入,已记录',
+ });
+ return this.courier.buildTrackCallbackResponse(true);
+ }
+
+ const payload = this.xiaofeixiaProvider.parseTrackCallback(body);
+ if (!payload) {
+ await logCourierCall(this.prisma, {
+ ...baseLog,
+ status: 'FAILED',
+ errorMessage: '回调体解析失败',
+ });
+ return this.courier.buildTrackCallbackResponse(false);
+ }
+
+ const order = payload.outNumber
+ ? await this.prisma.order.findUnique({ where: { orderNo: payload.outNumber } })
+ : payload.trackingNumber
+ ? await this.prisma.order.findFirst({
+ where: { delivery: { trackingNo: payload.trackingNumber } },
+ })
+ : null;
+
+ if (!order) {
+ await logCourierCall(this.prisma, {
+ ...baseLog,
+ status: 'FAILED',
+ errorMessage: '订单不存在',
+ externalNo: payload.outNumber || payload.trackingNumber,
+ });
+ return this.courier.buildTrackCallbackResponse(false);
+ }
+
+ const targetStatus = this.xiaofeixiaProvider.mapTrackStatus(payload.status);
+ let applied = false;
+ if (targetStatus && targetStatus !== order.status) {
+ await this.tradeService.applyStatusTransition(
+ order.id,
+ order.status,
+ targetStatus,
+ 'COURIER_TRACK_CALLBACK',
+ payload.trackInfo || payload.statusName,
+ );
+ applied = true;
+ }
+
+ const response = this.courier.buildTrackCallbackResponse(true);
+ await logCourierCall(this.prisma, {
+ ...baseLog,
+ responseBody: response,
+ status: 'SUCCESS',
+ externalNo: order.orderNo,
+ ref: { refType: 'ORDER', refId: order.id },
+ errorMessage: applied ? undefined : '状态未变更',
+ });
+
+ return response;
+ }
+}
diff --git a/server/dukang-api/src/callbacks/delivery-track.controller.ts b/server/dukang-api/src/callbacks/delivery-track.controller.ts
index db54c94..af9cc44 100644
--- a/server/dukang-api/src/callbacks/delivery-track.controller.ts
+++ b/server/dukang-api/src/callbacks/delivery-track.controller.ts
@@ -1,71 +1,27 @@
-import { Body, Controller, Post } from '@nestjs/common';
-import { TradeService } from '../modules/trade/trade.service';
-import { CourierService } from '../integrations/courier/courier.service';
-import { PrismaService } from '../common/prisma/prisma.module';
-import { logCourierCall } from '../integrations/courier/courier-log.util';
+import { Body, Controller, Param, Post } from '@nestjs/common';
+import { DeliveryCallbackService } from './delivery-callback.service';
-@Controller('callbacks/delivery')
+@Controller('callbacks')
export class DeliveryCallbackController {
- constructor(
- private readonly tradeService: TradeService,
- private readonly courier: CourierService,
- private readonly prisma: PrismaService,
- ) {}
+ constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {}
- @Post('track')
- async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
- const baseLog = {
- scene: 'TRACK_CALLBACK',
- requestUrl: '/api/v1/callbacks/delivery/track',
- requestBody: body as Record,
- };
+ /** 小飞侠/物流路由变化回调(适配器入口) */
+ @Post('courier/:provider/track')
+ trackByProvider(@Param('provider') provider: string, @Body() body: unknown) {
+ return this.deliveryCallbackService.handleTrackCallback(
+ provider,
+ body,
+ `/api/v1/callbacks/courier/${provider}/track`,
+ );
+ }
- if (!body.orderId && !body.orderNo) {
- await logCourierCall(this.prisma, {
- ...baseLog,
- status: 'FAILED',
- errorMessage: '缺少 orderId / orderNo',
- });
- return this.courier.buildTrackCallbackResponse(false);
- }
-
- const order = body.orderId
- ? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
- : await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
-
- if (!order) {
- await logCourierCall(this.prisma, {
- ...baseLog,
- status: 'FAILED',
- errorMessage: '订单不存在',
- externalNo: body.orderNo,
- });
- return this.courier.buildTrackCallbackResponse(false);
- }
-
- const statusMap: Record = {
- SHIPPED: 'SHIPPING',
- OUT_WAREHOUSE: 'OUT_WAREHOUSE',
- DELIVERED: 'COMPLETED',
- COMPLETED: 'COMPLETED',
- };
- const target = statusMap[body.status ?? ''] ?? body.status;
- let applied = false;
- if (target && target !== order.status) {
- await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
- applied = true;
- }
-
- const response = this.courier.buildTrackCallbackResponse(true);
- await logCourierCall(this.prisma, {
- ...baseLog,
- responseBody: response,
- status: 'SUCCESS',
- externalNo: order.orderNo,
- ref: { refType: 'ORDER', refId: order.id },
- errorMessage: applied ? undefined : '状态未变更',
- });
-
- return response;
+ /** 兼容旧路径,默认按小飞侠解析 */
+ @Post('delivery/track')
+ trackLegacy(@Body() body: unknown) {
+ return this.deliveryCallbackService.handleTrackCallback(
+ 'xfx',
+ body,
+ '/api/v1/callbacks/delivery/track',
+ );
}
}
diff --git a/server/dukang-api/src/integrations/courier/courier-log.util.ts b/server/dukang-api/src/integrations/courier/courier-log.util.ts
index 7c5a044..0908cf4 100644
--- a/server/dukang-api/src/integrations/courier/courier-log.util.ts
+++ b/server/dukang-api/src/integrations/courier/courier-log.util.ts
@@ -9,6 +9,7 @@ const CMD_SCENE: Record = {
[XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT',
[XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT',
[XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE',
+ [XIAOFEIXIA_CMD.SIGN_PHOTOS]: 'GET_SIGN_PHOTOS',
};
export type CourierLogRef = {
diff --git a/server/dukang-api/src/integrations/courier/courier.module.ts b/server/dukang-api/src/integrations/courier/courier.module.ts
index d6d88dc..6529318 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, CourierConfigService, COURIER_PROVIDER],
+ exports: [CourierService, CourierConfigService, COURIER_PROVIDER, XiaofeixiaProvider],
})
export class CourierModule {}
diff --git a/server/dukang-api/src/integrations/courier/courier.service.ts b/server/dukang-api/src/integrations/courier/courier.service.ts
index d0cc76d..e5b440e 100644
--- a/server/dukang-api/src/integrations/courier/courier.service.ts
+++ b/server/dukang-api/src/integrations/courier/courier.service.ts
@@ -12,6 +12,8 @@ import type {
ShipmentQuery,
TrackCallbackResponse,
TrackNode,
+ TrackCallbackPayload,
+ CourierMappedOrderStatus,
} from './courier.types';
/**
@@ -45,6 +47,10 @@ export class CourierService {
return this.provider.getTrack(query, options);
}
+ getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise {
+ return this.provider.getSignPhotos(query, options);
+ }
+
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise {
return this.provider.checkDeliveryCoverage(toAddress, options);
}
@@ -56,4 +62,12 @@ export class CourierService {
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
return this.provider.buildTrackCallbackResponse(success);
}
+
+ parseTrackCallback(body: unknown): TrackCallbackPayload | null {
+ return this.provider.parseTrackCallback(body);
+ }
+
+ mapTrackStatus(status: string): CourierMappedOrderStatus | null {
+ return this.provider.mapTrackStatus(status);
+ }
}
diff --git a/server/dukang-api/src/integrations/courier/courier.types.ts b/server/dukang-api/src/integrations/courier/courier.types.ts
index 58cfdda..036f378 100644
--- a/server/dukang-api/src/integrations/courier/courier.types.ts
+++ b/server/dukang-api/src/integrations/courier/courier.types.ts
@@ -109,6 +109,13 @@ export interface TrackCallbackResponse {
message: string;
}
+/** 承运商路由回调映射后的订单履约状态 */
+export type CourierMappedOrderStatus =
+ | 'OUT_WAREHOUSE'
+ | 'SHIPPING'
+ | 'PENDING_RECEIVE'
+ | 'COMPLETED';
+
export interface ICourierProvider {
readonly code: CourierProviderCode;
@@ -117,7 +124,10 @@ export interface ICourierProvider {
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise;
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise;
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise;
+ getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise;
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise;
estimateFreight(weight: number, options?: CourierCallOptions): Promise;
+ parseTrackCallback(body: unknown): TrackCallbackPayload | null;
+ mapTrackStatus(status: string): CourierMappedOrderStatus | null;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
}
diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.constants.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.constants.ts
index 0687343..9caf3b1 100644
--- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.constants.ts
+++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.constants.ts
@@ -6,6 +6,7 @@ export const XIAOFEIXIA_CMD = {
GET_ORDER: '100104',
ESTIMATE_FREIGHT: '100105',
BATCH_GET_ORDER: '100106',
+ SIGN_PHOTOS: '100108',
DELIVERY_COVERAGE: '100301',
} as const;
diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts
index 4de6b96..e46ba70 100644
--- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts
+++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts
@@ -12,7 +12,9 @@ import type {
ShipmentDetail,
ShipmentQuery,
TrackCallbackResponse,
+ TrackCallbackPayload,
TrackNode,
+ CourierMappedOrderStatus,
} from '../courier.types';
import { XiaofeixiaClient } from './xiaofeixia.client';
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
@@ -121,6 +123,57 @@ export class XiaofeixiaProvider implements ICourierProvider {
return data ?? [];
}
+ async getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise {
+ this.assertShipmentQuery(query);
+ const data = await this.client.request(
+ XIAOFEIXIA_CMD.SIGN_PHOTOS,
+ {
+ number: query.trackingNumber,
+ outNumber: query.outNumber,
+ },
+ options?.xiaofeixia,
+ );
+ return (data ?? []).filter((item) => typeof item === 'string' && item.trim().length > 0);
+ }
+
+ parseTrackCallback(body: unknown): TrackCallbackPayload | null {
+ if (!body || typeof body !== 'object') return null;
+ const raw = body as Record;
+ const outNumber = String(raw.outNumber ?? '').trim();
+ const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim();
+ const status = String(raw.status ?? '').trim();
+ const statusName = String(raw.statusName ?? '').trim();
+ const trackInfo = String(raw.trackInfo ?? '').trim();
+ const createTime = String(raw.createTime ?? '').trim();
+ if (!outNumber && !trackingNumber) return null;
+ if (!status && !trackInfo) return null;
+ return {
+ outNumber,
+ trackingNumber,
+ status,
+ statusName,
+ trackInfo,
+ createTime,
+ };
+ }
+
+ mapTrackStatus(status: string): CourierMappedOrderStatus | null {
+ switch (String(status).trim()) {
+ case '1':
+ return 'OUT_WAREHOUSE';
+ case '2':
+ case '3':
+ case '4':
+ return 'SHIPPING';
+ case '5':
+ return 'PENDING_RECEIVE';
+ case '7':
+ return null;
+ default:
+ return null;
+ }
+ }
+
async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise {
const data = await this.client.request(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts
index 0891bc1..a8024c2 100644
--- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts
+++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts
@@ -9,8 +9,10 @@ import {
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service';
-import { CourierPayMode } from '../../integrations/courier/courier.types';
+import { CourierPayMode, type TrackNode } from '../../integrations/courier/courier.types';
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
+import { OSS_PROVIDER } from '../../integrations/integrations.constants';
+import type { IOssProvider } from '../../integrations/oss/oss.interface';
import { TradeService } from '../trade/trade.service';
import { FulfillmentProviderService } from './fulfillment-provider.service';
@@ -32,6 +34,7 @@ export class FulfillmentService {
private readonly prisma: PrismaService,
private readonly courier: CourierService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
+ @Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@Inject(forwardRef(() => TradeService))
private readonly tradeService: TradeService,
) {}
@@ -246,13 +249,30 @@ export class FulfillmentService {
async getOrderTrack(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
- include: { delivery: true },
+ include: {
+ delivery: {
+ include: { signPhotoResource: true },
+ },
+ },
});
+ const base = {
+ nodes: [] as TrackNode[],
+ signPhotoUrls: [] as string[],
+ estimatedArrival: null as { arriveTime: string; siteName?: string } | null,
+ manualQueryUrl: order?.delivery?.manualQueryUrl ?? null,
+ provider: order?.delivery?.provider,
+ trackingNo: order?.delivery?.trackingNo ?? null,
+ logisticsCompany: order?.delivery?.logisticsCompany ?? null,
+ };
if (!order?.delivery) {
- return { nodes: [], manualQueryUrl: null };
+ return base;
}
- if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
+ const isXfx =
+ order.delivery.provider === 'XFX' || isXfxProviderCode(String(order.delivery.provider || ''));
+ const canQueryCourier = isXfx && (order.delivery.trackingNo || order.orderNo);
+
+ if (canQueryCourier) {
try {
const options = order.delivery.fulfillmentProviderId
? {
@@ -261,27 +281,44 @@ export class FulfillmentService {
),
}
: undefined;
- const nodes = await this.courier.getTrack(
- {
- trackingNumber: order.delivery.trackingNo ?? undefined,
- outNumber: order.orderNo,
- },
- options,
- );
- return {
- nodes,
- manualQueryUrl: order.delivery.manualQueryUrl,
- provider: order.delivery.provider,
- trackingNo: order.delivery.trackingNo,
- logisticsCompany: order.delivery.logisticsCompany,
+ const shipmentQuery = {
+ trackingNumber: order.delivery.trackingNo ?? undefined,
+ outNumber: order.orderNo,
};
+
+ const [nodes, signPhotoDataUris] = await Promise.all([
+ this.courier.getTrack(shipmentQuery, options).catch(() => [] as TrackNode[]),
+ this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
+ ]);
+
+ base.nodes = this.sortTrackNodesOldestFirst(nodes);
+ base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
+
+ if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
+ const toAddress = this.buildReceiverAddress(order);
+ if (toAddress) {
+ try {
+ const coverage = await this.courier.checkDeliveryCoverage(toAddress, options);
+ base.estimatedArrival = {
+ arriveTime: coverage.arriveTime,
+ siteName: coverage.siteName,
+ };
+ } catch {
+ // 预估送达失败不阻断
+ }
+ }
+ }
} catch {
// fall through
}
}
+ if (base.signPhotoUrls.length === 0 && order.delivery.signPhotoResource?.url) {
+ base.signPhotoUrls = [order.delivery.signPhotoResource.url];
+ }
+
return {
- nodes: [],
+ ...base,
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
@@ -289,6 +326,119 @@ export class FulfillmentService {
};
}
+ private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
+ return [...nodes].sort((a, b) => {
+ const ta = new Date(a.createTime).getTime();
+ const tb = new Date(b.createTime).getTime();
+ if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
+ if (Number.isNaN(ta)) return 1;
+ if (Number.isNaN(tb)) return -1;
+ return ta - tb;
+ });
+ }
+
+ private buildReceiverAddress(order: {
+ receiverProvince?: string | null;
+ receiverCity?: string | null;
+ receiverDistrict?: string | null;
+ receiverAddress?: string | null;
+ }) {
+ const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
+ .filter(Boolean)
+ .join('');
+ const detail = (order.receiverAddress || '').trim();
+ if (!region && !detail) return '';
+ if (region && detail.startsWith(region)) return detail;
+ return `${region}${detail}`;
+ }
+
+ private shouldFetchEstimatedArrival(status: string, nodes: TrackNode[]) {
+ if (!['OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED'].includes(status)) return false;
+ if (nodes.length === 0) return true;
+ return !nodes.some(
+ (node) =>
+ node.statusName?.includes('签收') ||
+ node.trackInfo?.includes('签收') ||
+ node.statusName?.includes('派件'),
+ );
+ }
+
+ private async resolveSignPhotoUrls(
+ order: {
+ id: bigint;
+ delivery: {
+ id: bigint;
+ signPhotoResourceId: bigint | null;
+ signPhotoResource: { url: string } | null;
+ } | null;
+ },
+ dataUris: string[],
+ ): Promise {
+ if (!order.delivery || dataUris.length === 0) {
+ return order.delivery?.signPhotoResource?.url ? [order.delivery.signPhotoResource.url] : [];
+ }
+
+ const urls: string[] = [];
+ let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
+
+ for (let i = 0; i < dataUris.length; i += 1) {
+ const parsed = this.parseDataUri(dataUris[i]);
+ if (!parsed) continue;
+
+ const result = await this.oss.putObject({
+ bizType: 'SIGN_PHOTO',
+ mediaType: 'IMAGE',
+ fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
+ buffer: parsed.buffer,
+ mimeType: parsed.mimeType,
+ });
+ 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;
+ }
+ }
+
+ if (firstResourceId && firstResourceId !== order.delivery.signPhotoResourceId) {
+ await this.prisma.orderDelivery.update({
+ where: { id: order.delivery.id },
+ data: { signPhotoResourceId: firstResourceId },
+ });
+ }
+
+ return urls;
+ }
+
+ private parseDataUri(dataUri: string): { buffer: Buffer; mimeType: string; ext: string } | null {
+ const trimmed = dataUri.trim();
+ const match = /^data:([^;]+);base64,(.+)$/i.exec(trimmed);
+ const base64 = match ? match[2] : trimmed;
+ const mimeType = match?.[1] || 'image/png';
+ try {
+ const buffer = Buffer.from(base64, 'base64');
+ if (!buffer.length) return null;
+ const ext = mimeType.includes('jpeg') || mimeType.includes('jpg') ? 'jpg' : 'png';
+ return { buffer, mimeType, ext };
+ } catch {
+ return null;
+ }
+ }
+
private async applyManualShip(
order: Order & { delivery: { trackingNo: string | null } | null },
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md
index dad5b67..1db0a03 100644
--- a/杜康好客-v3-PRD.md
+++ b/杜康好客-v3-PRD.md
@@ -48,7 +48,7 @@
| 版本 | 日期 | 说明 |
|------|------|------|
-| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**配送中物流摘要与详情时间线**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
+| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**物流增强(签收照/拨号/ETA/回调)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
---
diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md
index 8347fe7..c806696 100644
--- a/杜康好客-v3-现状对照.md
+++ b/杜康好客-v3-现状对照.md
@@ -341,7 +341,7 @@ C2~C7、C14 见 §1.3。
| H5 登录前端校验 | ✅ | partner/shop LoginPage |
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 |
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
-| mini-user 版本/物流 | ✅ | minClientVersion + UpdateManager;配送中摘要 + 物流详情(最新在上、custom 导航栏) |
+| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA + `/callbacks/courier/xfx/track` |
| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 |
| 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 |
diff --git a/杜康好客-v3.4.13-体验优化开发文档.md b/杜康好客-v3.4.13-体验优化开发文档.md
index e07eb84..c953ad1 100644
--- a/杜康好客-v3.4.13-体验优化开发文档.md
+++ b/杜康好客-v3.4.13-体验优化开发文档.md
@@ -8,9 +8,9 @@
| 模块 | 内容 |
|------|------|
| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
-| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**订单详情配送中物流摘要+详情页时间线**;**头像超 10MB 压缩** |
+| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
-| 后端 | 工单 priority 字段;client-config minClientVersion;合伙人微信登录暂停拦截 |
+| 后端 | 工单 priority;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器(100108/100301/路由回调)** |
## 2. ST 映射
@@ -34,7 +34,7 @@
| ST1785901775231811 | 门店套餐遮挡 | ✅ 折叠/行数限制 |
| ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 |
| ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview |
-| ST1785939375449985 | 订单物流追踪页 | ✅ 配送中摘要 + 物流详情页时间线(小飞侠路由) |
+| ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 |
| — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload |
## 3. 后端 API / 配置
@@ -44,16 +44,57 @@
| GET | `/common/client-config` | 新增 `minClientVersion`(env `MINI_USER_MIN_VERSION`) |
| — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL |
| — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 |
-| GET | `/trade/orders/:id/track` | 物流路由;小飞侠(`XFX`/`XIAOFEIXIA`)走小飞侠 TRACK_ROUTE,其他承运商返回 manualQueryUrl |
+| GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls`、`estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 |
+| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
+| POST | `/callbacks/courier/logistics/track` | 跨城物流回调占位(记录日志,后续接入) |
+| POST | `/callbacks/delivery/track` | 兼容旧路径,等同 `xfx` |
### 4. mini-user 物流(ST1785939375449985)
| 页面 | 行为 |
|------|------|
-| 订单详情 | 状态为**配送中**(`SHIPPING`/`SHIPPED`)时,状态下方展示最新一条路由信息;右侧「物流详情」进入详情页 |
-| 物流详情 | 物流信息 + 完整动态时间线(**最新在上**);无顶部配送状态区块;自定义导航栏无顶空白 |
-| 接口 | `GET /trade/orders/:id` + `GET /trade/orders/:id/track`;`shouldLoadOrderTrack`:有运单号或小飞侠 provider 时拉路由 |
-| 公共模块 | `apps/mini-user/src/lib/order-logistics.ts` |
+| 订单详情 | **配送中**展示最新路由摘要 + 「物流详情」;物流未到时展示 **预估送达**(100301) |
+| 物流详情 | 物流信息;**签收照片**(有则展示,`previewImage` 放大);**物流动态**旧→新、节点全红点亮、最新在底部 |
+| 电话拨号 | 物流文案中手机号可点击 `makePhoneCall` |
+| 接口 | `GET /trade/orders/:id/track`(`OrderTrackDto`);C 端禁止直连小飞侠 |
+| 公共模块 | `order-logistics.ts` + `LogisticsRichText` 组件 |
+
+#### 4.1 对外回调 URL(提供给小飞侠)
+
+**生产**
+
+```
+POST https://api.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
+Content-Type: application/json
+```
+
+**测试**
+
+```
+POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
+```
+
+请求体:
+
+| 字段 | 说明 |
+|------|------|
+| outNumber | 商户单号 |
+| number | 运单号 |
+| status | 1下单 2取件 3中转 4派件 5签收 7取消 |
+| statusName | 中文状态 |
+| trackInfo | 路由描述 |
+| createTime | 路由时间 |
+
+成功响应:`{"code":"100000","message":"success"}`
+
+#### 4.2 Courier 适配器(`integrations/courier`)
+
+| cmd | 能力 | Provider 方法 |
+|-----|------|---------------|
+| 100102 | 路由查询 | `getTrack` |
+| 100108 | 签收照片 | `getSignPhotos` → OSS → `signPhotoUrls` |
+| 100301 | 预估送达 | `checkDeliveryCoverage` → `estimatedArrival` |
+| 回调 | 路由推送 | `parseTrackCallback` + `mapTrackStatus` |
## 5. 数据表
@@ -70,6 +111,7 @@
- [ ] 技术支持可创建/筛选/编辑优先级
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常
-- [ ] mini-user:低版本弹窗、UpdateManager;配送中展示最新物流 + 物流详情页(最新在上、无顶空白)
+- [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达
+- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track` 可推进订单状态且幂等
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
- [ ] `pnpm lint` 无新增错误