diff --git a/src/common/http/all-exceptions.filter.ts b/src/common/http/all-exceptions.filter.ts index bad7689..cc208bd 100644 --- a/src/common/http/all-exceptions.filter.ts +++ b/src/common/http/all-exceptions.filter.ts @@ -1,3 +1,6 @@ +/** + * 全局异常过滤器负责统一错误 envelope,并补齐 guard/pipe 阶段的结构化错误日志。 + */ import { ArgumentsHost, Catch, @@ -11,6 +14,22 @@ import { ResponseWithHeaders, resolveRequestId, } from './request-id'; +import { StructuredLoggerService } from '../logging/structured-logger.service'; + +type RequestWithErrorLogging = RequestWithHeaders & { + method?: string; + originalUrl?: string; + url?: string; + ip?: string; + socket?: { + remoteAddress?: string; + }; + user?: { + id?: string; + account?: string; + role?: string; + }; +}; type JsonResponse = ResponseWithHeaders & { status: (statusCode: number) => { @@ -20,14 +39,17 @@ type JsonResponse = ResponseWithHeaders & { @Catch() export class AllExceptionsFilter implements ExceptionFilter { + constructor(private readonly logger?: StructuredLoggerService) {} + catch(exception: unknown, host: ArgumentsHost): void { const httpContext = host.switchToHttp(); - const request = httpContext.getRequest(); + const request = httpContext.getRequest(); const response = httpContext.getResponse(); const payload = this.toPayload(exception); const requestId = resolveRequestId(request.headers); response.setHeader('x-request-id', requestId); + this.logException(request, payload, requestId); response.status(payload.statusCode).json({ success: false, @@ -79,4 +101,54 @@ export class AllExceptionsFilter implements ExceptionFilter { message: '系统异常,请联系管理员', }; } + + private logException( + request: RequestWithErrorLogging, + payload: { + statusCode: number; + code: string; + message: string; + }, + requestId: string, + ): void { + if (!this.logger) { + return; + } + + const logPayload = { + event: 'http_request_completed', + outcome: 'error', + requestId, + method: request.method, + path: this.safePath(request.originalUrl ?? request.url), + statusCode: payload.statusCode, + durationMs: + typeof request.startedAt === 'number' + ? Date.now() - request.startedAt + : undefined, + actorId: request.user?.id, + actorName: request.user?.account, + actorRole: request.user?.role, + sourceIp: request.ip ?? request.socket?.remoteAddress, + errorCode: payload.code, + errorMessage: this.redactedMessage(payload.message), + }; + + if (payload.statusCode >= 500) { + this.logger.error(logPayload, 'http request failed'); + return; + } + + this.logger.warn(logPayload, 'http request completed with client error'); + } + + private redactedMessage(message: string): string { + const redacted = redactSensitive({ message }); + return typeof redacted.message === 'string' ? redacted.message : '已隐藏错误信息'; + } + + private safePath(value?: string): string { + const path = value?.split('?')[0]?.trim(); + return path || '/'; + } } diff --git a/src/common/http/request-id.ts b/src/common/http/request-id.ts index 443faab..a3dc5ba 100644 --- a/src/common/http/request-id.ts +++ b/src/common/http/request-id.ts @@ -7,6 +7,7 @@ export type RequestHeaders = Record; export type RequestWithHeaders = { headers?: RequestHeaders; + startedAt?: number; }; export type ResponseWithHeaders = { diff --git a/src/common/logging/http-request-logging.interceptor.ts b/src/common/logging/http-request-logging.interceptor.ts index 80e814e..b44749d 100644 --- a/src/common/logging/http-request-logging.interceptor.ts +++ b/src/common/logging/http-request-logging.interceptor.ts @@ -4,14 +4,11 @@ import { CallHandler, ExecutionContext, - HttpException, Injectable, NestInterceptor, } from '@nestjs/common'; -import { Observable, catchError, tap, throwError } from 'rxjs'; +import { Observable, tap } from 'rxjs'; import { AuthenticatedUser } from '../../auth/auth.types'; -import { AppError } from '../errors/app-error'; -import { redactSensitive } from '../security/redact-sensitive'; import { RequestHeaders, resolveRequestId } from '../http/request-id'; import { StructuredLoggerService } from './structured-logger.service'; @@ -49,10 +46,6 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor { tap(() => { this.logRequest('success', request, response, startedAt); }), - catchError((error: unknown) => { - this.logRequest('error', request, response, startedAt, error); - return throwError(() => error); - }), ); } @@ -61,9 +54,8 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor { request: RequestWithLoggingContext, response: ResponseWithStatus, startedAt: number, - error?: unknown, ): void { - const statusCode = this.resolveStatusCode(response, error); + const statusCode = response.statusCode ?? 200; const payload = { event: 'http_request_completed', outcome, @@ -76,78 +68,11 @@ export class HttpRequestLoggingInterceptor implements NestInterceptor { actorName: request.user?.account, actorRole: request.user?.role, sourceIp: request.ip ?? request.socket?.remoteAddress, - ...this.errorPayload(error), }; - if (statusCode >= 500) { - this.logger.error(payload, 'http request failed'); - return; - } - - if (statusCode >= 400) { - this.logger.warn(payload, 'http request completed with client error'); - return; - } - this.logger.info(payload, 'http request completed'); } - private resolveStatusCode( - response: ResponseWithStatus, - error?: unknown, - ): number { - if (error instanceof AppError) { - return error.statusCode; - } - - if (error instanceof HttpException) { - return error.getStatus(); - } - - if (error) { - return 500; - } - - return response.statusCode ?? 200; - } - - private errorPayload(error?: unknown): Record { - if (!error) { - return {}; - } - - if (error instanceof AppError) { - return { - errorCode: error.code, - errorMessage: this.redactedMessage(error.message), - }; - } - - if (error instanceof HttpException) { - return { - errorCode: 'HTTP_EXCEPTION', - errorMessage: this.redactedMessage('请求处理失败'), - }; - } - - if (error instanceof Error) { - return { - errorCode: 'UNEXPECTED_ERROR', - errorName: error.name, - errorMessage: this.redactedMessage(error.message), - }; - } - - return { - errorCode: 'UNEXPECTED_ERROR', - }; - } - - private redactedMessage(message: string): string { - const redacted = redactSensitive({ message }); - return typeof redacted.message === 'string' ? redacted.message : '已隐藏错误信息'; - } - private safePath(value?: string): string { const path = value?.split('?')[0]?.trim(); return path || '/'; diff --git a/src/main.ts b/src/main.ts index 6c119f3..76d0539 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,8 +5,13 @@ import { AppModule } from './app.module'; import { ApiEnvelopeInterceptor } from './common/http/api-envelope.interceptor'; import { AllExceptionsFilter } from './common/http/all-exceptions.filter'; import { HttpRequestLoggingInterceptor } from './common/logging/http-request-logging.interceptor'; +import { StructuredLoggerService } from './common/logging/structured-logger.service'; import { EnvConfig } from './config/env.schema'; +type RequestWithStartedAt = { + startedAt?: number; +}; + async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { bufferLogs: true, @@ -14,12 +19,18 @@ async function bootstrap(): Promise { }); const config = app.get(ConfigService); const corsOrigin = config.get('CORS_ORIGIN', { infer: true }); + const structuredLogger = app.get(StructuredLoggerService); + + app.use((request: RequestWithStartedAt, _response: unknown, next: () => void) => { + request.startedAt = Date.now(); + next(); + }); app.enableCors({ origin: corsOrigin ? corsOrigin.split(',').map((origin) => origin.trim()) : true, credentials: true, }); - app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalFilters(new AllExceptionsFilter(structuredLogger)); app.useGlobalInterceptors( app.get(HttpRequestLoggingInterceptor), new ApiEnvelopeInterceptor(),