Aegis.Security.Audit - 审计日志模块

📋 概述

Aegis.Security.Audit 是 Aegis 框架中的安全审计组件,用于记录系统操作日志和行为追踪。它提供了灵活、易用的审计日志记录系统,支持链式调用、预设操作者、同步/异步操作等特性。该组件适用于需要合规审计、操作追溯、安全监控的场景,如医疗系统、金融系统、企业管理系统等。

主要特性

  • 基于枚举的类型安全设计
  • 链式调用的流畅API
  • 预设操作者简化代码
  • 支持数据变更追踪
  • 可扩展的存储抽象
  • 集成依赖注入

支持版本

  • NuGet 包:Aegis.Security.Audit (版本 >= 1.0.0)
  • .NET 框架:.NET 8.0 或更高

📦 如何引入

1. Component.deps.json 配置(推荐)

在项目的 Component.deps.json 文件中添加依赖Security.Audit

{
  "Components": {
    "Services": [ 
      "Logging", 
      "Swagger", 
      "IdGenerator.SnowflakeId", 
      "BusinessServices", 
      "Security.Audit"
    ],
    "Middlewares": [ "Swagger" ]
  }
}

组件将自动注册 AuditLogger<> 到依赖注入容器中。

2. NuGet 安装

使用 NuGet 包管理器安装:

Install-Package Aegis.Security.Audit

或使用 .NET CLI:

dotnet add package Aegis.Security.Audit

🎯 使用特性

  • 链式调用:流畅的API设计,代码简洁优雅
  • 预设操作者:适合在Service类构造函数中预设,简化后续调用
  • 同步/异步:同时支持 Log()LogAsync()
  • 可选数据变更SetChangeData 可选,灵活记录数据变化
  • 泛型支持AuditLogger<TService> 用于Service级别的类型区分
  • 类型安全:通过接口链强制正确的调用顺序
  • 操作资源标识:支持记录操作的资源唯一标识

📊 审计记录结构

ID : 1
操作时间: xxxx-xx-xx 12:00:00

操作人ID : UserId_1
操作人 : 张三
操作人类型 : 医生 (自定义枚举)

操作领域 : 结算 (自定义枚举)
操作子域 : 结算单审核 (自定义枚举,可选)

操作类型 : 创建 (自定义枚举)
操作主题 : 创建结算单
操作正文 : 创建结算单XXX1100010
操作资源唯一标识 : SettlementId_123 (可选)

变更字段: 结算单金额 (可选)
变更前数据: {"Amount": 100} (JSON,可选)
变更后数据: {"Amount": 200} (JSON,可选)

🚀 如何使用

定义枚举类型

为了确保审计数据的规范性,首先定义项目特定的枚举类型:

/// <summary>
/// 操作者类型
/// </summary>
public enum OperatorType
{
    医生 = 1,
    护士 = 2,
    管理员 = 3,
    系统 = 4
}

/// <summary>
/// 业务领域
/// </summary>
public enum Domain
{
    结算单 = 1,
    患者管理 = 2,
    药品管理 = 3,
    检查检验 = 4
}

/// <summary>
/// 子领域
/// </summary>
public enum SubDomain
{
    结算单基本信息 = 1,
    结算单明细 = 2,
    结算单审核 = 3,
    患者基本信息 = 4,
    患者就诊记录 = 5
}

/// <summary>
/// 操作类型
/// </summary>
public enum OperationType
{
    创建 = 1,
    更新 = 2,
    删除 = 3,
    查询 = 4,
    导出 = 5,
    审核 = 6,
    作废 = 7
}
  • 说明:使用枚举来定义审计相关的类型,确保只有预定义的值可选,这样代码更安全,避免出现无效的审计记录。比如在医疗系统中,操作者类型只能是医生、护士、管理员或系统,防止了"临时工"这样的非法值出现。

实现存储接口

实现 IAuditStorage 接口以自定义存储逻辑:

public class AuditDatabaseStorage : IAuditStorage
{
    private readonly IFreeSql _freeSql;

    public AuditDatabaseStorage(IFreeSql freeSql)
    {
        _freeSql = freeSql;
    }

    public void Create(AuditRecord auditRecord)
    {
        _freeSql.Insert(auditRecord).ExecuteAffrows();
    }

