一、为什么需要接口审计日志

在企业管理系统里,光有 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}";
    }
}

两个容易忽略的点:

  1. OrdinalIgnoreCase 忽略大小写。参数可能叫 Password、UserPwd、accessToken,精确匹配会漏。
  2. 序列化必须套 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}");
    }
}

三个细节决定了这套代码”能用”还是”添乱”:

  1. _ = 丢弃 Task。审计落库和接口响应没有因果关系,调用方不需要等它。用 _ = 显式声明丢弃意图,响应线程直接返回。
  2. 内部整体 try-catch。丢弃 Task 意味着异常无人接收,未观察的异常会进入 TaskScheduler.UnobservedTaskException,极端情况下(throwOnUnobservedTaskExceptions=true)会终结进程。所以方法内部必须自己兜底。
  3. 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;
}

把”是否跳过审计”挂在已有的权限特性上,好处是一个标注两处生效:既是权限白名单,也是审计白名单,不用维护两份配置。

八、总结

回顾这套审计日志方案的核心设计:

  1. 切入点选 ActionFilter 而非中间件——能拿到绑定后的强类型参数和 Action 返回值,这是中间件做不到的;
  2. DisplayAttribute 双语标识——中文给业务,英文给开发,一份日志两边都能读;
  3. 入参按 key 脱敏——敏感词模糊匹配 + 忽略大小写,序列化失败自动降级;
  4. 出参反射递归截断——ExpandoObject 重建 + 匿名类型递归 + 集合逐元素处理,防日志表膨胀;
  5. fire-and-forget 异步落库——响应不被审计拖慢,内部 try-catch 防未观察异常。

这套方案在 SwitchData 的 API 层稳定运行,日审计量万级接口调用,日志表增长可控,排查”谁动了数据”基本秒级定位。如果你的系统也有操作留痕的合规需求,可以直接按这个骨架扩展——比如在落库时加上耗时统计、在截断时对集合加最大条数限制,都是顺手的事。