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