    public async Task CreateAsync(AuditRecord auditRecord)
    {
        await _freeSql.Insert(auditRecord).ExecuteAffrowsAsync();
    }
}
  • 说明:存储接口就像一个保险箱,负责把审计记录保存到数据库或其他存储中。你可以实现数据库存储、文件存储、消息队列等多种方式。场景:在医疗系统中,审计记录必须持久化到数据库,确保即使系统重启,操作记录也不会丢失,满足合规要求。

基本使用(异步)

// 完整链式调用
await auditLogger
    .SetOperator(CurrentUser.Value.Id, OperatorType.医生, "张三")
    .SetDomain(Domain.结算单)
    .SetContent(OperationType.创建, "创建结算单", "创建结算单XXX1100010")
    .LogAsync();
  • 说明:这是最基本的使用方式,通过链式调用完成审计记录。就像填写一张表单,依次填入操作者、领域、操作内容,最后提交。场景:用户创建结算单后,系统自动记录"谁、在什么领域、做了什么操作"。

基本使用(同步)

auditLogger
    .SetOperator(CurrentUser.Value.Id, OperatorType.医生, "张三")
    .SetDomain(Domain.结算单)
    .SetContent(OperationType.创建, "创建结算单", "创建结算单XXX1100010")
    .Log();
  • 说明:同步方式适用于非高并发场景,代码更简洁。但在高并发场景下建议使用异步方式避免阻塞。

带子领域的审计

await auditLogger
    .SetOperator(CurrentUser.Value.Id, OperatorType.医生, "张三")
    .SetDomain(Domain.结算单, SubDomain.结算单审核)
    .SetContent(OperationType.审核, "审核结算单", "审核通过结算单XXX1100010")
    .LogAsync();
  • 说明:子领域提供更细粒度的审计分类。比如"结算单"是大领域,下面可以细分为"结算单审核"、"结算单明细"等子领域,方便后续统计和查询。场景:财务人员审核结算单时,记录到"结算单审核"子领域,与普通的结算单操作区分开来。

带操作资源标识的审计

await auditLogger
    .SetOperator(CurrentUser.Value.Id, OperatorType.医生, "张三")
    .SetDomain(Domain.结算单)
    .SetContent(OperationType.更新, "SETTLE_20231119_001", "更新结算单", "修改结算单金额")
    .LogAsync();
  • 说明operationResourceId 用于记录操作的具体资源唯一标识,比如结算单ID、患者ID等。这样可以精确定位到具体哪条记录被操作了。场景:审计系统需要追溯某个结算单的所有操作历史,通过资源ID可以快速查询到该结算单的所有审计记录。

带数据变更的审计

// SetChangeData 是可选的
await auditLogger
    .SetOperator(CurrentUser.Value.Id, OperatorType.医生, "张三")
    .SetDomain(Domain.结算单)
    .SetContent(OperationType.更新, "更新结算单", "更新结算单XXX1100010")
    .SetChangeData("结算单金额,状态", newData, oldData)
    .LogAsync();
  • 说明SetChangeData 是可选的,用于记录数据修改前后的状态。就像拍照对比,记录"改了什么、改之前什么样、改之后什么样"。场景:修改结算单金额时,记录旧金额100元和新金额200元,方便后续审计追溯。

使用预设操作者(推荐用于Service)

// 在Service构造函数中预设操作者
auditLogger.PreSetOperator(CurrentUser.Value.Id, OperatorType.医生, "张三");

// 之后的调用可以省略 SetOperator,直接使用 SetDomain
await auditLogger
    .SetDomain(Domain.结算单)
    .SetContent(OperationType.创建, "创建结算单", "创建结算单XXX")
    .LogAsync();
  • 说明PreSetOperator 就像门禁卡,在进门(构造函数)时刷一次,后面就不用重复刷了。这样在Service中的每个方法都自动使用预设的操作者,避免重复代码。场景:在SettlementService中,所有操作都是当前登录用户执行的,只需在构造函数中预设一次。

在Service类中使用(推荐模式)

public class SettlementService
{
    private readonly AuditLogger<SettlementService> _auditLogger;

    public SettlementService(AuditLogger<SettlementService> auditLogger)
    {
        _auditLogger = auditLogger;

        // 在构造函数中预设操作者
        _auditLogger.PreSetOperator(
            CurrentUser.Value.Id, 
            OperatorType.医生, 
            CurrentUser.Value.Name
        );
    }

