Skip to content

第 5 篇:异常处理 —— 别让用户看到你的 stack trace

在前面的文章中,我们探讨了多租户、认证、授权和日志审计等横切关注点。现在,让我们关注另一个重要但经常被忽视的方面:异常处理。

异常处理不仅仅是捕获错误,更是确保系统稳定性和用户体验的关键。糟糕的异常处理会让用户看到敏感的系统信息,甚至可能暴露安全漏洞。

统一错误码体系(业务码 vs HTTP 状态码)

在构建统一的异常处理机制时,首先需要建立清晰的错误码体系:

typescript
// 业务错误码枚举
export enum BusinessErrorCode {
  // 认证相关错误 1000-1999
  AUTH_INVALID_CREDENTIALS = 1001,
  AUTH_TOKEN_EXPIRED = 1002,
  AUTH_TOKEN_INVALID = 1003,
  AUTH_USER_NOT_FOUND = 1004,
  
  // 授权相关错误 2000-2999
  AUTHZ_PERMISSION_DENIED = 2001,
  AUTHZ_RESOURCE_NOT_OWNED = 2002,
  
  // 资源相关错误 3000-3999
  RESOURCE_NOT_FOUND = 3001,
  RESOURCE_ALREADY_EXISTS = 3002,
  RESOURCE_CONFLICT = 3003,
  
  // 参数验证错误 4000-4999
  VALIDATION_FAILED = 4001,
  VALIDATION_MISSING_REQUIRED_FIELD = 4002,
  
  // 业务逻辑错误 5000-5999
  BUSINESS_LOGIC_ERROR = 5001,
  BUSINESS_OPERATION_NOT_ALLOWED = 5002,
  
  // 系统错误 6000-6999
  SYSTEM_ERROR = 6001,
  SYSTEM_DATABASE_ERROR = 6002,
  SYSTEM_EXTERNAL_SERVICE_ERROR = 6003,
}

// 业务异常基类
export class BusinessException extends HttpException {
  constructor(
    public readonly code: BusinessErrorCode,
    public readonly message: string,
    public readonly details?: any,
    public readonly statusCode: number = HttpStatus.BAD_REQUEST,
  ) {
    super(
      {
        code,
        message,
        details,
      },
      statusCode,
    );
  }
}

// 具体业务异常类
export class InvalidCredentialsException extends BusinessException {
  constructor(details?: any) {
    super(
      BusinessErrorCode.AUTH_INVALID_CREDENTIALS,
      'Invalid username or password',
      details,
      HttpStatus.UNAUTHORIZED,
    );
  }
}

export class TokenExpiredException extends BusinessException {
  constructor(details?: any) {
    super(
      BusinessErrorCode.AUTH_TOKEN_EXPIRED,
      'Token has expired',
      details,
      HttpStatus.UNAUTHORIZED,
    );
  }
}

export class PermissionDeniedException extends BusinessException {
  constructor(details?: any) {
    super(
      BusinessErrorCode.AUTHZ_PERMISSION_DENIED,
      'Permission denied',
      details,
      HttpStatus.FORBIDDEN,
    );
  }
}

export class ResourceNotFoundException extends BusinessException {
  constructor(resourceType: string, resourceId?: string, details?: any) {
    super(
      BusinessErrorCode.RESOURCE_NOT_FOUND,
      `${resourceType} not found${resourceId ? `: ${resourceId}` : ''}`,
      { resourceType, resourceId, ...details },
      HttpStatus.NOT_FOUND,
    );
  }
}

export class ValidationException extends BusinessException {
  constructor(details?: any) {
    super(
      BusinessErrorCode.VALIDATION_FAILED,
      'Validation failed',
      details,
      HttpStatus.BAD_REQUEST,
    );
  }
}

敏感信息过滤(数据库密码、用户 token)

在异常处理中,必须确保敏感信息不会泄露给客户端:

typescript
// 敏感信息过滤器
@Injectable()
export class SensitiveDataFilter {
  private readonly sensitivePatterns = [
    /password["']?\s*[:=]\s*["']?[^"'\s,}]*/gi,
    /token["']?\s*[:=]\s*["']?[^"'\s,}]*/gi,
    /secret["']?\s*[:=]\s*["']?[^"'\s,}]*/gi,
    /key["']?\s*[:=]\s*["']?[^"'\s,}]*/gi,
    /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, // 邮箱地址
    /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, // 信用卡号
    /\b\d{3}-?\d{2}-?\d{4}\b/, // 社保号码
  ];

  filterSensitiveData(obj: any): any {
    if (obj === null || obj === undefined) {
      return obj;
    }

    if (typeof obj === 'string') {
      return this.filterSensitiveString(obj);
    }

    if (typeof obj === 'object') {
      const filtered = Array.isArray(obj) ? [] : {};
      
      for (const [key, value] of Object.entries(obj)) {
        // 过滤敏感字段名
        if (this.isSensitiveKey(key)) {
          filtered[key] = '[REDACTED]';
        } else {
          filtered[key] = this.filterSensitiveData(value);
        }
      }
      
      return filtered;
    }

    return obj;
  }

  private filterSensitiveString(str: string): string {
    let filteredStr = str;
    
    for (const pattern of this.sensitivePatterns) {
      filteredStr = filteredStr.replace(pattern, (match) => {
        // 保留字段名,隐藏值
        const parts = match.split(/[:=]/);
        if (parts.length > 1) {
          return `${parts[0]}:${'*'.repeat(Math.min(20, parts[1].length))}`;
        }
        return '[REDACTED]';
      });
    }
    
    return filteredStr;
  }

  private isSensitiveKey(key: string): boolean {
    const sensitiveKeys = [
      'password',
      'token',
      'secret',
      'key',
      'apikey',
      'privatekey',
      'credential',
      'auth',
    ];
    
    return sensitiveKeys.some(sensitiveKey => 
      key.toLowerCase().includes(sensitiveKey)
    );
  }
}

// 异常过滤器
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(
    private readonly sensitiveDataFilter: SensitiveDataFilter,
    private readonly systemLogger: SystemLogger,
  ) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    // 记录异常日志
    this.logException(exception, request);

    // 处理不同类型的异常
    let businessException: BusinessException;
    
    if (exception instanceof BusinessException) {
      businessException = exception;
    } else if (exception instanceof HttpException) {
      // 将 NestJS 内置异常转换为业务异常
      businessException = new BusinessException(
        BusinessErrorCode.SYSTEM_ERROR,
        exception.message,
        {
          originalError: this.sensitiveDataFilter.filterSensitiveData({
            name: exception.name,
            message: exception.message,
            ...(exception instanceof BadRequestException ? exception.getResponse() : {}),
          }),
        },
        exception.getStatus(),
      );
    } else {
      // 处理未知异常
      businessException = new BusinessException(
        BusinessErrorCode.SYSTEM_ERROR,
        'Internal server error',
        {
          message: 'An unexpected error occurred',
        },
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }

    // 在生产环境中过滤敏感信息
    const isProduction = process.env.NODE_ENV === 'production';
    const responsePayload = {
      code: businessException.code,
      message: businessException.message,
      ...(businessException.details && {
        details: isProduction 
          ? this.sensitiveDataFilter.filterSensitiveData(businessException.details)
          : businessException.details,
      }),
      ...(process.env.NODE_ENV !== 'production' && {
        stack: exception instanceof Error ? exception.stack : undefined,
      }),
      timestamp: new Date().toISOString(),
      path: request.url,
    };

    response
      .status(businessException.getStatus())
      .json(responsePayload);
  }

  private logException(exception: unknown, request: any) {
    const logData = {
      url: request.url,
      method: request.method,
      ip: request.ip,
      userAgent: request.get('User-Agent'),
      userId: request.user?.id,
      tenantId: request.user?.tenantId,
    };

    if (exception instanceof Error) {
      this.systemLogger.error(
        `Exception: ${exception.message}`,
        {
          ...logData,
          stack: exception.stack,
          name: exception.name,
        },
      );
    } else {
      this.systemLogger.error(
        'Unknown exception',
        {
          ...logData,
          exception,
        },
      );
    }
  }
}

全局异常中间件 + Sentry 集成

为了更好地监控和追踪异常,我们可以集成 Sentry 等错误监控服务:

typescript
// Sentry 配置
@Injectable()
export class SentryService {
  constructor(private readonly configService: ConfigService) {
    const sentryDsn = this.configService.get<string>('SENTRY_DSN');
    if (sentryDsn) {
      Sentry.init({
        dsn: sentryDsn,
        environment: this.configService.get<string>('NODE_ENV', 'development'),
        release: this.configService.get<string>('RELEASE_VERSION'),
        tracesSampleRate: this.configService.get<number>('SENTRY_TRACES_SAMPLE_RATE', 0.1),
      });
    }
  }

