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:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user