    public async Task CreateSettlement(Settlement settlement)
    {
        // 业务逻辑
        await _repository.CreateAsync(settlement);

        // 审计日志(自动使用预设的操作者,带资源ID)
        await _auditLogger
            .SetDomain(Domain.结算单)
            .SetContent(OperationType.创建, settlement.Id, "创建结算单", $"创建结算单{settlement.Code}")
            .LogAsync();
    }

    public async Task UpdateSettlement(Settlement oldData, Settlement newData)
    {
        // 业务逻辑
        await _repository.UpdateAsync(newData);

        // 审计日志(带数据变更和资源ID)
        await _auditLogger
            .SetDomain(Domain.结算单)
            .SetContent(OperationType.更新, newData.Id, "更新结算单", $"更新结算单{newData.Code}")
            .SetChangeData("金额,状态", newData, oldData)
            .LogAsync();
    }

    public async Task ApproveSettlement(string settlementId, string settlementCode)
    {
        // 业务逻辑
        await _repository.ApproveAsync(settlementId);

        // 审计日志(带子领域和资源ID)
        await _auditLogger
            .SetDomain(Domain.结算单, SubDomain.结算单审核)
            .SetContent(OperationType.审核, settlementId, "审核结算单", $"审核通过结算单{settlementCode}")
            .LogAsync();
    }
}
  • 说明:这是推荐的使用模式,在Service构造函数中预设操作者,业务方法中只需关注业务逻辑和审计内容。就像工厂的装配线,每个工位(方法)只做自己的事,操作者信息已经预设好了。完整流程:系统接收到创建结算单请求,Service方法执行业务逻辑,然后调用审计记录器,内部自动使用预设的操作者信息完成记录。

📚 API 文档

AuditLogger

泛型审计日志记录器,TService 用于标识使用该审计记录器的服务类型。

方法

PreSetOperator

void PreSetOperator(string operatorId, Enum operatorType, string operatorName)

在Service构造函数中预设操作者信息,后续调用可省略 SetOperator

SetOperator

IAuditDomainBuilder SetOperator(string operatorId, Enum operatorType, string operatorName)

设置操作者信息并开始构建审计记录。

SetDomain

IAuditContentBuilder SetDomain(Enum domain, Enum subDomain = null)

使用预设的操作者信息开始构建(需先调用 PreSetOperator)。

Log / LogAsync

AuditRecord Log(AuditRecord record)
Task<AuditRecord> LogAsync(AuditRecord record)

直接记录审计日志对象。


IAuditDomainBuilder

设置领域信息的构建器接口。

IAuditContentBuilder SetDomain(Enum domain, Enum subDomain = null);
  • domain: 业务领域(必填)
  • subDomain: 子领域(可选)

IAuditContentBuilder

设置操作内容的构建器接口。

方法1:基本设置

IAuditDataBuilder SetContent(Enum operationType, string subject, string content);
  • operationType: 操作类型(必填)
  • subject: 操作主题(必填)
  • content: 操作正文(可选)

方法2:带资源标识

IAuditDataBuilder SetContent(Enum operationType, string operationResourceId, string subject, string content);
  • operationType: 操作类型(必填)
  • operationResourceId: 操作资源唯一标识(必填)
  • subject: 操作主题(必填)
  • content: 操作正文(可选)

注意

  1. 操作类型 operationType 从原来在 SetDomain 中设置改为在 SetContent 中设置,使得API更加语义化。
  2. 如果需要记录具体操作的资源ID(如结算单ID、患者ID),使用带 operationResourceId 的重载方法。

IAuditDataBuilder

设置数据变更和完成记录的构建器接口。

IAuditDataBuilder SetChangeData<T>(string changedFields, T newData, T oldData = default);
AuditRecord Log();
Task<AuditRecord> LogAsync();
  • SetChangeData: 可选方法,记录数据变更
  • Log: 同步记录日志
  • LogAsync: 异步记录日志

🔄 审计记录流程

审计日志记录器在执行时遵循以下步骤,确保记录完整可靠:

  1. 设置操作者:确定是谁执行的操作(用户ID、类型、姓名)。如果使用预设模式,此步骤在构造函数中完成。

  2. 设置领域:确定操作发生在哪个业务领域(如结算单、患者管理),可选设置子领域以更精确分类。

  3. 设置操作内容:记录操作类型(创建/更新/删除等)、操作主题和详细内容。这是审计的核心信息。

  4. 设置数据变更(可选):如果是更新操作,记录变更的字段、修改前后的数据。此步骤对于数据追溯很重要。

  5. 持久化记录:调用 Log()LogAsync() 将审计记录保存到存储中(数据库/文件等)。如果保存失败,应抛出异常。

