diff --git a/src/core/utils/logger/logger.module.ts b/src/core/utils/logger/logger.module.ts new file mode 100644 index 0000000..3dc5acd --- /dev/null +++ b/src/core/utils/logger/logger.module.ts @@ -0,0 +1,81 @@ +/** + * 日志模块 + * + * 功能描述: + * - 配置和提供全局日志服务 + * - 集成 Pino 高性能日志库 + * - 支持不同环境的日志配置 + * - 提供统一的日志记录接口 + * + * 依赖模块: + * - ConfigModule: 环境配置模块 + * - PinoLoggerModule: Pino 日志模块 + * - AppLoggerService: 应用日志服务 + * + * @author 开发团队 + * @version 1.0.0 + * @since 2024-12-13 + */ + +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino'; +import { AppLoggerService } from './logger.service'; + +/** + * 日志模块类 + * + * 职责: + * - 配置 Pino 日志库的各种选项 + * - 根据环境变量调整日志输出格式和级别 + * - 提供全局可用的日志服务 + * - 管理日志相关的依赖注入 + * + * 配置说明: + * - 开发环境:使用 pino-pretty 美化输出,日志级别为 debug + * - 生产环境:使用 JSON 格式输出,日志级别为 info + * - 自动过滤请求和响应中的敏感信息 + * + * 使用场景: + * - 在 AppModule 中导入,提供全局日志服务 + * - 在其他模块中注入 AppLoggerService 使用 + */ +@Module({ + imports: [ + ConfigModule, + PinoLoggerModule.forRootAsync({ + imports: [ConfigModule], + useFactory: () => ({ + pinoHttp: { + // 根据环境设置日志级别 + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + + // 开发环境使用美化输出 + transport: process.env.NODE_ENV !== 'production' ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + } : undefined, + + // 自定义序列化器,过滤敏感信息 + serializers: { + req: (req) => ({ + method: req.method, + url: req.url, + headers: req.headers, + }), + res: (res) => ({ + statusCode: res.statusCode, + }), + }, + }, + }), + }), + ], + providers: [AppLoggerService], + exports: [AppLoggerService], +}) +export class LoggerModule {} diff --git a/src/core/utils/logger/logger.service.spec.ts b/src/core/utils/logger/logger.service.spec.ts new file mode 100644 index 0000000..5851548 --- /dev/null +++ b/src/core/utils/logger/logger.service.spec.ts @@ -0,0 +1,132 @@ +/** + * 应用日志服务测试 + * + * 功能描述: + * - 测试日志服务的核心功能 + * - 验证不同日志级别的正确性 + * - 测试敏感信息过滤功能 + * - 验证请求上下文绑定功能 + * + * 测试覆盖: + * - 服务实例化 + * - 日志方法调用 + * - 敏感数据过滤 + * - 请求上下文绑定 + * + * @author moyin + * @version 1.0.0 + * @since 2024-12-13 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { AppLoggerService } from './logger.service'; + +describe('AppLoggerService', () => { + let service: AppLoggerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AppLoggerService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => { + const config: Record = { + NODE_ENV: 'test', + APP_NAME: 'test-app', + }; + return config[key] || defaultValue; + }), + }, + }, + ], + }).compile(); + + service = module.get(AppLoggerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + /** + * 测试信息日志记录功能 + * + * 验证点: + * - info 方法能够正确调用内部 log 方法 + * - 传递的参数格式正确 + * - 日志级别设置正确 + */ + it('should log info messages', () => { + // 监听内部 log 方法调用 + const logSpy = jest.spyOn(service as any, 'log').mockImplementation(); + + // 调用 info 方法 + service.info('Test message', { module: 'TestModule' }); + + // 验证调用参数 + expect(logSpy).toHaveBeenCalledWith('info', { + message: 'Test message', + context: { module: 'TestModule' } + }); + + logSpy.mockRestore(); + }); + + /** + * 测试敏感信息过滤功能 + * + * 验证点: + * - 敏感信息过滤方法被正确调用 + * - 包含敏感字段的日志会触发过滤逻辑 + * - 过滤功能不影响正常的日志记录流程 + */ + it('should filter sensitive data', () => { + // 监听敏感信息过滤方法 + const redactSpy = jest.spyOn(service as any, 'redactSensitiveData'); + + // 记录包含敏感信息的日志 + service.info('Login attempt', { + module: 'AuthModule', + password: 'secret123', + token: 'jwt-token' + }); + + // 验证过滤方法被调用 + expect(redactSpy).toHaveBeenCalled(); + + redactSpy.mockRestore(); + }); + + /** + * 测试请求上下文绑定功能 + * + * 验证点: + * - bindRequest 方法返回正确的日志方法对象 + * - 返回的对象包含所有必要的日志方法 + * - 绑定的上下文信息能够正确传递 + */ + it('should bind request context', () => { + // 模拟 HTTP 请求对象 + const mockReq = { + id: 'req-123', + headers: { + 'x-user-id': 'user-456' + }, + ip: '127.0.0.1' + }; + + // 绑定请求上下文 + const boundLogger = service.bindRequest(mockReq, 'TestController'); + + // 验证返回的日志方法对象 + expect(boundLogger).toHaveProperty('info'); + expect(boundLogger).toHaveProperty('error'); + expect(boundLogger).toHaveProperty('warn'); + expect(boundLogger).toHaveProperty('debug'); + expect(boundLogger).toHaveProperty('fatal'); + expect(boundLogger).toHaveProperty('trace'); + }); +}); diff --git a/src/core/utils/logger/logger.service.ts b/src/core/utils/logger/logger.service.ts new file mode 100644 index 0000000..ac59b42 --- /dev/null +++ b/src/core/utils/logger/logger.service.ts @@ -0,0 +1,487 @@ +/** + * 日志系统模块 + * + * 功能描述: + * - 提供统一的日志记录服务,支持多种日志级别 + * - 集成 Pino 高性能日志库,支持降级到 NestJS 内置 Logger + * - 自动过滤敏感信息,保护系统安全 + * - 支持请求上下文绑定,便于链路追踪 + * + * 依赖模块: + * - ConfigService: 环境配置服务 + * - PinoLogger: 高性能日志库(可选) + * - Logger: NestJS 内置日志服务(降级使用) + * + * @author moyin + * @version 1.0.0 + * @since 2024-12-13 + */ + +import { Injectable, Logger, Inject, Optional } from '@nestjs/common'; +import { PinoLogger } from 'nestjs-pino'; +import { ConfigService } from '@nestjs/config'; + +/** + * 日志级别枚举 + * + * 级别说明: + * - trace: 极细粒度调试信息 + * - debug: 调试信息,开发环境使用 + * - info: 重要业务操作记录 + * - warn: 警告信息,需要关注但不影响正常流程 + * - error: 错误信息,影响功能正常使用 + * - fatal: 致命错误,可能导致系统不可用 + */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'trace'; + +/** + * 日志上下文接口 + * + * 用于补充日志的上下文信息,便于问题排查和链路追踪 + */ +export interface LogContext { + /** 请求 ID,用于链路追踪 */ + reqId?: string; + /** 模块名称,标识日志来源 */ + module?: string; + /** 用户 ID,关联用户行为 */ + userId?: string; + /** 操作类型,描述具体操作 */ + operation?: string; + /** 时间戳,记录操作时间 */ + timestamp?: string; + /** 执行时长,性能监控 */ + duration?: number; + /** 自定义扩展字段 */ + [key: string]: any; +} + +/** + * 日志选项接口 + * + * 定义日志记录时的参数结构 + */ +export interface LogOptions { + /** 日志消息内容 */ + message: string; + /** 日志上下文信息 */ + context?: LogContext; + /** 错误堆栈信息(仅用于 error/fatal 级别) */ + stack?: string; +} + +/** + * 应用日志服务类 + * + * 职责: + * - 提供统一的日志记录接口 + * - 管理不同环境下的日志级别控制 + * - 自动过滤敏感信息,防止数据泄露 + * - 支持请求上下文绑定,便于问题追踪 + * + * 主要方法: + * - debug(): 记录调试信息 + * - info(): 记录重要业务操作 + * - warn(): 记录警告信息 + * - error(): 记录错误信息 + * - fatal(): 记录致命错误 + * - trace(): 记录追踪信息 + * - bindRequest(): 绑定请求上下文 + * + * 使用场景: + * - 业务操作日志记录 + * - 系统异常监控 + * - 性能监控和问题排查 + * - 安全审计和行为追踪 + */ +@Injectable() +export class AppLoggerService { + // 底层日志实例(优先 Pino,无则用内置 Logger) + private readonly logger: PinoLogger | Logger; + // 日志级别开关(生产环境可动态调整) + private readonly enableLevels: Record; + + constructor( + // 注入 Pino Logger(可选,无则降级到内置 Logger) + @Optional() @Inject(PinoLogger) private readonly pinoLogger: PinoLogger, + private readonly configService: ConfigService, + ) { + // 初始化底层日志实例 + this.logger = this.pinoLogger || new Logger('AppLogger'); + + // 从环境变量读取启用的日志级别(默认开发环境全开启,生产环境仅开启 warn/error/fatal) + const env = this.configService.get('NODE_ENV', 'development'); + this.enableLevels = { + trace: env === 'development', + debug: env === 'development', + info: env !== 'production', + warn: true, + error: true, + fatal: true, + }; + } + + /** + * 通用日志记录方法 + * + * 功能描述: + * 封装所有日志级别的核心记录逻辑,统一处理日志格式化、上下文补充和敏感信息过滤 + * + * 业务逻辑: + * 1. 检查日志级别是否启用 + * 2. 补充默认上下文信息 + * 3. 合并自定义上下文 + * 4. 过滤敏感信息 + * 5. 构造标准日志数据 + * 6. 根据底层日志实例类型选择合适的输出方式 + * + * @param level 日志级别,决定日志的重要程度和输出策略 + * @param options 日志选项,包含消息内容、上下文信息等 + * @private + */ + private log(level: LogLevel, options: LogOptions): void { + // 过滤禁用的日志级别(生产环境不输出 debug/trace) + if (!this.enableLevels[level]) return; + + // 1. 补充默认上下文 + const defaultContext: LogContext = { + module: options.context?.module || 'Unknown', + reqId: options.context?.reqId || 'no-req-id', + userId: options.context?.userId || 'anonymous', + timestamp: new Date().toISOString(), + app: this.configService.get('APP_NAME', 'nest-app'), + }; + + // 2. 合并上下文(自定义上下文覆盖默认) + const context = { ...defaultContext, ...options.context }; + + // 3. 敏感信息过滤(避免日志泄露密码/Token) + this.redactSensitiveData(context); + + // 4. 构造日志数据 + const logData = { + message: options.message, + context, + ...(options.stack ? { stack: options.stack } : {}), // 仅错误级别携带栈信息 + }; + + // 5. 适配 Pino/内置 Logger 的调用方式 + if (this.pinoLogger) { + // Pino 调用方式:直接使用 pinoLogger 实例 + switch (level) { + case 'debug': + this.pinoLogger.debug(logData.message, logData); + break; + case 'info': + this.pinoLogger.info(logData.message, logData); + break; + case 'warn': + this.pinoLogger.warn(logData.message, logData); + break; + case 'error': + this.pinoLogger.error(logData.message, logData); + break; + case 'fatal': + this.pinoLogger.fatal(logData.message, logData); + break; + case 'trace': + this.pinoLogger.trace(logData.message, logData); + break; + default: + this.pinoLogger.info(logData.message, logData); + } + } else { + // 内置 Logger 降级调用:根据级别调用对应方法 + const builtInLogger = this.logger as Logger; + const contextString = JSON.stringify(logData.context); + + switch (level) { + case 'debug': + builtInLogger.debug(logData.message, contextString); + break; + case 'info': + builtInLogger.log(logData.message, contextString); // 内置 Logger 使用 log 方法代替 info + break; + case 'warn': + builtInLogger.warn(logData.message, contextString); + break; + case 'error': + case 'fatal': // fatal 级别降级为 error + builtInLogger.error(logData.message, options.stack || '', contextString); + break; + case 'trace': + builtInLogger.verbose(logData.message, contextString); // trace 级别降级为 verbose + break; + default: + builtInLogger.log(logData.message, contextString); + } + } + } + + /** + * 敏感信息过滤方法 + * + * 功能描述: + * 递归扫描日志数据中的敏感字段,将其替换为占位符,防止敏感信息泄露 + * + * 业务逻辑: + * 1. 定义敏感字段关键词列表 + * 2. 遍历数据对象的所有键 + * 3. 检查键名是否包含敏感关键词 + * 4. 将敏感字段值替换为 [REDACTED] + * 5. 递归处理嵌套对象 + * + * @param data 需要过滤的日志数据对象 + * @private + */ + private redactSensitiveData(data: Record): void { + const sensitiveKeys = ['password', 'token', 'secret', 'authorization', 'cardNo']; + Object.keys(data).forEach((key) => { + if (sensitiveKeys.some((sk) => key.toLowerCase().includes(sk))) { + data[key] = '[REDACTED]'; // 替换为占位符,或直接删除 + } + // 递归过滤嵌套对象 + if (typeof data[key] === 'object' && data[key] !== null) { + this.redactSensitiveData(data[key]); + } + }); + } + + // ========== 公共日志记录方法 ========== + + /** + * 记录调试日志 + * + * 功能描述: + * 记录详细的调试信息,主要用于开发环境的问题排查 + * + * 使用场景: + * - 方法调用参数记录 + * - 中间计算结果输出 + * - 详细的执行流程追踪 + * + * 注意事项: + * - 仅在开发环境启用 + * - 生产环境自动禁用以提高性能 + * + * @param message 调试消息内容 + * @param context 调试上下文信息 + * + * @example + * ```typescript + * this.logger.debug('开始处理用户请求', { + * module: 'UserService', + * operation: 'getUserInfo', + * userId: 'user123', + * params: { includeProfile: true } + * }); + * ``` + */ + debug(message: string, context?: LogContext): void { + this.log('debug', { message, context }); + } + + /** + * 记录信息日志 + * + * 功能描述: + * 记录重要的业务操作和系统状态变更,用于业务监控和审计 + * + * 使用场景: + * - 用户登录成功 + * - 重要业务操作完成 + * - 系统状态变更 + * - 关键流程节点记录 + * + * @param message 信息消息内容 + * @param context 操作上下文信息 + * + * @example + * ```typescript + * this.logger.info('用户登录成功', { + * module: 'AuthService', + * operation: 'userLogin', + * userId: 'user123', + * timestamp: new Date().toISOString() + * }); + * ``` + */ + info(message: string, context?: LogContext): void { + this.log('info', { message, context }); + } + + /** + * 记录警告日志 + * + * 功能描述: + * 记录需要关注但不影响正常业务流程的警告信息 + * + * 使用场景: + * - 参数验证失败 + * - 权限检查失败 + * - 资源不存在 + * - 业务规则违反 + * - 性能指标异常 + * + * @param message 警告消息内容 + * @param context 警告上下文信息 + * + * @example + * ```typescript + * this.logger.warn('用户尝试访问不存在的资源', { + * module: 'ResourceService', + * operation: 'getResource', + * userId: 'user123', + * resourceId: 'res456', + * reason: 'resource_not_found' + * }); + * ``` + */ + warn(message: string, context?: LogContext): void { + this.log('warn', { message, context }); + } + + /** + * 记录错误日志 + * + * 功能描述: + * 记录影响业务功能正常使用的错误信息,包含详细的错误上下文和堆栈信息 + * + * 使用场景: + * - 业务逻辑异常 + * - 数据库操作失败 + * - 第三方服务调用失败 + * - 系统内部错误 + * + * @param message 错误消息内容 + * @param context 错误上下文信息 + * @param stack 错误堆栈信息,用于问题定位 + * + * @example + * ```typescript + * this.logger.error('数据库连接失败', { + * module: 'DatabaseService', + * operation: 'connect', + * error: error.message, + * timestamp: new Date().toISOString() + * }, error.stack); + * ``` + */ + error(message: string, context?: LogContext, stack?: string): void { + this.log('error', { message, context, stack }); + } + + /** + * 记录致命错误日志 + * + * 功能描述: + * 记录可能导致系统不可用的严重错误,需要立即处理 + * + * 使用场景: + * - 数据库完全不可用 + * - 关键服务宕机 + * - 系统资源耗尽 + * - 安全漏洞被利用 + * + * @param message 致命错误消息内容 + * @param context 错误上下文信息 + * @param stack 错误堆栈信息 + * + * @example + * ```typescript + * this.logger.fatal('数据库连接池耗尽', { + * module: 'DatabaseService', + * operation: 'getConnection', + * activeConnections: 100, + * maxConnections: 100, + * timestamp: new Date().toISOString() + * }, error.stack); + * ``` + */ + fatal(message: string, context?: LogContext, stack?: string): void { + this.log('fatal', { message, context, stack }); + } + + /** + * 记录追踪日志 + * + * 功能描述: + * 记录极细粒度的执行追踪信息,用于深度调试和性能分析 + * + * 使用场景: + * - 循环内的变量状态 + * - 算法执行步骤 + * - 性能关键路径追踪 + * - 复杂业务逻辑的详细执行流程 + * + * 注意事项: + * - 仅在开发环境启用 + * - 会产生大量日志,谨慎使用 + * + * @param message 追踪消息内容 + * @param context 追踪上下文信息 + * + * @example + * ```typescript + * this.logger.trace('处理数组元素', { + * module: 'DataProcessor', + * operation: 'processArray', + * currentIndex: i, + * elementValue: array[i], + * totalElements: array.length + * }); + * ``` + */ + trace(message: string, context?: LogContext): void { + this.log('trace', { message, context }); + } + + // ========== 便捷方法 ========== + + /** + * 绑定请求上下文的日志记录器 + * + * 功能描述: + * 创建一个绑定了特定请求上下文的日志记录器,自动携带请求相关信息 + * + * 业务逻辑: + * 1. 从请求对象中提取关键信息 + * 2. 构建基础上下文对象 + * 3. 返回包装后的日志方法集合 + * 4. 每次调用时自动合并基础上下文和额外上下文 + * + * 使用场景: + * - HTTP 请求处理过程中的日志记录 + * - WebSocket 连接的日志追踪 + * - 需要关联用户行为的业务操作 + * + * @param req HTTP 请求对象或类似的上下文对象 + * @param module 模块名称,标识日志来源 + * @returns 绑定了请求上下文的日志方法对象 + * + * @example + * ```typescript + * // 在 Controller 中使用 + * const requestLogger = this.logger.bindRequest(req, 'UserController'); + * requestLogger.info('开始处理用户请求', { action: 'getUserProfile' }); + * requestLogger.error('处理失败', error.stack, { reason: 'database_error' }); + * ``` + */ + bindRequest(req: any, module: string) { + const baseContext: LogContext = { + reqId: req.id || req.headers['x-request-id'], + userId: req.headers['x-user-id'] || 'anonymous', + ip: req.ip, + module, + }; + + return { + debug: (message: string, extra?: LogContext) => this.debug(message, { ...baseContext, ...extra }), + info: (message: string, extra?: LogContext) => this.info(message, { ...baseContext, ...extra }), + warn: (message: string, extra?: LogContext) => this.warn(message, { ...baseContext, ...extra }), + error: (message: string, stack?: string, extra?: LogContext) => this.error(message, { ...baseContext, ...extra }, stack), + fatal: (message: string, stack?: string, extra?: LogContext) => this.fatal(message, { ...baseContext, ...extra }, stack), + trace: (message: string, extra?: LogContext) => this.trace(message, { ...baseContext, ...extra }), + }; + } +} \ No newline at end of file