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:
2026-08-07 19:08:03 +08:00
parent 8726cf14e3
commit 0692bebf07
4 changed files with 164 additions and 44 deletions
@@ -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 {
if (!body || typeof body !== 'object') return null;
const root = body as Record<string, unknown>;
// 兼容顶层字段或 data/payload 包裹
const nested =
root.data && typeof root.data === 'object'
? (root.data as Record<string, unknown>)
: root.payload && typeof root.payload === 'object'
? (root.payload as Record<string, unknown>)
: null;
const raw = nested ? { ...nested, ...root } : root;
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();
// 兼容顶层字段或 data/payload 包裹;data 若为 JSON 字符串也解开
const nestedObj = this.unwrapNestedObject(root.data) ?? this.unwrapNestedObject(root.payload);
const raw = nestedObj ? { ...nestedObj, ...root } : root;
const outNumber = this.firstString(raw, ['outNumber', 'out_number', 'outNo']);
const trackingNumber = this.firstString(raw, ['number', 'trackingNumber', 'trackingNo']);
const status = this.firstString(raw, ['status']);
const statusName = this.firstString(raw, ['statusName', 'status_name']);
const trackInfo = this.firstString(raw, ['trackInfo', 'track_info']);
const createTime = this.firstString(raw, ['createTime', 'create_time']);
if (!outNumber && !trackingNumber) return null;
if (!status && !trackInfo) return null;
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 {
const name = (statusName || '').trim();
if (status === '5' || /签收/.test(name)) {