这个流程就像填写一份标准化的操作日志表,确保每次操作都被完整记录,方便后续审计和追溯。


📖 完整示例

示例1:完整的CRUD审计

public class PatientService
{
    private readonly AuditLogger<PatientService> _auditLogger;
    private readonly IPatientRepository _repository;

    public PatientService(
        AuditLogger<PatientService> auditLogger,
        IPatientRepository repository)
    {
        _auditLogger = auditLogger;
        _repository = repository;

        // 预设操作者
        _auditLogger.PreSetOperator(
            CurrentUser.Value.Id,
            OperatorType.医生,
            CurrentUser.Value.Name
        );
    }

    // 创建
    public async Task<Patient> CreateAsync(Patient patient)
    {
        var result = await _repository.CreateAsync(patient);

        await _auditLogger
            .SetDomain(Domain.患者管理, SubDomain.患者基本信息)
            .SetContent(OperationType.创建, result.Id, "创建患者", $"创建患者:{patient.Name}")
            .LogAsync();

        return result;
    }

    // 更新
    public async Task UpdateAsync(Patient oldPatient, Patient newPatient)
    {
        await _repository.UpdateAsync(newPatient);

        await _auditLogger
            .SetDomain(Domain.患者管理, SubDomain.患者基本信息)
            .SetContent(OperationType.更新, newPatient.Id, "更新患者", $"更新患者:{newPatient.Name}")
            .SetChangeData("姓名,年龄,性别", newPatient, oldPatient)
            .LogAsync();
    }

    // 删除
    public async Task DeleteAsync(string patientId, string patientName)
    {
        await _repository.DeleteAsync(patientId);

        await _auditLogger
            .SetDomain(Domain.患者管理, SubDomain.患者基本信息)
            .SetContent(OperationType.删除, patientId, "删除患者", $"删除患者:{patientName}")
            .LogAsync();
    }

    // 导出
    public async Task<byte[]> ExportAsync(List<string> patientIds)
    {
        var data = await _repository.ExportAsync(patientIds);

        await _auditLogger
            .SetDomain(Domain.患者管理)
            .SetContent(OperationType.导出, "导出患者数据", $"导出{patientIds.Count}条患者数据")
            .LogAsync();

        return data;
    }
}

示例2:不使用预设操作者

public class AuditController : ControllerBase
{
    private readonly AuditLogger<AuditController> _auditLogger;

    [HttpPost("manual-audit")]
    public async Task<IActionResult> ManualAudit([FromBody] AuditRequest request)
    {
        // 每次调用都指定操作者
        await _auditLogger
            .SetOperator(request.UserId, OperatorType.管理员, request.UserName)
            .SetDomain(Domain.系统配置)
            .SetContent(OperationType.更新, "手动审计", request.Content)
            .LogAsync();

        return Ok();
    }
}

💡 最佳实践

1. 在Service构造函数中使用 PreSetOperator

public YourService(AuditLogger<YourService> auditLogger)
{
    _auditLogger = auditLogger;

    // ✅ 推荐:在构造函数中预设
    _auditLogger.PreSetOperator(
        CurrentUser.Value.Id,
        OperatorType.XXX,
        CurrentUser.Value.UserInfo.Name
    );
}

2. 优先使用异步方法

// ✅ 推荐
await _auditLogger.SetOperator(...).SetDomain(...).SetContent(...).LogAsync();

// ❌ 避免在高并发场景使用同步方法
_auditLogger.SetOperator(...).SetDomain(...).SetContent(...).Log();

3. 只在需要时使用 SetChangeData

// ✅ 创建操作:不需要 SetChangeData
await _auditLogger
    .SetDomain(Domain.XXX)
    .SetContent(OperationType.创建, "...", "...")
    .LogAsync();

// ✅ 更新操作:使用 SetChangeData
await _auditLogger
    .SetDomain(Domain.XXX)
    .SetContent(OperationType.更新, "...", "...")
    .SetChangeData("字段列表", newData, oldData)
    .LogAsync();

4. 使用泛型区分不同Service

// ✅ 推荐:使用泛型指定Service类型
AuditLogger<SettlementService> _auditLogger;
AuditLogger<PatientService> _patientAuditLogger;

