Files
glz 766be485d0 feat: 新增操作日志功能并优化定时任务配置
1.  新增OperationLogAttribute与OperationLogActionFilter,实现自动化操作日志记录
2.  新增OperationLogRecordInput输入模型,重构IOperationLogService日志接口
3.  为所有业务控制器接口添加操作日志注解
4.  调整期刊AI批改任务的执行周期与方法适配异步调用
5.  优化Hangfire定时任务注册逻辑,支持异步任务
6.  补充完善操作日志类型与目标类型枚举
2026-07-09 18:01:37 +08:00

398 lines
13 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}