feat: 新增操作日志功能并优化定时任务配置

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

View File

@ -1,4 +1,3 @@
using Hangfire;
using QYZH.InteractiveMagazine.Infrastructure.OSS;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
@ -27,67 +26,73 @@ public class JournalTaskAiScoreJob(
private const int DefaultAiScoreRetryDelayMilliseconds = 1000;
private const int DefaultAiMaxConcurrency = 1;
private const long DefaultMaxImageBytes = 10 * 1024 * 1024;
private const string InvalidAnswerResult = "作答内容不符合要求";
private const string AiProcessingMessage = "AI批阅中请稍后";
private static readonly object AiSemaphoreLock = new();
private static readonly object RunWindowLock = new();
private static readonly object ExecutionLock = new();
private static SemaphoreSlim? aiSemaphore;
private static int aiSemaphoreLimit;
private static DateTime? lastRunAt;
/// <summary>
/// 执行AI批改。
/// </summary>
[DisableConcurrentExecution(1800)]
public void Execute()
{
ExecuteAsync().GetAwaiter().GetResult();
}
private static bool isExecuting;
private static DateTime executionStartedAt;
/// <summary>
/// 异步执行AI批改。
/// </summary>
public async Task ExecuteAsync()
{
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
var windowEnd = DateTime.Now;
var windowStart = GetWindowStart(windowEnd);
var batchSize = GetPendingAnswerBatchSize();
var pendingAnswers = await client.Queryable<JournalPageTaskUserAnswer>()
.Where(a => !a.IsDeleted
&& a.Status == (int)UserAnswerStatusEnum.Processing
&& ((a.UpdatedAt != null && a.UpdatedAt > windowStart && a.UpdatedAt <= windowEnd)
|| (a.UpdatedAt == null && a.CreatedAt > windowStart && a.CreatedAt <= windowEnd)))
.OrderBy(a => a.UpdatedAt, OrderByType.Asc)
.OrderBy(a => a.CreatedAt, OrderByType.Asc)
.Take(batchSize)
.ToListAsync();
if (pendingAnswers.Count == 0)
var jobInterval = GetJobInterval();
if (!TryEnterExecutionLock(jobInterval))
{
SetLastRunAt(windowEnd);
logger.LogInformation("期刊AI批改任务没有待处理答案WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", windowStart, windowEnd);
logger.LogInformation("期刊AI批改任务仍在执行锁内跳过本次调度Interval: {Interval}", jobInterval);
return;
}
logger.LogInformation("期刊AI批改任务开始Count: {Count}, WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", pendingAnswers.Count, windowStart, windowEnd);
foreach (var pendingAnswer in pendingAnswers)
try
{
try
using var scope = scopeFactory.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
var windowEnd = DateTime.Now;
var windowStart = GetWindowStart(windowEnd, jobInterval);
var batchSize = GetPendingAnswerBatchSize();
var pendingAnswers = await client.Queryable<JournalPageTaskUserAnswer>()
.Where(a => !a.IsDeleted
&& a.Status == (int)UserAnswerStatusEnum.Processing
&& ((a.UpdatedAt != null && a.UpdatedAt > windowStart && a.UpdatedAt <= windowEnd)
|| (a.UpdatedAt == null && a.CreatedAt > windowStart && a.CreatedAt <= windowEnd)))
.OrderBy(a => a.UpdatedAt, OrderByType.Asc)
.OrderBy(a => a.CreatedAt, OrderByType.Asc)
.Take(batchSize)
.ToListAsync();
if (pendingAnswers.Count == 0)
{
await ProcessPendingAnswerAsync(client, pendingAnswer.Id, windowStart, windowEnd, CancellationToken.None);
SetLastRunAt(windowEnd);
logger.LogInformation("期刊AI批改任务没有待处理答案WindowStart: {WindowStart}, WindowEnd: {WindowEnd}, Interval: {Interval}", windowStart, windowEnd, jobInterval);
return;
}
catch (Exception ex)
logger.LogInformation("期刊AI批改任务开始Count: {Count}, WindowStart: {WindowStart}, WindowEnd: {WindowEnd}, Interval: {Interval}", pendingAnswers.Count, windowStart, windowEnd, jobInterval);
foreach (var pendingAnswer in pendingAnswers)
{
logger.LogError(ex, "期刊AI批改答案失败AnswerId: {AnswerId}", pendingAnswer.Id);
try
{
await ProcessPendingAnswerAsync(client, pendingAnswer.Id, windowStart, windowEnd, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "期刊AI批改答案失败AnswerId: {AnswerId}", pendingAnswer.Id);
}
}
SetLastRunAt(windowEnd);
}
finally
{
ExitExecutionLock();
}
SetLastRunAt(windowEnd);
}
private async Task ProcessPendingAnswerAsync(
ISqlSugarClient client,
long answerId,
@ -410,16 +415,17 @@ public class JournalTaskAiScoreJob(
prompt.AppendLine($" 判断力上限:{context.Task.Judgment}");
prompt.AppendLine($" 表达力上限:{context.Task.Expression}");
prompt.AppendLine($" 说服力上限:{context.Task.Persuasiveness}");
prompt.AppendLine($" 分数上线100");
}
prompt.AppendLine();
prompt.AppendLine("评分要求:");
prompt.AppendLine("- 不得超过题目配置中的各项上限。");
prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion不要猜测高分。");
prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,尽量猜测让分数偏高。");
prompt.AppendLine("- Completion 表示作答完整度,范围 0-100。");
prompt.AppendLine("- Result 返回 50 字内中文评语。");
prompt.AppendLine("- Result 返回 50 字内中文评语,对象是低龄儿童,回复要委婉友好。");
prompt.AppendLine("若作答内容与题目要求不符、答非所问、空白、仅抄题或无法形成有效答案Score/GrowthPoint/Points/Comprehension/Judgment/Expression/Persuasiveness 均返回 0。");
prompt.AppendLine("若作答内容不符合要求Result 必须且只能返回:作答内容不符合要求。不要补充原因、建议或其他文字。");
prompt.AppendLine("若作答内容不符合要求Result 提示偏离要求,并补充原因、建议或其他文字。");
prompt.AppendLine();
prompt.AppendLine("只返回如下 JSON 字段:");
prompt.AppendLine("{");
@ -751,12 +757,6 @@ public class JournalTaskAiScoreJob(
private static string NormalizeResult(string? result)
{
var trimmedResult = TrimResult(result);
if (trimmedResult.Contains("不符合要求", StringComparison.Ordinal) ||
trimmedResult.Contains("不符", StringComparison.Ordinal))
{
return InvalidAnswerResult;
}
return trimmedResult;
}
@ -890,23 +890,119 @@ public class JournalTaskAiScoreJob(
}
}
private int GetPendingAnswerMinutes()
{
var minutes = configuration.GetValue<int>("AiChat:PendingAnswerMinutes");
return minutes > 0 ? minutes : DefaultPendingAnswerMinutes;
}
private int GetPendingAnswerBatchSize()
{
var batchSize = configuration.GetValue<int>("AiChat:PendingAnswerBatchSize");
return batchSize > 0 ? batchSize : DefaultPendingAnswerBatchSize;
}
private DateTime GetWindowStart(DateTime windowEnd)
private TimeSpan GetJobInterval()
{
var jobTypeName = typeof(JournalTaskAiScoreJob).FullName;
foreach (var jobSection in configuration.GetSection("HangfireJobs:Jobs").GetChildren())
{
var configuredType = jobSection["JobType"];
if (!string.Equals(configuredType, jobTypeName, StringComparison.Ordinal))
{
continue;
}
var cron = jobSection["Cron"];
if (TryParseCronInterval(cron, out var interval))
{
return interval;
}
}
var minutes = configuration.GetValue<int>("AiChat:PendingAnswerMinutes");
return TimeSpan.FromMinutes(minutes > 0 ? minutes : DefaultPendingAnswerMinutes);
}
private static bool TryParseCronInterval(string? cron, out TimeSpan interval)
{
interval = default;
if (string.IsNullOrWhiteSpace(cron))
{
return false;
}
var parts = cron.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5)
{
return false;
}
if (TryParseCronStep(parts[0], out var minuteStep))
{
interval = TimeSpan.FromMinutes(minuteStep);
return true;
}
if (parts[0] == "*" && parts[1] == "*")
{
interval = TimeSpan.FromMinutes(1);
return true;
}
if (parts[0] == "0" && TryParseCronStep(parts[1], out var hourStep))
{
interval = TimeSpan.FromHours(hourStep);
return true;
}
if (int.TryParse(parts[0], out _) && parts[1] == "*")
{
interval = TimeSpan.FromHours(1);
return true;
}
return false;
}
private static bool TryParseCronStep(string value, out int step)
{
step = 0;
if (value.StartsWith("*/", StringComparison.Ordinal))
{
return int.TryParse(value[2..], out step) && step > 0;
}
var slashIndex = value.IndexOf('/');
return slashIndex > 0
&& slashIndex < value.Length - 1
&& int.TryParse(value[(slashIndex + 1)..], out step)
&& step > 0;
}
private DateTime GetWindowStart(DateTime windowEnd, TimeSpan jobInterval)
{
lock (RunWindowLock)
{
return lastRunAt ?? windowEnd.AddMinutes(-GetPendingAnswerMinutes());
return lastRunAt ?? windowEnd.Subtract(jobInterval);
}
}
private static bool TryEnterExecutionLock(TimeSpan lockTimeout)
{
lock (ExecutionLock)
{
var now = DateTime.Now;
if (isExecuting && now - executionStartedAt < lockTimeout)
{
return false;
}
isExecuting = true;
executionStartedAt = now;
return true;
}
}
private static void ExitExecutionLock()
{
lock (ExecutionLock)
{
isExecuting = false;
}
}

View File

@ -123,9 +123,22 @@ if (jobSettings?.Jobs != null)
var param = Expression.Parameter(jobType, "job");
var call = Expression.Call(param, method);
var lambda = Expression.Lambda<Action>(call, param);
if (method.ReturnType == typeof(void))
{
var lambda = Expression.Lambda(typeof(Action<>).MakeGenericType(jobType), call, param);
InvokeRecurringJobAddOrUpdate(jobType, typeof(Action<>), job.Name, lambda, job.Cron);
}
else if (method.ReturnType == typeof(Task))
{
var lambda = Expression.Lambda(typeof(Func<,>).MakeGenericType(jobType, typeof(Task)), call, param);
InvokeRecurringJobAddOrUpdate(jobType, typeof(Func<,>), job.Name, lambda, job.Cron);
}
else
{
Log.Warning("定时任务 [{Name}] 方法返回类型不支持 {ReturnType},跳过注册", job.Name, method.ReturnType);
continue;
}
RecurringJob.AddOrUpdate(job.Name, lambda, job.Cron);
Log.Information("定时任务 [{Name}] 已注册Cron: {Cron}", job.Name, job.Cron);
}
}
@ -133,3 +146,27 @@ if (jobSettings?.Jobs != null)
Log.Information("WorkService 已启动Hangfire Dashboard: /hangfire");
app.Run();
static void InvokeRecurringJobAddOrUpdate(Type jobType, Type delegateGenericTypeDefinition, string jobName, LambdaExpression lambda, string cron)
{
var method = typeof(RecurringJob).GetMethods()
.Where(m => m.Name == nameof(RecurringJob.AddOrUpdate) && m.IsGenericMethodDefinition)
.First(m =>
{
var parameters = m.GetParameters();
if (parameters.Length != 3
|| parameters[0].ParameterType != typeof(string)
|| parameters[2].ParameterType != typeof(string)
|| !parameters[1].ParameterType.IsGenericType
|| parameters[1].ParameterType.GetGenericTypeDefinition() != typeof(Expression<>))
{
return false;
}
var expressionArgument = parameters[1].ParameterType.GetGenericArguments()[0];
return expressionArgument.IsGenericType
&& expressionArgument.GetGenericTypeDefinition() == delegateGenericTypeDefinition;
});
method.MakeGenericMethod(jobType).Invoke(null, [jobName, lambda, cron]);
}

View File

@ -51,8 +51,8 @@
{
"Name": "journal-task-ai-score-job",
"JobType": "QYZH.InteractiveMagazine.WorkService.Jobs.JournalTaskAiScoreJob",
"MethodName": "Execute",
"Cron": "*/30 * * * *",
"MethodName": "ExecuteAsync",
"Cron": "*/5 * * * *",
"Enabled": true,
"Description": "每30分钟扫描上次执行到本次执行之间的期刊答题记录并提交AI批改"
}