  captureException(exception: unknown, context?: any) {
    Sentry.withScope((scope) => {
      if (context) {
        scope.setContext('context', context);
      }
      Sentry.captureException(exception);
    });
  }

  captureMessage(message: string, level: Sentry.SeverityLevel = 'info', context?: any) {
    Sentry.withScope((scope) => {
      if (context) {
        scope.setContext('context', context);
      }
      Sentry.captureMessage(message, level);
    });
  }
}

// 全局异常处理模块
@Module({
  providers: [
    GlobalExceptionFilter,
    SensitiveDataFilter,
    SentryService,
    SystemLogger,
  ],
  exports: [
    GlobalExceptionFilter,
    SensitiveDataFilter,
    SentryService,
  ],
})
export class ExceptionHandlingModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(ExceptionHandlingMiddleware)
      .forRoutes('*');
  }
}

// 异常处理中间件
@Injectable()
export class ExceptionHandlingMiddleware implements NestMiddleware {
  constructor(
    private readonly sentryService: SentryService,
  ) {}

  use(req: Request, res: Response, next: NextFunction) {
    // 为每个请求设置 Sentry 上下文
    Sentry.configureScope((scope) => {
      scope.setTag('tenant_id', req.user?.tenantId);
      scope.setTag('user_id', req.user?.id);
      scope.setTag('url', req.url);
      scope.setTag('method', req.method);
    });

    next();
  }
}

// 增强的全局异常过滤器
@Catch()
export class EnhancedGlobalExceptionFilter implements ExceptionFilter {
  constructor(
    private readonly sensitiveDataFilter: SensitiveDataFilter,
    private readonly systemLogger: SystemLogger,
    private readonly sentryService: SentryService,
  ) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    // 记录异常日志
    this.logException(exception, request);

    // 发送到 Sentry
    this.reportToSentry(exception, request);

    // 处理响应
    let businessException: BusinessException;
    
    if (exception instanceof BusinessException) {
      businessException = exception;
    } else if (exception instanceof HttpException) {
      businessException = this.convertHttpException(exception);
    } else {
      businessException = this.handleUnknownException(exception);
    }

    const responsePayload = this.prepareResponse(businessException, request);
    response.status(businessException.getStatus()).json(responsePayload);
  }

  private convertHttpException(exception: HttpException): BusinessException {
    const response = exception.getResponse();
    const message = typeof response === 'string' 
      ? response 
      : (response as any).message || exception.message;

    return new BusinessException(
      BusinessErrorCode.SYSTEM_ERROR,
      message,
      {
        originalError: this.sensitiveDataFilter.filterSensitiveData({
          name: exception.name,
          message: exception.message,
          response,
        }),
      },
      exception.getStatus(),
    );
  }

  private handleUnknownException(exception: unknown): BusinessException {
    return new BusinessException(
      BusinessErrorCode.SYSTEM_ERROR,
      'Internal server error',
      {
        message: 'An unexpected error occurred',
      },
      HttpStatus.INTERNAL_SERVER_ERROR,
    );
  }

  private logException(exception: unknown, request: any) {
    const logData = {
      url: request.url,
      method: request.method,
      ip: request.ip,
      userAgent: request.get('User-Agent'),
      userId: request.user?.id,
      tenantId: request.user?.tenantId,
    };

    if (exception instanceof Error) {
      this.systemLogger.error(
        `Exception: ${exception.message}`,
        {
          ...logData,
          stack: exception.stack,
          name: exception.name,
        },
      );
    } else {
      this.systemLogger.error(
        'Unknown exception',
        {
          ...logData,
          exception: this.sensitiveDataFilter.filterSensitiveData(exception),
        },
      );
    }
  }

  private reportToSentry(exception: unknown, request: any) {
    try {
      const context = {
        url: request.url,
        method: request.method,
        ip: request.ip,
        userAgent: request.get('User-Agent'),
        user: request.user ? {
          id: request.user.id,
          tenantId: request.user.tenantId,
        } : undefined,
      };

      this.sentryService.captureException(exception, context);
    } catch (error) {
      this.systemLogger.error('Failed to report exception to Sentry', { error });
    }
  }

  private prepareResponse(exception: BusinessException, request: any): any {
    const isProduction = process.env.NODE_ENV === 'production';
    
    return {
      code: exception.code,
      message: exception.message,
      ...(exception.details && {
        details: isProduction 
          ? this.sensitiveDataFilter.filterSensitiveData(exception.details)
          : exception.details,
      }),
      timestamp: new Date().toISOString(),
      path: request.url,
    };
  }
}

