diff --git a/server/dukang-api/src/callbacks/delivery-callback.service.ts b/server/dukang-api/src/callbacks/delivery-callback.service.ts index 18fca3f..115aebf 100644 --- a/server/dukang-api/src/callbacks/delivery-callback.service.ts +++ b/server/dukang-api/src/callbacks/delivery-callback.service.ts @@ -6,6 +6,13 @@ import { logCourierCall } from '../integrations/courier/courier-log.util'; import { TradeService } from '../modules/trade/trade.service'; const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']); +const RAW_BODY_LOG_LIMIT = 4000; + +export type TrackCallbackRequestMeta = { + contentType?: string | null; + rawBody?: string | null; + query?: Record; +}; @Injectable() export class DeliveryCallbackService { @@ -16,15 +23,17 @@ export class DeliveryCallbackService { private readonly tradeService: TradeService, ) {} - async handleTrackCallback(providerKey: string, body: unknown, requestUrl: string) { + async handleTrackCallback( + providerKey: string, + body: unknown, + requestUrl: string, + meta?: TrackCallbackRequestMeta, + ) { const normalized = providerKey.trim().toLowerCase(); const baseLog = { scene: 'TRACK_CALLBACK', requestUrl, - requestBody: (body && typeof body === 'object' ? body : { value: body }) as Record< - string, - unknown - >, + requestBody: this.buildRequestBodyForLog(body, meta), }; if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') { @@ -105,4 +114,58 @@ export class DeliveryCallbackService { return response; } + + /** 第三方日志:保留解析后字段,并附带 Content-Type / rawBody / query 便于排查空 body */ + private buildRequestBodyForLog( + body: unknown, + meta?: TrackCallbackRequestMeta, + ): Record { + const parsed = + body && typeof body === 'object' + ? ({ ...(body as Record) } as Record) + : body === undefined || body === null + ? {} + : { value: body }; + + const rawBody = meta?.rawBody != null ? this.redactSecrets(String(meta.rawBody)) : null; + const query = + meta?.query && Object.keys(meta.query).length > 0 + ? this.redactSecretFields({ ...meta.query }) + : undefined; + + return { + ...this.redactSecretFields(parsed), + _meta: { + contentType: meta?.contentType ?? null, + rawBody: + rawBody && rawBody.length > RAW_BODY_LOG_LIMIT + ? `${rawBody.slice(0, RAW_BODY_LOG_LIMIT)}…(truncated)` + : rawBody, + rawBodyLength: meta?.rawBody != null ? Buffer.byteLength(meta.rawBody, 'utf8') : 0, + query, + }, + }; + } + + private redactSecretFields(input: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (/^sign$/i.test(key) || /api[_-]?key/i.test(key)) { + out[key] = '[REDACTED]'; + } else if (key === 'mchId' && value != null) { + const s = String(value); + out[key] = s.length <= 4 ? '****' : `${s.slice(0, 4)}****`; + } else { + out[key] = value; + } + } + return out; + } + + 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]'); + } } diff --git a/server/dukang-api/src/callbacks/delivery-track.controller.ts b/server/dukang-api/src/callbacks/delivery-track.controller.ts index 4dfc0ab..abcdebf 100644 --- a/server/dukang-api/src/callbacks/delivery-track.controller.ts +++ b/server/dukang-api/src/callbacks/delivery-track.controller.ts @@ -1,7 +1,9 @@ -import { Body, Controller, Param, Post, Res } from '@nestjs/common'; -import type { Response } from 'express'; +import { Body, Controller, Param, Post, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; import { DeliveryCallbackService } from './delivery-callback.service'; +type CourierCallbackRequest = Request & { rawBody?: Buffer }; + @Controller('callbacks') export class DeliveryCallbackController { constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {} @@ -11,12 +13,14 @@ export class DeliveryCallbackController { async trackByProvider( @Param('provider') provider: string, @Body() body: unknown, + @Req() req: CourierCallbackRequest, @Res() res: Response, ) { const result = await this.deliveryCallbackService.handleTrackCallback( provider, body, `/api/v1/callbacks/courier/${provider}/track`, + this.buildRequestMeta(req), ); // 直出承运商约定结构,避免被全局 { code:0, data } 包装 return res.status(200).json(result); @@ -24,12 +28,29 @@ export class DeliveryCallbackController { /** 兼容旧路径,默认按小飞侠解析 */ @Post('delivery/track') - async trackLegacy(@Body() body: unknown, @Res() res: Response) { + async trackLegacy( + @Body() body: unknown, + @Req() req: CourierCallbackRequest, + @Res() res: Response, + ) { const result = await this.deliveryCallbackService.handleTrackCallback( 'xfx', body, '/api/v1/callbacks/delivery/track', + this.buildRequestMeta(req), ); return res.status(200).json(result); } + + private buildRequestMeta(req: CourierCallbackRequest) { + const contentType = req.headers['content-type']; + return { + contentType: Array.isArray(contentType) ? contentType.join(', ') : contentType || null, + rawBody: req.rawBody?.toString('utf8') ?? null, + query: + req.query && typeof req.query === 'object' + ? (req.query as Record) + : undefined, + }; + } } diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts index 0f68e37..65ec1df 100644 --- a/server/dukang-api/src/main.ts +++ b/server/dukang-api/src/main.ts @@ -3,7 +3,8 @@ import { NestFactory } from '@nestjs/core'; import { loadAppConfig } from '@dukang/shared-types'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; -import { json, urlencoded } 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 { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; @@ -12,6 +13,61 @@ import { preloadSystemConfigEnv } from './common/system-config/system-config.env import { AlertService } from './common/alert/alert.service'; import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap'; +function isCourierTrackCallbackUrl(url?: string): boolean { + if (!url) return false; + const path = url.split('?')[0] ?? ''; + return ( + (path.includes('/callbacks/courier/') && path.endsWith('/track')) || + path.endsWith('/callbacks/delivery/track') + ); +} + +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 再尽力解析(便于第三方日志排查) */ +function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) { + if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) { + return next(); + } + + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('error', (err) => next(err)); + req.on('end', () => { + const buf = Buffer.concat(chunks); + const rawText = buf.toString('utf8'); + (req as Request & { rawBody?: Buffer }).rawBody = buf; + req.body = parseCourierCallbackBody(rawText, String(req.headers['content-type'] || '')); + next(); + }); +} + async function bootstrap() { const preloaded = await preloadSystemConfigEnv().catch((e) => { console.warn('[config] system_config preload skipped:', e instanceof Error ? e.message : e); @@ -26,9 +82,14 @@ async function bootstrap() { app.setGlobalPrefix('api/v1'); app.set('trust proxy', true); app.enableCors({ origin: true, credentials: true }); - // 微信支付等需 rawBody;小飞侠回调多为 x-www-form-urlencoded + // 小飞侠路由回调:优先捕获 rawBody(Content-Type 异常时也能入第三方日志) + app.use(courierTrackRawBodyMiddleware); + // 微信支付等需 rawBody;其它 JSON / form 请求走常规解析(跳过已由上面吃掉 body 的小飞侠回调) app.use( json({ + type: (req) => + !isCourierTrackCallbackUrl(req.originalUrl || req.url) && + Boolean(req.headers['content-type']?.includes('json')), verify: (req, _res, buf) => { if ( req.url?.includes('/callbacks/wechat/pay') || @@ -40,7 +101,14 @@ async function bootstrap() { }, }), ); - app.use(urlencoded({ extended: true })); + app.use( + urlencoded({ + extended: true, + type: (req) => + !isCourierTrackCallbackUrl(req.originalUrl || req.url) && + Boolean(req.headers['content-type']?.includes('urlencoded')), + }), + ); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService))); app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));