第 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;
}
}创业团队行动清单
立即行动:
- 建立统一的业务错误码体系
- 实现全局异常过滤器
- 添加敏感信息过滤机制
一周内完成:
- 集成错误监控服务(如 Sentry)
- 实现异常分类机制
- 添加异常重试机制
一月内完善:
- 建立异常处理的监控和告警
- 实现异常统计和分析功能
- 完善不同环境下的异常处理策略
总结
异常处理是保障系统稳定性和用户体验的重要环节,正确的实现需要:
- 统一错误码体系:建立清晰的业务错误码和HTTP状态码映射
- 敏感信息保护:确保异常信息中不包含敏感数据
- 监控集成:通过 Sentry 等工具实时监控异常
- 异常分类:区分可恢复、瞬时和致命异常,制定不同处理策略
在下一篇文章中,我们将探讨输入校验机制,这是保障系统安全的第一道防线。