跳到主要内容
版本:2.2.0

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>

泛型审计日志记录器,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: 可以实现批量存储接口,或使用后台队列异步批量处理:

public class BatchAuditStorage : IAuditStorage
{
private readonly List<AuditRecord> _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();
}
}