基于TypeORM与NestJS实现通用审计日志的方案问询
NestJS + TypeORM 通用审计日志实现:在订阅者中获取当前用户ID
针对你在NestJS API中实现审计日志时,TypeORM订阅者无法获取当前请求用户ID的问题,这里提供基于AsyncLocalStorage的通用解决方案,支持增删改所有操作,且无需在实体中添加临时字段。
核心思路
利用Node.js的AsyncLocalStorage(异步本地存储)保存请求上下文,在请求拦截器中将当前用户ID存入上下文,之后在TypeORM的订阅者中直接从上下文提取用户ID,确保所有数据库操作(包括删除)都能关联到操作用户。
步骤1:创建请求上下文存储服务
// src/common/request-context.service.ts import { Injectable } from '@nestjs/common'; import { AsyncLocalStorage } from 'async_hooks'; @Injectable() export class RequestContextService { private readonly als = new AsyncLocalStorage<{ userId: string | 'system' }>(); // 启动上下文并存储用户ID runWithUserId(userId: string | 'system', callback: () => void): void { this.als.run({ userId }, callback); } // 获取当前上下文的用户ID getCurrentUserId(): string | 'system' { return this.als.getStore()?.userId || 'system'; } }
步骤2:实现请求拦截器注入用户ID
创建拦截器,从请求中提取用户ID(需根据你的认证方式调整,示例假设用户信息存在req.user.id),并注入到上下文:
// src/common/auth.interceptor.ts import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { RequestContextService } from './request-context.service'; @Injectable() export class AuthInterceptor implements NestInterceptor { constructor(private readonly requestContextService: RequestContextService) {} intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const userId = request.user?.id || 'system'; return new Observable(observer => { this.requestContextService.runWithUserId(userId, () => { next.handle().subscribe({ next: val => observer.next(val), error: err => observer.error(err), complete: () => observer.complete(), }); }); }); } }
步骤3:修改TypeORM订阅者获取用户ID
更新你的LogEventService,注入上下文服务并在生成日志时获取用户ID:
// src/log/log-event.service.ts import GenericEntity from "@generic/repository/generic.entity"; import { Injectable } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent, RemoveEvent, UpdateEvent } from "typeorm"; import { LogService } from "./log.service"; import { eLogType } from "./repository/log.entity"; import { AppLogger } from "@utils/logger"; import { RequestContextService } from "../common/request-context.service"; interface Events { log: { type: eLogType; userId: string | "system"; tabela: string; values: { id: string; old: Record<string, any>; new: Record<string, any>; }; }; } @EventSubscriber() @Injectable() export class LogEventService implements EntitySubscriberInterface { private tabelasSemLog: string[] = []; constructor( @InjectDataSource() readonly dataSource: DataSource, private readonly logService: LogService, private readonly requestContextService: RequestContextService, ) { dataSource.subscribers.push(this); } private async saveLog(data: Events["log"]) { if (await this.logService.saveLog(data.type, data.userId, data.tabela, data.values)) AppLogger.log(`Log salvo com sucesso para a tabela ${data.tabela}`); else throw new Error("Erro ao salvar log -> " + JSON.stringify(data)); } private async generateLogData(tabela: string, type: eLogType, entity: GenericEntity) { const copy = { ...entity }; const oldValue = copy.previousState; delete copy.previousState; // 从上下文获取当前用户ID const userId = this.requestContextService.getCurrentUserId(); await this.saveLog({ tabela, type, userId, values: { id: entity.id, new: copy, old: oldValue, }, }); } async afterInsert(event: InsertEvent<GenericEntity>): Promise<any> { const tabela = event.metadata.tableName; if (this.tabelasSemLog.includes(tabela)) return; event.entity.id = event.entity.id ?? event.entityId?.toString(); await this.generateLogData(tabela, eLogType.INCLUSAO, event.entity); } async afterUpdate(event: UpdateEvent<GenericEntity>): Promise<any> { const tabela = event.metadata.tableName; if (this.tabelasSemLog.includes(tabela)) return; event.entity.id = event.entity.id; await this.generateLogData(tabela, eLogType.ALTERACAO, event.entity); } async afterRemove(event: RemoveEvent<GenericEntity>): Promise<any> { const tabela = event.metadata.tableName; if (this.tabelasSemLog.includes(tabela)) return; event.entity.id = event.entity.id ?? event.entityId?.toString(); await this.generateLogData(tabela, eLogType.EXCLUSAO, event.entity); } }
步骤4:模块配置
在根模块中注册上下文服务和全局拦截器:
// src/app.module.ts import { Module } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { RequestContextService } from './common/request-context.service'; import { AuthInterceptor } from './common/auth.interceptor'; @Module({ providers: [ RequestContextService, { provide: APP_INTERCEPTOR, useClass: AuthInterceptor, }, ], }) export class AppModule {}
关键说明
- 全操作支持:新增、更新、删除操作均能获取用户ID,解决你之前删除操作无法关联用户的问题。
- 无实体侵入:不需要修改实体类添加临时字段,保持实体结构纯净。
- 上下文隔离:
AsyncLocalStorage自动隔离不同请求的上下文,不会出现用户ID串号问题。 - 系统操作兼容:无用户ID时(如定时任务、系统初始化),默认使用
system作为操作标识。
内容的提问来源于stack exchange,提问作者Lucas Gardini Dias
相关产品推荐
相关产品推荐