// 这样可以在日志查询时区分不同Service的审计记录

5. 合理使用子领域

// ✅ 推荐:细粒度的领域划分
await _auditLogger
    .SetDomain(Domain.结算单, SubDomain.结算单审核)  // 明确是审核子领域
    .SetContent(OperationType.审核, "审核结算单", "...")
    .LogAsync();

// ⚠️ 可选:不需要细分时可以不传子领域
await _auditLogger
    .SetDomain(Domain.结算单)
    .SetContent(OperationType.创建, "创建结算单", "...")
    .LogAsync();

🔍 API调用顺序

方式1:使用 SetOperator(完整调用)
SetOperator → SetDomain → SetContent → [SetChangeData] → Log/LogAsync

方式2:使用 PreSetOperator(预设操作者,推荐)
PreSetOperator(构造函数中) → SetDomain → SetContent → [SetChangeData] → Log/LogAsync

🔧 如何扩展

扩展存储实现

实现 IAuditStorage 接口以支持不同的存储方式:

文件存储示例:

public class AuditFileStorage : IAuditStorage
{
    private readonly string _logPath;

    public AuditFileStorage(string logPath)
    {
        _logPath = logPath;
    }

    public void Create(AuditRecord auditRecord)
    {
        var json = JsonConvert.SerializeObject(auditRecord);
        var fileName = $"{DateTime.Now:yyyyMMdd}.log";
        var filePath = Path.Combine(_logPath, fileName);

        File.AppendAllText(filePath, json + Environment.NewLine);
    }

    public async Task CreateAsync(AuditRecord auditRecord)
    {
        var json = JsonConvert.SerializeObject(auditRecord);
        var fileName = $"{DateTime.Now:yyyyMMdd}.log";
        var filePath = Path.Combine(_logPath, fileName);

        await File.AppendAllTextAsync(filePath, json + Environment.NewLine);
    }
}

⚙️ 数据库表设计(参考)

CREATE TABLE AuditRecords (
    Id VARCHAR(50) PRIMARY KEY,
    OperationTime TIMESTAMP NOT NULL,

    -- 操作者信息
    OperatorId VARCHAR(50) NOT NULL,
    OperatorName VARCHAR(100) NOT NULL,
    OperatorType INT NOT NULL,

    -- 领域信息
    Domain INT NOT NULL,
    SubDomain INT,

    -- 操作信息
    OperationType INT NOT NULL,
    Subject VARCHAR(200) NOT NULL,
    Content TEXT,
    OperationResourceId VARCHAR(100),

    -- 数据变更
    ChangedFields VARCHAR(500),
    NewData TEXT,
    OldData TEXT,

    INDEX idx_operator (OperatorId, OperationTime),
    INDEX idx_operation (OperationType, OperationTime),
    INDEX idx_domain (Domain, SubDomain, OperationTime),
    INDEX idx_resource (OperationResourceId),
    INDEX idx_time (OperationTime)
);

❓ 常见问题

Q: 如何处理审计记录失败的情况?

A: 存储失败时会抛出异常。建议在业务代码中捕获异常并记录日志,但不要影响主业务流程:

try
{
    await _auditLogger.SetDomain(...).SetContent(...).LogAsync();
}
catch (Exception ex)
{
    _logger.LogError(ex, "审计日志记录失败");
    // 不影响主业务流程
}

Q: 如何在多租户系统中使用?

A: 可以在操作者ID中包含租户信息,或在自定义上下文中添加租户字段:

var operatorId = $"{tenantId}_{userId}";

Q: operationResourceId 是必填的吗?

A: 不是必填的。SetContent 有两个重载方法:

  • 不带 operationResourceId:适用于不需要关联具体资源的操作(如系统级操作)
  • operationResourceId:适用于需要精确追溯具体资源的操作(如修改某条记录)

Q: 如何批量记录审计日志?

A: 可以实现批量存储接口,或使用后台队列异步批量处理:

```csharp public class BatchAuditStorage : IAuditStorage { private readonly List _buffer = new();

public async Task CreateAsync(AuditRecord record)
{
    _buffer.Add(record);
    if (_buffer.Count >= 100)
    {
        await FlushAsync();
    }
}

private async Task FlushAsync()
{
    await _repository.BulkInsertAsync(_buffer);
    _buffer.Clear();
}

}

results matching ""

    No results matching ""