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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 23:34:09 +08:00
parent 62652cd072
commit 345d5db9c9
19 changed files with 710 additions and 140 deletions
@@ -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 (
<Text className={className}>
{segments.map((segment, index) =>
segment.type === 'phone' ? (
<Text
key={`${segment.value}-${index}`}
className={phoneClassName}
onClick={(e) => {
e.stopPropagation();
dial(segment.value);
}}
>
{segment.value}
</Text>
) : (
<Text key={`${index}-text`}>{segment.value}</Text>
),
)}
</Text>
);
}
+58 -7
View File
@@ -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<OrderTrackResponse>(`/trade/orders/${orderId}/track`);
}
+37 -16
View File
@@ -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<OrderDetail | null>(null);
const [confirming, setConfirming] = useState(false);
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(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<OrderDetail>(`/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<OrderDetail>(`/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() {
<Text className="order-proxy-hint"> {order.proxyPartnerName} </Text>
) : null}
{showLogisticsPreview ? (
<View className="order-logistics-preview" onClick={goLogistics}>
<View className="order-logistics-preview-main">
{trackLoading ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : latestTrack?.trackInfo ? (
<Text className="order-logistics-preview-info">{latestTrack.trackInfo}</Text>
) : order.delivery?.manualQueryUrl ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : (
<Text className="order-logistics-preview-info u-muted"></Text>
)}
<View className="order-logistics-preview-block">
<View className="order-logistics-preview" onClick={goLogistics}>
<View className="order-logistics-preview-main">
{trackLoading ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : latestTrack?.trackInfo ? (
<LogisticsRichText
text={latestTrack.trackInfo}
className="order-logistics-preview-info"
/>
) : order.delivery?.manualQueryUrl ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : (
<Text className="order-logistics-preview-info u-muted"></Text>
)}
</View>
<Text className="order-logistics-preview-link"></Text>
</View>
<Text className="order-logistics-preview-link"></Text>
{estimatedArrival ? (
<Text className="order-logistics-eta">{formatEstimatedArrival(estimatedArrival)}</Text>
) : null}
</View>
) : canViewLogistics ? (
<Text className="order-logistics-link" onClick={goLogistics}>
@@ -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<OrderDetail | null>(null);
const [trackNodes, setTrackNodes] = useState<OrderTrackNode[]>([]);
const [signPhotoUrls, setSignPhotoUrls] = useState<string[]>([]);
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}
</View>
{signPhotoUrls.length > 0 ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-logistics-sign-photos">
{signPhotoUrls.map((url, index) => (
<Image
key={url}
className="order-logistics-sign-photo"
src={url}
mode="aspectFill"
onClick={() => previewSignPhotos(index)}
/>
))}
</View>
</View>
) : null}
{trackLoading ? (
<View className="order-card">
<Text className="order-card-title"></Text>
@@ -122,22 +158,38 @@ export default function OrderLogisticsPage() {
) : sortedTrackNodes.length > 0 ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-logistics-timeline">
{sortedTrackNodes.map((node, index) => (
<View
key={`${node.trackInfo}-${node.createTime}-${index}`}
className={`order-logistics-timeline-item${index === 0 ? ' order-logistics-timeline-item--latest' : ''}`}
>
<View className="order-logistics-timeline-dot" />
<View className="order-logistics-timeline-body">
{node.statusName ? (
<Text className="order-logistics-timeline-status">{node.statusName}</Text>
) : null}
<Text className="order-logistics-node-info">{node.trackInfo || '—'}</Text>
<Text className="order-logistics-node-time">{formatTrackNodeTime(node)}</Text>
<View
className={`order-logistics-timeline${
timelineComplete ? ' order-logistics-timeline--complete' : ''
}`}
>
{sortedTrackNodes.map((node, index) => {
const isLatest = index === sortedTrackNodes.length - 1;
return (
<View
key={`${node.trackInfo}-${node.createTime}-${index}`}
className={[
'order-logistics-timeline-item',
'order-logistics-timeline-item--done',
isLatest ? 'order-logistics-timeline-item--latest' : '',
]
.filter(Boolean)
.join(' ')}
>
<View className="order-logistics-timeline-dot" />
<View className="order-logistics-timeline-body">
{node.statusName ? (
<Text className="order-logistics-timeline-status">{node.statusName}</Text>
) : null}
<LogisticsRichText
text={node.trackInfo || '—'}
className="order-logistics-node-info"
/>
<Text className="order-logistics-node-time">{formatTrackNodeTime(node)}</Text>
</View>
</View>
</View>
))}
);
})}
</View>
</View>
) : shouldLoadOrderTrack(delivery) ? (
+48 -1
View File
@@ -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 {
+24
View File
@@ -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<string, string> = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
@@ -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,
@@ -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;
}
}
@@ -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<string, unknown>,
};
/** 小飞侠/物流路由变化回调(适配器入口) */
@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<string, string> = {
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',
);
}
}
@@ -9,6 +9,7 @@ const CMD_SCENE: Record<string, string> = {
[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 = {
@@ -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 {}
@@ -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<string[]> {
return this.provider.getSignPhotos(query, options);
}
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
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);
}
}
@@ -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<ShipmentDetail>;
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise<string[]>;
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
parseTrackCallback(body: unknown): TrackCallbackPayload | null;
mapTrackStatus(status: string): CourierMappedOrderStatus | null;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
}
@@ -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;
@@ -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<string[]> {
this.assertShipmentQuery(query);
const data = await this.client.request<string[]>(
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<string, unknown>;
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<DeliveryCoverageResult> {
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
@@ -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<string[]> {
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' },
+1 -1
View File
@@ -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) |
---
+1 -1
View File
@@ -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 开发文档 |
@@ -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;合伙人微信登录暂停拦截 |
| 后端 | 工单 priorityclient-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` 无新增错误