Skip to Content
Week 11Day 2 - 审计日志

Day 2 - 审计日志

建议用时:180-210 分钟

你将学会什么

  • 审计日志和普通日志有什么区别
  • 审计日志应该记录哪些字段
  • 为什么成功和失败都要记录
  • 为什么时间建议用 UTC
  • 怎么记录修改前和修改后
  • 怎么按对象查询审计记录

审计日志不是普通“调试日志”。它记录的是关键业务动作:谁在什么时候,对哪个对象,做了什么,成功还是失败,失败原因是什么。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“角色与权限”,今天继续学习“审计日志”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“审计日志”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。审计日志的重点是业务追踪,不是调试变量。

审计日志和普通日志的区别

类型主要用途例子
普通日志排查技术问题文件读取失败、接口超时
审计日志追踪业务动作admin 删除了商品 2

普通日志常给开发者看。

审计日志常用于追踪关键操作。

审计日志应该记录什么

一条审计日志至少要包含:

字段作用
User谁做的
Action做了什么
TargetType操作对象类型
TargetId操作对象 Id
CreatedAtUtc什么时候发生
Success成功还是失败
Message结果说明

修改操作还可以记录:

字段作用
Before修改前
After修改后

为什么成功和失败都要记录

只记录成功不够。

失败也很重要,例如:

  • 没有权限删除。
  • 商品不存在。
  • 导入文件格式错误。
  • 保存失败。

失败记录能说明:有人尝试过某个动作,但系统拒绝了。

为什么时间用 UTC

本页使用:

DateTimeOffset.UtcNow

原因是:

  • 用户可能在不同时区。
  • 本地时间可能被电脑设置影响。
  • 服务器和客户端时间可能不一致。

存储时用 UTC,显示时再转成本地时间,是更稳的做法。

为什么审计写在服务层

审计记录的是业务动作。

业务动作发生在服务层,例如:

  • 商品更新。
  • 商品删除。
  • 权限拒绝。

所以审计应该写在服务层附近,而不是只写在按钮里。

如果只在按钮里写审计,将来换成批量导入、快捷键、自动任务时,就可能漏掉记录。

不要记录敏感内容

审计日志不要写:

  • 密码
  • Token
  • Secret
  • 私人证件号
  • 银行卡号

如果确实需要定位对象,记录内部 Id 或脱敏后的信息。

审计日志常用写法速查

需求写法说明
审计记录AuditEntry谁在什么时候做了什么
操作人UserName记录责任人
动作ActionCreate、Update、Delete
目标Target商品、订单、设置
成功失败Success操作结果
原因Message失败原因或备注
时间DateTime.Now发生时间
保存审计auditService.Record(...)统一入口
查询审计Where(entry => entry.Target == ...)查某个对象历史

审计日志固定字段:

时间 用户 动作 目标 是否成功 原因

第二部分:把知识组合成完整例子

今天做一个审计日志程序。

它会记录四种情况:

  • 修改商品成功。
  • 修改不存在商品失败。
  • 删除商品因为没有权限被拒绝。
  • 删除商品成功。

最后会输出全部审计日志,也会单独查询商品 2 的审计记录。

完整 Program.cs

