import './load-env'; 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, 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'; 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'; 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); return 0; }); if (preloaded > 0) { console.log(`[config] loaded ${preloaded} keys from system_config`); } initSentryIfConfigured(); const app = await NestFactory.create(AppModule, { bodyParser: false }); app.setGlobalPrefix('api/v1'); app.set('trust proxy', true); app.enableCors({ origin: true, credentials: true }); // 小飞侠路由回调:优先捕获 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') || req.url?.includes('/callbacks/wechat/refund') || req.url?.includes('/callbacks/wechat/message') ) { (req as { rawBody?: Buffer }).rawBody = buf; } }, }), ); 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)); const port = process.env.PORT || 3000; const cfg = loadAppConfig(); const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN'; console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`); await app.listen(port); console.log(`dukang-api listening on http://localhost:${port}/api/v1`); } bootstrap();