1. 新增OperationLogAttribute与OperationLogActionFilter,实现自动化操作日志记录 2. 新增OperationLogRecordInput输入模型,重构IOperationLogService日志接口 3. 为所有业务控制器接口添加操作日志注解 4. 调整期刊AI批改任务的执行周期与方法适配异步调用 5. 优化Hangfire定时任务注册逻辑,支持异步任务 6. 补充完善操作日志类型与目标类型枚举
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;
|
||
}
|
||
}
|