using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using QYZH.InteractiveMagazine.IService; using QYZH.InteractiveMagazine.Models.Dto; using System.Collections; using System.Diagnostics; using System.Reflection; using System.Security.Claims; using System.Text.Json; using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.Infrastructure.Middleware; /// /// 操作日志过滤器 /// public class OperationLogActionFilter( IOperationLogService operationLogService, ILogger logger) : IAsyncActionFilter { private static readonly HashSet SensitiveNames = new(StringComparer.OrdinalIgnoreCase) { "password", "oldPassword", "newPassword", "token", "secret", "authorization" }; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, ReferenceHandler = ReferenceHandler.IgnoreCycles }; /// /// 执行操作日志过滤器 /// /// Action 执行上下文 /// 后续执行委托 public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var attribute = context.ActionDescriptor.EndpointMetadata .OfType() .FirstOrDefault(); if (attribute == null) { await next(); return; } var stopwatch = Stopwatch.StartNew(); var executedContext = await next(); stopwatch.Stop(); if (executedContext.Exception != null && !executedContext.ExceptionHandled) { return; } if (!IsSuccessResult(executedContext.Result, context.HttpContext.Response.StatusCode)) { return; } try { var operatorId = GetOperatorId(context); if (!operatorId.HasValue) { return; } var operatorName = GetOperatorName(context); var responseValue = GetResponseValue(executedContext.Result); var responseResult = GetResponseResult(responseValue); var targetId = ResolveTargetId(attribute, context, responseResult, operatorId.Value); var targetName = ResolveTargetName(attribute, context, responseResult); var detail = BuildDetail(attribute, context, responseValue, stopwatch.ElapsedMilliseconds); await operationLogService.LogAsync(new OperationLogRecordInput { OperatorId = operatorId.Value, OperatorName = operatorName, ActionType = attribute.ActionType, TargetType = attribute.TargetType, TargetId = targetId, TargetName = targetName, Detail = detail, IpAddress = GetClientIp(context) }); } catch (Exception ex) { logger.LogError(ex, "自动记录操作日志失败,Path: {Path}", context.HttpContext.Request.Path); } } private static bool IsSuccessResult(IActionResult? result, int responseStatusCode) { var responseValue = GetResponseValue(result); if (responseValue is BaseResponse response) { return response.isSuccess; } if (result is ObjectResult objectResult && objectResult.StatusCode.HasValue) { return IsSuccessStatusCode(objectResult.StatusCode.Value); } if (result is StatusCodeResult statusCodeResult) { return IsSuccessStatusCode(statusCodeResult.StatusCode); } return IsSuccessStatusCode(responseStatusCode == 0 ? StatusCodes.Status200OK : responseStatusCode); } private static bool IsSuccessStatusCode(int statusCode) { return statusCode >= StatusCodes.Status200OK && statusCode < StatusCodes.Status300MultipleChoices; } private static long? GetOperatorId(ActionExecutingContext context) { var value = context.HttpContext.User.Claims .FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier) ?.Value; return long.TryParse(value, out var operatorId) ? operatorId : null; } private static string GetOperatorName(ActionExecutingContext context) { return context.HttpContext.User.Claims .FirstOrDefault(c => c.Type == ClaimTypes.Name) ?.Value ?? string.Empty; } private static string? GetClientIp(ActionExecutingContext context) { var request = context.HttpContext.Request; var forwardedFor = request.Headers["X-Forwarded-For"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(forwardedFor)) { return forwardedFor.Split(',')[0].Trim(); } var realIp = request.Headers["X-Real-IP"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(realIp)) { return realIp; } return context.HttpContext.Connection.RemoteIpAddress?.ToString(); } private static long ResolveTargetId(OperationLogAttribute attribute, ActionExecutingContext context, object? responseResult, long operatorId) { if (attribute.UseOperatorAsTargetId) { return operatorId; } if (TryGetLongRouteValue(context, attribute.TargetIdRouteKey, out var routeId)) { return routeId; } if (TryGetLongArgumentValue(context, attribute.TargetIdArgumentName, out var argumentId)) { return argumentId; } foreach (var key in new[] { "id", "userId", "adminUserId", "roleId", "taskId", "recordId" }) { if (TryGetLongRouteValue(context, key, out routeId) || TryGetLongArgumentValue(context, key, out argumentId)) { return routeId != 0 ? routeId : argumentId; } } if (TryConvertToLong(responseResult, out var responseId)) { return responseId; } return TryGetLongPropertyValue(responseResult, "Id", out responseId) ? responseId : 0; } private static string? ResolveTargetName(OperationLogAttribute attribute, ActionExecutingContext context, object? responseResult) { if (!string.IsNullOrWhiteSpace(attribute.TargetNameArgumentName) && context.ActionArguments.TryGetValue(attribute.TargetNameArgumentName, out var nameArgument)) { if (nameArgument is string name) { return name; } if (!string.IsNullOrWhiteSpace(attribute.TargetNameProperty) && TryGetStringPropertyValue(nameArgument, attribute.TargetNameProperty, out name)) { return name; } } foreach (var value in context.ActionArguments.Values) { if (!string.IsNullOrWhiteSpace(attribute.TargetNameProperty) && TryGetStringPropertyValue(value, attribute.TargetNameProperty, out var configuredName)) { return configuredName; } if (TryGetStringPropertyValue(value, "Name", out var name) || TryGetStringPropertyValue(value, "Title", out name)) { return name; } } if (!string.IsNullOrWhiteSpace(attribute.TargetNameProperty) && TryGetStringPropertyValue(responseResult, attribute.TargetNameProperty, out var responseName)) { return responseName; } return TryGetStringPropertyValue(responseResult, "Name", out var defaultName) || TryGetStringPropertyValue(responseResult, "Title", out defaultName) ? defaultName : null; } private static string BuildDetail(OperationLogAttribute attribute, ActionExecutingContext context, object? responseValue, long elapsedMilliseconds) { var detail = new Dictionary { ["method"] = context.HttpContext.Request.Method, ["path"] = context.HttpContext.Request.Path.Value, ["routeValues"] = context.RouteData.Values.ToDictionary(k => k.Key, v => v.Value?.ToString()), ["arguments"] = attribute.LogArguments ? SanitizeValue(context.ActionArguments, 0) : null, ["responseMessage"] = GetResponseMessage(responseValue), ["elapsedMilliseconds"] = elapsedMilliseconds }; return JsonSerializer.Serialize(detail, JsonOptions); } private static string? GetResponseMessage(object? responseValue) { return responseValue is BaseResponse response ? response.message : null; } private static object? GetResponseValue(IActionResult? result) { return result switch { ObjectResult objectResult => objectResult.Value, JsonResult jsonResult => jsonResult.Value, _ => null }; } private static object? GetResponseResult(object? responseValue) { if (responseValue == null) { return null; } return responseValue.GetType() .GetProperty("result", BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase) ?.GetValue(responseValue); } private static object? SanitizeValue(object? value, int depth) { if (value == null) { return null; } if (depth > 4) { return value.ToString(); } var type = value.GetType(); if (type.IsPrimitive || value is string or decimal or DateTime or DateTimeOffset or Guid || type.IsEnum) { return value; } if (value is IDictionary dictionary) { var result = new Dictionary(); foreach (DictionaryEntry item in dictionary) { var key = item.Key?.ToString() ?? string.Empty; result[key] = SensitiveNames.Contains(key) ? "***" : SanitizeValue(item.Value, depth + 1); } return result; } if (value is IEnumerable enumerable && value is not string) { return enumerable.Cast() .Take(20) .Select(item => SanitizeValue(item, depth + 1)) .ToList(); } return type.GetProperties(BindingFlags.Instance | BindingFlags.Public) .Where(p => p.GetIndexParameters().Length == 0) .ToDictionary( p => p.Name, p => SensitiveNames.Contains(p.Name) ? "***" : SanitizeValue(p.GetValue(value), depth + 1)); } private static bool TryGetLongRouteValue(ActionExecutingContext context, string? key, out long value) { value = 0; if (string.IsNullOrWhiteSpace(key) || !context.RouteData.Values.TryGetValue(key, out var routeValue)) { return false; } return long.TryParse(routeValue?.ToString(), out value); } private static bool TryGetLongArgumentValue(ActionExecutingContext context, string? key, out long value) { value = 0; if (string.IsNullOrWhiteSpace(key) || !context.ActionArguments.TryGetValue(key, out var argumentValue)) { return false; } if (TryConvertToLong(argumentValue, out value)) { return true; } return TryGetLongPropertyValue(argumentValue, "Id", out value); } private static bool TryGetLongPropertyValue(object? source, string propertyName, out long value) { value = 0; if (source == null) { return false; } var property = source.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); return property != null && TryConvertToLong(property.GetValue(source), out value); } private static bool TryConvertToLong(object? source, out long value) { value = 0; return source switch { long longValue => SetValue(longValue, out value), int intValue => SetValue(intValue, out value), string stringValue => long.TryParse(stringValue, out value), _ => long.TryParse(source?.ToString(), out value) }; } private static bool TryGetStringPropertyValue(object? source, string propertyName, out string? value) { value = null; if (source == null) { return false; } var property = source.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); value = property?.GetValue(source)?.ToString(); return !string.IsNullOrWhiteSpace(value); } private static bool SetValue(long source, out long value) { value = source; return true; } }