using QYZH.InteractiveMagazine.Infrastructure.OSS; using QYZH.InteractiveMagazine.Models.Entity; using QYZH.InteractiveMagazine.Models.Enum; using SqlSugar; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.WorkService.Consumers; /// /// 期刊任务接收消费者 /// public class JournalTaskReceiveConsumer( ILogger logger, IConfiguration configuration, IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, OssService ossService) : IQueueConsumer { private const int DefaultAiScoreMaxRetryCount = 3; private const int DefaultAiScoreRetryDelayMilliseconds = 1000; private const int DefaultAiMaxConcurrency = 1; private const long DefaultMaxImageBytes = 10 * 1024 * 1024; private const float DefaultCompletionThreshold = 80; private const float DefaultCommunityScoreThreshold = 90; private const string AiProcessingMessage = "AI批阅中,请稍后"; private static readonly object AiSemaphoreLock = new(); private static SemaphoreSlim? aiSemaphore; private static int aiSemaphoreLimit; public string Exchange => "ex.journal"; public string QueueName => "mq.journal.task.receive"; public string RoutingKey => "rk.journal.task.receive"; public async Task HandleAsync(byte[] body, CancellationToken cancellationToken = default) { var message = Encoding.UTF8.GetString(body); logger.LogInformation("收到期刊任务消息: {Message}", message); using var scope = scopeFactory.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); var data = JsonSerializer.Deserialize(message, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidOperationException("期刊任务消息内容为空"); data.Normalize(); if (data.Questions == null || data.Questions.Length == 0) { logger.LogWarning("期刊任务消息没有题目,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}", data.UserId, data.JournalId, data.PageId); return; } var taskIds = data.Questions.Select(q => q.Id).Distinct().ToList(); var tasks = await client.Queryable() .Where(t => taskIds.Contains(t.Id) && !t.IsDeleted) .ToListAsync(cancellationToken); var taskMap = tasks.ToDictionary(t => t.Id); var groupIds = tasks.Select(t => t.GroupId > 0 ? t.GroupId : t.Id).Distinct().ToList(); var groupTasks = await client.Queryable() .Where(t => groupIds.Contains(t.GroupId) && !t.IsDeleted) .ToListAsync(cancellationToken); var persistedTaskMap = groupTasks .GroupBy(t => t.GroupId > 0 ? t.GroupId : t.Id) .ToDictionary( g => g.Key, g => g.FirstOrDefault(t => t.Id == g.Key) ?? g.OrderBy(t => t.Id).First()); var groupTaskIds = groupTasks.Select(t => t.Id).Distinct().ToList(); var referenceAnswers = await client.Queryable() .Where(a => groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) .ToListAsync(cancellationToken); var referenceAnswerMap = referenceAnswers .GroupBy(a => a.JournalPageTaskId) .ToDictionary(g => g.Key, g => g.ToList()); var pageIds = groupTasks.Select(t => t.JournalPageId).Append(data.PageId).Distinct().ToList(); var pages = await client.Queryable() .Where(p => pageIds.Contains(p.Id) && !p.IsDeleted) .ToListAsync(cancellationToken); var pageMap = pages.ToDictionary(p => p.Id); var existingAnswers = await client.Queryable() .Where(a => a.UserId == data.UserId && groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) .ToListAsync(cancellationToken); var existingAnswerMap = existingAnswers .GroupBy(a => a.JournalPageTaskId) .ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.UpdatedAt ?? a.CreatedAt).First()); var questionContexts = new List(); var answerContexts = new List(); foreach (var question in data.Questions) { if (!taskMap.TryGetValue(question.Id, out var task)) { logger.LogWarning("未找到期刊任务,TaskId: {TaskId}, UserId: {UserId}", question.Id, data.UserId); continue; } if (!task.NeedAiProcess) { logger.LogInformation("期刊任务配置为人工批改,跳过AI评分,TaskId: {TaskId}, UserId: {UserId}", task.Id, data.UserId); continue; } pageMap.TryGetValue(task.JournalPageId, out var taskPage); questionContexts.Add(new JournalQuestionContext(question, task, taskPage)); } foreach (var scoreUnit in BuildScoreUnits(questionContexts)) { var persistedContext = BuildPersistedContext(scoreUnit, persistedTaskMap, pageMap); if (persistedContext == null) { logger.LogWarning("期刊任务评分单元缺少可入库任务,UserId: {UserId}, GroupId: {GroupId}", data.UserId, scoreUnit.GroupId); continue; } if (IsCompletedSameAnswer(scoreUnit, persistedContext.Task, data, existingAnswerMap)) { logger.LogInformation("期刊任务作答已完成且内容未变化,跳过AI评分,UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}", data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id))); continue; } try { var scoreResult = await ScoreQuestionAsync(scoreUnit, referenceAnswerMap, cancellationToken); var normalizedResult = NormalizeScoreResult(scoreResult, persistedContext.Task); answerContexts.Add(new JournalAnswerContext( BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, normalizedResult, GetCompletionThreshold()), persistedContext.Question, persistedContext.Task, normalizedResult)); LogSkippedGroupTasks(scoreUnit, persistedContext.Task); } catch (OperationCanceledException) { throw; } catch (Exception ex) { logger.LogWarning(ex, "期刊任务AI评分失败,已标记为处理中,UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}", data.UserId, scoreUnit.GroupId, string.Join(",", scoreUnit.Questions.Select(q => q.Task.Id))); var failureResult = BuildFailureScoreResult(); answerContexts.Add(new JournalAnswerContext( BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, failureResult, GetCompletionThreshold()), persistedContext.Question, persistedContext.Task, failureResult)); LogSkippedGroupTasks(scoreUnit, persistedContext.Task); } } if (answerContexts.Count == 0) { logger.LogWarning("期刊任务消息没有可入库的答题记录,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}", data.UserId, data.JournalId, data.PageId); return; } client.Ado.BeginTran(); try { foreach (var context in answerContexts) { var answer = context.Answer; var existing = await client.Queryable() .Where(a => a.UserId == answer.UserId && a.JournalPageTaskId == answer.JournalPageTaskId && !a.IsDeleted) .FirstAsync(cancellationToken); if (existing == null) { await client.Insertable(answer).ExecuteCommandAsync(cancellationToken); } else { await client.Insertable(BuildAnswerSnapshot(existing)).ExecuteCommandAsync(cancellationToken); answer.Id = existing.Id; answer.CreatedBy = existing.CreatedBy; answer.CreatedAt = existing.CreatedAt; answer.UpdatedBy = answer.UserId.ToString(); answer.UpdatedAt = DateTime.Now; await client.Updateable(answer) .IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt }) .Where(a => a.Id == existing.Id) .ExecuteCommandAsync(cancellationToken); } await InsertCommunityMessageIfNeededAsync(client, context, cancellationToken); } client.Ado.CommitTran(); } catch { client.Ado.RollbackTran(); throw; } logger.LogInformation("期刊任务答题记录保存完成,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}, Count: {Count}", data.UserId, data.JournalId, data.PageId, answerContexts.Count); } public Task OnErrorAsync(byte[] body, Exception exception) { var message = Encoding.UTF8.GetString(body); logger.LogError(exception, "处理期刊任务消息失败: {Message}", message); return Task.CompletedTask; } private async Task ScoreQuestionAsync( JournalScoreUnit scoreUnit, Dictionary> referenceAnswerMap, CancellationToken cancellationToken) { var apiKey = configuration["AiChat:ApiKey"]; var baseUrl = configuration["AiChat:BaseUrl"]; var model = configuration["AiChat:Model"]; var timeoutSeconds = configuration.GetValue("AiChat:TimeoutSeconds"); var maxTokens = configuration.GetValue("AiChat:MaxTokens"); var temperature = configuration.GetValue("AiChat:Temperature"); if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) { throw new InvalidOperationException("AI聊天服务配置不完整,请检查 AiChat 配置节点"); } var content = new List { new { type = "text", text = BuildScorePrompt(scoreUnit, referenceAnswerMap) } }; var totalAnswerImageCount = 0; foreach (var context in scoreUnit.Questions) { var answerImages = await BuildAnswerImageContentsAsync(context.Question, cancellationToken); totalAnswerImageCount += answerImages.Count; content.Add(new { type = "text", text = $"以下为任务 {context.Task.Id} 的学生作答图片,共 {answerImages.Count} 张。" }); foreach (var answerImage in answerImages) { content.Add(new { type = "image_url", image_url = new { url = answerImage.DataUrl } }); } } if (totalAnswerImageCount == 0) { throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片"); } foreach (var context in scoreUnit.Questions) { referenceAnswerMap.TryGetValue(context.Task.Id, out var referenceAnswers); var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(context.Task.Id, referenceAnswers ?? [], cancellationToken); if (referenceAnswerImages.Count == 0) { continue; } content.Add(new { type = "text", text = $"以下为任务 {context.Task.Id} 的参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有,评分时以题目Prompt和学生答案为主。" }); foreach (var referenceAnswerImage in referenceAnswerImages) { content.Add(new { type = "image_url", image_url = new { url = referenceAnswerImage.DataUrl } }); } } var requestBody = new { model, messages = new object[] { new { role = "system", content = "你是专业的学生作答评分助手。必须只返回合法 JSON,不要返回 Markdown、解释或代码块。" }, new { role = "user", content } }, max_tokens = maxTokens > 0 ? maxTokens : 2000, temperature = temperature is >= 0 ? temperature.Value : 0.1, stream = false, response_format = new { type = "json_object" } }; var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); var semaphore = GetAiSemaphore(); await semaphore.WaitAsync(cancellationToken); try { var scoreResult = await SendAiScoreRequestWithRetryAsync( scoreUnit.GroupId, $"{baseUrl.TrimEnd('/')}/chat/completions", apiKey, requestJson, timeoutSeconds > 0 ? timeoutSeconds : 300, cancellationToken); scoreResult.Result = TrimResult(scoreResult.Result); return scoreResult; } finally { semaphore.Release(); } } private static string BuildScorePrompt( JournalScoreUnit scoreUnit, Dictionary> referenceAnswerMap) { var referenceAnswerTexts = scoreUnit.Questions .SelectMany(q => { referenceAnswerMap.TryGetValue(q.Task.Id, out var answers); return answers ?? []; }) .Select(a => a.Answer?.Trim()) .Where(a => !string.IsNullOrWhiteSpace(a)) .Distinct() .ToList(); var prompt = new StringBuilder(); prompt.AppendLine(scoreUnit.Questions.Count > 1 ? "这是同一跨页题目的多页作答,请综合全部学生作答图片评分。" : "这是单页题目作答,请根据学生作答图片评分。"); prompt.AppendLine("参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。"); if (referenceAnswerTexts.Count > 0) { prompt.AppendLine("参考答案文本:"); for (var i = 0; i < referenceAnswerTexts.Count; i++) { prompt.AppendLine($"{i + 1}. {referenceAnswerTexts[i]}"); } } else { prompt.AppendLine("参考答案文本:无"); } prompt.AppendLine(); prompt.AppendLine("题目信息:"); foreach (var context in scoreUnit.Questions) { prompt.AppendLine($"- TaskId:{context.Task.Id}"); prompt.AppendLine($" 页码:{context.Page?.PageNum ?? 0}"); prompt.AppendLine($" 题号:{context.Task.No}"); prompt.AppendLine($" 题目内容:{context.Task.Task}"); prompt.AppendLine($" 评分Prompt:{context.Task.Prompt}"); prompt.AppendLine($" 成长值上限:{context.Task.GrowthPoint}"); prompt.AppendLine($" 积分上限:{context.Task.Points}"); prompt.AppendLine($" 理解力上限:{context.Task.Comprehension}"); prompt.AppendLine($" 判断力上限:{context.Task.Judgment}"); prompt.AppendLine($" 表达力上限:{context.Task.Expression}"); prompt.AppendLine($" 说服力上限:{context.Task.Persuasiveness}"); } prompt.AppendLine(); prompt.AppendLine("评分要求:"); prompt.AppendLine("- 不得超过题目配置中的各项上限。"); prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion,不要猜测高分。"); prompt.AppendLine("- Completion 表示作答完整度,范围 0-100。"); prompt.AppendLine("- Result 返回 50 字内中文评语。"); prompt.AppendLine(); prompt.AppendLine("只返回如下 JSON 字段:"); prompt.AppendLine("{"); prompt.AppendLine(" \"Score\": 0,"); prompt.AppendLine(" \"GrowthPoint\": 0,"); prompt.AppendLine(" \"Points\": 0,"); prompt.AppendLine(" \"Comprehension\": 0,"); prompt.AppendLine(" \"Judgment\": 0,"); prompt.AppendLine(" \"Expression\": 0,"); prompt.AppendLine(" \"Persuasiveness\": 0,"); prompt.AppendLine(" \"Completion\": 100,"); prompt.AppendLine(" \"Result\": \"50字内的中文评语\""); prompt.AppendLine("}"); return prompt.ToString(); } private async Task> BuildAnswerImageContentsAsync(Question question, CancellationToken cancellationToken) { var imageUrls = question.AnswerUrl?.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList() ?? []; var maxImageBytes = configuration.GetValue("AiChat:MaxImageBytes"); if (maxImageBytes <= 0) { maxImageBytes = DefaultMaxImageBytes; } var result = new List(); foreach (var imageUrl in imageUrls) { cancellationToken.ThrowIfCancellationRequested(); await using var imageStream = ossService.GetObjectStream(imageUrl); if (imageStream == null) { throw new InvalidOperationException($"答案图片读取失败,TaskId: {question.Id}, Url: {imageUrl}"); } using var memoryStream = new MemoryStream(); await imageStream.CopyToAsync(memoryStream, cancellationToken); if (memoryStream.Length == 0) { throw new InvalidOperationException($"答案图片内容为空,TaskId: {question.Id}, Url: {imageUrl}"); } if (memoryStream.Length > maxImageBytes) { throw new InvalidOperationException($"答案图片超过大小限制,TaskId: {question.Id}, Url: {imageUrl}, Size: {memoryStream.Length}"); } var imageBytes = memoryStream.ToArray(); var mimeType = GetImageMimeType(imageUrl, imageBytes); result.Add(new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}")); } return result; } private async Task> BuildReferenceAnswerImageContentsAsync( long taskId, List referenceAnswers, CancellationToken cancellationToken) { var imageUrls = referenceAnswers .Select(a => a.AnswerUrL) .Where(url => !string.IsNullOrWhiteSpace(url)) .Distinct() .Select(url => url!) .ToList(); var maxImageBytes = configuration.GetValue("AiChat:MaxImageBytes"); if (maxImageBytes <= 0) { maxImageBytes = DefaultMaxImageBytes; } var result = new List(); foreach (var imageUrl in imageUrls) { cancellationToken.ThrowIfCancellationRequested(); try { await using var imageStream = ossService.GetObjectStream(imageUrl); if (imageStream == null) { throw new InvalidOperationException($"参考答案图片读取失败,TaskId: {taskId}, Url: {imageUrl}"); } using var memoryStream = new MemoryStream(); await imageStream.CopyToAsync(memoryStream, cancellationToken); if (memoryStream.Length == 0) { throw new InvalidOperationException($"参考答案图片内容为空,TaskId: {taskId}, Url: {imageUrl}"); } if (memoryStream.Length > maxImageBytes) { throw new InvalidOperationException($"参考答案图片超过大小限制,TaskId: {taskId}, Url: {imageUrl}, Size: {memoryStream.Length}"); } var imageBytes = memoryStream.ToArray(); var mimeType = GetImageMimeType(imageUrl, imageBytes); result.Add(new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}")); } catch (OperationCanceledException) { throw; } catch (Exception ex) { logger.LogWarning(ex, "参考答案图片读取失败,已跳过,TaskId: {TaskId}, Url: {Url}", taskId, imageUrl); } } return result; } private async Task SendAiScoreRequestWithRetryAsync( long taskId, string requestUrl, string apiKey, string requestJson, int timeoutSeconds, CancellationToken cancellationToken) { var maxRetryCount = configuration.GetValue("AiChat:ScoreMaxRetryCount"); if (maxRetryCount <= 0) { maxRetryCount = DefaultAiScoreMaxRetryCount; } var retryDelayMilliseconds = configuration.GetValue("AiChat:ScoreRetryDelayMilliseconds"); if (retryDelayMilliseconds <= 0) { retryDelayMilliseconds = DefaultAiScoreRetryDelayMilliseconds; } Exception? lastException = null; for (var attempt = 1; attempt <= maxRetryCount; attempt++) { cancellationToken.ThrowIfCancellationRequested(); try { var httpClient = httpClientFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Content = new StringContent(requestJson, Encoding.UTF8, "application/json"); using var response = await httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { var resultJson = ExtractAssistantContent(responseContent); return ParseScoreResult(resultJson); } logger.LogWarning("AI评分调用失败,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}", taskId, attempt, maxRetryCount, response.StatusCode, responseContent); if (!ShouldRetry(response.StatusCode)) { throw new NonRetryAiScoreException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); } if (attempt == maxRetryCount) { throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (OperationCanceledException ex) when (attempt < maxRetryCount) { lastException = ex; logger.LogWarning(ex, "AI评分调用超时,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount); } catch (OperationCanceledException ex) { throw new InvalidOperationException($"AI评分调用超时,TaskId: {taskId}", ex); } catch (Exception ex) when (attempt < maxRetryCount && ex is not NonRetryAiScoreException) { lastException = ex; logger.LogWarning(ex, "AI评分调用异常,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount); } var delay = TimeSpan.FromMilliseconds(retryDelayMilliseconds * attempt); await Task.Delay(delay, cancellationToken); } throw new InvalidOperationException($"AI评分调用失败,TaskId: {taskId}", lastException); } private static bool ShouldRetry(System.Net.HttpStatusCode statusCode) { var status = (int)statusCode; return status == 408 || status == 429 || status >= 500; } private static string GetImageMimeType(string imageUrl, byte[] imageBytes) { if (imageBytes.Length >= 4) { if (imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47) { return "image/png"; } if (imageBytes[0] == 0xFF && imageBytes[1] == 0xD8) { return "image/jpeg"; } if (imageBytes[0] == 0x47 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46) { return "image/gif"; } if (imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46) { return "image/webp"; } } var extension = Path.GetExtension(imageUrl).ToLowerInvariant(); return extension switch { ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" }; } private static string ExtractAssistantContent(string responseContent) { using var document = JsonDocument.Parse(responseContent); var message = document.RootElement.GetProperty("choices")[0].GetProperty("message"); if (!message.TryGetProperty("content", out var contentElement)) { throw new InvalidOperationException("AI评分响应缺少 content"); } if (contentElement.ValueKind == JsonValueKind.String) { return contentElement.GetString() ?? string.Empty; } return contentElement.GetRawText(); } private static JournalAnswerScoreResult ParseScoreResult(string resultJson) { var cleanedJson = CleanJsonContent(resultJson); EnsureRequiredScoreFields(cleanedJson); var result = JsonSerializer.Deserialize(cleanedJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return result ?? throw new InvalidOperationException("AI评分结果解析失败"); } private static string CleanJsonContent(string content) { var text = content.Trim(); if (text.StartsWith("```", StringComparison.Ordinal)) { var firstLineEnd = text.IndexOf('\n'); if (firstLineEnd >= 0) { text = text[(firstLineEnd + 1)..]; } var fenceIndex = text.LastIndexOf("```", StringComparison.Ordinal); if (fenceIndex >= 0) { text = text[..fenceIndex]; } } var start = text.IndexOf('{'); var end = text.LastIndexOf('}'); if (start >= 0 && end > start) { text = text[start..(end + 1)]; } return text.Trim(); } private static void EnsureRequiredScoreFields(string json) { using var document = JsonDocument.Parse(json); var requiredFields = new[] { nameof(JournalAnswerScoreResult.Score), nameof(JournalAnswerScoreResult.GrowthPoint), nameof(JournalAnswerScoreResult.Points), nameof(JournalAnswerScoreResult.Comprehension), nameof(JournalAnswerScoreResult.Judgment), nameof(JournalAnswerScoreResult.Expression), nameof(JournalAnswerScoreResult.Persuasiveness), nameof(JournalAnswerScoreResult.Completion), nameof(JournalAnswerScoreResult.Result) }; foreach (var field in requiredFields) { if (!document.RootElement.EnumerateObject().Any(p => string.Equals(p.Name, field, StringComparison.OrdinalIgnoreCase))) { throw new InvalidOperationException($"AI评分结果缺少字段:{field}"); } } } private static JournalAnswerScoreResult NormalizeScoreResult(JournalAnswerScoreResult scoreResult, JournalPageTask task) { var scoreMax = task.Comprehension + task.Judgment + task.Expression + task.Persuasiveness; if (scoreMax <= 0) { scoreMax = 100; } return new JournalAnswerScoreResult { Score = Clamp(scoreResult.Score, 0, scoreMax), GrowthPoint = (int)Clamp(scoreResult.GrowthPoint, 0, task.GrowthPoint), Points = (int)Clamp(scoreResult.Points, 0, task.Points), Comprehension = Clamp(scoreResult.Comprehension, 0, task.Comprehension), Judgment = Clamp(scoreResult.Judgment, 0, task.Judgment), Expression = Clamp(scoreResult.Expression, 0, task.Expression), Persuasiveness = Clamp(scoreResult.Persuasiveness, 0, task.Persuasiveness), Completion = Clamp(scoreResult.Completion, 0, 100), Result = TrimResult(scoreResult.Result) }; } private static float Clamp(float value, float min, float max) { if (float.IsNaN(value) || float.IsInfinity(value)) { return min; } if (max < min) { max = min; } return Math.Min(Math.Max(value, min), max); } private static JournalAnswerScoreResult BuildFailureScoreResult() { return new JournalAnswerScoreResult { Completion = 0, Result = AiProcessingMessage }; } private static List BuildScoreUnits(List contexts) { return contexts .GroupBy(c => c.Task.GroupId > 0 ? c.Task.GroupId : c.Task.Id) .Select(g => new JournalScoreUnit( g.Key, g.OrderBy(c => c.Page?.PageNum ?? 0) .ThenBy(c => ParseTaskNo(c.Task.No)) .ThenBy(c => c.Task.Id) .ToList())) .ToList(); } private static JournalQuestionContext? BuildPersistedContext( JournalScoreUnit scoreUnit, Dictionary persistedTaskMap, Dictionary pageMap) { if (scoreUnit.Questions.Count == 0) { return null; } var sourceContext = scoreUnit.Questions.FirstOrDefault(q => q.Task.Id == scoreUnit.GroupId) ?? scoreUnit.Questions.First(); var persistedTask = persistedTaskMap.TryGetValue(scoreUnit.GroupId, out var task) ? task : sourceContext.Task; pageMap.TryGetValue(persistedTask.JournalPageId, out var persistedPage); return new JournalQuestionContext(sourceContext.Question, persistedTask, persistedPage); } private static int ParseTaskNo(string? taskNo) { return int.TryParse(taskNo, out var no) ? no : int.MaxValue; } private static bool IsCompletedSameAnswer( JournalScoreUnit scoreUnit, JournalPageTask persistedTask, QuestionData data, Dictionary existingAnswerMap) { return scoreUnit.Questions.Count > 0 && existingAnswerMap.TryGetValue(persistedTask.Id, out var existing) && existing.Status == (int)UserAnswerStatusEnum.Complete && string.Equals(existing.PageAnswerUrl ?? string.Empty, data.PageAnswerUrl ?? string.Empty, StringComparison.Ordinal) && string.Equals(existing.AnswerUrl ?? string.Empty, SerializeAnswerUrls(scoreUnit), StringComparison.Ordinal) && existing.AnswerStartTime == GetAnswerStartTime(scoreUnit) && existing.AnswerEndTime == GetAnswerEndTime(scoreUnit); } private SemaphoreSlim GetAiSemaphore() { var maxConcurrency = configuration.GetValue("AiChat:MaxConcurrency"); if (maxConcurrency <= 0) { maxConcurrency = DefaultAiMaxConcurrency; } lock (AiSemaphoreLock) { if (aiSemaphore == null || aiSemaphoreLimit != maxConcurrency) { aiSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); aiSemaphoreLimit = maxConcurrency; } return aiSemaphore; } } private void LogSkippedGroupTasks(JournalScoreUnit scoreUnit, JournalPageTask persistedTask) { var skippedTaskIds = scoreUnit.Questions .Select(q => q.Task.Id) .Where(id => id != persistedTask.Id) .Distinct() .ToList(); if (skippedTaskIds.Count == 0) { return; } logger.LogInformation("期刊跨页题评分结果仅保存到主任务,GroupId: {GroupId}, PersistedTaskId: {PersistedTaskId}, SkippedTaskIds: {SkippedTaskIds}", scoreUnit.GroupId, persistedTask.Id, string.Join(",", skippedTaskIds)); } private static string SerializeAnswerUrls(JournalScoreUnit scoreUnit) { var answerUrls = scoreUnit.Questions .SelectMany(q => q.Question.AnswerUrl ?? []) .Where(url => !string.IsNullOrWhiteSpace(url)) .Distinct() .ToArray(); return JsonSerializer.Serialize(answerUrls); } private static DateTime GetAnswerStartTime(JournalScoreUnit scoreUnit) { var startTimes = scoreUnit.Questions .Select(q => q.Question.AnswerStartTime) .Where(t => t != default) .ToList(); return startTimes.Count == 0 ? default : startTimes.Min(); } private static DateTime GetAnswerEndTime(JournalScoreUnit scoreUnit) { var endTimes = scoreUnit.Questions .Select(q => q.Question.AnswerEndTime) .Where(t => t != default) .ToList(); return endTimes.Count == 0 ? default : endTimes.Max(); } private static int GetAnswerSeconds(JournalScoreUnit scoreUnit) { return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.AnswerTime)); } private static int GetBreakCount(JournalScoreUnit scoreUnit) { return scoreUnit.Questions.Sum(q => Math.Max(0, q.Question.BreakCount)); } private static string SerializeBreakTimes(JournalScoreUnit scoreUnit) { var breakTimes = scoreUnit.Questions .SelectMany(q => q.Question.BreakTimes ?? []) .ToList(); return JsonSerializer.Serialize(breakTimes); } private static JournalPageTaskUserAnswer BuildAnswerEntity( QuestionData data, JournalScoreUnit scoreUnit, Question question, JournalPageTask task, JournalPage? page, JournalAnswerScoreResult scoreResult, float completionThreshold) { var now = DateTime.Now; var growthPoint = Math.Max(0, scoreResult.GrowthPoint); var points = Math.Max(0, scoreResult.Points); var answerStatus = scoreResult.Completion >= completionThreshold ? UserAnswerStatusEnum.Complete : UserAnswerStatusEnum.Processing; return new JournalPageTaskUserAnswer { JournalId = data.JournalId, JournalPageId = data.PageId, JournalPageTaskId = task.Id, JournalPageTaskGroupId = task.GroupId, UserId = data.UserId, Result = scoreResult.Result, Points = points, GrowthPoints = growthPoint, Score = Math.Max(0, scoreResult.Score), Comprehension = Math.Max(0, scoreResult.Comprehension), Judgment = Math.Max(0, scoreResult.Judgment), Expression = Math.Max(0, scoreResult.Expression), Persuasiveness = Math.Max(0, scoreResult.Persuasiveness), QuestionAnswerUrl = question.Url, AnswerUrl = SerializeAnswerUrls(scoreUnit), PageAnswerUrl = data.PageAnswerUrl, Revision = 0, AnswerStartTime = GetAnswerStartTime(scoreUnit), AnswerEndTime = GetAnswerEndTime(scoreUnit), AnswerSeconds = GetAnswerSeconds(scoreUnit), ImageRecognition = 0, JournalPageNum = page?.PageNum ?? 0, Modify = 0, LastTag = 0, DotPageNum = page?.PageNum ?? 0, PageResultUrl = string.Empty, Type = task.Type.ToString(), DotPageNo = page?.PageNo ?? string.Empty, PageAnswerDotUrl = string.Empty, BreakCount = GetBreakCount(scoreUnit), BreakTimes = SerializeBreakTimes(scoreUnit), AssignmentStatus = (int)AssignmentStatusEnum.UnAssignmented, Status = (int)answerStatus, CreatedBy = data.UserId.ToString(), CreatedAt = data.CreatedTime == default ? now : data.CreatedTime, UpdatedBy = data.UserId.ToString(), UpdatedAt = now }; } private static JournalPageTaskUserAnswerSnapshot BuildAnswerSnapshot(JournalPageTaskUserAnswer answer) { var now = DateTime.Now; return new JournalPageTaskUserAnswerSnapshot { JournalPageTaskUserAnswerId = answer.Id, JournalId = answer.JournalId, JournalPageId = answer.JournalPageId, JournalPageTaskId = answer.JournalPageTaskId, JournalPageTaskGroupId = answer.JournalPageTaskGroupId, UserId = answer.UserId, Result = answer.Result, Points = answer.Points, GrowthPoints = answer.GrowthPoints, Score = answer.Score, Comprehension = answer.Comprehension, Judgment = answer.Judgment, Expression = answer.Expression, Persuasiveness = answer.Persuasiveness, QuestionAnswerUrl = answer.QuestionAnswerUrl, AnswerUrl = answer.AnswerUrl, PageAnswerUrl = answer.PageAnswerUrl, Revision = answer.Revision, AnswerStartTime = answer.AnswerStartTime, AnswerEndTime = answer.AnswerEndTime, AnswerSeconds = answer.AnswerSeconds, ImageRecognition = answer.ImageRecognition, JournalPageNum = answer.JournalPageNum, Modify = answer.Modify, LastTag = answer.LastTag, DotPageNum = answer.DotPageNum, PageResultUrl = answer.PageResultUrl, Type = answer.Type, DotPageNo = answer.DotPageNo, PageAnswerDotUrl = answer.PageAnswerDotUrl, BreakCount = answer.BreakCount, BreakTimes = answer.BreakTimes, AssignmentStatus = answer.AssignmentStatus, Status = answer.Status, CreatedBy = answer.UpdatedBy ?? answer.CreatedBy ?? string.Empty, CreatedAt = now, UpdatedBy = answer.UpdatedBy ?? string.Empty, UpdatedAt = now }; } private async Task InsertCommunityMessageIfNeededAsync( ISqlSugarClient client, JournalAnswerContext context, CancellationToken cancellationToken) { var scoreThreshold = configuration.GetValue("AiChat:CommunityScoreThreshold"); if (scoreThreshold <= 0) { scoreThreshold = DefaultCommunityScoreThreshold; } if (context.Answer.Status != (int)UserAnswerStatusEnum.Complete || context.ScoreResult.Score < scoreThreshold) { return; } var exists = await client.Queryable() .Where(m => m.JournalTaskAnswerId == context.Answer.Id && !m.IsDeleted) .AnyAsync(cancellationToken); if (exists) { return; } var userJournal = await client.Queryable() .Where(uj => uj.UserId == context.Answer.UserId && uj.JournalId == context.Answer.JournalId && !uj.IsDeleted) .FirstAsync(cancellationToken); if (userJournal == null) { logger.LogWarning("高分答案未找到用户期刊关系,跳过社区消息写入,UserId: {UserId}, JournalId: {JournalId}, AnswerId: {AnswerId}", context.Answer.UserId, context.Answer.JournalId, context.Answer.Id); return; } var now = DateTime.Now; var communityMessage = new CommunityMessage { JournalId = context.Answer.JournalId, UserId = context.Answer.UserId, UserJournalId = userJournal.Id, JournalTaskId = context.Answer.JournalPageTaskId, JournalTaskAnswerId = context.Answer.Id, Content = context.ScoreResult.Result, ImageUrl = context.Question.AnswerUrl.FirstOrDefault() ?? string.Empty, SortOrder = 0, IsActive = true, Type = MessageTypeEnum.Message, LikeCount = 0, IsFeatured = 0, Status = 1, CreatedBy = context.Answer.UserId.ToString(), CreatedAt = now, UpdatedBy = context.Answer.UserId.ToString(), UpdatedAt = now }; await client.Insertable(communityMessage).ExecuteCommandAsync(cancellationToken); } private float GetCompletionThreshold() { var threshold = configuration.GetValue("AiChat:CompletionThreshold"); if (threshold <= 0) { threshold = DefaultCompletionThreshold; } return threshold; } private static string TrimResult(string? result) { if (string.IsNullOrWhiteSpace(result)) { return string.Empty; } return result.Length <= 50 ? result : result[..50]; } } /// /// 期刊题目作答消息 /// public class QuestionData { /// /// 用户ID /// public long UserId { get; set; } /// /// 学生ID /// public long StudentId { get; set; } /// /// 期刊ID /// public long JournalId { get; set; } /// /// 作业ID /// public long HomeworkId { get; set; } /// /// 书籍ID /// public long BookId { get; set; } /// /// 页ID /// public long PageId { get; set; } /// /// 页面作答图片地址 /// public string PageAnswerUrl { get; set; } = string.Empty; /// /// 题目作答列表 /// public Question[] Questions { get; set; } = []; /// /// 创建时间 /// public DateTime CreatedTime { get; set; } /// /// 兼容新版作答消息字段 /// public void Normalize() { if (UserId <= 0) { UserId = StudentId; } if (JournalId <= 0) { JournalId = BookId > 0 ? BookId : HomeworkId; } } } /// /// 题目作答数据 /// public class Question { /// /// 题目ID /// public long Id { get; set; } /// /// 题目图片地址 /// public string Url { get; set; } = string.Empty; /// /// 答案图片地址 /// public string[] AnswerUrl { get; set; } = []; /// /// 作答开始时间 /// public DateTime AnswerStartTime { get; set; } /// /// 作答结束时间 /// public DateTime AnswerEndTime { get; set; } /// /// 作答耗时秒数 /// public int AnswerTime { get; set; } /// /// 中断次数 /// public int BreakCount { get; set; } /// /// 中断记录 /// public List BreakTimes { get; set; } = []; } /// /// 中断时间记录 /// public class BreakTime { /// /// 中断时间 /// public DateTime Time { get; set; } /// /// 等待时间 /// public long WaitTime { get; set; } } /// /// AI评分结果 /// public class JournalAnswerScoreResult { /// /// 题目得分 /// public float Score { get; set; } /// /// 成长值 /// public int GrowthPoint { get; set; } /// /// 积分 /// public int Points { get; set; } /// /// 理解力评分 /// public float Comprehension { get; set; } /// /// 判断力评分 /// public float Judgment { get; set; } /// /// 表达力评分 /// public float Expression { get; set; } /// /// 说服力评分 /// public float Persuasiveness { get; set; } /// /// 瀹屾垚搴? /// public float Completion { get; set; } /// /// 50字内评语 /// public string Result { get; set; } = string.Empty; } public record JournalAnswerContext( JournalPageTaskUserAnswer Answer, Question Question, JournalPageTask Task, JournalAnswerScoreResult ScoreResult); public record JournalQuestionContext( Question Question, JournalPageTask Task, JournalPage? Page); public record JournalScoreUnit( long GroupId, List Questions); public record AnswerImageContent(string DataUrl); /// /// AI评分不可重试异常。 /// public class NonRetryAiScoreException(string message) : Exception(message);