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 long DefaultMaxImageBytes = 10 * 1024 * 1024; 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("期刊任务消息内容为空"); 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 referenceAnswers = await client.Queryable() .Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) .ToListAsync(cancellationToken); var referenceAnswerMap = referenceAnswers .GroupBy(a => a.JournalPageTaskId) .ToDictionary(g => g.Key, g => g.ToList()); var page = await client.Queryable() .Where(p => p.Id == data.PageId && !p.IsDeleted) .FirstAsync(cancellationToken); var answerEntities = 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; } referenceAnswerMap.TryGetValue(task.Id, out var taskReferenceAnswers); var scoreResult = await ScoreQuestionAsync(task, question, taskReferenceAnswers ?? [], cancellationToken); answerEntities.Add(BuildAnswerEntity(data, question, task, page, scoreResult)); } if (answerEntities.Count == 0) { logger.LogWarning("期刊任务消息没有可入库的答题记录,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}", data.UserId, data.JournalId, data.PageId); return; } client.Ado.BeginTran(); try { foreach (var answer in answerEntities) { 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); continue; } 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); } client.Ado.CommitTran(); } catch { client.Ado.RollbackTran(); throw; } logger.LogInformation("期刊任务答题记录保存完成,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}, Count: {Count}", data.UserId, data.JournalId, data.PageId, answerEntities.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( JournalPageTask task, Question question, List referenceAnswers, 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 answerImages = await BuildAnswerImageContentsAsync(question, cancellationToken); if (answerImages.Count == 0) { throw new InvalidOperationException($"题目 {question.Id} 缺少答案图片"); } var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync(task.Id, referenceAnswers, cancellationToken); var content = new List { new { type = "text", text = BuildScorePrompt(task, question, answerImages.Count, referenceAnswers, referenceAnswerImages.Count) } }; foreach (var answerImage in answerImages) { content.Add(new { type = "image_url", image_url = new { url = answerImage.DataUrl } }); } if (referenceAnswerImages.Count > 0) { content.Add(new { type = "text", text = $"以下为参考答案图片,共 {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 > 0 ? temperature : 0.2, stream = false, response_format = new { type = "json_object" } }; var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); var responseContent = await SendAiScoreRequestWithRetryAsync( task.Id, $"{baseUrl.TrimEnd('/')}/chat/completions", apiKey, requestJson, timeoutSeconds > 0 ? timeoutSeconds : 300, cancellationToken); var resultJson = ExtractAssistantContent(responseContent); var scoreResult = ParseScoreResult(resultJson); scoreResult.Result = TrimResult(scoreResult.Result); return scoreResult; } private static string BuildScorePrompt( JournalPageTask task, Question question, int answerImageCount, List referenceAnswers, int referenceAnswerImageCount) { var referenceAnswerTexts = referenceAnswers .Select(a => a.Answer?.Trim()) .Where(a => !string.IsNullOrWhiteSpace(a)) .Distinct() .ToList(); var prompt = new StringBuilder(); prompt.AppendLine("参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。"); prompt.AppendLine($"参考答案图片数量:{referenceAnswerImageCount}"); 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("请根据题目评分 Prompt 和学生答案图片进行评分。"); prompt.AppendLine(); prompt.AppendLine($"题目内容:{task.Task}"); prompt.AppendLine("评分Prompt:"); prompt.AppendLine(task.Prompt); prompt.AppendLine(); prompt.AppendLine("题目配置:"); prompt.AppendLine($"- 成长值上限:{task.GrowthPoint ?? 0}"); prompt.AppendLine($"- 积分上限:{task.Points}"); prompt.AppendLine($"- 理解力上限:{task.Comprehension}"); prompt.AppendLine($"- 判断力上限:{task.Judgment}"); prompt.AppendLine($"- 表达力上限:{task.Expression}"); prompt.AppendLine($"- 说服力上限:{task.Persuasiveness}"); prompt.AppendLine(); prompt.AppendLine($"学生答案图片数量:{answerImageCount}"); 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(" \"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) { return responseContent; } logger.LogWarning("AI评分调用失败,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}", taskId, attempt, maxRetryCount, response.StatusCode, responseContent); if (!ShouldRetry(response.StatusCode) || attempt == maxRetryCount) { throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); } } catch (OperationCanceledException) { throw; } catch (Exception ex) when (attempt < maxRetryCount) { 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); 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 JournalPageTaskUserAnswer BuildAnswerEntity( QuestionData data, Question question, JournalPageTask task, JournalPage? page, JournalAnswerScoreResult scoreResult) { var now = DateTime.Now; var growthPoint = Math.Max(0, scoreResult.GrowthPoint); var points = Math.Max(0, scoreResult.Points); 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 = JsonSerializer.Serialize(question.AnswerUrl ?? []), PageAnswerUrl = data.PageAnswerUrl, Revision = 0, AnswerStatus = (int)UserAnswerStatusEnum.Complete, AnswerStartTime = question.AnswerStartTime, AnswerEndTime = question.AnswerEndTime, AnswerSeconds = question.AnswerTime, 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 = question.BreakCount, BreakTimes = JsonSerializer.Serialize(question.BreakTimes ?? []), AssignmentStatus = UserAnswerStatusEnum.Complete.ToString(), Status = (int)UserAnswerStatusEnum.Complete, 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, AnswerStatus = answer.AnswerStatus, 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 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 JournalId { 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 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; } /// /// 50字内评语 /// public string Result { get; set; } = string.Empty; } public record AnswerImageContent(string DataUrl);