异常分类:可恢复 vs 致命

合理地对异常进行分类有助于制定不同的处理策略:

typescript
// 异常分类枚举
export enum ExceptionCategory {
  RECOVERABLE = 'recoverable',     // 可恢复异常
  TRANSIENT = 'transient',         // 瞬时异常(可重试)
  FATAL = 'fatal',                 // 致命异常
  BUSINESS_LOGIC = 'business',     // 业务逻辑异常
  SECURITY = 'security',           // 安全相关异常
}

// 异常分类服务
@Injectable()
export class ExceptionClassificationService {
  classifyException(exception: unknown): ExceptionCategory {
    if (exception instanceof BusinessException) {
      // 业务逻辑异常通常是可恢复的
      if (exception.code >= 5000 && exception.code < 6000) {
        return ExceptionCategory.BUSINESS_LOGIC;
      }
      
      // 认证授权异常需要特殊处理
      if (exception.code >= 1000 && exception.code < 3000) {
        return ExceptionCategory.SECURITY;
      }
      
      return ExceptionCategory.RECOVERABLE;
    }

    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      
      // 4xx 错误通常是客户端问题,可恢复
      if (status >= 400 && status < 500) {
        return ExceptionCategory.RECOVERABLE;
      }
      
      // 5xx 错误可能是服务端问题,需要进一步分析
      if (status >= 500) {
        // 数据库连接问题等可能是瞬时的
        if (exception.message.includes('timeout') || 
            exception.message.includes('connection')) {
          return ExceptionCategory.TRANSIENT;
        }
        return ExceptionCategory.FATAL;
      }
    }

    // 未知异常默认为致命异常
    return ExceptionCategory.FATAL;
  }

  shouldRetry(exception: unknown): boolean {
    const category = this.classifyException(exception);
    return category === ExceptionCategory.TRANSIENT;
  }

  shouldNotifyAdmin(exception: unknown): boolean {
    const category = this.classifyException(exception);
    return category === ExceptionCategory.FATAL || 
           category === ExceptionCategory.SECURITY;
  }
}

// 带重试机制的服务示例
@Injectable()
export class RetryableService {
  constructor(
    private readonly exceptionClassificationService: ExceptionClassificationService,
  ) {}

  async executeWithRetry<T>(
    operation: () => Promise<T>,
    maxRetries: number = 3,
    delayMs: number = 1000,
  ): Promise<T> {
    let lastError: unknown;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        return await operation();
      } catch (error) {
        lastError = error;
        
        // 如果是最后一次尝试,抛出异常
        if (attempt === maxRetries) {
          throw error;
        }

        // 检查是否应该重试
        if (!this.exceptionClassificationService.shouldRetry(error)) {
          throw error;
        }

        // 等待后重试
        await new Promise(resolve => setTimeout(resolve, delayMs * attempt));
      }
    }

    throw lastError;
  }
}

创业团队行动清单

  1. 立即行动

    • 建立统一的业务错误码体系
    • 实现全局异常过滤器
    • 添加敏感信息过滤机制
  2. 一周内完成

    • 集成错误监控服务(如 Sentry)
    • 实现异常分类机制
    • 添加异常重试机制
  3. 一月内完善

    • 建立异常处理的监控和告警
    • 实现异常统计和分析功能
    • 完善不同环境下的异常处理策略

总结

异常处理是保障系统稳定性和用户体验的重要环节,正确的实现需要:

  1. 统一错误码体系:建立清晰的业务错误码和HTTP状态码映射
  2. 敏感信息保护:确保异常信息中不包含敏感数据
  3. 监控集成:通过 Sentry 等工具实时监控异常
  4. 异常分类:区分可恢复、瞬时和致命异常,制定不同处理策略

在下一篇文章中,我们将探讨输入校验机制,这是保障系统安全的第一道防线。