using System; using System.Collections.Generic; using System.Linq; var auditService = new AuditService(); var repository = new ProductRepository(); var productService = new ProductService(repository, auditService); void Print(OperationResult result) { Console.WriteLine($"{result.Success} - {result.Message}"); } Print(productService.UpdateProduct("admin", 1, "Keyboard Pro")); Print(productService.UpdateProduct("admin", 99, "Missing Product")); Print(productService.DeleteProduct("guest", 2, canDelete: false)); Print(productService.DeleteProduct("admin", 2, canDelete: true)); Console.WriteLine("全部审计日志:"); foreach (AuditEntry entry in auditService.GetAll()) { Console.WriteLine($"{entry.CreatedAtUtc:HH:mm:ss} {entry.User} {entry.Action} {entry.TargetType}:{entry.TargetId} {entry.Success} {entry.Message}"); } Console.WriteLine("商品 2 的审计日志:"); foreach (AuditEntry entry in auditService.FindByTarget("Product", "2")) { Console.WriteLine($"{entry.Action} {entry.Success} {entry.Message}"); } public sealed class ProductService { private readonly ProductRepository repository; private readonly AuditService auditService; public ProductService(ProductRepository repository, AuditService auditService) { this.repository = repository; this.auditService = auditService; } public OperationResult UpdateProduct(string user, int productId, string newName) { Product? product = repository.FindById(productId); if (product is null) { auditService.Write( user, "Product.Update", "Product", productId.ToString(), success: false, message: "商品不存在"); return OperationResult.Fail($"商品 {productId} 不存在。"); } string before = product.Name; product.Name = newName.Trim(); auditService.Write( user, "Product.Update", "Product", productId.ToString(), success: true, message: "商品名称已更新", before: before, after: product.Name); return OperationResult.Ok($"商品 {productId} 已更新。"); } public OperationResult DeleteProduct(string user, int productId, bool canDelete) { if (!canDelete) { auditService.Write( user, "Product.Delete.Denied", "Product", productId.ToString(), success: false, message: "没有删除权限"); return OperationResult.Fail($"{user} 没有删除权限。"); } Product? product = repository.FindById(productId); if (product is null) { auditService.Write( user, "Product.Delete", "Product", productId.ToString(), success: false, message: "商品不存在"); return OperationResult.Fail($"商品 {productId} 不存在。"); } repository.Delete(productId); auditService.Write( user, "Product.Delete", "Product", productId.ToString(), success: true, message: "商品已删除", before: product.Name, after: ""); return OperationResult.Ok($"商品 {productId} 已删除。"); } } public sealed class AuditService { private readonly List<AuditEntry> entries = new(); public void Write( string user, string action, string targetType, string targetId, bool success, string message, string before = "", string after = "") { entries.Add(new AuditEntry { Id = Guid.NewGuid(), CreatedAtUtc = DateTimeOffset.UtcNow, User = user, Action = action, TargetType = targetType, TargetId = targetId, Success = success, Message = message, Before = before, After = after }); } public IReadOnlyList<AuditEntry> GetAll() { return entries .OrderBy(entry => entry.CreatedAtUtc) .ToList(); } public IReadOnlyList<AuditEntry> FindByTarget(string targetType, string targetId) { return entries .Where(entry => entry.TargetType == targetType && entry.TargetId == targetId) .OrderBy(entry => entry.CreatedAtUtc) .ToList(); } } public sealed class AuditEntry { public Guid Id { get; set; } public DateTimeOffset CreatedAtUtc { get; set; } public string User { get; set; } = ""; public string Action { get; set; } = ""; public string TargetType { get; set; } = ""; public string TargetId { get; set; } = ""; public bool Success { get; set; } public string Message { get; set; } = ""; public string Before { get; set; } = ""; public string After { get; set; } = ""; } public sealed class ProductRepository { private readonly List<Product> products = new() { new Product(1, "Keyboard"), new Product(2, "Mouse") }; public Product? FindById(int id) { return products.FirstOrDefault(product => product.Id == id); } public void Delete(int id) { products.RemoveAll(product => product.Id == id); } } public sealed class Product { public Product(int id, string name) { Id = id; Name = name; } public int Id { get; } public string Name { get; set; } } public sealed class OperationResult { public bool Success { get; init; } public string Message { get; init; } = ""; public static OperationResult Ok(string message) { return new OperationResult { Success = true, Message = message }; } public static OperationResult Fail(string message) { return new OperationResult { Success = false, Message = message }; } }

正常运行结果

True - 商品 1 已更新。 False - 商品 99 不存在。 False - guest 没有删除权限。 True - 商品 2 已删除。 全部审计日志: ... 商品 2 的审计日志: Product.Delete.Denied False 没有删除权限 Product.Delete True 商品已删除

第三部分:跟着敲代码

从这里开始动手。新建控制台项目。

第 1 步:创建项目

dotnet new console -n AuditLogDemo cd AuditLogDemo

打开 Program.cs,清空原内容。

第 2 步:写 AuditEntry

public sealed class AuditEntry { public Guid Id { get; set; } public DateTimeOffset CreatedAtUtc { get; set; } public string User { get; set; } = ""; public string Action { get; set; } = ""; public string TargetType { get; set; } = ""; public string TargetId { get; set; } = ""; public bool Success { get; set; } public string Message { get; set; } = ""; public string Before { get; set; } = ""; public string After { get; set; } = ""; }

解释:

  • TargetType 是对象类型,例如 Product
  • TargetId 是对象 Id,例如 2
  • BeforeAfter 用来记录修改前后。

第 3 步:写 AuditService.Write

