fix(courier): parse XFX multipart/form-data track callbacks
Xiaofeixia posts multipart form fields; previously mis-read as urlencoded empty payload. Parse multipart text parts and map into order status transitions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../common/prisma/prisma.module';
|
import { PrismaService } from '../common/prisma/prisma.module';
|
||||||
import { CourierService } from '../integrations/courier/courier.service';
|
import { CourierService } from '../integrations/courier/courier.service';
|
||||||
|
import { parseCourierCallbackBody } from '../integrations/courier/xiaofeixia/xiaofeixia-callback-body';
|
||||||
import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider';
|
import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider';
|
||||||
import { logCourierCall } from '../integrations/courier/courier-log.util';
|
import { logCourierCall } from '../integrations/courier/courier-log.util';
|
||||||
import { TradeService } from '../modules/trade/trade.service';
|
import { TradeService } from '../modules/trade/trade.service';
|
||||||
@@ -54,7 +55,9 @@ export class DeliveryCallbackService {
|
|||||||
return this.courier.buildTrackCallbackResponse(true);
|
return this.courier.buildTrackCallbackResponse(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = this.xiaofeixiaProvider.parseTrackCallback(body);
|
const payload =
|
||||||
|
this.xiaofeixiaProvider.parseTrackCallback(body) ??
|
||||||
|
this.parseFromRawMeta(meta);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
const response = this.courier.buildTrackCallbackResponse(false);
|
const response = this.courier.buildTrackCallbackResponse(false);
|
||||||
await logCourierCall(this.prisma, {
|
await logCourierCall(this.prisma, {
|
||||||
@@ -162,10 +165,17 @@ export class DeliveryCallbackService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseFromRawMeta(meta?: TrackCallbackRequestMeta) {
|
||||||
|
if (!meta?.rawBody?.trim()) return null;
|
||||||
|
const parsed = parseCourierCallbackBody(meta.rawBody, meta.contentType || '');
|
||||||
|
return this.xiaofeixiaProvider.parseTrackCallback(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
private redactSecrets(raw: string): string {
|
private redactSecrets(raw: string): string {
|
||||||
return raw
|
return raw
|
||||||
.replace(/(sign=)[^&\s]*/gi, '$1[REDACTED]')
|
.replace(/(sign=)[^&\s]*/gi, '$1[REDACTED]')
|
||||||
.replace(/("sign"\s*:\s*")[^"]*/gi, '$1[REDACTED]')
|
.replace(/("sign"\s*:\s*")[^"]*/gi, '$1[REDACTED]')
|
||||||
.replace(/(api[_-]?key=)[^&\s]*/gi, '$1[REDACTED]');
|
.replace(/(api[_-]?key=)[^&\s]*/gi, '$1[REDACTED]')
|
||||||
|
.replace(/(name="sign"[\s\S]*?\r?\n\r?\n)([^\r\n-]+)/gi, '$1[REDACTED]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { parse as parseQueryString } from 'node:querystring';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小飞侠路由回调实际推送多为 multipart/form-data(非 JSON / urlencoded)。
|
||||||
|
* 解析失败时保留 _rawText,便于第三方日志排查。
|
||||||
|
*/
|
||||||
|
export function parseCourierCallbackBody(
|
||||||
|
rawText: string,
|
||||||
|
contentType: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const ct = contentType.toLowerCase();
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
|
||||||
|
if (ct.includes('multipart/form-data') || looksLikeMultipart(trimmed)) {
|
||||||
|
const multipart = parseMultipartFormData(rawText, contentType);
|
||||||
|
if (multipart && Object.keys(multipart).length > 0) return multipart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.includes('application/json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object'
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: { value: parsed };
|
||||||
|
} catch {
|
||||||
|
return { _rawText: rawText.slice(0, 4000) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.includes('application/x-www-form-urlencoded') || looksLikeUrlEncoded(trimmed)) {
|
||||||
|
return flattenQueryValues(parseQueryString(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { _rawText: rawText.slice(0, 4000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeMultipart(raw: string): boolean {
|
||||||
|
return raw.startsWith('--') && /Content-Disposition:\s*form-data/i.test(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUrlEncoded(raw: string): boolean {
|
||||||
|
if (looksLikeMultipart(raw)) return false;
|
||||||
|
return /^[^=&\s]+=/.test(raw) && !raw.includes('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenQueryValues(
|
||||||
|
parsed: Record<string, string | string[] | undefined>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (Array.isArray(value)) out[key] = value[value.length - 1];
|
||||||
|
else if (value !== undefined) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅解析文本字段(小飞侠回调无文件) */
|
||||||
|
export function parseMultipartFormData(
|
||||||
|
rawText: string,
|
||||||
|
contentType: string,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const boundary = extractBoundary(contentType, rawText);
|
||||||
|
if (!boundary) return null;
|
||||||
|
|
||||||
|
const delimiter = `--${boundary}`;
|
||||||
|
const parts = rawText.split(delimiter);
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmedPart = part.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
||||||
|
if (!trimmedPart || trimmedPart === '--' || trimmedPart.startsWith('--')) continue;
|
||||||
|
|
||||||
|
let headers = '';
|
||||||
|
let body = '';
|
||||||
|
const crlfIdx = trimmedPart.indexOf('\r\n\r\n');
|
||||||
|
const lfIdx = trimmedPart.indexOf('\n\n');
|
||||||
|
if (crlfIdx >= 0 && (lfIdx < 0 || crlfIdx <= lfIdx)) {
|
||||||
|
headers = trimmedPart.slice(0, crlfIdx);
|
||||||
|
body = trimmedPart.slice(crlfIdx + 4);
|
||||||
|
} else if (lfIdx >= 0) {
|
||||||
|
headers = trimmedPart.slice(0, lfIdx);
|
||||||
|
body = trimmedPart.slice(lfIdx + 2);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.replace(/\r?\n$/, '');
|
||||||
|
|
||||||
|
const nameMatch = /Content-Disposition:[^\r\n]*;\s*name="([^"]+)"/i.exec(headers);
|
||||||
|
if (!nameMatch?.[1]) continue;
|
||||||
|
if (/filename=/i.test(headers)) continue;
|
||||||
|
result[nameMatch[1]] = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBoundary(contentType: string, rawText: string): string | null {
|
||||||
|
const fromHeader = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
|
||||||
|
if (fromHeader?.[1] || fromHeader?.[2]) {
|
||||||
|
return (fromHeader[1] || fromHeader[2] || '').trim() || null;
|
||||||
|
}
|
||||||
|
const firstLine = rawText.split(/\r?\n/, 1)[0]?.trim() ?? '';
|
||||||
|
if (firstLine.startsWith('--') && firstLine.length > 2) {
|
||||||
|
return firstLine.slice(2).replace(/--$/, '') || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -139,20 +139,15 @@ export class XiaofeixiaProvider implements ICourierProvider {
|
|||||||
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
|
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
|
||||||
if (!body || typeof body !== 'object') return null;
|
if (!body || typeof body !== 'object') return null;
|
||||||
const root = body as Record<string, unknown>;
|
const root = body as Record<string, unknown>;
|
||||||
// 兼容顶层字段或 data/payload 包裹
|
// 兼容顶层字段或 data/payload 包裹;data 若为 JSON 字符串也解开
|
||||||
const nested =
|
const nestedObj = this.unwrapNestedObject(root.data) ?? this.unwrapNestedObject(root.payload);
|
||||||
root.data && typeof root.data === 'object'
|
const raw = nestedObj ? { ...nestedObj, ...root } : root;
|
||||||
? (root.data as Record<string, unknown>)
|
const outNumber = this.firstString(raw, ['outNumber', 'out_number', 'outNo']);
|
||||||
: root.payload && typeof root.payload === 'object'
|
const trackingNumber = this.firstString(raw, ['number', 'trackingNumber', 'trackingNo']);
|
||||||
? (root.payload as Record<string, unknown>)
|
const status = this.firstString(raw, ['status']);
|
||||||
: null;
|
const statusName = this.firstString(raw, ['statusName', 'status_name']);
|
||||||
const raw = nested ? { ...nested, ...root } : root;
|
const trackInfo = this.firstString(raw, ['trackInfo', 'track_info']);
|
||||||
const outNumber = String(raw.outNumber ?? '').trim();
|
const createTime = this.firstString(raw, ['createTime', 'create_time']);
|
||||||
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 (!outNumber && !trackingNumber) return null;
|
||||||
if (!status && !trackInfo) return null;
|
if (!status && !trackInfo) return null;
|
||||||
return {
|
return {
|
||||||
@@ -165,6 +160,38 @@ export class XiaofeixiaProvider implements ICourierProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private unwrapNestedObject(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private firstString(raw: Record<string, unknown>, keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = raw[key];
|
||||||
|
if (value == null) continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const last = value[value.length - 1];
|
||||||
|
if (last != null && String(last).trim()) return String(last).trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
|
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
|
||||||
const name = (statusName || '').trim();
|
const name = (statusName || '').trim();
|
||||||
if (status === '5' || /签收/.test(name)) {
|
if (status === '5' || /签收/.test(name)) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { loadAppConfig } from '@dukang/shared-types';
|
|||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { json, urlencoded, type NextFunction, type Request, type Response } from 'express';
|
import { json, urlencoded, type NextFunction, type Request, type Response } from 'express';
|
||||||
import { parse as parseQueryString } from 'node:querystring';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
@@ -12,6 +11,7 @@ import { LoggingInterceptor } from './common/logging/logging.interceptor';
|
|||||||
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
||||||
import { AlertService } from './common/alert/alert.service';
|
import { AlertService } from './common/alert/alert.service';
|
||||||
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
|
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
|
||||||
|
import { parseCourierCallbackBody } from './integrations/courier/xiaofeixia/xiaofeixia-callback-body';
|
||||||
|
|
||||||
function isCourierTrackCallbackUrl(url?: string): boolean {
|
function isCourierTrackCallbackUrl(url?: string): boolean {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
@@ -22,33 +22,7 @@ function isCourierTrackCallbackUrl(url?: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCourierCallbackBody(
|
/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(含 multipart/form-data) */
|
||||||
rawText: string,
|
|
||||||
contentType: string,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const ct = contentType.toLowerCase();
|
|
||||||
const trimmed = rawText.trim();
|
|
||||||
if (!trimmed) return {};
|
|
||||||
|
|
||||||
if (ct.includes('application/json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed) as unknown;
|
|
||||||
return parsed && typeof parsed === 'object'
|
|
||||||
? (parsed as Record<string, unknown>)
|
|
||||||
: { value: parsed };
|
|
||||||
} catch {
|
|
||||||
return { _rawText: rawText.slice(0, 4000) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ct.includes('application/x-www-form-urlencoded') || /[=&]/.test(trimmed)) {
|
|
||||||
return parseQueryString(trimmed) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { _rawText: rawText.slice(0, 4000) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(便于第三方日志排查) */
|
|
||||||
function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) {
|
function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) {
|
||||||
if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) {
|
if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
Reference in New Issue
Block a user