一、为什么需要接口审计日志
在企业管理系统里,光有 Nginx 访问日志是不够的。排查”谁在什么时候改了配置”“这个接口当时传了什么参数、返回了什么”这类问题时,我们需要的是业务级别的审计日志:接口归属哪个模块、哪个操作、入参是什么、出参是什么、调用方是谁。
用中间件可以实现吗?不太行。中间件工作在 MVC 管道之前,拿到的只是原始
HttpContext,此时请求体还没绑定成强类型参数,更拿不到
Action 的返回值。而 IAsyncActionFilter
正好卡在参数绑定之后、Action
执行前后,是记录审计日志的最佳切入点。
本文基于 SwitchData 项目中真实的
ApiDbLoggingActionFilter,完整拆解这套方案的实现要点。
flowchart LR
A[请求进入] --> B[模型绑定]
B --> C[ActionFilter.OnActionExecutionAsync]
C --> D{白名单接口?}
D -- 是 --> H[跳过审计]
D -- 否 --> E[记录入参 + 脱敏]
E --> F["await next()<br/>执行 Action"]
F --> G[提取出参 + 截断]
G --> I["fire-and-forget<br/>异步落库"]
I --> J[返回响应]
H --> J
二、过滤器骨架:一个 await 切出”前”与”后”
IAsyncActionFilter
的核心只有一个方法,await next()
这一行把整个流程切成两半:之前是执行前,之后是执行后。
public class ApiDbLoggingActionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
// 白名单接口或未携带 user-id 头的请求,跳过审计
if (ShouldSkipDbLogging(context) ||
!context.HttpContext.Request.Headers.Keys.Contains("user-id"))
{
await next();
return;
}
// 执行前:拿到绑定后的强类型参数
var (controller, action, description) = GetDisplayInfo(context);
var inputParameters = GetInputParameters(context.ActionArguments);
// 放行,执行 Action
var executedContext = await next();
// 执行后:从 executedContext 拿返回结果
var outputParameters = await GetOutputParametersAsync(executedContext);
// 异步落库,不阻塞响应
_ = LogBusinessOperationAsync(controller, action,
context.HttpContext.Request.Path, description,
inputParameters, outputParameters, executedContext);
}
}
三个关键信息源,正好对应审计日志的三大要素:
| 要素 | 数据来源 |
|---|---|
| 调的什么接口 | ActionDescriptor + 控制器/方法的
DisplayAttribute |
| 传了什么参数 | context.ActionArguments(已绑定的字典) |
| 返回了什么 | executedContext.Result(ObjectResult/JsonResult
等) |
三、用 DisplayAttribute 生成”人话”操作名
审计日志如果只记
UserController.Update,业务人员看不懂。项目里控制器和
Action 都标注了
Display(Name="用户管理", Description="修改用户角色"),过滤器通过反射把它们提取出来拼接展示:
private (string catalog, string operation, string description) GetDisplayInfo(
ActionExecutingContext context)
{
var controllerType = context.Controller.GetType();
var controllerDisplay = controllerType
.GetCustomAttributes(typeof(DisplayAttribute), true)
.OfType<DisplayAttribute>().FirstOrDefault();
var actionDisplay = context.ActionDescriptor.EndpointMetadata
.OfType<DisplayAttribute>().LastOrDefault();
var controllerName = controllerType.Name.Replace("Controller", "");
var actionName = GetActionName(context.ActionDescriptor);
// 模块名:Display 名称(类名),如 "用户管理(User)"
var catalog = controllerDisplay?.Name ?? controllerName;
catalog = !catalog.Contains(controllerName)
? $"{catalog}({controllerName})" : catalog;
// 操作名:Display 名称(方法名),如 "修改角色(Update)"
var operation = actionDisplay?.Name ?? actionName;
operation = !operation.Contains(actionName)
? $"{operation}({actionName})" : operation;
var description = actionDisplay?.Description ?? string.Empty;
return (catalog, operation, description);
}
注意一个细节:方法上的特性要用
EndpointMetadata.OfType<DisplayAttribute>().LastOrDefault()
取,而不是从 MethodInfo 反射——这样写能同时兼容 Action
特性和 Endpoint 级别的元数据,管道合并后的描述符里都能拿到。
最终落库的模块名形如 用户管理(User)、操作名形如
修改角色(Update),开发看英文方法名、业务看中文名,两头都照顾到了。
四、入参脱敏:别把密码写进日志
审计日志是给内部排查用的,但落库内容也可能被更多人看到。password、token
这类敏感参数必须脱敏,这是合规底线。实现思路很直接:遍历
ActionArguments,按 key 匹配敏感词,命中就替换成
***:
private string GetInputParameters(IDictionary<string, object> actionArguments)
{
if (actionArguments == null || actionArguments.Count == 0)
return "{}";
try
{
var safeArguments = new Dictionary<string, object>();
var sensitiveKeys = new[] { "password", "pwd", "token", "secret", "key" };
foreach (var arg in actionArguments)
{
safeArguments[arg.Key] = sensitiveKeys.Any(
s => arg.Key.Contains(s, StringComparison.OrdinalIgnoreCase))
? "***" // 敏感参数脱敏
: arg.Value;
}
return JsonHelper.Serialize(safeArguments);
}
catch (Exception ex)
{
// 序列化失败不能影响主流程,降级记错误信息
return $"入参序列化失败: {ex.Message}";
}
}
两个容易忽略的点:
OrdinalIgnoreCase忽略大小写。参数可能叫Password、UserPwd、accessToken,精确匹配会漏。- 序列化必须套
try-catch。参数里如果包含循环引用对象或不可序列化类型,
Serialize会抛异常——审计日志挂掉不能拖垮业务接口,降级返回一句错误描述即可。
五、出参截断:反射 + ExpandoObject 防止日志膨胀
出参比入参更危险:一个列表查询接口可能返回上万行数据,直接序列化落库,日志表会被撑爆。方案是递归遍历出参对象,把所有超长字符串截断到 1000 字符:
private object TruncateWithReflection(object obj, int maxLength = 1000,
string suffix = "...(已截断)")
{
if (obj == null) return null;
Type type = obj.GetType();
// 用 ExpandoObject 重建对象,而非直接改原对象
dynamic result = new ExpandoObject();
var resultDict = (IDictionary<string, object>)result;
foreach (PropertyInfo prop in type.GetProperties())
{
if (prop.CanRead)
resultDict[prop.Name] = ProcessValue(
prop.GetValue(obj), maxLength, suffix);
}
return result;
}
ProcessValue 按 value 的实际类型分四路处理:
private object ProcessValue(object value, int maxLength, string suffix)
{
if (value == null) return null;
Type valueType = value.GetType();
// 1. 字符串:超长截断
if (valueType == typeof(string))
{
string str = (string)value;
if (str.Length <= maxLength) return str;
int truncateLength = maxLength - suffix.Length;
return truncateLength <= 0
? suffix
: str.Substring(0, truncateLength) + suffix;
}
// 2. 匿名类型:递归处理嵌套属性
if (valueType.Name.Contains("AnonymousType"))
return TruncateWithReflection(value, maxLength, suffix);
// 3. 集合:逐元素处理(排除 string 本身)
if (value is IEnumerable enumerable && !(value is string))
return ProcessEnumerable(enumerable, maxLength, suffix);
// 4. 其他类型原样返回
return value;
}
这里有两个设计取舍值得说:
- 为什么用
ExpandoObject重建而不是原对象修改?出参 DTO 可能是不可变类型,也没有 setter 可改。重建一个动态对象,截断逻辑对任何类型都通用。 - 截断长度要给后缀留位置。
maxLength - suffix.Length保证最终长度不超过 1000,而不是 1000 再拼一截后缀。
匿名类型没有正式类型名,运行时名称是
<>f__AnonymousType...,所以用
Name.Contains("AnonymousType")
判断递归——虽然不如类型匹配优雅,但对”序列化前体检”这种场景足够可靠。
六、fire-and-forget:审计不能拖慢响应
拿到出入参后,最后一步是落库。这里用了典型的 fire-and-forget 模式:
// 异步记录业务操作结果
_ = LogBusinessOperationAsync(controller, action, url, description,
inputParameters, outputParameters, executedContext);
private async Task LogBusinessOperationAsync(...)
{
try
{
await Task.Run(() =>
{
DbLogHelper.AddApiLog(
url: url,
controller: controller,
action: action,
userid: executedContext.HttpContext.Request.Headers["user-id"],
ip: executedContext.HttpContext.GetClientIpAddress(),
request: inputParameters,
response: outputParameters,
message: messageBuilder.ToString(),
remark: string.Empty);
}).ConfigureAwait(false);
}
catch (Exception ex)
{
Console.WriteLine($"记录业务操作日志失败: {ex.Message}");
}
}
三个细节决定了这套代码”能用”还是”添乱”:
_ =丢弃 Task。审计落库和接口响应没有因果关系,调用方不需要等它。用_ =显式声明丢弃意图,响应线程直接返回。- 内部整体 try-catch。丢弃 Task
意味着异常无人接收,未观察的异常会进入
TaskScheduler.UnobservedTaskException,极端情况下(throwOnUnobservedTaskExceptions=true)会终结进程。所以方法内部必须自己兜底。 ConfigureAwait(false)。落库逻辑不需要回到请求上下文,避免无谓的上下文捕获开销。
另外注意:在 await next() 之后,请求的
HttpContext 仍然可用(连接未结束时),所以取
Headers["user-id"] 和客户端 IP
都没问题。但如果落库动作特别慢、连接已断开,部分属性可能抛异常——这也是把整段逻辑包在
try-catch 里的原因之一。
七、白名单接口的跳过策略
有些接口天然不需要审计,比如白名单放行的心跳、健康检查。项目通过反射读取方法上的
PermissionAttribute 来判断:
private bool ShouldSkipDbLogging(ActionExecutingContext context)
{
if (context.ActionDescriptor is ControllerActionDescriptor descriptor)
{
var attrs = descriptor.MethodInfo
.GetCustomAttributes(typeof(PermissionAttribute), false);
if (attrs.Length > 0 && attrs[0] is PermissionAttribute pa)
{
// 白名单权限的接口跳过审计日志
return pa.Permission?.Contains(
nameof(PermissionPrivileges.White.Default)) ?? false;
}
}
return false;
}
把”是否跳过审计”挂在已有的权限特性上,好处是一个标注两处生效:既是权限白名单,也是审计白名单,不用维护两份配置。
八、总结
回顾这套审计日志方案的核心设计:
- 切入点选 ActionFilter 而非中间件——能拿到绑定后的强类型参数和 Action 返回值,这是中间件做不到的;
- DisplayAttribute 双语标识——中文给业务,英文给开发,一份日志两边都能读;
- 入参按 key 脱敏——敏感词模糊匹配 + 忽略大小写,序列化失败自动降级;
- 出参反射递归截断——ExpandoObject 重建 + 匿名类型递归 + 集合逐元素处理,防日志表膨胀;
- fire-and-forget 异步落库——响应不被审计拖慢,内部 try-catch 防未观察异常。
这套方案在 SwitchData 的 API 层稳定运行,日审计量万级接口调用,日志表增长可控,排查”谁动了数据”基本秒级定位。如果你的系统也有操作留痕的合规需求,可以直接按这个骨架扩展——比如在落库时加上耗时统计、在截断时对集合加最大条数限制,都是顺手的事。