This commit is contained in:
@@ -6,6 +6,13 @@ import { logCourierCall } from '../integrations/courier/courier-log.util';
|
|||||||
import { TradeService } from '../modules/trade/trade.service';
|
import { TradeService } from '../modules/trade/trade.service';
|
||||||
|
|
||||||
const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']);
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeliveryCallbackService {
|
export class DeliveryCallbackService {
|
||||||
@@ -16,15 +23,17 @@ export class DeliveryCallbackService {
|
|||||||
private readonly tradeService: TradeService,
|
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 normalized = providerKey.trim().toLowerCase();
|
||||||
const baseLog = {
|
const baseLog = {
|
||||||
scene: 'TRACK_CALLBACK',
|
scene: 'TRACK_CALLBACK',
|
||||||
requestUrl,
|
requestUrl,
|
||||||
requestBody: (body && typeof body === 'object' ? body : { value: body }) as Record<
|
requestBody: this.buildRequestBodyForLog(body, meta),
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') {
|
if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') {
|
||||||
@@ -105,4 +114,58 @@ export class DeliveryCallbackService {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 第三方日志:保留解析后字段,并附带 Content-Type / rawBody / query 便于排查空 body */
|
||||||
|
private buildRequestBodyForLog(
|
||||||
|
body: unknown,
|
||||||
|
meta?: TrackCallbackRequestMeta,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const parsed =
|
||||||
|
body && typeof body === 'object'
|
||||||
|
? ({ ...(body as Record<string, unknown>) } as Record<string, unknown>)
|
||||||
|
: 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<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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]');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Body, Controller, Param, Post, Res } from '@nestjs/common';
|
import { Body, Controller, Param, Post, Req, Res } from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { DeliveryCallbackService } from './delivery-callback.service';
|
import { DeliveryCallbackService } from './delivery-callback.service';
|
||||||
|
|
||||||
|
type CourierCallbackRequest = Request & { rawBody?: Buffer };
|
||||||
|
|
||||||
@Controller('callbacks')
|
@Controller('callbacks')
|
||||||
export class DeliveryCallbackController {
|
export class DeliveryCallbackController {
|
||||||
constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {}
|
constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {}
|
||||||
@@ -11,12 +13,14 @@ export class DeliveryCallbackController {
|
|||||||
async trackByProvider(
|
async trackByProvider(
|
||||||
@Param('provider') provider: string,
|
@Param('provider') provider: string,
|
||||||
@Body() body: unknown,
|
@Body() body: unknown,
|
||||||
|
@Req() req: CourierCallbackRequest,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const result = await this.deliveryCallbackService.handleTrackCallback(
|
const result = await this.deliveryCallbackService.handleTrackCallback(
|
||||||
provider,
|
provider,
|
||||||
body,
|
body,
|
||||||
`/api/v1/callbacks/courier/${provider}/track`,
|
`/api/v1/callbacks/courier/${provider}/track`,
|
||||||
|
this.buildRequestMeta(req),
|
||||||
);
|
);
|
||||||
// 直出承运商约定结构,避免被全局 { code:0, data } 包装
|
// 直出承运商约定结构,避免被全局 { code:0, data } 包装
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
@@ -24,12 +28,29 @@ export class DeliveryCallbackController {
|
|||||||
|
|
||||||
/** 兼容旧路径,默认按小飞侠解析 */
|
/** 兼容旧路径,默认按小飞侠解析 */
|
||||||
@Post('delivery/track')
|
@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(
|
const result = await this.deliveryCallbackService.handleTrackCallback(
|
||||||
'xfx',
|
'xfx',
|
||||||
body,
|
body,
|
||||||
'/api/v1/callbacks/delivery/track',
|
'/api/v1/callbacks/delivery/track',
|
||||||
|
this.buildRequestMeta(req),
|
||||||
);
|
);
|
||||||
return res.status(200).json(result);
|
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<string, unknown>)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
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 { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
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 { AlertService } from './common/alert/alert.service';
|
||||||
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
|
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<string, unknown> {
|
||||||
|
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<string, unknown>)
|
||||||
|
: { value: parsed };
|
||||||
|
} catch {
|
||||||
|
return { _rawText: rawText.slice(0, 4000) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ct.includes('application/x-www-form-urlencoded') || /[=&]/.test(trimmed)) {
|
||||||
|
return parseQueryString(trimmed) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function bootstrap() {
|
||||||
const preloaded = await preloadSystemConfigEnv().catch((e) => {
|
const preloaded = await preloadSystemConfigEnv().catch((e) => {
|
||||||
console.warn('[config] system_config preload skipped:', e instanceof Error ? e.message : 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.setGlobalPrefix('api/v1');
|
||||||
app.set('trust proxy', true);
|
app.set('trust proxy', true);
|
||||||
app.enableCors({ origin: true, credentials: true });
|
app.enableCors({ origin: true, credentials: true });
|
||||||
// 微信支付等需 rawBody;小飞侠回调多为 x-www-form-urlencoded
|
// 小飞侠路由回调:优先捕获 rawBody(Content-Type 异常时也能入第三方日志)
|
||||||
|
app.use(courierTrackRawBodyMiddleware);
|
||||||
|
// 微信支付等需 rawBody;其它 JSON / form 请求走常规解析(跳过已由上面吃掉 body 的小飞侠回调)
|
||||||
app.use(
|
app.use(
|
||||||
json({
|
json({
|
||||||
|
type: (req) =>
|
||||||
|
!isCourierTrackCallbackUrl(req.originalUrl || req.url) &&
|
||||||
|
Boolean(req.headers['content-type']?.includes('json')),
|
||||||
verify: (req, _res, buf) => {
|
verify: (req, _res, buf) => {
|
||||||
if (
|
if (
|
||||||
req.url?.includes('/callbacks/wechat/pay') ||
|
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.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
|
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
|
||||||
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
|
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
|
||||||
|
|||||||
Reference in New Issue
Block a user