From 6985a4af42504411e613ce4b477e500a66d3867e Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Tue, 30 Jun 2026 11:03:39 +0800 Subject: [PATCH 1/3] feat(journal): add journal publish feature 1. add PublishAsync method to IJournalService and implement it in JournalService 2. add publish start/end time fields to Journal entity, DTOs and edit form 3. add journal publish API endpoint 4. add rabbitMQ message sending for book and page publish events 5. clean up some commented-out old controller methods --- .../IJournalService.cs | 2 + .../Dto/Journal/JournalDto.cs | 25 +++++++ .../Dto/Journal/JournalEditDto.cs | 10 +++ .../Entity/Journal.cs | 14 ++++ .../JournalService.cs | 73 ++++++++++++++++++- .../Controllers/JournalController.cs | 69 +++--------------- 6 files changed, 133 insertions(+), 60 deletions(-) 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/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.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 From 6ec47220c2d3a608df287d1388fcb74ea95bedfe Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Tue, 30 Jun 2026 16:16:12 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EAI=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=89=B9=E6=94=B9=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E4=BB=BB=E5=8A=A1=E8=AF=84=E5=88=86=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 添加AI评分配置节点到appsettings.json 2. 新增NeedAiProcess字段控制是否使用AI批改 3. 扩展答题记录实体,新增理解力、判断力、表达力、说服力评分字段 4. 实现期刊任务AI自动批改消费者逻辑,支持重试机制和图片处理 5. 优化实体类注释和代码结构 6. 新增Dockerfile用于容器化部署 --- .../Dto/Journal/JournalPageTaskAddInput.cs | 5 + .../Dto/Journal/JournalPageTaskOutput.cs | 5 + .../Dto/Journal/JournalPageTaskUpdateInput.cs | 5 + .../Entity/JournalPageTaskUserAnswer.cs | 95 ++- .../JournalPageTaskUserAnswerSnapshot.cs | 22 +- .../JournalPageTaskService.cs | 6 +- .../Consumers/JournalTaskReceiveConsumer.cs | 769 +++++++++++++++++- .../Dockerfile | 16 + .../appsettings.json | 11 + 9 files changed, 880 insertions(+), 54 deletions(-) create mode 100644 QYZH.InteractiveMagazine.WorkService/Dockerfile 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/JournalPageTaskUserAnswer.cs b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs index dfb1dfd..43f18e7 100644 --- a/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs +++ b/QYZH.InteractiveMagazine.Models/Entity/JournalPageTaskUserAnswer.cs @@ -2,9 +2,9 @@ using SqlSugar; namespace QYZH.InteractiveMagazine.Models.Entity { - /// - ///杂志任务用户作答记录; - /// + /// + /// 杂志任务用户作答记录 + /// [SugarTable("JournalPageTaskUserAnswer")] public class JournalPageTaskUserAnswer : SqlSugarBaseEntity { @@ -12,204 +12,231 @@ 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; } /// /// Desc:作答状态 /// Default: /// Nullable:False - /// + /// public int AnswerStatus { get; set; } /// /// 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.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.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..c4ca07a --- /dev/null +++ b/QYZH.InteractiveMagazine.WorkService/Dockerfile @@ -0,0 +1,16 @@ +# 使用 ASP.NET Core 8.0 运行时基础镜像 +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base + +# 设置工作目录 +WORKDIR /app + +# 将当前目录(发布文件夹)的所有内容复制到容器内的 /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"] \ No newline at end of file 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", From ab09c24338b20ef9168a54ce49eb6e84f7cdc753 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Tue, 30 Jun 2026 17:41:00 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=93=BA=E7=A0=81=E6=9C=8D=E5=8A=A1=E4=B8=BA=E7=8B=AC?= =?UTF-8?q?=E7=AB=8BPrintWorker=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 将原WorkService中的AutoDotCodeConsumer、RabbitMQHostedService、IQueueConsumer迁移至新的PrintWorker项目 2. 移除WorkService中冗余的PrintTool依赖文件复制配置 3. 将PrintToolV2.7相关资源移动到PrintWorker项目中统一管理 4. 从WorkService中移除AutoDotCodeConsumer的服务注册 5. 新增PrintWorker项目的完整宿主程序与配置文件 --- .../Consumers/AutoDotCodeConsumer.cs | 338 +++++++++++++++++ .../Consumers/IQueueConsumer.cs | 32 ++ .../Consumers/RabbitMQHostedService.cs | 105 ++++++ .../PrintToolV2.7/PrintTool.exe | Bin .../sublic_license_1714.0.0.0_10.xml | 0 .../sublic_license_1761.172.8.16_1000.xml | 0 .../sublic_license_1761.211.21.48_10000.xml | 0 .../PrintToolV2.7/wfdlicense.dll | Bin .../PrintToolV2.7/wfdprint.dll | Bin .../PrintToolV2.7/命令行方式调用方法.txt | 0 .../PrintToolV2.7/返回值说明.txt | 0 .../Program.cs | 59 +++ ...YZH.InteractiveMagazine.PrintWorker.csproj | 33 ++ .../appsettings.json | 85 +++++ .../Consumers/AutoDotCodeConsumer.cs | 355 ------------------ .../Dockerfile | 8 +- .../Program.cs | 1 - ...YZH.InteractiveMagazine.WorkService.csproj | 54 --- QYZH.InteractiveMagazine.slnx | 1 + 19 files changed, 654 insertions(+), 417 deletions(-) create mode 100644 QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs create mode 100644 QYZH.InteractiveMagazine.PrintWorker/Consumers/IQueueConsumer.cs create mode 100644 QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/PrintTool.exe (100%) rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/sublic_license_1714.0.0.0_10.xml (100%) rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/sublic_license_1761.172.8.16_1000.xml (100%) rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/sublic_license_1761.211.21.48_10000.xml (100%) rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/wfdlicense.dll (100%) rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/wfdprint.dll (100%) rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/命令行方式调用方法.txt (100%) rename {QYZH.InteractiveMagazine.WorkService => QYZH.InteractiveMagazine.PrintWorker}/PrintToolV2.7/返回值说明.txt (100%) create mode 100644 QYZH.InteractiveMagazine.PrintWorker/Program.cs create mode 100644 QYZH.InteractiveMagazine.PrintWorker/QYZH.InteractiveMagazine.PrintWorker.csproj create mode 100644 QYZH.InteractiveMagazine.PrintWorker/appsettings.json delete mode 100644 QYZH.InteractiveMagazine.WorkService/Consumers/AutoDotCodeConsumer.cs 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.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/Dockerfile b/QYZH.InteractiveMagazine.WorkService/Dockerfile index c4ca07a..b9b6927 100644 --- a/QYZH.InteractiveMagazine.WorkService/Dockerfile +++ b/QYZH.InteractiveMagazine.WorkService/Dockerfile @@ -1,16 +1,10 @@ -# 使用 ASP.NET Core 8.0 运行时基础镜像 FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -# 设置工作目录 WORKDIR /app -# 将当前目录(发布文件夹)的所有内容复制到容器内的 /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"] \ No newline at end of file +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.slnx b/QYZH.InteractiveMagazine.slnx index ca05cf1..99e2027 100644 --- a/QYZH.InteractiveMagazine.slnx +++ b/QYZH.InteractiveMagazine.slnx @@ -3,6 +3,7 @@ +