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(); JournalPagePrintDto? request = null; List reservedDotDetailIds = []; var dotPagesCommitted = false; try { 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); await TryCallbackCodeFailAsync(request, cancellationToken); return; } await using var pdfStream = ossService.GetObjectStream(journalPdfKey); if (pdfStream == null) { logger.LogError("获取书籍上传 PDF 文件失败,OSS Key: {OssKey}", journalPdfKey); await TryCallbackCodeFailAsync(request, cancellationToken); 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); await TryCallbackCodeFailAsync(request, cancellationToken); 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 TryCallbackCodeFailAsync(request, cancellationToken); return; } var xmlPath = Path.Combine(printToolDirectory, dotFile.FileName); if (!File.Exists(xmlPath)) { logger.LogError("铺码授权文件不存在,路径:{XmlPath}", xmlPath); await TryCallbackCodeFailAsync(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 TryCallbackCodeFailAsync(request, cancellationToken); return; } var dotFileDetailPageName = dotFileDetailList.Select(x => x.PageName).OrderBy(x => x).ToArray(); reservedDotDetailIds = dotFileDetailList.Select(x => x.Id).ToList(); var reservedRows = await dbContext.Updateable() .SetColumns(x => x.IsUse == true) .SetColumns(x => x.UpdatedAt == DateTime.Now) .Where(x => reservedDotDetailIds.Contains(x.Id) && !x.IsUse) .ExecuteCommandAsync(cancellationToken); if (reservedRows != reservedDotDetailIds.Count) { logger.LogError("点阵页码预占失败,DotId: {DotId}, Need: {Need}, Reserved: {Reserved}", dotId, reservedDotDetailIds.Count, reservedRows); await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken); reservedDotDetailIds.Clear(); await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName); return; } 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 ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken); reservedDotDetailIds.Clear(); await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName); return; } var statusModel = new JournalPagePrintDto { JournalId = request.JournalId, PageNo = dotFileDetailPageName }; if (!ValidatePrintOutput(printResult.Output, dotFileDetailPageName, dPrint)) { await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken); reservedDotDetailIds.Clear(); await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName); 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(); }); dotPagesCommitted = true; } else { await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken); reservedDotDetailIds.Clear(); logger.LogError("回调接口修改书籍状态失败,JournalId: {JournalId},接口返回消息:{Message}", statusModel.JournalId, callbackResponse.Message); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { logger.LogWarning("自动铺码处理已取消,JournalId: {JournalId}", request?.JournalId); throw; } catch (Exception ex) { logger.LogError(ex, "自动铺码处理失败"); if (request != null) { if (!dotPagesCommitted && reservedDotDetailIds.Count > 0) { await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, CancellationToken.None); } await TryCallbackCodeFailAsync(request, CancellationToken.None); } } } /// /// 处理消费异常。 /// 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 async Task TryCallbackCodeFailAsync(JournalPagePrintDto request, CancellationToken cancellationToken, string[]? pageNo = null) { try { var callbackResponse = await ExecuteUpdateJournalStatusAsync(BuildFailResponse(request, pageNo), cancellationToken); if (!callbackResponse.IsSuccess) { logger.LogError("回调接口修改书籍为铺码失败状态失败,JournalId: {JournalId},接口返回消息:{Message}", request.JournalId, callbackResponse.Message); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception callbackEx) { logger.LogError(callbackEx, "铺码失败后请求回调接口失败,JournalId: {JournalId}", request.JournalId); } } private static async Task ReleaseReservedDotPagesAsync(ISqlSugarClient dbContext, List dotDetailIds, CancellationToken cancellationToken) { if (dotDetailIds.Count == 0) { return; } await dbContext.Updateable() .SetColumns(x => x.IsUse == false) .SetColumns(x => x.UpdatedAt == DateTime.Now) .Where(x => dotDetailIds.Contains(x.Id)) .ExecuteCommandAsync(cancellationToken); } 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 object? Code { get; set; } public bool Result { get; set; } public bool IsSuccess { get; set; } }