public void Write( string user, string action, string targetType, string targetId, bool success, string message, string before = "", string after = "") { entries.Add(new AuditEntry { Id = Guid.NewGuid(), CreatedAtUtc = DateTimeOffset.UtcNow, User = user, Action = action, TargetType = targetType, TargetId = targetId, Success = success, Message = message, Before = before, After = after }); }

解释:

  • 所有审计都通过这个方法写入。
  • 成功和失败都能写。

第 4 步:写查询方法

public IReadOnlyList<AuditEntry> FindByTarget(string targetType, string targetId) { return entries .Where(entry => entry.TargetType == targetType && entry.TargetId == targetId) .OrderBy(entry => entry.CreatedAtUtc) .ToList(); }

解释:

  • 可以查某个商品的所有审计记录。

第 5 步:在更新商品时写审计

string before = product.Name; product.Name = newName.Trim(); auditService.Write( user, "Product.Update", "Product", productId.ToString(), success: true, message: "商品名称已更新", before: before, after: product.Name);

解释:

  • 更新成功时记录修改前后。

第 6 步:在失败时写审计

auditService.Write( user, "Product.Delete.Denied", "Product", productId.ToString(), success: false, message: "没有删除权限");

解释:

  • 权限拒绝也要记录。

第 7 步:运行后检查

运行后检查:

  1. 更新成功有审计。
  2. 更新不存在商品有审计。
  3. 删除被拒绝有审计。
  4. 删除成功有审计。
  5. 能查询商品 2 的两条删除相关审计。

小白重复敲写训练

审计日志先记录“谁、何时、做了什么”。

训练 1:创建一条日志

var log = new AuditLog { Time = DateTimeOffset.Now, User = "admin", Action = "CreateProduct", Detail = "Keyboard" }; Console.WriteLine($"{log.Time} | {log.User} | {log.Action} | {log.Detail}");

训练 2:封装记录方法

void WriteAudit(string user, string action, string detail) { _logs.Add(new AuditLog { Time = DateTimeOffset.Now, User = user, Action = action, Detail = detail }); }

连续记录新增、编辑、删除三种动作。

训练 3:筛选某个用户的日志

var adminLogs = logs .Where(log => log.User == "admin") .OrderByDescending(log => log.Time);

第三遍增加按动作类型筛选。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:审计日志。今天至少要掌握的 3 个点是:

1. 审计日志和普通日志有什么区别 2. 审计日志应该记录哪些字段 3. 为什么成功和失败都要记录

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:审计日志和普通日志有什么区别。

上位机专项练习

关键操作必须留下审计记录:谁、何时、对什么、做了什么、修改前后是什么。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:审计记录模型

public record AuditEntry( DateTime Time, string UserName, string Action, string Target, string Detail);

运行结果或界面效果:

一条记录包含五项关键信息

改动任务: 增加 Workstation 字段。

专项例子 2:记录阈值修改

audit.Write(new AuditEntry( DateTime.Now, currentUser.Name, "修改报警上限", "PLC-01/Temperature", "80 -> 85"));

运行结果或界面效果:

能追踪阈值从 80 改到 85

改动任务: 记录一次报警确认操作。

专项例子 3:显示审计列表

<DataGrid ItemsSource="{Binding AuditEntries}"> <DataGrid.Columns> <DataGridTextColumn Header="时间" Binding="{Binding Time}" /> <DataGridTextColumn Header="用户" Binding="{Binding UserName}" /> <DataGridTextColumn Header="操作" Binding="{Binding Action}" /> </DataGrid.Columns> </DataGrid>

运行结果或界面效果:

管理员可查看审计记录

改动任务: 增加目标和详情列。

第四部分:作业完整答案

作业要求:写一个审计日志程序,记录更新成功、更新失败、删除拒绝、删除成功,并支持按商品查询审计。

答案文件:Program.cs

