diff --git a/QYZH.InteractiveMagazine.IService/IJournalService.cs b/QYZH.InteractiveMagazine.IService/IJournalService.cs index 1f16412..153e7db 100644 --- a/QYZH.InteractiveMagazine.IService/IJournalService.cs +++ b/QYZH.InteractiveMagazine.IService/IJournalService.cs @@ -49,6 +49,8 @@ public interface IJournalService : IBaseService Task StatusAsync(long id, JournalStatusEnum status); + Task PublishAsync(long id); + Task PrintCodeAsync(long id); Task ResultReportAsync(DotMatrixNoteJournalReportInput input); diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs index 0b14eaf..31329c7 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalDto.cs @@ -120,8 +120,33 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// public string? DownloadJournalPagePdfName { get; set; } + /// + /// 发布开始时间 + /// + public DateTime? StartTime { get; set; } + + /// + /// 发布结束时间 + /// + public DateTime? EndTime { get; set; } + public string CreatedBy { get; set; } public DateTime CreatedAt { get; set; } } + + public class JournalPublishBookMessage + { + public long BookId { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + } + + public class JournalPublishBookPageMessage + { + public long BookId { get; set; } + public long PageId { get; set; } + public string? PageNo { get; set; } + public string? Layout { get; set; } + } } diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalEditDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalEditDto.cs index a06f000..7a57d4c 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalEditDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalEditDto.cs @@ -69,6 +69,16 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// public string? PdfPreviewUrl { get; set; } + /// + /// Desc:发布开始时间 + /// + public DateTime? StartTime { get; set; } + + /// + /// Desc:发布结束时间 + /// + public DateTime? EndTime { get; set; } + } public partial class JournalEditDto: JournalAddDto { diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs index e910085..a6dda42 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskAddInput.cs @@ -29,5 +29,10 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// public TaskBankTypeEnum Type { get; set; } + /// + /// 是否需要AI批改,默认需要 + /// + public bool NeedAiProcess { get; set; } = true; + } } diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs index 7bb9e41..44f11b6 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskOutput.cs @@ -74,6 +74,11 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// Prompt配置 /// public string Prompt { get; set; } + + /// + /// 是否需要AI批改 + /// + public bool NeedAiProcess { get; set; } } /// diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs index c797da2..9cbaf60 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/JournalPageTaskUpdateInput.cs @@ -71,6 +71,11 @@ namespace QYZH.InteractiveMagazine.Models.Dto.Journal /// public string Prompt { get; set; } + /// + /// 是否需要AI批改,false时由人工批改 + /// + public bool NeedAiProcess { get; set; } = true; + } public class JournalPageTaskComplementInput diff --git a/QYZH.InteractiveMagazine.Models/Entity/Journal.cs b/QYZH.InteractiveMagazine.Models/Entity/Journal.cs index 2a97b97..4e1cb8b 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/Journal.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/Journal.cs @@ -106,6 +106,20 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// public string DownloadJournalPagePdfName { get; set; } + /// + /// Desc:发布日期开始时间 + /// Default: + /// Nullable:True + /// + public DateTime? StartTime { get; set; } + + /// + /// Desc:发布日期结束时间 + /// Default: + /// Nullable:True + /// + public DateTime? EndTime { get; set; } + /// /// Desc:书籍描述/简介 diff --git a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs index 737e93c..9d22c65 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs @@ -3,9 +3,9 @@ using SqlSugar; namespace QYZH.InteractiveMagazine.Models.Entity { - /// - ///杂志任务用户作答记录; - /// + /// + /// 杂志任务用户作答记录 + /// [SugarTable("JournalPageTaskUserAnswer")] public class JournalPageTaskUserAnswer : SqlSugarBaseEntity { @@ -13,91 +13,119 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// Desc:书id /// Default: /// Nullable:False - /// + /// public long JournalId { get; set; } /// /// Desc:书页id /// Default: /// Nullable:False - /// + /// public long JournalPageId { get; set; } /// /// Desc:任务id /// Default: /// Nullable:False - /// + /// public long JournalPageTaskId { get; set; } /// /// Desc:任务分组id /// Default: /// Nullable:False - /// + /// public long JournalPageTaskGroupId { get; set; } /// /// Desc:用户id /// Default: /// Nullable:False - /// + /// public long UserId { get; set; } /// /// Desc:作答结果 /// Default: /// Nullable:True - /// + /// public string Result { get; set; } /// /// Desc:积分 /// Default:0 /// Nullable:False - /// - public float Points { get; set; } + /// + public int Points { get; set; } /// /// Desc:成长值 /// Default:0 /// Nullable:False - /// + /// public float GrowthPoints { get; set; } /// /// Desc:题目得分 /// Default:0 /// Nullable:False - /// + /// public float Score { get; set; } + /// + /// Desc:理解力评分 + /// Default:0 + /// Nullable:False + /// + public float Comprehension { get; set; } + + /// + /// Desc:判断力评分 + /// Default:0 + /// Nullable:False + /// + public float Judgment { get; set; } + + /// + /// Desc:表达力评分 + /// Default:0 + /// Nullable:False + /// + public float Expression { get; set; } + + /// + /// Desc:说服力评分 + /// Default:0 + /// Nullable:False + /// + public float Persuasiveness { get; set; } + /// /// Desc:题目作答图片地址 /// Default: /// Nullable:True - /// + /// public string QuestionAnswerUrl { get; set; } /// /// Desc:答案图片地址 /// Default: /// Nullable:True - /// + /// public string AnswerUrl { get; set; } /// /// Desc:页面作答图片地址 /// Default: /// Nullable:True - /// + /// public string PageAnswerUrl { get; set; } /// /// Desc:乐观锁 /// Default: /// Nullable:False - /// + /// public int Revision { get; set; } /// @@ -111,106 +139,105 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// Desc:作答开始时间 /// Default: /// Nullable:True - /// + /// public DateTime AnswerStartTime { get; set; } /// /// Desc:作答结束时间 /// Default: /// Nullable:True - /// + /// public DateTime AnswerEndTime { get; set; } /// /// Desc:作答耗时(秒) /// Default:0 /// Nullable:False - /// + /// public int AnswerSeconds { get; set; } /// /// Desc:图像识别结果 /// Default: /// Nullable:False - /// + /// public int ImageRecognition { get; set; } /// /// Desc:书页序号 /// Default: /// Nullable:False - /// + /// public int JournalPageNum { get; set; } /// /// Desc:修改次数 /// Default: /// Nullable:False - /// + /// public int Modify { get; set; } /// /// Desc:最后标签id /// Default: /// Nullable:False - /// + /// public long LastTag { get; set; } /// /// Desc:点读书页序号 /// Default: /// Nullable:False - /// + /// public int DotPageNum { get; set; } /// /// Desc:页面结果图片地址 /// Default: /// Nullable:True - /// + /// public string PageResultUrl { get; set; } /// /// Desc:题型 /// Default: /// Nullable:True - /// + /// public string Type { get; set; } /// /// Desc:点读书页编号 /// Default: /// Nullable:True - /// + /// public string DotPageNo { get; set; } /// /// Desc:页面作答点读图片地址 /// Default: /// Nullable:True - /// + /// public string PageAnswerDotUrl { get; set; } /// /// Desc:中断次数 /// Default:0 /// Nullable:False - /// + /// public int BreakCount { get; set; } /// /// Desc:中断时间记录 /// Default: /// Nullable:True - /// + /// public string BreakTimes { get; set; } /// /// Desc:作业状态 /// Default: /// Nullable:True - /// + /// public string AssignmentStatus { get; set; } - } } diff --git a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs index 300176e..6bf3ef3 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswerSnapshot.cs @@ -46,7 +46,7 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// /// Points awarded. /// - public float Points { get; set; } + public int Points { get; set; } /// /// Growth points awarded. @@ -58,6 +58,26 @@ namespace QYZH.InteractiveMagazine.Models.Entity /// public float Score { get; set; } + /// + /// Comprehension score. + /// + public float Comprehension { get; set; } + + /// + /// Judgment score. + /// + public float Judgment { get; set; } + + /// + /// Expression score. + /// + public float Expression { get; set; } + + /// + /// Persuasiveness score. + /// + public float Persuasiveness { get; set; } + /// /// Question answer image URL. /// diff --git a/QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs b/QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs new file mode 100644 index 0000000..5580094 --- /dev/null +++ b/QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs @@ -0,0 +1,338 @@ +using QYZH.InteractiveMagazine.Common.Extensions; +using QYZH.InteractiveMagazine.Infrastructure.OSS; +using QYZH.InteractiveMagazine.Models.Dto.Journal; +using QYZH.InteractiveMagazine.Models.Entity; +using QYZH.InteractiveMagazine.Models.Enum; +using SqlSugar; +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace QYZH.InteractiveMagazine.PrintWorker.Consumers; + +/// +/// 自动铺码消费者。 +/// +public class AutoDotCodeConsumer( + IConfiguration configuration, + IServiceScopeFactory scopeFactory, + IHostEnvironment hostEnvironment, + ILogger logger, + IHttpClientFactory httpClientFactory, + OssService ossService) : IQueueConsumer +{ + /// + /// 交换机名称。 + /// + public string Exchange => "ex.journal"; + + /// + /// 队列名称。 + /// + public string QueueName => "mq.journal.dotcode.auto"; + + /// + /// 路由键。 + /// + public string RoutingKey => "rk.journal.dotcode.auto"; + + /// + /// 处理自动铺码消息。 + /// + 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 dbContext = scope.ServiceProvider.GetRequiredService(); + + try + { + var request = JsonSerializer.Deserialize(message, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + if (request == null) + { + return; + } + + var workDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "JournalPagePdf"); + var uploadDirectory = Path.Combine(workDirectory, "upload"); + var downloadDirectory = Path.Combine(workDirectory, "download"); + Directory.CreateDirectory(uploadDirectory); + Directory.CreateDirectory(downloadDirectory); + + var uploadFileName = $"upload_{request.JournalId}_{DateTime.Now:yyyyMMddHHmmssffffff}{Random.Shared.Next(1000, 9999)}.pdf"; + var uploadFilePath = Path.Combine(uploadDirectory, uploadFileName); + + var journalPdfKey = request.JournalPdfUrl?.RemoveDomain(); + if (string.IsNullOrWhiteSpace(journalPdfKey)) + { + logger.LogError("书籍上传 PDF 文件地址为空,JournalId: {JournalId}", request.JournalId); + return; + } + + await using var pdfStream = ossService.GetObjectStream(journalPdfKey); + if (pdfStream == null) + { + logger.LogError("获取书籍上传 PDF 文件失败,OSS Key: {OssKey}", journalPdfKey); + return; + } + + await using (var fs = new FileStream(uploadFilePath, FileMode.CreateNew, FileAccess.Write)) + { + await pdfStream.CopyToAsync(fs, cancellationToken); + logger.LogInformation("书籍 PDF 已下载到本地,长度:{Length}", fs.Length); + } + + var dotId = configuration.GetValue("PrintConfig:DotId", 765837655859269); + var dotFile = await dbContext.Queryable().FirstAsync(x => x.Id == dotId, cancellationToken); + if (dotFile == null) + { + logger.LogError("配置打印数据错误,点阵文件不存在,DotId: {DotId}", dotId); + return; + } + + var printToolDirectory = Path.Combine(hostEnvironment.ContentRootPath, "PrintToolV2.7"); + var exePath = Path.Combine(printToolDirectory, "PrintTool.exe"); + if (!File.Exists(exePath)) + { + logger.LogError("PrintTool.exe 不存在,路径:{ExePath}", exePath); + await ExecuteUpdateJournalStatusAsync(BuildFailResponse(request), cancellationToken); + return; + } + + var xmlPath = Path.Combine(printToolDirectory, dotFile.FileName); + if (!File.Exists(xmlPath)) + { + logger.LogError("铺码授权文件不存在,路径:{XmlPath}", xmlPath); + await ExecuteUpdateJournalStatusAsync(BuildFailResponse(request), cancellationToken); + return; + } + + var downloadFileName = $"download_{request.JournalId}_{DateTime.Now:yyyyMMddHHmmssffffff}{Random.Shared.Next(1000, 9999)}_dot.pdf"; + var downloadFilePath = Path.Combine(downloadDirectory, downloadFileName); + var dPrint = configuration.GetValue("PrintConfig:DPrint", 0); + var pageNumMax = request.PageNum.Max(x => x); + var dotFileDetailList = await dbContext.Queryable() + .Where(x => x.DotId == dotId && !x.IsUse) + .OrderBy(x => x.Id) + .Take(pageNumMax) + .ToListAsync(cancellationToken); + + if (dotFileDetailList.Count < pageNumMax) + { + logger.LogError("点阵页码余量不足,DotId: {DotId}, Need: {Need}, Available: {Available}", dotId, pageNumMax, dotFileDetailList.Count); + await ExecuteUpdateJournalStatusAsync(BuildFailResponse(request), cancellationToken); + return; + } + + var dotFileDetailPageName = dotFileDetailList.Select(x => x.PageName).OrderBy(x => x).ToArray(); + var pageStr = "{" + $"[{dotFileDetailList[0].PageName},{pageNumMax}]" + "}"; + var arguments = $"-sMode=Generate -sPDF=\"{uploadFilePath}\" -sLIC=\"{xmlPath}\" -pStart=1 -oPDF=\"{downloadFilePath}\" -dPageAddr=1 -dPrint={dPrint} -dDotSize=40 -dType=0 -dOutFile=0 -dControlPageNum={pageStr}"; + var printResult = await ExecutePrintToolAsync(exePath, printToolDirectory, arguments, cancellationToken); + + if (printResult.Timeout || printResult.ExitCode != 0) + { + logger.LogError("执行 PrintTool.exe 失败,退出码:{ExitCode},错误信息:{ErrorMessage}", printResult.ExitCode, printResult.Error); + await ExecuteUpdateJournalStatusAsync(BuildFailResponse(request, dotFileDetailPageName), cancellationToken); + return; + } + + var statusModel = new JournalPagePrintDto + { + JournalId = request.JournalId, + PageNo = dotFileDetailPageName + }; + + if (!ValidatePrintOutput(printResult.Output, dotFileDetailPageName, dPrint)) + { + statusModel.Status = JournalStatusEnum.CodeFail; + await ExecuteUpdateJournalStatusAsync(statusModel, cancellationToken); + return; + } + + var tempOssDownloadKey = "journal/download/" + downloadFileName; + const int bufferSize = 1 * 1024 * 1024; + await using var downloadFs = new FileStream(downloadFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.SequentialScan | FileOptions.Asynchronous); + var downloadBookPagePdfName = ossService.PutObject(tempOssDownloadKey, downloadFs); + var ossDomain = configuration.GetSection("AliyunOSSConfigs:Domain").Get() ?? string.Empty; + + statusModel.DownloadJournalPagePdfName = ossDomain + downloadBookPagePdfName; + await downloadFs.DisposeAsync(); + + TryDeleteFile(uploadFilePath); + TryDeleteFile(downloadFilePath); + + statusModel.Status = JournalStatusEnum.CodeSuccess; + var callbackResponse = await ExecuteUpdateJournalStatusAsync(statusModel, cancellationToken); + if (callbackResponse.IsSuccess) + { + await dbContext.Ado.UseTranAsync(async () => + { + foreach (var dotFileDetail in dotFileDetailList) + { + dotFileDetail.IsUse = true; + dotFileDetail.UpdatedAt = DateTime.Now; + } + + await dbContext.Updateable(dotFileDetailList).ExecuteCommandAsync(); + await dbContext.Updateable() + .SetColumns(x => x.TotalUse == x.TotalUse + dotFileDetailList.Count) + .SetColumns(x => x.UpdatedAt == DateTime.Now) + .Where(x => x.Id == dotId) + .ExecuteCommandAsync(); + }); + } + else + { + logger.LogError("回调接口修改书籍状态失败,JournalId: {JournalId},接口返回消息:{Message}", statusModel.JournalId, callbackResponse.Message); + } + } + catch (Exception ex) + { + logger.LogError(ex, "自动铺码处理失败"); + throw; + } + } + + /// + /// 处理消费异常。 + /// + public Task OnErrorAsync(byte[] message, Exception exception) + { + var body = Encoding.UTF8.GetString(message); + logger.LogError(exception, "处理自动铺码消息失败: {Message}", body); + return Task.CompletedTask; + } + + private async Task ExecutePrintToolAsync(string exePath, string workingDirectory, string arguments, CancellationToken cancellationToken) + { + var printToolTimeoutSeconds = configuration.GetValue("PrintConfig:TimeoutSeconds", 300); + var printToolTimeout = TimeSpan.FromSeconds(printToolTimeoutSeconds); + var cmd = $"\"{exePath}\" {arguments}"; + logger.LogInformation("执行 PrintTool.exe 的命令: {Command},超时时间:{TimeoutSeconds} 秒", cmd, printToolTimeoutSeconds); + + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + WorkingDirectory = workingDirectory, + FileName = exePath, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + process.Start(); + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + var timeout = false; + + try + { + await process.WaitForExitAsync(cancellationToken).WaitAsync(printToolTimeout, cancellationToken); + } + catch (TimeoutException) + { + timeout = true; + logger.LogError("执行 PrintTool.exe 超时,准备终止进程树,超时时间:{TimeoutSeconds} 秒,命令:{Command}", printToolTimeoutSeconds, cmd); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + + var output = await outputTask; + var error = await errorTask; + var exitCode = process.ExitCode; + + logger.LogInformation("执行 PrintTool.exe 后,退出码:{ExitCode},stdout:{Output}", exitCode, output); + if (!string.IsNullOrWhiteSpace(error)) + { + logger.LogWarning("执行 PrintTool.exe 后,stderr:{ErrorMessage}", error); + } + + return new PrintToolResult(output, error, exitCode, timeout); + } + + private bool ValidatePrintOutput(string output, string[] dotFileDetailPageName, int dPrint) + { + if (string.IsNullOrWhiteSpace(output)) + { + logger.LogInformation("执行铺码程序没有任何输出"); + return false; + } + + if (dPrint == 0) + { + var pageNoList = JsonSerializer.Deserialize(output) ?? []; + return pageNoList.OrderBy(x => x).SequenceEqual(dotFileDetailPageName.OrderBy(x => x), StringComparer.Ordinal); + } + + return dotFileDetailPageName.AsParallel().Any(ip => output.Contains(ip, StringComparison.Ordinal)); + } + + private async Task ExecuteUpdateJournalStatusAsync(JournalPagePrintDto request, CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient(); + var callbackUrl = configuration.GetValue("PrintConfig:CallBackApiUrl"); + var callbackRequest = new HttpRequestMessage(HttpMethod.Post, callbackUrl) + { + Content = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(callbackRequest, cancellationToken); + if (response.StatusCode == HttpStatusCode.OK) + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogInformation("回调接口返回内容:{ResponseContent}", responseContent); + return JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? new CallbackUpdateJournalStatusResponse(); + } + + return new CallbackUpdateJournalStatusResponse(); + } + + private static JournalPagePrintDto BuildFailResponse(JournalPagePrintDto request, string[]? pageNo = null) + { + return new JournalPagePrintDto + { + JournalId = request.JournalId, + PageNo = pageNo ?? [], + Status = JournalStatusEnum.CodeFail + }; + } + + private void TryDeleteFile(string filePath) + { + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "删除临时文件失败,路径:{FilePath}", filePath); + } + } +} + +internal sealed record PrintToolResult(string Output, string Error, int ExitCode, bool Timeout); + +internal class CallbackUpdateJournalStatusResponse +{ + public string? Message { get; set; } + + public string? Code { get; set; } + + public bool Result { get; set; } + + public bool IsSuccess { get; set; } +} diff --git a/QYZH.InteractiveMagazine.PrintWorker/Consumers/IQueueConsumer.cs b/QYZH.InteractiveMagazine.PrintWorker/Consumers/IQueueConsumer.cs new file mode 100644 index 0000000..8aa709d --- /dev/null +++ b/QYZH.InteractiveMagazine.PrintWorker/Consumers/IQueueConsumer.cs @@ -0,0 +1,32 @@ +namespace QYZH.InteractiveMagazine.PrintWorker.Consumers; + +/// +/// 队列消费者接口。 +/// +public interface IQueueConsumer +{ + /// + /// 交换机名称。 + /// + string Exchange { get; } + + /// + /// 队列名称。 + /// + string QueueName { get; } + + /// + /// 路由键。 + /// + string RoutingKey { get; } + + /// + /// 处理消息。 + /// + Task HandleAsync(byte[] message, CancellationToken cancellationToken = default); + + /// + /// 处理消费异常。 + /// + Task OnErrorAsync(byte[] message, Exception exception); +} diff --git a/QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs b/QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs new file mode 100644 index 0000000..721dbdc --- /dev/null +++ b/QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs @@ -0,0 +1,105 @@ +using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ; + +namespace QYZH.InteractiveMagazine.PrintWorker.Consumers; + +/// +/// RabbitMQ 消费者后台服务。 +/// +public class RabbitMQHostedService(IServiceProvider serviceProvider, ILogger logger) : BackgroundService +{ + /// + /// 启动所有已注册队列消费者。 + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var scope = serviceProvider.CreateScope(); + var consumers = scope.ServiceProvider.GetServices().ToList(); + + if (consumers.Count == 0) + { + logger.LogWarning("未注册任何队列消费者"); + return; + } + + var tasks = consumers.Select(c => StartConsumerAsync(c, stoppingToken)).ToList(); + await Task.WhenAll(tasks); + } + + private async Task StartConsumerAsync(IQueueConsumer queueConsumer, CancellationToken stoppingToken) + { + var queueName = queueConsumer.QueueName; + var exchange = queueConsumer.Exchange; + var routingKey = queueConsumer.RoutingKey; + logger.LogInformation("正在启动消费者 {QueueName}, Exchange: {Exchange}, RoutingKey: {RoutingKey}", queueName, exchange, routingKey); + + try + { + var rabbitMQService = serviceProvider.GetRequiredService(); + await rabbitMQService.ReceiveAsync(exchange, queueName, routingKey, async (channel, ea) => + { + var dlqName = $"{queueName}.dlq"; + var dlqRoutingKey = $"{routingKey}.dlq"; + await channel.QueueDeclareAsync(queue: dlqName, durable: true, exclusive: false, autoDelete: false, arguments: null); + await channel.QueueBindAsync(queue: dlqName, exchange: exchange, routingKey: dlqRoutingKey, arguments: null); + + using var messageScope = serviceProvider.CreateScope(); + var consumer = messageScope.ServiceProvider + .GetServices() + .First(c => c.QueueName == queueName); + + try + { + await consumer.HandleAsync(ea.Body.ToArray(), stoppingToken); + await channel.BasicAckAsync(ea.DeliveryTag, false, stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "消费者 {QueueName} 处理消息异常", queueName); + var dlqSent = await SendToDeadLetterQueueAsync(exchange, dlqName, dlqRoutingKey, ea.Body.ToArray(), stoppingToken); + await channel.BasicNackAsync(ea.DeliveryTag, false, !dlqSent, stoppingToken); + + try + { + await consumer.OnErrorAsync(ea.Body.ToArray(), ex); + } + catch (Exception errorEx) + { + logger.LogError(errorEx, "消费者 {QueueName} OnErrorAsync 执行异常", queueName); + } + } + }, stoppingToken); + } + catch (OperationCanceledException) + { + logger.LogInformation("消费者 {QueueName} 已停止", queueName); + } + catch (Exception ex) + { + logger.LogError(ex, "消费者 {QueueName} 启动失败", queueName); + } + } + + private async Task SendToDeadLetterQueueAsync(string exchange, string dlqName, string dlqRoutingKey, byte[] body, CancellationToken cancellationToken) + { + try + { + var rabbitMQConnection = serviceProvider.GetRequiredService(); + using var channel = await rabbitMQConnection.CreateChannel(); + await channel.QueueDeclareAsync(queue: dlqName, durable: true, exclusive: false, autoDelete: false, arguments: null); + await channel.QueueBindAsync(queue: dlqName, exchange: exchange, routingKey: dlqRoutingKey, arguments: null); + + var properties = new RabbitMQ.Client.BasicProperties + { + Persistent = true + }; + await channel.BasicPublishAsync(exchange, dlqRoutingKey, false, properties, body, cancellationToken); + logger.LogInformation("消息已发送到死信队列: {DlqName}", dlqName); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "发送消息到死信队列 {DlqName} 失败", dlqName); + return false; + } + } +} diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/PrintTool.exe b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/PrintTool.exe similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/PrintTool.exe rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/PrintTool.exe diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/sublic_license_1714.0.0.0_10.xml b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/sublic_license_1714.0.0.0_10.xml similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/sublic_license_1714.0.0.0_10.xml rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/sublic_license_1714.0.0.0_10.xml diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/sublic_license_1761.172.8.16_1000.xml b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/sublic_license_1761.172.8.16_1000.xml similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/sublic_license_1761.172.8.16_1000.xml rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/sublic_license_1761.172.8.16_1000.xml diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/sublic_license_1761.211.21.48_10000.xml b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/sublic_license_1761.211.21.48_10000.xml similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/sublic_license_1761.211.21.48_10000.xml rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/sublic_license_1761.211.21.48_10000.xml diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/wfdlicense.dll b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/wfdlicense.dll similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/wfdlicense.dll rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/wfdlicense.dll diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/wfdprint.dll b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/wfdprint.dll similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/wfdprint.dll rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/wfdprint.dll diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/命令行方式调用方法.txt b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/命令行方式调用方法.txt similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/命令行方式调用方法.txt rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/命令行方式调用方法.txt diff --git a/QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/返回值说明.txt b/QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/返回值说明.txt similarity index 100% rename from QYZH.InteractiveMagazine.WorkService/PrintToolV2.7/返回值说明.txt rename to QYZH.InteractiveMagazine.PrintWorker/PrintToolV2.7/返回值说明.txt diff --git a/QYZH.InteractiveMagazine.PrintWorker/Program.cs b/QYZH.InteractiveMagazine.PrintWorker/Program.cs new file mode 100644 index 0000000..0694b35 --- /dev/null +++ b/QYZH.InteractiveMagazine.PrintWorker/Program.cs @@ -0,0 +1,59 @@ +using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ; +using QYZH.InteractiveMagazine.Infrastructure.Redis; +using QYZH.InteractiveMagazine.Infrastructure.SDK; +using QYZH.InteractiveMagazine.PrintWorker.Consumers; +using Serilog; +using SqlSugar; +using SqlSugar.IOC; +using Yitter.IdGenerator; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); + +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .CreateLogger(); + +builder.Services.AddSerilog(); +builder.Services.AddWindowsService(options => options.ServiceName = "QYZH InteractiveMagazine PrintWorker"); + +YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = 3 }); + +builder.Services.AddSqlSugar(new IocConfig +{ + ConfigId = 0, + DbType = IocDbType.MySql, + ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection"), + IsAutoCloseConnection = true +}); + +SugarIocServices.ConfigurationSugar(db => +{ + db.Aop.OnLogExecuting = (sql, pars) => + { + Log.Information("[SQL] {Sql}", UtilMethods.GetSqlString((DbType)IocDbType.MySql, sql, pars)); + }; + db.Aop.OnError = ex => + { + Log.Error(ex, "[SQL Error] {Message}", ex.Message); + }; +}); + +builder.Services.AddScoped(_ => DbScoped.SugarScope); +builder.Services.AddCSRedisCacheExtension(builder.Configuration.GetSection("RedisSettings")); +builder.Services.AddHttpClient(); +builder.Services.AddSDKService(builder.Configuration); +builder.Services.AddRabbitMQ(builder.Configuration); + +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +Log.Information("PrintWorker 已启动,仅消费自动铺码队列"); + +await app.RunAsync(); diff --git a/QYZH.InteractiveMagazine.PrintWorker/QYZH.InteractiveMagazine.PrintWorker.csproj b/QYZH.InteractiveMagazine.PrintWorker/QYZH.InteractiveMagazine.PrintWorker.csproj new file mode 100644 index 0000000..741c9bd --- /dev/null +++ b/QYZH.InteractiveMagazine.PrintWorker/QYZH.InteractiveMagazine.PrintWorker.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/QYZH.InteractiveMagazine.PrintWorker/appsettings.json b/QYZH.InteractiveMagazine.PrintWorker/appsettings.json new file mode 100644 index 0000000..f571c74 --- /dev/null +++ b/QYZH.InteractiveMagazine.PrintWorker/appsettings.json @@ -0,0 +1,85 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "server=192.168.20.150;port=13306;database=InteractiveMagazine;user=user;password=n68792bu!y99r905;charset=utf8mb4;" + }, + "RedisSettings": { + "ConnectionString": "192.168.20.150:16379,defaultDatabase=5", + "Sentinels": [], + "ExpireSecondRange": [ 3600, 7200 ] + }, + "RabbitMq": { + "HostName": "192.168.20.150", + "Port": 5672, + "UserName": "smartschool", + "Password": "@ss%&*otz%d*pq2S", + "VirtualHost": "InteractiveMagazine" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning", + "Hangfire": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console" + }, + { + "Name": "File", + "Args": { + "path": "logs/worker-log-.txt", + "rollingInterval": "Day" + } + } + ] + }, + "HangfireJobs": { + "_说明": "定时任务配置。新增任务只需在 Jobs 数组中追加一项。Cron格式:分 时 日 月 星期(5位)。常用:* * * * * 每分钟 | */5 * * * * 每5分钟 | 0 * * * * 每小时 | 0 9 * * * 每天9点 | 0 0 * * 1 每周一午夜", + "Jobs": [ + { + "Name": "sample-job", + "JobType": "QYZH.InteractiveMagazine.WorkService.Jobs.SampleJob", + "MethodName": "ExecuteAsync", + "Cron": "* * * * *", + "Enabled": false, + "Description": "示例任务(默认关闭,仅用于验证框架运行)" + } + ] + }, + "AiChat": { + "ApiKey": "Ollama", + "BaseUrl": "http://172.16.10.130:11434/v1/", + "Model": "qwen2.5vl:7b", + "TimeoutSeconds": 300, + "MaxTokens": 2000, + "Temperature": 0.5, + "ScoreMaxRetryCount": 3, + "ScoreRetryDelayMilliseconds": 1000, + "MaxImageBytes": 10485760 + }, + "AllowedHosts": "*", + "AliyunOSSConfigs": { + "AccessKeyID": "LTAI5tEBXGewpHSLiSxyx6Bf", + "AccessKeySecret": "w29b8wkw6XQVL8GWXgp3ZesgYeDKvf", + "VodBucketName": "outin-5277bbb52bec11f08dbd00163e169e2b.oss-cn-beijing.aliyuncs.com", + "BucketName": "qyzh2025test", + "Region": "beijing", + "RoleArn": "acs:ram::1064745380176636:role/aliyunosstokengeneratorrole", + "DurationSeconds": 3600, //过期时间(秒) + "Endpoint": "oss-cn-beijing.aliyuncs.com", + "ProjectName": "InteractiveMagazine", + "Domain": "http://oss-test.qyzhjy.com/" + }, + "PrintConfig": { + "TimeoutSeconds": 300, + "DPrint": 0, //打印场景,0:普通激光打印机,1:工业印刷,默认为0(可选) + "CallBackApiUrl": "http://localhost:8080/api/page/callbackupdatepageno", // 开发环境 打印成功回调API地址修改打印状态 + //"CallBackApiUrl": "http://192.168.20.150:8000/api/icr/page/callbackupdatepageno", // 测试环境 打印成功回调API地址修改打印状态 + //"CallBackApiUrl": "https://zyb.qyzhjy.com/zybback/api/icr/page/callbackupdatepageno", // 正式环境 打印成功回调API地址修改打印状态 + //这个ID执行的xml文件是:sublic_license_1761.172.8.16_1000.xml,如果想执行sublic_license_1761.211.21.48_10000.xml 换成 765837655859269 ---暂时没有导入页码 + "DotId": 765837655859269 + } +} diff --git a/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs b/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs index efb5ae4..4976a0a 100644 --- a/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs +++ b/QYZH.InteractiveMagazine.Service/JournalPageTaskService.cs @@ -24,6 +24,7 @@ public class JournalPageTaskService(BaseRepository journalRepository, O var map = input.Adapt(); map.Id = YitIdHelper.NextId(); map.GroupId = map.Id; + map.NeedAiProcess = input.NeedAiProcess; var no = input.No.Split('-').Select(int.Parse).ToArray(); if (no.Last() >= 2) { @@ -59,7 +60,6 @@ public class JournalPageTaskService(BaseRepository journalRepository, O task.Type = input.Type; task.Task = input.Task; - task.No = input.No; task.Points = input.Points; task.GrowthPoint = (int?)input.GrowthPoint; task.Comprehension = input.Comprehension; @@ -68,6 +68,7 @@ public class JournalPageTaskService(BaseRepository journalRepository, O task.Persuasiveness = input.Persuasiveness; task.AnswerTime = input.AnswerTime; task.Prompt = input.Prompt; + task.NeedAiProcess = input.NeedAiProcess; var no = input.No.Split('-').Select(int.Parse).ToArray(); @@ -104,7 +105,8 @@ public class JournalPageTaskService(BaseRepository journalRepository, O Expression = task.Expression, Persuasiveness = task.Persuasiveness, AnswerTime = task.AnswerTime, - Prompt = task.Prompt + Prompt = task.Prompt, + NeedAiProcess = task.NeedAiProcess }; // 查询答案列表 diff --git a/QYZH.InteractiveMagazine.Service/JournalService.cs b/QYZH.InteractiveMagazine.Service/JournalService.cs index ce5e227..6194492 100644 --- a/QYZH.InteractiveMagazine.Service/JournalService.cs +++ b/QYZH.InteractiveMagazine.Service/JournalService.cs @@ -21,8 +21,17 @@ public class JournalService(BaseRepository JournalPageRepository, BaseRepository JournalPageTaskAnswerRepository, BaseRepository JournalCatalogRepository, BaseRepository dotFileRepository, - BaseRepository dotFileDetailRepository, OssService ossService, ILogger logger) : BaseRepository, IJournalService + BaseRepository dotFileDetailRepository, + OssService ossService, + IRabbitMQService rabbitMqService, + ILogger logger) : BaseRepository, IJournalService { + private const string JournalExchange = "ex.journal"; + private const string PublishBookQueue = "mq.journal.publish.book"; + private const string PublishBookRoutingKey = "rk.journal.publish.book"; + private const string PublishBookPageQueue = "mq.journal.publish.bookpage"; + private const string PublishBookPageRoutingKey = "rk.journal.publish.bookpage"; + /// /// 查询List /// @@ -82,6 +91,8 @@ public class JournalService(BaseRepository JournalPageRepository, Cover = input.Cover?.RemoveDomain(), BackCover = input.BackCover?.RemoveDomain(), PdfPreviewUrl = input.PdfPreviewUrl?.RemoveDomain(), + StartTime = input.StartTime, + EndTime = input.EndTime, CreatedBy = "System", UpdatedBy = "System", CreatedAt = DateTime.Now, @@ -167,6 +178,9 @@ public class JournalService(BaseRepository JournalPageRepository, Journal.Height = input.Height; Journal.Name = input.Name; Journal.Title = input.Title; + Journal.StartTime = input.StartTime; + Journal.EndTime = input.EndTime; + Journal.UpdatedAt = DateTime.Now; var res = await UseTranAsync(async () => { @@ -320,4 +334,59 @@ public class JournalService(BaseRepository JournalPageRepository, var res = await base.Updateable().SetColumns(s => s.Status, status).Where(w => w.Id == id).ExecuteCommandAsync() > 0; return res; } -} \ No newline at end of file + + public async Task PublishAsync(long id) + { + var book = await base.GetByIdAsync(id); + BusinessException.ThrowIf(book == null || book.IsDeleted, "杂志不存在", ResultCode.NOT_FOUND); + + var pages = await JournalPageRepository.Queryable() + .Where(x => x.JournalId == id && !x.IsDeleted) + .OrderBy(x => x.PageNum) + .OrderBy(x => x.Sort) + .ToListAsync(); + BusinessException.ThrowIf(pages.Count == 0, "书籍未添加任何书页,无法发布", ResultCode.UNPROCESSABLE_ENTITY); + + BusinessException.ThrowIf(!book.StartTime.HasValue, "发布开始时间不能为空", ResultCode.UNPROCESSABLE_ENTITY); + BusinessException.ThrowIf(!book.EndTime.HasValue, "发布结束时间不能为空", ResultCode.UNPROCESSABLE_ENTITY); + BusinessException.ThrowIf(book.EndTime < book.StartTime, "发布结束时间不能早于开始时间", ResultCode.UNPROCESSABLE_ENTITY); + + var bookMessageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam + { + Exchange = JournalExchange, + Queue = PublishBookQueue, + RoutingKey = PublishBookRoutingKey, + Data = new JournalPublishBookMessage + { + BookId = id, + StartTime = book.StartTime.Value, + EndTime = book.EndTime.Value + } + }); + BusinessException.ThrowIf(!bookMessageSent, "发布书籍消息发送失败", ResultCode.GLOBAL_ERROR); + + foreach (var page in pages) + { + var pageMessageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam + { + Exchange = JournalExchange, + Queue = PublishBookPageQueue, + RoutingKey = PublishBookPageRoutingKey, + Data = new JournalPublishBookPageMessage + { + BookId = id, + PageId = page.Id, + PageNo = page.PageNo, + Layout = page.Layout + } + }); + BusinessException.ThrowIf(!pageMessageSent, $"发布书页消息发送失败,PageId: {page.Id}", ResultCode.GLOBAL_ERROR); + } + + return await base.Updateable() + .SetColumns(s => s.Status, JournalStatusEnum.Published) + .SetColumns(s => s.UpdatedAt, DateTime.Now) + .Where(w => w.Id == id) + .ExecuteCommandAsync() > 0; + } +} diff --git a/QYZH.InteractiveMagazine.WebApi/Controllers/JournalController.cs b/QYZH.InteractiveMagazine.WebApi/Controllers/JournalController.cs index 8d2b452..89255f1 100644 --- a/QYZH.InteractiveMagazine.WebApi/Controllers/JournalController.cs +++ b/QYZH.InteractiveMagazine.WebApi/Controllers/JournalController.cs @@ -141,6 +141,17 @@ namespace QYZH.InteractiveMagazine.WebApi.Controllers return BaseResponse.Success(data); } + /// + /// 书籍发布 + /// + /// + [HttpPost, Route("journal/publish/{id:long}")] + public async Task> Publish(long id) + { + var data = await JournalService.PublishAsync(id); + return BaseResponse.Success(data); + } + /// /// 书籍铺码 /// @@ -200,17 +211,6 @@ namespace QYZH.InteractiveMagazine.WebApi.Controllers #region 书籍目录 - ///// - ///// 书籍目录详情 - ///// - ///// - //[HttpGet, Route("catalog/tree/{JournalId:long}")] - //public async Task>> CatalogDetailAsync(long JournalId) - //{ - // var data = await _JournalCatalogService.DetailAsync(JournalId); - // return BaseResponse>.Success(data); - //} - /// /// 书籍目录详情 /// @@ -312,18 +312,6 @@ namespace QYZH.InteractiveMagazine.WebApi.Controllers return BaseResponse.Success(data); } - ///// - ///// 书籍目录复制 - ///// - ///// - ///// - //[HttpPost, Route("catalog/copy")] - //[ProducesResponseType(typeof(BaseResponse), 200)] - //public async Task CatalogCopy(CopyInput input) - //{ - // var data = await _JournalCatalogServices.CopyAsync(input); - // return ApiResult(data, "书籍目录删除失败!"); - //} /// /// 书籍目录移动 @@ -411,30 +399,6 @@ namespace QYZH.InteractiveMagazine.WebApi.Controllers } - ///// - ///// 打印书页(全部) 暂时废弃这个方,直接使用 Adobe Reader X 软件打印就可以了 - ///// - ///// - //[HttpPost, Route("page/printJournalPage/{JournalId:long}")] - //public async Task> PrintJournalPage([Required(ErrorMessage = "书籍编号不允许为空")] long JournalId) - //{ - // var data = await JournalPageService.PrintJournalPageAsync(JournalId); - // return BaseResponse.Success(data); - //} - - - ///// - ///// 书页删除 - ///// - ///// - //[HttpPost, Route("page/delete/{id:long}")] - //[ProducesResponseType(typeof(BaseResponse), 200)] - //public async Task PageDelete(long id) - //{ - // var data = await _JournalPageServices.DeleteAsync(id); - // return ApiResult(data, "更新书页失败!"); - //} - /// /// 书页详情 /// @@ -446,17 +410,6 @@ namespace QYZH.InteractiveMagazine.WebApi.Controllers return BaseResponse.Success(data); } - ///// - ///// 不包含有文章的书页 - ///// - ///// - //[HttpGet, Route("page/no-article/{JournalId:long}")] - //[ProducesResponseType(typeof(BaseResponse>), 200)] - //public async Task PageNoArticleAsync(long JournalId) - //{ - // var data = await _JournalPageService.PageNoArticleAsync(JournalId); - // return BaseResponse>(data, "查询书页失败!"); - //} #endregion diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs deleted file mode 100644 index 9e8024f..0000000 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs +++ /dev/null @@ -1,355 +0,0 @@ -using Newtonsoft.Json; -using QYZH.InteractiveMagazine.Common.Extensions; -using QYZH.InteractiveMagazine.Infrastructure.OSS; -using QYZH.InteractiveMagazine.Models.Dto.Journal; -using QYZH.InteractiveMagazine.Models.Entity; -using QYZH.InteractiveMagazine.Models.Enum; -using SqlSugar; -using System.Diagnostics; -using System.Net; -using System.Text; -using System.Threading.Channels; - -namespace QYZH.InteractiveMagazine.WorkService.Consumers; - -/// -/// 自动铺码消费者 -/// -public class AutoDotCodeConsumer(IConfiguration configuration, - IServiceScopeFactory scopeFactory, - IWebHostEnvironment webHostEnvironment, - ILogger logger, - IHttpClientFactory httpClientFactory, - OssService ossService - ) : IQueueConsumer -{ - - public string Exchange => "ex.journal"; - - public string QueueName => "mq.journal.dotcode.auto"; - - public string RoutingKey => "rk.journal.dotcode.auto"; - - public async Task HandleAsync(byte[] body, CancellationToken cancellationToken = default) - { - var message = Encoding.UTF8.GetString(body); - logger.LogInformation("收到自动铺码消息: {Message}", message); - - // TODO: 在此编写具体的铺码处理逻辑 - using var scope = scopeFactory.CreateScope(); - var dBContext = scope.ServiceProvider.GetRequiredService(); - - try - { - var journalPagePrintDtoMessage = JsonConvert.DeserializeObject(message); - if (journalPagePrintDtoMessage == null) - return; - - await dBContext.Ado.BeginTranAsync(); - - #region 创建文件夹以及下载PDF文件 - - var currentDomainDic = AppDomain.CurrentDomain.BaseDirectory + "JournalPagePdf"; - if (!Directory.Exists(currentDomainDic)) - Directory.CreateDirectory(currentDomainDic); - - var uploadPdfDic = currentDomainDic + "/upload/"; - if (!Directory.Exists(uploadPdfDic)) - Directory.CreateDirectory(uploadPdfDic); - - var downloadDic = currentDomainDic + "/download/"; - if (!Directory.Exists(downloadDic)) - Directory.CreateDirectory(downloadDic); - - var uploadFileName = $"upload_{journalPagePrintDtoMessage.JournalId}_{DateTime.Now:yyyyMMddHHmmssffffff}{Random.Shared.Next(1000, 9999)}.pdf"; - - var uploadFilePath = uploadPdfDic + uploadFileName; - - // 获取上传成功的书籍页码pdf文件 - var journalPdfKey = journalPagePrintDtoMessage.JournalPdfUrl?.RemoveDomain(); - if (string.IsNullOrWhiteSpace(journalPdfKey)) - { - logger.LogError("书籍上传的PDF文件地址为空,JournalId: {JournalId}", journalPagePrintDtoMessage.JournalId); - return; - } - - await using var pdfSteam = ossService.GetObjectStream(journalPdfKey); - if (pdfSteam == null) - { - logger.LogError("获取书籍上传的PDF文件失败,OSS Key: {OssKey}", journalPdfKey); - return; - } - - await using (var fs = new FileStream(uploadFilePath, FileMode.CreateNew, FileAccess.Write)) - { - await pdfSteam.CopyToAsync(fs, cancellationToken); - logger.LogInformation($" 获取书籍上传的PDF文件成功下载到本地,长度为:{fs.Length}"); - } - - #endregion - - - #region 调用铺码程序 - - // 从配置中获取点阵文件Id - var dotId = configuration.GetValue("PrintConfig:DotId", 765837655859269); - - var dotfile = await dBContext.Queryable().FirstAsync(x => x.Id == dotId, cancellationToken); - if (dotfile == null) - { - logger.LogError($"配置打印数据错误,点阵文件不存在,dotId: {dotId}"); - return; - } - - var printToolDirectory = Path.Combine(webHostEnvironment.ContentRootPath, "PrintToolV2.7"); - var exePath = Path.Combine(printToolDirectory, "PrintTool.exe"); - - var xmlPath = Path.Combine(printToolDirectory, dotfile.FileName); - - - #region 注释说明 - - //1、-sMode = Generate (必须) - //2、-sPDF = 源文件pdf格式的完整路径(必须) - //3、-sLIC = 铺码资源文件完整路径(必须) - //4、-oPDF = 生成pdf文件的完整路径(必须) - //5、-dType = 点阵形状,0:方点,1:圆点,默认为0(可选) - //6、-dPrint = 打印场景,0:普通激光打印机,1:工业印刷,默认为0(可选) - //7、-pStart = 数字,整数,可以制定资源文件从第几个编号开始铺码(非必须,默认0,表示从资源的剩余页码开始,每次铺码成功后点阵资源会相应减少;大于0时点阵资源不会减少,pStart = 1时表示从第一页开始铺码) - //8、-dPageAddr = 是否显示点阵页码地址,0:不显示,1:显示,默认为0(可选) - //9、-sPrinter = 打印机名称(必须) - //10、-dPageStart = 数字,设置打印起始页码,1表示从第一页开始打印,0:全部打印,默认0(可选) - //11、-dPageEnd = 数字,设置打印结束页码,0:全部打印,默认0(可选) - //12、-dCopy = 数字,设置打印份数,默认1(可选) - //13、-dKValue = 数字,设置码点颜色深度,取值范围50 - 100,默认100,比如取值90表示k值为90 %(可选) - //14、-dDotSize = 数字,设置码点大小,取值范围30 - 50,默认40(可选),方点仅支持40 - //15、-dOutFile = 数字,0:只生成带点阵pdf文件,1:只生成纯点阵文件,2:既生成纯点阵文件也生成带点阵pdf文件,默认为0(可选) - - //16、-dControlPageNum ={ [页地址, 连续数量],[页地址, 连续数量]...} - //可一个pdf有多段页码段,默认从第一页开始(可选)(最后一段若是不想数pdf剩下多少页,可直接放0默认用最后字段铺完剩下的页) - - //说明: - //当 -dOutFile = 2时,纯点阵文件名为输入的 - oPDF参数,带点阵pdf文件名为在 - oPDF参数后加上"_dp",即"D:\pdf\28_dot_dp.pdf"。 - - //示例: - //制作点阵: - //-sMode=Generate -sPDF="D:\pdf\28.pdf" -sLIC="D:\Root licnese segment 70_70.0.0.0_100.xml" -oPDF="D:\pdf\28_dot.pdf" -pStart=1 - - //制作纯点阵文件: - //-sMode=Generate -sPDF="D:\pdf\28.pdf" -sLIC="D:\Root licnese segment 70_70.0.0.0_100.xml" -oPDF="D:\pdf\28_dot.pdf" -dOutFile=1 - - //打印: - //-sMode=Print -sPDF="D:\pdf\28_dot.pdf" -sPrinter="HP LaserJet Professional M1216nfh MFP (副本 1)" -dPageStart=2 -dPageEnd=3 -dCopy=5 - - #endregion - - var downloadFileName = $"download_{journalPagePrintDtoMessage.JournalId}_{DateTime.Now:yyyyMMddHHmmssffffff}{Random.Shared.Next(1000, 9999)}_dot.pdf"; - - // 生成成功的PDF文件路径 - var downloadFilePath = downloadDic + downloadFileName; - - // -dPrint = 打印场景,0:普通激光打印机,1:工业印刷,默认为0(可选) - var dPrint = configuration.GetValue("PrintConfig:DPrint", 0); - - // 获取书籍页码中的最大页数,作为铺码程序需要铺的页数(连续数量) - var pageNumMax = journalPagePrintDtoMessage.PageNum.Max(x => x); - - // 根据点阵文件Id获取对应的页码详情列表,按照Id升序排序,取前N条(N为书籍页数) - var dotFileDetailList = await dBContext.Queryable().Where(x => x.DotId == dotId && !x.IsUse).OrderBy(x => x.Id).Take(pageNumMax).ToListAsync(cancellationToken); - - // 获取页码详情列表中的页地址,组成一个数组 - var dotFileDetailPageName = dotFileDetailList.Select(x => x.PageName).OrderBy(x => x).ToArray(); - - // -dControlPageNum ={ [页地址, 连续数量],[页地址, 连续数量]...} - // string pageStr = "{" + string.Join(",", item.Pages.Select(s => $"[{s.PageAddress},{s.PageNum}]")) + "}"; - - // 从第一个开始执行,连续铺码N条(N为书籍页数) - string pageStr = "{" + $"[{dotFileDetailList[0].PageName},{pageNumMax}]" + "}"; - - var arguments = $"-sMode=Generate -sPDF=\"{uploadFilePath}\" -sLIC=\"{xmlPath}\" -pStart=1 -oPDF=\"{downloadFilePath}\" -dPageAddr=1 -dPrint={dPrint} -dDotSize=40 -dType=0 -dOutFile=0 -dControlPageNum={pageStr}"; - var cmd = $"\"{exePath}\" {arguments}"; - - logger.LogInformation($"执行PrintTool.exe 的命令: {cmd}"); - - string output; - using (var p = new Process()) - { - p.StartInfo = new ProcessStartInfo - { - WorkingDirectory = printToolDirectory, - FileName = exePath, - Arguments = arguments, - UseShellExecute = false, //是否使用操作系统shell启动 - RedirectStandardInput = true, //接受来自调用程序的输入信息 - RedirectStandardOutput = true, //由调用程序获取输出信息 - RedirectStandardError = true, //重定向标准错误输出 - CreateNoWindow = true, //不显示程序窗口 - }; - - p.Start(); - output = await p.StandardOutput.ReadToEndAsync(cancellationToken); - - logger.LogInformation("执行 ProcessStartInfo 执行命令后,output 输出值:{Output}", output); - - var exeErrorMsg = await p.StandardError.ReadToEndAsync(cancellationToken); - if (!string.IsNullOrWhiteSpace(exeErrorMsg)) - { - logger.LogInformation($"执行 ProcessStartInfo 执行命令后,返回的错误信息为:{exeErrorMsg}"); - } - - await p.WaitForExitAsync(cancellationToken); - p.Kill(); - } - logger.LogInformation("执行 PrintTool.exe 文件成功"); - - #endregion - - var journalPagePrintDtoModel = new JournalPagePrintDto - { - JournalId = journalPagePrintDtoMessage.JournalId, - PageNo = dotFileDetailPageName, - }; - - #region 根据配置的dPrint 验证铺码程序执行的结果 output数据 - - if (string.IsNullOrWhiteSpace(output)) - { - logger.LogInformation("执行铺码程序没有任何输出,请联系管理员"); - journalPagePrintDtoModel.Status = JournalStatusEnum.CodeFail; - await ExecuteUpdateJournalStatus(journalPagePrintDtoModel, dBContext); - return; - } - - // 如果是激光打印场景,output肯定和 dotFileDetailPageName 是一一对应的关系,如果是工业印刷场景,output可能会有其他信息,所以需要验证output中是否包含dotFileDetailPageName中的页码 - if (dPrint == 0) - { - var pageNoList = JsonConvert.DeserializeObject(output) ?? []; - var isequalArray = pageNoList.OrderBy(x => x).SequenceEqual(dotFileDetailPageName.OrderBy(x => x), StringComparer.Ordinal); - if (!isequalArray) - { - logger.LogInformation("执行铺码程序输出的页码与期望的页码不一致,请联系管理员"); - journalPagePrintDtoModel.Status = JournalStatusEnum.CodeFail; - await ExecuteUpdateJournalStatus(journalPagePrintDtoModel, dBContext); - return; - } - - } - else if (dPrint == 1)// 如果是工业印刷场景,验证页码是否打印过,如果打印过则直接使用原来的页码,不再执行铺码程序 - { - // 并行验证页码是否存在于铺码程序的输出中,存在则说明打印过,不存在则说明没有打印过,说明铺码程序没有执行成功 - var existPageNo = dotFileDetailPageName.AsParallel().Any(ip => output.Contains(ip, StringComparison.Ordinal)); - if (!existPageNo) - { - logger.LogInformation("执行铺码程序没有任何输出,请联系管理员"); - journalPagePrintDtoModel.Status = JournalStatusEnum.CodeFail; - await ExecuteUpdateJournalStatus(journalPagePrintDtoModel, dBContext); - return; - } - } - - #endregion - - #region 把本地文集上传到OOS上 - - var tempOssDownloadKey = "journal/download/" + downloadFileName; - - const int bufferSize = 1 * 1024 * 1024; // 1MB - - await using var downloadFs = new FileStream(downloadFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, FileOptions.SequentialScan | FileOptions.Asynchronous); - - //把本地文件上传到OOS上 - var downloadBookPagePdfName = ossService.PutObject(tempOssDownloadKey, downloadFs); - - var ossDomain = configuration.GetSection("AliyunOSSConfigs:Domain").Get() ?? string.Empty; - - journalPagePrintDtoModel.DownloadJournalPagePdfName = ossDomain + downloadBookPagePdfName; - - // 这里一定要释放上面的流,否则下面无法删除文件 - await downloadFs.DisposeAsync(); - - #endregion - - ////删除临时文件 - File.Delete(uploadFilePath); - - File.Delete(downloadFilePath); - - #region 调用API,成功后修改页码状态为已使用 - - journalPagePrintDtoModel.Status = JournalStatusEnum.CodeSuccess; - - // 调用回调接口修改书籍状态为铺码成功 - var callbackResponse = await ExecuteUpdateJournalStatus(journalPagePrintDtoModel, dBContext); - - if (callbackResponse != null && callbackResponse.IsSuccess) - { - logger.LogInformation($"回调接口成功修改书籍状态为铺码成功,书籍ID:{journalPagePrintDtoModel.JournalId}"); - - foreach (var dotFileDetail in dotFileDetailList) - { - dotFileDetail.IsUse = true; - dotFileDetail.UpdatedAt = DateTime.Now; - } - - await dBContext.Updateable(dotFileDetailList).ExecuteCommandAsync(); - - await dBContext.Updateable() - .SetColumns(x => x.TotalUse == x.TotalUse + dotFileDetailList.Count) - .SetColumns(x => x.UpdatedAt == DateTime.Now) - .Where(x => x.Id == dotId).ExecuteCommandAsync(); - - ////手动确认消息已处理(由于下方 autoAck 设为 false) - //await channel.BasicAckAsync(deliveryTag: ea.DeliveryTag, multiple: false, cancellationToken: cancellationToken); - } - else - { - logger.LogError($"回调接口修改书籍状态为铺码成功没有成功,书籍ID:{journalPagePrintDtoModel.JournalId},接口返回消息:{callbackResponse?.Message}"); - } - - #endregion - - - await dBContext.Ado.CommitTranAsync(); - - } - catch (Exception ex) - { - await dBContext.Ado.RollbackTranAsync(); - logger.LogError(ex, "铺码错误,回调接口修改书籍状态为铺码失败没有成功"); - } - await Task.CompletedTask; - } - private async Task ExecuteUpdateJournalStatus(JournalPagePrintDto request, ISqlSugarClient dBContext) - { - var client = httpClientFactory.CreateClient(); - var callbackUrl = configuration.GetValue("PrintConfig:CallBackApiUrl"); - var callbackRequest = new HttpRequestMessage(HttpMethod.Post, callbackUrl) - { - Content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json") - }; - var response = await client.SendAsync(callbackRequest); - if (response.StatusCode == HttpStatusCode.OK) - { - var responseContent = await response.Content.ReadAsStringAsync(); - logger.LogInformation($"回调接口成功修改书籍状态为铺码失败,接口返回内容:{responseContent}"); - return JsonConvert.DeserializeObject(responseContent) ?? new CallbackUpdateJournalStatusResponse(); - } - return new CallbackUpdateJournalStatusResponse(); - } - public Task OnErrorAsync(byte[] message, Exception exception) - { - var body = Encoding.UTF8.GetString(message); - logger.LogError(exception, "处理自动铺码消息失败: {Message}", body); - return Task.CompletedTask; - } -} -internal class CallbackUpdateJournalStatusResponse -{ - public string? Message { get; set; } - - public string? Code { get; set; } - - public bool Result { get; set; } - - public bool IsSuccess { get; set; } -} diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs index a44f653..d3267b0 100644 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs @@ -1,21 +1,27 @@ -using Newtonsoft.Json; 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.Threading.Channels; -using Yitter.IdGenerator; +using System.Text.Json; +using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.WorkService.Consumers; /// -/// 期刊任务接收消费者(示例) +/// 期刊任务接收消费者 /// -public class JournalTaskReceiveConsumer(ILogger logger, IConfiguration configuration, +public class JournalTaskReceiveConsumer( + ILogger logger, + IConfiguration configuration, IServiceScopeFactory scopeFactory, - IWebHostEnvironment webHostEnvironment, 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"; @@ -30,21 +36,98 @@ public class JournalTaskReceiveConsumer(ILogger logg using var scope = scopeFactory.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); - // TODO: 在此编写具体的消息处理逻辑 + 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 { - var data = System.Text.Json.JsonSerializer.Deserialize(message); + 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 (Exception ex) + catch { client.Ado.RollbackTran(); - logger.LogError(ex.Message + ex.StackTrace); + throw; } - await Task.CompletedTask; + + 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) @@ -54,32 +137,684 @@ public class JournalTaskReceiveConsumer(ILogger logg 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; } - public Question[] Questions { 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; } - public string[] AnswerUrl { 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 List BreakTimes { get; set; } = []; } + +/// +/// 中断时间记录 +/// public class BreakTime { + /// + /// 中断时间 + /// public DateTime Time { get; set; } + + /// + /// 等待时间 + /// public long WaitTime { get; set; } -} \ No newline at end of file +} + +/// +/// 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); diff --git a/QYZH.InteractiveMagazine.WorkService/Dockerfile b/QYZH.InteractiveMagazine.WorkService/Dockerfile new file mode 100644 index 0000000..b9b6927 --- /dev/null +++ b/QYZH.InteractiveMagazine.WorkService/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base + +WORKDIR /app + +COPY . . + +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +ENTRYPOINT ["dotnet", "QYZH.InteractiveMagazine.WorkService.dll"] diff --git a/QYZH.InteractiveMagazine.WorkService/Program.cs b/QYZH.InteractiveMagazine.WorkService/Program.cs index bafdc3b..16ccff2 100644 --- a/QYZH.InteractiveMagazine.WorkService/Program.cs +++ b/QYZH.InteractiveMagazine.WorkService/Program.cs @@ -77,7 +77,6 @@ builder.Services.AddRabbitMQ(builder.Configuration); // 注册队列消费者(新增消费者只需实现 IQueueConsumer 并在此注册) builder.Services.AddScoped(); -builder.Services.AddScoped(); // 注册消费者后台服务 builder.Services.AddHostedService(); diff --git a/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj b/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj index 0789df2..b931f2a 100644 --- a/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj +++ b/QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj @@ -22,58 +22,4 @@ - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - diff --git a/QYZH.InteractiveMagazine.WorkService/appsettings.json b/QYZH.InteractiveMagazine.WorkService/appsettings.json index af6d9bf..5658ea6 100644 --- a/QYZH.InteractiveMagazine.WorkService/appsettings.json +++ b/QYZH.InteractiveMagazine.WorkService/appsettings.json @@ -49,6 +49,17 @@ } ] }, + "AiChat": { + "ApiKey": "Ollama", + "BaseUrl": "http://172.16.10.130:11434/v1/", + "Model": "qwen2.5vl:7b", + "TimeoutSeconds": 300, + "MaxTokens": 2000, + "Temperature": 0.5, + "ScoreMaxRetryCount": 3, + "ScoreRetryDelayMilliseconds": 1000, + "MaxImageBytes": 10485760 + }, "AllowedHosts": "*", "AliyunOSSConfigs": { "AccessKeyID": "LTAI5tEBXGewpHSLiSxyx6Bf", diff --git a/QYZH.InteractiveMagazine.slnx b/QYZH.InteractiveMagazine.slnx index ca05cf1..99e2027 100644 --- a/QYZH.InteractiveMagazine.slnx +++ b/QYZH.InteractiveMagazine.slnx @@ -3,6 +3,7 @@ +