refactor: 拆分自动铺码服务为独立PrintWorker项目

1.  将原WorkService中的AutoDotCodeConsumer、RabbitMQHostedService、IQueueConsumer迁移至新的PrintWorker项目
2.  移除WorkService中冗余的PrintTool依赖文件复制配置
3.  将PrintToolV2.7相关资源移动到PrintWorker项目中统一管理
4.  从WorkService中移除AutoDotCodeConsumer的服务注册
5.  新增PrintWorker项目的完整宿主程序与配置文件
This commit is contained in:
glz
2026-06-30 17:41:00 +08:00
parent 6ec47220c2
commit ab09c24338
19 changed files with 654 additions and 417 deletions

View File

@ -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;
/// <summary>
/// 自动铺码消费者。
/// </summary>
public class AutoDotCodeConsumer(
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
IHostEnvironment hostEnvironment,
ILogger<AutoDotCodeConsumer> logger,
IHttpClientFactory httpClientFactory,
OssService ossService) : IQueueConsumer
{
/// <summary>
/// 交换机名称。
/// </summary>
public string Exchange => "ex.journal";
/// <summary>
/// 队列名称。
/// </summary>
public string QueueName => "mq.journal.dotcode.auto";
/// <summary>
/// 路由键。
/// </summary>
public string RoutingKey => "rk.journal.dotcode.auto";
/// <summary>
/// 处理自动铺码消息。
/// </summary>
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<ISqlSugarClient>();
try
{
var request = JsonSerializer.Deserialize<JournalPagePrintDto>(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<DotFile>().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<DotFileDetail>()
.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>() ?? 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<DotFile>()
.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;
}
}
/// <summary>
/// 处理消费异常。
/// </summary>
public Task OnErrorAsync(byte[] message, Exception exception)
{
var body = Encoding.UTF8.GetString(message);
logger.LogError(exception, "处理自动铺码消息失败: {Message}", body);
return Task.CompletedTask;
}
private async Task<PrintToolResult> 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<string[]>(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<CallbackUpdateJournalStatusResponse> ExecuteUpdateJournalStatusAsync(JournalPagePrintDto request, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient();
var callbackUrl = configuration.GetValue<string>("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<CallbackUpdateJournalStatusResponse>(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; }
}

View File

@ -0,0 +1,32 @@
namespace QYZH.InteractiveMagazine.PrintWorker.Consumers;
/// <summary>
/// 队列消费者接口。
/// </summary>
public interface IQueueConsumer
{
/// <summary>
/// 交换机名称。
/// </summary>
string Exchange { get; }
/// <summary>
/// 队列名称。
/// </summary>
string QueueName { get; }
/// <summary>
/// 路由键。
/// </summary>
string RoutingKey { get; }
/// <summary>
/// 处理消息。
/// </summary>
Task HandleAsync(byte[] message, CancellationToken cancellationToken = default);
/// <summary>
/// 处理消费异常。
/// </summary>
Task OnErrorAsync(byte[] message, Exception exception);
}

View File

@ -0,0 +1,105 @@
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
namespace QYZH.InteractiveMagazine.PrintWorker.Consumers;
/// <summary>
/// RabbitMQ 消费者后台服务。
/// </summary>
public class RabbitMQHostedService(IServiceProvider serviceProvider, ILogger<RabbitMQHostedService> logger) : BackgroundService
{
/// <summary>
/// 启动所有已注册队列消费者。
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var scope = serviceProvider.CreateScope();
var consumers = scope.ServiceProvider.GetServices<IQueueConsumer>().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<IRabbitMQService>();
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<IQueueConsumer>()
.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<bool> SendToDeadLetterQueueAsync(string exchange, string dlqName, string dlqRoutingKey, byte[] body, CancellationToken cancellationToken)
{
try
{
var rabbitMQConnection = serviceProvider.GetRequiredService<IRabbitMQConnection>();
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;
}
}
}

View File

@ -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<ISqlSugarClient>(_ => 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<IQueueConsumer, AutoDotCodeConsumer>();
builder.Services.AddHostedService<RabbitMQHostedService>();
var app = builder.Build();
Log.Information("PrintWorker 已启动,仅消费自动铺码队列");
await app.RunAsync();

View File

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\QYZH.InteractiveMagazine.Common\QYZH.InteractiveMagazine.Common.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Models\QYZH.InteractiveMagazine.Models.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Infrastructure\QYZH.InteractiveMagazine.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
<PackageReference Include="MySqlConnector" Version="2.5.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="SqlSugar.IOC" Version="2.0.1" />
<PackageReference Include="SqlSugarCoreNoDrive" Version="5.1.4.213" />
<PackageReference Include="Yitter.IdGenerator" Version="1.0.14" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="PrintToolV2.7\**\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -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
}
}

View File

@ -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;
/// <summary>
/// 自动铺码消费者
/// </summary>
public class AutoDotCodeConsumer(IConfiguration configuration,
IServiceScopeFactory scopeFactory,
IWebHostEnvironment webHostEnvironment,
ILogger<AutoDotCodeConsumer> 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<ISqlSugarClient>();
try
{
var journalPagePrintDtoMessage = JsonConvert.DeserializeObject<JournalPagePrintDto>(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<DotFile>().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<DotFileDetail>().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<string[]>(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>() ?? 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<DotFile>()
.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<CallbackUpdateJournalStatusResponse> ExecuteUpdateJournalStatus(JournalPagePrintDto request, ISqlSugarClient dBContext)
{
var client = httpClientFactory.CreateClient();
var callbackUrl = configuration.GetValue<string>("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<CallbackUpdateJournalStatusResponse>(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; }
}

View File

@ -1,16 +1,10 @@
# 使用 ASP.NET Core 8.0 运行时基础镜像
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
# 设置工作目录
WORKDIR /app WORKDIR /app
# 将当前目录(发布文件夹)的所有内容复制到容器内的 /app 目录
COPY . . COPY . .
# 设置时区(可选)
ENV TZ=Asia/Shanghai ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 启动应用
ENTRYPOINT ["dotnet", "QYZH.InteractiveMagazine.WorkService.dll"] ENTRYPOINT ["dotnet", "QYZH.InteractiveMagazine.WorkService.dll"]

View File

@ -77,7 +77,6 @@ builder.Services.AddRabbitMQ(builder.Configuration);
// 注册队列消费者(新增消费者只需实现 IQueueConsumer 并在此注册) // 注册队列消费者(新增消费者只需实现 IQueueConsumer 并在此注册)
builder.Services.AddScoped<IQueueConsumer, JournalTaskReceiveConsumer>(); builder.Services.AddScoped<IQueueConsumer, JournalTaskReceiveConsumer>();
builder.Services.AddScoped<IQueueConsumer, AutoDotCodeConsumer>();
// 注册消费者后台服务 // 注册消费者后台服务
builder.Services.AddHostedService<RabbitMQHostedService>(); builder.Services.AddHostedService<RabbitMQHostedService>();

View File

@ -22,58 +22,4 @@
<PackageReference Include="Yitter.IdGenerator" Version="1.0.14" /> <PackageReference Include="Yitter.IdGenerator" Version="1.0.14" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="PrintToolV2.7\bin\gsdll32.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\gsdll32_1.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\gsdll32_10.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\gsdll32_9.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\gswin32c.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\info.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\libiconv2.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\print.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\bin\tk.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\PrintTool.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\sublic_license_1714.0.0.0_10.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\sublic_license_1761.172.8.16_1000.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\sublic_license_1761.211.21.48_10000.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\wfdlicense.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\wfdprint.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\命令行方式调用方法.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="PrintToolV2.7\返回值说明.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>

View File

@ -3,6 +3,7 @@
<Project Path="QYZH.InteractiveMagazine.Infrastructure/QYZH.InteractiveMagazine.Infrastructure.csproj" /> <Project Path="QYZH.InteractiveMagazine.Infrastructure/QYZH.InteractiveMagazine.Infrastructure.csproj" />
<Project Path="QYZH.InteractiveMagazine.IService/QYZH.InteractiveMagazine.IService.csproj" /> <Project Path="QYZH.InteractiveMagazine.IService/QYZH.InteractiveMagazine.IService.csproj" />
<Project Path="QYZH.InteractiveMagazine.Models/QYZH.InteractiveMagazine.Models.csproj" /> <Project Path="QYZH.InteractiveMagazine.Models/QYZH.InteractiveMagazine.Models.csproj" />
<Project Path="QYZH.InteractiveMagazine.PrintWorker/QYZH.InteractiveMagazine.PrintWorker.csproj" />
<Project Path="QYZH.InteractiveMagazine.Repository/QYZH.InteractiveMagazine.Repository.csproj" /> <Project Path="QYZH.InteractiveMagazine.Repository/QYZH.InteractiveMagazine.Repository.csproj" />
<Project Path="QYZH.InteractiveMagazine.Service/QYZH.InteractiveMagazine.Service.csproj" /> <Project Path="QYZH.InteractiveMagazine.Service/QYZH.InteractiveMagazine.Service.csproj" />
<Project Path="QYZH.InteractiveMagazine.WebApi/QYZH.InteractiveMagazine.WebApi.csproj" /> <Project Path="QYZH.InteractiveMagazine.WebApi/QYZH.InteractiveMagazine.WebApi.csproj" />