ASP.NET Core项目中如何实现不污染实体接口的业务化半自动审计日志,且确保操作全记录?
你提出的这个需求非常贴合实际业务场景——比起冷冰冰的属性变更记录,带有业务语义的审计日志才真正能帮团队快速定位问题、追溯操作。而且你担心的「实体类被审计逻辑污染」「操作记录遗漏」这两个核心痛点,完全可以通过领域事件+中介者模式+审计处理器的组合方案解决,既符合DDD的分层原则,又能保障审计的可靠性。
核心思路:用领域事件解耦业务与审计
核心逻辑是:让实体类只负责完成业务逻辑并发布「操作完成」的事件,审计逻辑完全交给独立的处理器来处理。这样实体类不需要知道任何审计、认证相关的细节,同时只要业务操作被执行,对应的事件就会触发审计,从机制上避免遗漏。
1. 定义领域事件:承载业务操作的语义信息
首先,我们需要为每个业务操作定义对应的领域事件,事件中只包含审计需要的业务核心数据,不需要涉及审计逻辑或用户信息。
第一步:定义领域事件基础接口
public interface IDomainEvent { Guid EventId { get; } DateTime OccurredOn { get; } }
第二步:为具体业务操作定义事件
比如学生选课、考试成绩记录的事件:
// 学生选课事件 public record StudentEnrolledEvent(string StudentId, string CourseId) : IDomainEvent { public Guid EventId => Guid.NewGuid(); public DateTime OccurredOn => DateTime.UtcNow; } // 考试成绩记录事件 public record ExamResultRecordedEvent(string StudentId, string ExamId, string Result, string Grade) : IDomainEvent { public Guid EventId => Guid.NewGuid(); public DateTime OccurredOn => DateTime.UtcNow; }
2. 实体类:只负责业务逻辑与事件发布
实体类的业务方法(Enroll、PassExam等)在完成核心业务逻辑后,仅发布对应的领域事件,完全不涉及审计、用户ID等外部信息,保持领域层的纯粹性。
我们可以给所有实体定义一个基类,统一管理领域事件:
public abstract class EntityBase { private readonly List<IDomainEvent> _domainEvents = new(); public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly(); protected void AddDomainEvent(IDomainEvent domainEvent) { _domainEvents.Add(domainEvent); } public void ClearDomainEvents() { _domainEvents.Clear(); } }
然后是Student实体类:
public class Student : EntityBase { public string Id { get; private set; } public List<Course> EnrolledCourses { get; private set; } = new(); public List<ExamResult> ExamResults { get; private set; } = new(); // 业务方法:选课 public void Enroll(Course course) { // 核心业务逻辑:校验是否已选课、课程是否可报名等 if (EnrolledCourses.Any(c => c.Id == course.Id)) throw new InvalidOperationException("学生已选该课程"); EnrolledCourses.Add(course); // 发布领域事件:通知外部「学生完成选课」 AddDomainEvent(new StudentEnrolledEvent(Id, course.Id)); } // 业务方法:记录考试通过结果 public void PassExam(Exam exam, string grade) { // 核心业务逻辑:校验考试是否对应学生、成绩是否合法等 var examResult = new ExamResult(Id, exam.Id, "Pass", grade); ExamResults.Add(examResult); // 发布领域事件:通知外部「学生考试通过」 AddDomainEvent(new ExamResultRecordedEvent(Id, exam.Id, "Pass", grade)); } // 业务方法:记录考试不及格结果 public void FailExam(Exam exam) { var examResult = new ExamResult(Id, exam.Id, "Fail", null); ExamResults.Add(examResult); AddDomainEvent(new ExamResultRecordedEvent(Id, exam.Id, "Fail", null)); } }
3. 审计处理器:独立处理事件生成审计日志
审计逻辑完全放在基础设施层的处理器中,处理器负责监听领域事件,获取当前用户信息,生成并保存审计日志实体。这里可以依赖IHttpContextAccessor获取当前登录用户ID,或者注入专门的用户信息服务。
第一步:定义审计日志实体
public class StudentAuditTrail { public int Id { get; set; } public string StudentId { get; set; } public string Action { get; set; } public DateTime Timestamp { get; set; } public string UserId { get; set; } public string Details { get; set; } }
第二步:实现事件处理器(以MediatR为例)
我们可以用MediatR这个成熟的中介者库来分发事件和处理审计:
学生选课事件的审计处理器
public class StudentEnrolledAuditHandler : INotificationHandler<StudentEnrolledEvent> { private readonly AppDbContext _dbContext; private readonly ICurrentUserService _currentUserService; public StudentEnrolledAuditHandler(AppDbContext dbContext, ICurrentUserService currentUserService) { _dbContext = dbContext; _currentUserService = currentUserService; } public async Task Handle(StudentEnrolledEvent notification, CancellationToken cancellationToken) { // 获取当前操作的用户ID(从认证上下文或服务中获取) var operaterUserId = _currentUserService.GetCurrentUserId(); // 生成审计日志 var auditLog = new StudentAuditTrail { StudentId = notification.StudentId, Action = "Enrollment", Timestamp = notification.OccurredOn, UserId = operaterUserId, Details = JsonSerializer.Serialize(new { CourseId = notification.CourseId }) }; // 保存审计日志 await _dbContext.StudentAuditTrails.AddAsync(auditLog, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); } }
考试结果事件的审计处理器
public class ExamResultAuditHandler : INotificationHandler<ExamResultRecordedEvent> { private readonly AppDbContext _dbContext; private readonly ICurrentUserService _currentUserService; public ExamResultAuditHandler(AppDbContext dbContext, ICurrentUserService currentUserService) { _dbContext = dbContext; _currentUserService = currentUserService; } public async Task Handle(ExamResultRecordedEvent notification, CancellationToken cancellationToken) { var operaterUserId = _currentUserService.GetCurrentUserId(); var auditLog = new StudentAuditTrail { StudentId = notification.StudentId, Action = "ExamResult", Timestamp = notification.OccurredOn, UserId = operaterUserId, Details = JsonSerializer.Serialize(new { ExamId = notification.ExamId, Result = notification.Result, Grade = notification.Grade }) }; await _dbContext.StudentAuditTrails.AddAsync(auditLog, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); } }
其中,ICurrentUserService是获取当前用户的抽象,实现可以用IHttpContextAccessor:
public interface ICurrentUserService { string GetCurrentUserId(); } public class CurrentUserService : ICurrentUserService { private readonly IHttpContextAccessor _httpContextAccessor; public CurrentUserService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public string GetCurrentUserId() { return _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; } }
4. 确保事件被自动处理:拦截EF Core的SaveChanges
为了避免在应用服务层手动发布事件,我们可以用EF Core的SaveChanges拦截器,在保存更改时自动捕获实体的领域事件并分发给处理器:
public class DomainEventInterceptor : SaveChangesInterceptor { private readonly IMediator _mediator; public DomainEventInterceptor(IMediator mediator) { _mediator = mediator; } public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { var dbContext = eventData.Context; if (dbContext == null) return result; // 捕获所有带有领域事件的实体 var entitiesWithEvents = dbContext.ChangeTracker .Entries<EntityBase>() .Where(e => e.Entity.DomainEvents.Any()) .Select(e => e.Entity); // 分发所有领域事件 foreach (var entity in entitiesWithEvents) { var events = entity.DomainEvents.ToList(); entity.ClearDomainEvents(); foreach (var domainEvent in events) { await _mediator.Publish(domainEvent, cancellationToken); } } return await base.SavingChangesAsync(eventData, result, cancellationToken); } }
然后在Program.cs中注册拦截器:
builder.Services.AddDbContext<AppDbContext>((sp, options) => { options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")); // 添加领域事件拦截器 options.AddInterceptors(sp.GetRequiredService<DomainEventInterceptor>()); }); // 注册MediatR、当前用户服务等 builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddHttpContextAccessor();
方案优势对比
| 你的原有方案 | 本方案的改进 |
|---|---|
| 传递UserId给实体方法 | 实体完全不需要知道用户信息,由处理器从服务获取 |
| 注入服务到实体 | 实体与基础设施层完全解耦,仅依赖领域事件接口 |
| 实体引用Auditor | 实体不依赖任何审计相关服务,单一职责更清晰 |
| 控制器处理审计 | 审计由事件驱动,只要业务操作执行就一定会触发审计,从机制上避免遗漏 |
额外注意事项
- 事务一致性:可以用EF Core的事务范围,确保业务操作和审计日志要么都成功,要么都失败,避免数据不一致。
- 无上下文场景:如果是后台任务等没有HttpContext的场景,可以在应用服务层传入用户ID到事件中,或者修改
ICurrentUserService的实现来适配。 - 代码规范保障:通过单元测试确保实体的业务方法一定会触发对应的领域事件,避免业务逻辑完成但事件未发布的情况。
内容来源于stack exchange

