Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs

819 lines
29 KiB
C#
Raw Normal View History

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;
/// <summary>
/// 期刊任务接收消费者
/// </summary>
public class JournalTaskReceiveConsumer(
ILogger<JournalTaskReceiveConsumer> 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<ISqlSugarClient>();
var data = JsonSerializer.Deserialize<QuestionData>(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<JournalPageTask>()
.Where(t => taskIds.Contains(t.Id) && !t.IsDeleted)
.ToListAsync(cancellationToken);
var taskMap = tasks.ToDictionary(t => t.Id);
var referenceAnswers = await client.Queryable<JournalPageTaskAnswer>()
.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<JournalPage>()
.Where(p => p.Id == data.PageId && !p.IsDeleted)
.FirstAsync(cancellationToken);
var answerEntities = new List<JournalPageTaskUserAnswer>();
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<JournalPageTaskUserAnswer>()
.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<JournalAnswerScoreResult> ScoreQuestionAsync(
JournalPageTask task,
Question question,
List<JournalPageTaskAnswer> referenceAnswers,
CancellationToken cancellationToken)
{
var apiKey = configuration["AiChat:ApiKey"];
var baseUrl = configuration["AiChat:BaseUrl"];
var model = configuration["AiChat:Model"];
var timeoutSeconds = configuration.GetValue<int>("AiChat:TimeoutSeconds");
var maxTokens = configuration.GetValue<int>("AiChat:MaxTokens");
var temperature = configuration.GetValue<double>("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<object>
{
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<JournalPageTaskAnswer> 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<List<AnswerImageContent>> BuildAnswerImageContentsAsync(Question question, CancellationToken cancellationToken)
{
var imageUrls = question.AnswerUrl?.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList() ?? [];
var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
if (maxImageBytes <= 0)
{
maxImageBytes = DefaultMaxImageBytes;
}
var result = new List<AnswerImageContent>();
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<List<AnswerImageContent>> BuildReferenceAnswerImageContentsAsync(
long taskId,
List<JournalPageTaskAnswer> referenceAnswers,
CancellationToken cancellationToken)
{
var imageUrls = referenceAnswers
.Select(a => a.AnswerUrL)
.Where(url => !string.IsNullOrWhiteSpace(url))
.Distinct()
.Select(url => url!)
.ToList();
var maxImageBytes = configuration.GetValue<long>("AiChat:MaxImageBytes");
if (maxImageBytes <= 0)
{
maxImageBytes = DefaultMaxImageBytes;
}
var result = new List<AnswerImageContent>();
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<string> SendAiScoreRequestWithRetryAsync(
long taskId,
string requestUrl,
string apiKey,
string requestJson,
int timeoutSeconds,
CancellationToken cancellationToken)
{
var maxRetryCount = configuration.GetValue<int>("AiChat:ScoreMaxRetryCount");
if (maxRetryCount <= 0)
{
maxRetryCount = DefaultAiScoreMaxRetryCount;
}
var retryDelayMilliseconds = configuration.GetValue<int>("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<JournalAnswerScoreResult>(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,
2026-07-01 09:20:07 +08:00
Revision = 0,
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,
2026-07-01 09:20:07 +08:00
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 static string TrimResult(string? result)
{
if (string.IsNullOrWhiteSpace(result))
{
return string.Empty;
}
return result.Length <= 50 ? result : result[..50];
}
}
/// <summary>
/// 期刊题目作答消息
/// </summary>
public class QuestionData
{
/// <summary>
/// 用户ID
/// </summary>
public long UserId { get; set; }
/// <summary>
/// 期刊ID
/// </summary>
public long JournalId { get; set; }
/// <summary>
/// 页ID
/// </summary>
public long PageId { get; set; }
/// <summary>
/// 页面作答图片地址
/// </summary>
public string PageAnswerUrl { get; set; } = string.Empty;
/// <summary>
/// 题目作答列表
/// </summary>
public Question[] Questions { get; set; } = [];
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
}
/// <summary>
/// 题目作答数据
/// </summary>
public class Question
{
/// <summary>
/// 题目ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 题目图片地址
/// </summary>
public string Url { get; set; } = string.Empty;
/// <summary>
/// 答案图片地址
/// </summary>
public string[] AnswerUrl { get; set; } = [];
/// <summary>
/// 作答开始时间
/// </summary>
public DateTime AnswerStartTime { get; set; }
/// <summary>
/// 作答结束时间
/// </summary>
public DateTime AnswerEndTime { get; set; }
/// <summary>
/// 作答耗时秒数
/// </summary>
public int AnswerTime { get; set; }
/// <summary>
/// 中断次数
/// </summary>
public int BreakCount { get; set; }
/// <summary>
/// 中断记录
/// </summary>
public List<BreakTime> BreakTimes { get; set; } = [];
}
/// <summary>
/// 中断时间记录
/// </summary>
public class BreakTime
{
/// <summary>
/// 中断时间
/// </summary>
public DateTime Time { get; set; }
/// <summary>
/// 等待时间
/// </summary>
public long WaitTime { get; set; }
}
/// <summary>
/// AI评分结果
/// </summary>
public class JournalAnswerScoreResult
{
/// <summary>
/// 题目得分
/// </summary>
public float Score { get; set; }
/// <summary>
/// 成长值
/// </summary>
public int GrowthPoint { get; set; }
/// <summary>
/// 积分
/// </summary>
public int Points { get; set; }
/// <summary>
/// 理解力评分
/// </summary>
public float Comprehension { get; set; }
/// <summary>
/// 判断力评分
/// </summary>
public float Judgment { get; set; }
/// <summary>
/// 表达力评分
/// </summary>
public float Expression { get; set; }
/// <summary>
/// 说服力评分
/// </summary>
public float Persuasiveness { get; set; }
/// <summary>
/// 50字内评语
/// </summary>
public string Result { get; set; } = string.Empty;
}
public record AnswerImageContent(string DataUrl);