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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user