398 lines
13 KiB
C#
398 lines
13 KiB
C#
|
|
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;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 操作日志过滤器
|
|||
|
|
/// </summary>
|
|||
|
|
public class OperationLogActionFilter(
|
|||
|
|
IOperationLogService operationLogService,
|
|||
|
|
ILogger<OperationLogActionFilter> logger) : IAsyncActionFilter
|
|||
|
|
{
|
|||
|
|
private static readonly HashSet<string> SensitiveNames = new(StringComparer.OrdinalIgnoreCase)
|
|||
|
|
{
|
|||
|
|
"password",
|
|||
|
|
"oldPassword",
|
|||
|
|
"newPassword",
|
|||
|
|
"token",
|
|||
|
|
"secret",
|
|||
|
|
"authorization"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|||
|
|
{
|
|||
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|||
|
|
ReferenceHandler = ReferenceHandler.IgnoreCycles
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 执行操作日志过滤器
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="context">Action 执行上下文</param>
|
|||
|
|
/// <param name="next">后续执行委托</param>
|
|||
|
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
|||
|
|
{
|
|||
|
|
var attribute = context.ActionDescriptor.EndpointMetadata
|
|||
|
|
.OfType<OperationLogAttribute>()
|
|||
|
|
.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<string, object?>
|
|||
|
|
{
|
|||
|
|
["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<string, object?>();
|
|||
|
|
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<object?>()
|
|||
|
|
.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;
|
|||
|
|
}
|
|||
|
|
}
|