using System; using System.Collections.Generic; using System.Linq; var auditService = new AuditService(); var repository = new ProductRepository(); var productService = new ProductService(repository, auditService); void Print(OperationResult result) { Console.WriteLine($"{result.Success} - {result.Message}"); } Print(productService.UpdateProduct("admin", 1, "Keyboard Pro")); Print(productService.UpdateProduct("admin", 99, "Missing Product")); Print(productService.DeleteProduct("guest", 2, canDelete: false)); Print(productService.DeleteProduct("admin", 2, canDelete: true)); Console.WriteLine("全部审计日志:"); foreach (AuditEntry entry in auditService.GetAll()) { Console.WriteLine($"{entry.CreatedAtUtc:HH:mm:ss} {entry.User} {entry.Action} {entry.TargetType}:{entry.TargetId} {entry.Success} {entry.Message}"); } Console.WriteLine("商品 2 的审计日志:"); foreach (AuditEntry entry in auditService.FindByTarget("Product", "2")) { Console.WriteLine($"{entry.Action} {entry.Success} {entry.Message}"); } public sealed class ProductService { private readonly ProductRepository repository; private readonly AuditService auditService; public ProductService(ProductRepository repository, AuditService auditService) { this.repository = repository; this.auditService = auditService; } public OperationResult UpdateProduct(string user, int productId, string newName) { Product? product = repository.FindById(productId); if (product is null) { auditService.Write(user, "Product.Update", "Product", productId.ToString(), false, "商品不存在"); return OperationResult.Fail($"商品 {productId} 不存在。"); } string before = product.Name; product.Name = newName.Trim(); auditService.Write(user, "Product.Update", "Product", productId.ToString(), true, "商品名称已更新", before, product.Name); return OperationResult.Ok($"商品 {productId} 已更新。"); } public OperationResult DeleteProduct(string user, int productId, bool canDelete) { if (!canDelete) { auditService.Write(user, "Product.Delete.Denied", "Product", productId.ToString(), false, "没有删除权限"); return OperationResult.Fail($"{user} 没有删除权限。"); } Product? product = repository.FindById(productId); if (product is null) { auditService.Write(user, "Product.Delete", "Product", productId.ToString(), false, "商品不存在"); return OperationResult.Fail($"商品 {productId} 不存在。"); } repository.Delete(productId); auditService.Write(user, "Product.Delete", "Product", productId.ToString(), true, "商品已删除", product.Name, ""); return OperationResult.Ok($"商品 {productId} 已删除。"); } } public sealed class AuditService { private readonly List<AuditEntry> entries = new(); public void Write( string user, string action, string targetType, string targetId, bool success, string message, string before = "", string after = "") { entries.Add(new AuditEntry { Id = Guid.NewGuid(), CreatedAtUtc = DateTimeOffset.UtcNow, User = user, Action = action, TargetType = targetType, TargetId = targetId, Success = success, Message = message, Before = before, After = after }); } public IReadOnlyList<AuditEntry> GetAll() { return entries .OrderBy(entry => entry.CreatedAtUtc) .ToList(); } public IReadOnlyList<AuditEntry> FindByTarget(string targetType, string targetId) { return entries .Where(entry => entry.TargetType == targetType && entry.TargetId == targetId) .OrderBy(entry => entry.CreatedAtUtc) .ToList(); } } public sealed class AuditEntry { public Guid Id { get; set; } public DateTimeOffset CreatedAtUtc { get; set; } public string User { get; set; } = ""; public string Action { get; set; } = ""; public string TargetType { get; set; } = ""; public string TargetId { get; set; } = ""; public bool Success { get; set; } public string Message { get; set; } = ""; public string Before { get; set; } = ""; public string After { get; set; } = ""; } public sealed class ProductRepository { private readonly List<Product> products = new() { new Product(1, "Keyboard"), new Product(2, "Mouse") }; public Product? FindById(int id) { return products.FirstOrDefault(product => product.Id == id); } public void Delete(int id) { products.RemoveAll(product => product.Id == id); } } public sealed class Product { public Product(int id, string name) { Id = id; Name = name; } public int Id { get; } public string Name { get; set; } } public sealed class OperationResult { public bool Success { get; init; } public string Message { get; init; } = ""; public static OperationResult Ok(string message) { return new OperationResult { Success = true, Message = message }; } public static OperationResult Fail(string message) { return new OperationResult { Success = false, Message = message }; } }

验收结果

运行后必须看到:

  1. 更新商品 1 成功。
  2. 更新商品 99 失败并记录审计。
  3. guest 删除商品 2 被拒绝并记录审计。
  4. admin 删除商品 2 成功并记录审计。
  5. 查询商品 2 能看到拒绝和成功两条记录。

为什么这个答案是对的

这个答案把审计日志的关键字段补齐了:

字段作用
User谁操作
Action做了什么
TargetType / TargetId操作哪个对象
Success成功还是失败
Message结果原因
Before / After修改前后
CreatedAtUtc发生时间

审计日志的价值是追踪业务动作。成功要记录,失败也要记录,尤其是权限拒绝和数据不存在这类边界情况。