feat: 新增操作日志功能并优化定时任务配置
1. 新增OperationLogAttribute与OperationLogActionFilter,实现自动化操作日志记录 2. 新增OperationLogRecordInput输入模型,重构IOperationLogService日志接口 3. 为所有业务控制器接口添加操作日志注解 4. 调整期刊AI批改任务的执行周期与方法适配异步调用 5. 优化Hangfire定时任务注册逻辑,支持异步任务 6. 补充完善操作日志类型与目标类型枚举
This commit is contained in:
@ -0,0 +1,397 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// 操作日志标记
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class OperationLogAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="actionType">操作类型</param>
|
||||
/// <param name="targetType">目标类型</param>
|
||||
public OperationLogAttribute(string actionType, string targetType)
|
||||
{
|
||||
ActionType = actionType;
|
||||
TargetType = targetType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 操作类型
|
||||
/// </summary>
|
||||
public string ActionType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型
|
||||
/// </summary>
|
||||
public string TargetType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标Id路由键
|
||||
/// </summary>
|
||||
public string? TargetIdRouteKey { get; set; } = "id";
|
||||
|
||||
/// <summary>
|
||||
/// 目标Id参数名
|
||||
/// </summary>
|
||||
public string? TargetIdArgumentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标名称参数名
|
||||
/// </summary>
|
||||
public string? TargetNameArgumentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标名称属性名
|
||||
/// </summary>
|
||||
public string? TargetNameProperty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用当前操作人Id作为目标Id
|
||||
/// </summary>
|
||||
public bool UseOperatorAsTargetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否记录参数摘要
|
||||
/// </summary>
|
||||
public bool LogArguments { get; set; } = true;
|
||||
}
|
||||
Reference in New Issue
Block a user