Day 2 - 审计日志
建议用时:180-210 分钟
你将学会什么
- 审计日志和普通日志有什么区别
- 审计日志应该记录哪些字段
- 为什么成功和失败都要记录
- 为什么时间建议用 UTC
- 怎么记录修改前和修改后
- 怎么按对象查询审计记录
审计日志不是普通“调试日志”。它记录的是关键业务动作:谁在什么时候,对哪个对象,做了什么,成功还是失败,失败原因是什么。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“角色与权限”,今天继续学习“审计日志”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“审计日志”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。审计日志的重点是业务追踪,不是调试变量。
审计日志和普通日志的区别
| 类型 | 主要用途 | 例子 |
|---|---|---|
| 普通日志 | 排查技术问题 | 文件读取失败、接口超时 |
| 审计日志 | 追踪业务动作 | admin 删除了商品 2 |
普通日志常给开发者看。
审计日志常用于追踪关键操作。
审计日志应该记录什么
一条审计日志至少要包含:
| 字段 | 作用 |
|---|---|
User | 谁做的 |
Action | 做了什么 |
TargetType | 操作对象类型 |
TargetId | 操作对象 Id |
CreatedAtUtc | 什么时候发生 |
Success | 成功还是失败 |
Message | 结果说明 |
修改操作还可以记录:
| 字段 | 作用 |
|---|---|
Before | 修改前 |
After | 修改后 |
为什么成功和失败都要记录
只记录成功不够。
失败也很重要,例如:
- 没有权限删除。
- 商品不存在。
- 导入文件格式错误。
- 保存失败。
失败记录能说明:有人尝试过某个动作,但系统拒绝了。
为什么时间用 UTC
本页使用:
DateTimeOffset.UtcNow原因是:
- 用户可能在不同时区。
- 本地时间可能被电脑设置影响。
- 服务器和客户端时间可能不一致。
存储时用 UTC,显示时再转成本地时间,是更稳的做法。
为什么审计写在服务层
审计记录的是业务动作。
业务动作发生在服务层,例如:
- 商品更新。
- 商品删除。
- 权限拒绝。
所以审计应该写在服务层附近,而不是只写在按钮里。
如果只在按钮里写审计,将来换成批量导入、快捷键、自动任务时,就可能漏掉记录。
不要记录敏感内容
审计日志不要写:
- 密码
- Token
- Secret
- 私人证件号
- 银行卡号
如果确实需要定位对象,记录内部 Id 或脱敏后的信息。
审计日志常用写法速查
| 需求 | 写法 | 说明 |
|---|---|---|
| 审计记录 | AuditEntry | 谁在什么时候做了什么 |
| 操作人 | UserName | 记录责任人 |
| 动作 | Action | Create、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。Before和After用来记录修改前后。
第 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 步:运行后检查
运行后检查:
- 更新成功有审计。
- 更新不存在商品有审计。
- 删除被拒绝有审计。
- 删除成功有审计。
- 能查询商品 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 成功。
- 更新商品 99 失败并记录审计。
- guest 删除商品 2 被拒绝并记录审计。
- admin 删除商品 2 成功并记录审计。
- 查询商品 2 能看到拒绝和成功两条记录。
为什么这个答案是对的
这个答案把审计日志的关键字段补齐了:
| 字段 | 作用 |
|---|---|
User | 谁操作 |
Action | 做了什么 |
TargetType / TargetId | 操作哪个对象 |
Success | 成功还是失败 |
Message | 结果原因 |
Before / After | 修改前后 |
CreatedAtUtc | 发生时间 |
审计日志的价值是追踪业务动作。成功要记录,失败也要记录,尤其是权限拒绝和数据不存在这类边界情况。