0692bebf07
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>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
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;
|
|
}
|