import { parse as parseQueryString } from 'node:querystring'; /** * 小飞侠路由回调实际推送多为 multipart/form-data(非 JSON / urlencoded)。 * 解析失败时保留 _rawText,便于第三方日志排查。 */ export function parseCourierCallbackBody( rawText: string, contentType: string, ): Record { 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) : { 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, ): Record { const out: Record = {}; 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 | null { const boundary = extractBoundary(contentType, rawText); if (!boundary) return null; const delimiter = `--${boundary}`; const parts = rawText.split(delimiter); const result: Record = {}; 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; }