From 345d5db9c9c4cfc241f6cde196567b23508e2b1d Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 5 Aug 2026 23:34:09 +0800 Subject: [PATCH] feat(mini-user): logistics sign photos, dial, ETA and courier callbacks (v3.4.13) Extend Courier adapter for XFX sign photos and route callbacks; mini-user logistics UI with timeline, phone dial, and estimated arrival. Co-authored-by: Cursor --- .../src/components/LogisticsRichText.tsx | 44 +++++ apps/mini-user/src/lib/order-logistics.ts | 65 +++++- .../src/pages/order-detail/index.tsx | 53 +++-- .../src/pages/order-logistics/index.tsx | 92 +++++++-- apps/mini-user/src/styles/order.css | 49 ++++- packages/shared-types/src/trade.ts | 24 +++ .../src/callbacks/callbacks.module.ts | 5 +- .../callbacks/delivery-callback.service.ts | 101 ++++++++++ .../callbacks/delivery-track.controller.ts | 86 ++------ .../integrations/courier/courier-log.util.ts | 1 + .../integrations/courier/courier.module.ts | 2 +- .../integrations/courier/courier.service.ts | 14 ++ .../src/integrations/courier/courier.types.ts | 10 + .../xiaofeixia/xiaofeixia.constants.ts | 1 + .../courier/xiaofeixia/xiaofeixia.provider.ts | 53 +++++ .../fulfillment/fulfillment.service.ts | 186 ++++++++++++++++-- 杜康好客-v3-PRD.md | 2 +- 杜康好客-v3-现状对照.md | 2 +- 杜康好客-v3.4.13-体验优化开发文档.md | 60 +++++- 19 files changed, 710 insertions(+), 140 deletions(-) create mode 100644 apps/mini-user/src/components/LogisticsRichText.tsx create mode 100644 server/dukang-api/src/callbacks/delivery-callback.service.ts 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` 无新增错误