diff --git a/server/dukang-api/src/callbacks/delivery-callback.service.ts b/server/dukang-api/src/callbacks/delivery-callback.service.ts index 115aebf..3c474be 100644 --- a/server/dukang-api/src/callbacks/delivery-callback.service.ts +++ b/server/dukang-api/src/callbacks/delivery-callback.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../common/prisma/prisma.module'; 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 { logCourierCall } from '../integrations/courier/courier-log.util'; import { TradeService } from '../modules/trade/trade.service'; @@ -54,7 +55,9 @@ export class DeliveryCallbackService { return this.courier.buildTrackCallbackResponse(true); } - const payload = this.xiaofeixiaProvider.parseTrackCallback(body); + const payload = + this.xiaofeixiaProvider.parseTrackCallback(body) ?? + this.parseFromRawMeta(meta); if (!payload) { const response = this.courier.buildTrackCallbackResponse(false); await logCourierCall(this.prisma, { @@ -162,10 +165,17 @@ export class DeliveryCallbackService { 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 { return raw .replace(/(sign=)[^&\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]'); } } diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia-callback-body.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia-callback-body.ts new file mode 100644 index 0000000..caae260 --- /dev/null +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia-callback-body.ts @@ -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 { + 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; +} diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts index e116d7e..9e9be30 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts @@ -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; - // 兼容顶层字段或 data/payload 包裹 - const nested = - root.data && typeof root.data === 'object' - ? (root.data as Record) - : root.payload && typeof root.payload === 'object' - ? (root.payload as Record) - : 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 | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + 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; + } + } catch { + return null; + } + } + return null; + } + + private firstString(raw: Record, 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)) { diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index c93ff1c..3480393 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -4,7 +4,6 @@ import { loadAppConfig } from '@dukang/shared-types'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; import { json, urlencoded, type NextFunction, type Request, type Response } from 'express'; -import { parse as parseQueryString } from 'node:querystring'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; 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 { AlertService } from './common/alert/alert.service'; import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap'; +import { parseCourierCallbackBody } from './integrations/courier/xiaofeixia/xiaofeixia-callback-body'; function isCourierTrackCallbackUrl(url?: string): boolean { if (!url) return false; @@ -22,33 +22,7 @@ function isCourierTrackCallbackUrl(url?: string): boolean { ); } -function parseCourierCallbackBody( - rawText: string, - contentType: string, -): Record { - 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) - : { value: parsed }; - } catch { - return { _rawText: rawText.slice(0, 4000) }; - } - } - - if (ct.includes('application/x-www-form-urlencoded') || /[=&]/.test(trimmed)) { - return parseQueryString(trimmed) as Record; - } - - return { _rawText: rawText.slice(0, 4000) }; -} - -/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(便于第三方日志排查) */ +/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(含 multipart/form-data) */ function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) { if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) { return next();