1018 lines
37 KiB
C#
1018 lines
37 KiB
C#
using YD_Event.Application.Certificate.Dtos;
|
||
using YD_Event.Core.Entities;
|
||
using YD_Event.Core.Domain.Certificate;
|
||
using SqlSugar;
|
||
using Mapster;
|
||
using Microsoft.Extensions.Options;
|
||
using YD_Event.Core.Options;
|
||
using Microsoft.AspNetCore.Hosting;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.Extensions.Logging;
|
||
using System.Diagnostics;
|
||
using System.ComponentModel.DataAnnotations;
|
||
using DocumentFormat.OpenXml.Packaging;
|
||
using DocumentFormat.OpenXml.Wordprocessing;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using QuestPDF.Fluent;
|
||
using QuestPDF.Helpers;
|
||
using QuestPDF.Infrastructure;
|
||
using YD_Event.Application.File;
|
||
|
||
namespace YD_Event.Application.Certificate;
|
||
|
||
/// <summary>
|
||
/// 证书生成服务
|
||
/// </summary>
|
||
public class CertificateGenerateService : IDynamicApiController, ITransient
|
||
{
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Certificate> _certificateRepository;
|
||
private readonly ISqlSugarRepository<CertificateTemplate> _templateRepository;
|
||
private readonly ISqlSugarRepository<CertificatePlaceholder> _placeholderRepository;
|
||
private readonly FileService _fileService;
|
||
private readonly IOptionsMonitor<OssConnectionOptions> _ossOptionsMonitor;
|
||
private readonly IIdGenerator _idGenerator;
|
||
private readonly IWebHostEnvironment _webHostEnvironment;
|
||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||
private readonly ILogger<CertificateGenerateService> _logger;
|
||
private readonly DocxTemplateProcessor _docxProcessor;
|
||
private readonly QRCodeService _qrCodeService;
|
||
|
||
public CertificateGenerateService(
|
||
ISqlSugarRepository<YD_Event.Core.Entities.Certificate> certificateRepository,
|
||
ISqlSugarRepository<CertificateTemplate> templateRepository,
|
||
ISqlSugarRepository<CertificatePlaceholder> placeholderRepository,
|
||
FileService fileService,
|
||
IOptionsMonitor<OssConnectionOptions> ossOptionsMonitor,
|
||
IIdGenerator idGenerator,
|
||
IWebHostEnvironment webHostEnvironment,
|
||
IHttpContextAccessor httpContextAccessor,
|
||
ILogger<CertificateGenerateService> logger,
|
||
DocxTemplateProcessor docxProcessor,
|
||
QRCodeService qrCodeService)
|
||
{
|
||
_certificateRepository = certificateRepository;
|
||
_templateRepository = templateRepository;
|
||
_placeholderRepository = placeholderRepository;
|
||
_fileService = fileService;
|
||
_ossOptionsMonitor = ossOptionsMonitor;
|
||
_idGenerator = idGenerator;
|
||
_webHostEnvironment = webHostEnvironment;
|
||
_httpContextAccessor = httpContextAccessor;
|
||
_logger = logger;
|
||
_docxProcessor = docxProcessor;
|
||
_qrCodeService = qrCodeService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成PDF证书
|
||
/// </summary>
|
||
[DisplayName("生成PDF证书")]
|
||
public async Task<PdfCertificateGenerationResult> GeneratePdf(GeneratePdfCertificateInput input)
|
||
{
|
||
var stopwatch = Stopwatch.StartNew();
|
||
var result = new PdfCertificateGenerationResult
|
||
{
|
||
GeneratedTime = DateTime.Now,
|
||
IsSuccess = true
|
||
};
|
||
|
||
try
|
||
{
|
||
// 1. 获取证书信息
|
||
var certificate = await _certificateRepository.GetByIdAsync(input.CertificateId);
|
||
if (certificate == null)
|
||
{
|
||
result.IsSuccess = false;
|
||
result.ErrorMessage = "证书不存在";
|
||
return result;
|
||
}
|
||
|
||
if (certificate.Status != Core.Enum.AvailabilityStatus.Enable)
|
||
{
|
||
result.IsSuccess = false;
|
||
result.ErrorMessage = "证书已禁用";
|
||
return result;
|
||
}
|
||
|
||
// 2. 获取模板
|
||
CertificateTemplate template;
|
||
if (input.TemplateId.HasValue)
|
||
{
|
||
template = await _templateRepository.GetByIdAsync(input.TemplateId.Value);
|
||
}
|
||
else
|
||
{
|
||
template = await _templateRepository.GetByIdAsync(certificate.TemplateId);
|
||
}
|
||
|
||
if (template == null)
|
||
{
|
||
result.IsSuccess = false;
|
||
result.ErrorMessage = "未找到有效证书模板";
|
||
return result;
|
||
}
|
||
|
||
// 3. 检查模板类型
|
||
if (template.TemplateType != "Doc")
|
||
{
|
||
result.IsSuccess = false;
|
||
result.ErrorMessage = "指定的模板不是DOC类型";
|
||
return result;
|
||
}
|
||
|
||
// 4. 获取占位符配置
|
||
var placeholders = await _placeholderRepository.AsQueryable()
|
||
.Where(x => x.TemplateId == template.Id)
|
||
.OrderBy(x => x.Sort)
|
||
.ToListAsync();
|
||
|
||
// 5. 验证必填数据
|
||
var requiredPlaceholders = placeholders.Where(x => x.IsRequired).Select(x => x.PlaceholderKey).ToList();
|
||
var validationResult = _docxProcessor.ValidateTemplateData(requiredPlaceholders, input.Data);
|
||
|
||
if (!validationResult.IsValid)
|
||
{
|
||
result.IsSuccess = false;
|
||
result.ErrorMessage = $"缺少必填数据: {string.Join(", ", validationResult.MissingPlaceholders)}";
|
||
return result;
|
||
}
|
||
|
||
// 6. 下载DOC模板
|
||
using var httpClient = new HttpClient();
|
||
var templateStream = new MemoryStream(await httpClient.GetByteArrayAsync(template.DocxTemplateUrl));
|
||
|
||
// 7. 生成二维码数据(用于DOC模板中的占位符)
|
||
var qrCodeData = new Dictionary<string, string>();
|
||
if (input.GenerateQRCode)
|
||
{
|
||
var certificateUrl = GenerateCertificateUrl(input.CertificateId);
|
||
qrCodeData["CertificateQR"] = certificateUrl;
|
||
qrCodeData["VerificationQR"] = certificateUrl + "/verify";
|
||
}
|
||
|
||
// 8. 处理模板
|
||
var processedStream = _docxProcessor.ProcessTemplate(templateStream, input.Data, qrCodeData);
|
||
|
||
// 9. 先生成证书保存地址(预设的PDF地址)
|
||
var pdfUrl = GeneratePdfUrl(input.CertificateId, certificate, template);
|
||
|
||
// 10. 生成二维码图片(使用预设的PDF地址)
|
||
byte[] qrCodeImage = null;
|
||
if (input.GenerateQRCode)
|
||
{
|
||
using var qrStream = _qrCodeService.GenerateQRCode(pdfUrl, input.QRCodeSize);
|
||
using var memoryStream = new MemoryStream();
|
||
qrStream.CopyTo(memoryStream);
|
||
qrCodeImage = memoryStream.ToArray();
|
||
}
|
||
|
||
// 11. 转换为PDF(包含二维码)
|
||
var pdfStream = await ConvertDocToPdf(processedStream, qrCodeImage);
|
||
|
||
// 12. 保存PDF文件到预设地址
|
||
await SavePdfFileToUrl(pdfStream, pdfUrl);
|
||
|
||
// 12. 生成二维码图片(如果需要)
|
||
string qrCodeUrl = null;
|
||
if (input.GenerateQRCode)
|
||
{
|
||
qrCodeUrl = await GenerateQRCodeImage(input.CertificateId, input.QRCodeSize);
|
||
}
|
||
|
||
// 13. 设置结果
|
||
result.PdfUrl = pdfUrl;
|
||
result.QRCodeUrl = qrCodeUrl;
|
||
result.VerificationUrl = GenerateCertificateUrl(input.CertificateId);
|
||
result.Warnings.AddRange(validationResult.Warnings);
|
||
|
||
_logger.LogInformation("PDF证书生成成功. CertificateId: {CertificateId}, Url: {Url}",
|
||
input.CertificateId, pdfUrl);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
result.IsSuccess = false;
|
||
result.ErrorMessage = ex.Message;
|
||
_logger.LogError(ex, "PDF证书生成失败. CertificateId: {CertificateId}", input.CertificateId);
|
||
}
|
||
finally
|
||
{
|
||
stopwatch.Stop();
|
||
result.GenerationTimeMs = stopwatch.ElapsedMilliseconds;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 批量生成PDF证书
|
||
/// </summary>
|
||
[DisplayName("批量生成PDF证书")]
|
||
public async Task<BatchPdfCertificateGenerationResult> BatchGeneratePdf(BatchGeneratePdfCertificateInput input)
|
||
{
|
||
var result = new BatchPdfCertificateGenerationResult
|
||
{
|
||
StartTime = DateTime.Now,
|
||
TotalCount = input.DataList.Count
|
||
};
|
||
|
||
_logger.LogInformation("开始批量生成PDF证书. Count: {Count}", input.DataList.Count);
|
||
|
||
foreach (var data in input.DataList)
|
||
{
|
||
var generateInput = new GeneratePdfCertificateInput
|
||
{
|
||
CertificateId = input.CertificateId,
|
||
TemplateId = input.TemplateId,
|
||
Data = data,
|
||
GenerateQRCode = input.GenerateQRCode,
|
||
QRCodeSize = input.QRCodeSize
|
||
};
|
||
|
||
var generateResult = await GeneratePdf(generateInput);
|
||
|
||
if (generateResult.IsSuccess)
|
||
{
|
||
result.SuccessResults.Add(generateResult);
|
||
}
|
||
else
|
||
{
|
||
result.FailedResults.Add(generateResult);
|
||
}
|
||
}
|
||
|
||
result.EndTime = DateTime.Now;
|
||
|
||
_logger.LogInformation("批量生成PDF证书完成. Total: {Total}, Success: {Success}, Failed: {Failed}",
|
||
result.TotalCount, result.SuccessCount, result.FailedCount);
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 转换DOC为PDF(使用QuestPDF和DocumentFormat.OpenXml)
|
||
/// </summary>
|
||
private async Task<Stream> ConvertDocToPdf(Stream docStream, byte[] qrCodeImage = null)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogInformation("开始DOCX转PDF转换");
|
||
|
||
// 重置流位置
|
||
docStream.Position = 0;
|
||
|
||
// 使用QuestPDF许可证
|
||
QuestPDF.Settings.License = LicenseType.Community;
|
||
|
||
// 解析DOCX内容
|
||
var documentData = await ExtractDocumentData(docStream);
|
||
|
||
// 添加二维码图片
|
||
if (qrCodeImage != null)
|
||
{
|
||
documentData.QRCodeImage = qrCodeImage;
|
||
documentData.QRCodeMimeType = "image/png";
|
||
}
|
||
|
||
// 生成PDF
|
||
var pdfStream = new MemoryStream();
|
||
await GeneratePdfWithQuestPDF(pdfStream, documentData);
|
||
|
||
pdfStream.Position = 0;
|
||
_logger.LogInformation("DOCX转PDF完成,PDF大小: {Size} bytes", pdfStream.Length);
|
||
return pdfStream;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "DOCX转PDF失败");
|
||
throw new Exception("DOCX转PDF转换失败", ex);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提取DOCX文档数据(包括段落、表格、背景图片等)
|
||
/// </summary>
|
||
private async Task<DocumentData> ExtractDocumentData(Stream docStream)
|
||
{
|
||
var documentData = new DocumentData();
|
||
|
||
using var wordprocessingDocument = WordprocessingDocument.Open(docStream, false);
|
||
var body = wordprocessingDocument.MainDocumentPart?.Document?.Body;
|
||
|
||
// 提取背景图片
|
||
await ExtractBackgroundImage(wordprocessingDocument, documentData);
|
||
|
||
if (body != null)
|
||
{
|
||
// 提取段落(包含位置信息)
|
||
foreach (var paragraph in body.Elements<Paragraph>())
|
||
{
|
||
var paragraphText = paragraph.InnerText?.Trim();
|
||
if (!string.IsNullOrEmpty(paragraphText))
|
||
{
|
||
var paragraphData = new ParagraphData
|
||
{
|
||
Text = paragraphText,
|
||
Style = DetermineParagraphStyle(paragraph),
|
||
FontSize = GetParagraphFontSize(paragraph),
|
||
Alignment = GetParagraphAlignment(paragraph),
|
||
IsBold = IsParagraphBold(paragraph),
|
||
FontColor = GetParagraphColor(paragraph)
|
||
};
|
||
|
||
// 尝试获取位置信息
|
||
GetParagraphPosition(paragraph, paragraphData);
|
||
|
||
documentData.Paragraphs.Add(paragraphData);
|
||
}
|
||
}
|
||
|
||
// 提取表格
|
||
foreach (var table in body.Elements<Table>())
|
||
{
|
||
var tableData = new TableData();
|
||
foreach (var row in table.Elements<TableRow>())
|
||
{
|
||
var rowData = new List<string>();
|
||
foreach (var cell in row.Elements<TableCell>())
|
||
{
|
||
var cellText = cell.InnerText?.Trim() ?? string.Empty;
|
||
rowData.Add(cellText);
|
||
}
|
||
if (rowData.Any())
|
||
{
|
||
tableData.Rows.Add(rowData);
|
||
}
|
||
}
|
||
if (tableData.Rows.Any())
|
||
{
|
||
documentData.Tables.Add(tableData);
|
||
}
|
||
}
|
||
}
|
||
|
||
return documentData;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提取背景图片
|
||
/// </summary>
|
||
private async Task ExtractBackgroundImage(WordprocessingDocument wordDoc, DocumentData documentData)
|
||
{
|
||
try
|
||
{
|
||
var mainPart = wordDoc.MainDocumentPart;
|
||
if (mainPart != null)
|
||
{
|
||
// 查找文档中的图片
|
||
var imageParts = mainPart.GetPartsOfType<ImagePart>().ToList();
|
||
|
||
if (imageParts.Any())
|
||
{
|
||
// 尝试获取第一个图片作为背景图片
|
||
var firstImage = imageParts.FirstOrDefault();
|
||
if (firstImage != null)
|
||
{
|
||
using var imageStream = firstImage.GetStream(FileMode.Open, FileAccess.Read);
|
||
using var memoryStream = new MemoryStream();
|
||
await imageStream.CopyToAsync(memoryStream);
|
||
|
||
documentData.BackgroundImage = memoryStream.ToArray();
|
||
documentData.BackgroundImageMimeType = firstImage.ContentType;
|
||
|
||
_logger.LogInformation("成功提取背景图片,类型: {Type}, 大小: {Size} bytes",
|
||
documentData.BackgroundImageMimeType, documentData.BackgroundImage.Length);
|
||
}
|
||
}
|
||
|
||
// 如果没有找到图片,尝试从页眉页脚中查找
|
||
if (documentData.BackgroundImage == null)
|
||
{
|
||
var headerParts = mainPart.HeaderParts;
|
||
foreach (var headerPart in headerParts)
|
||
{
|
||
var headerImages = headerPart.GetPartsOfType<ImagePart>();
|
||
foreach (var image in headerImages)
|
||
{
|
||
using var imageStream = image.GetStream(FileMode.Open, FileAccess.Read);
|
||
using var memoryStream = new MemoryStream();
|
||
await imageStream.CopyToAsync(memoryStream);
|
||
|
||
documentData.BackgroundImage = memoryStream.ToArray();
|
||
documentData.BackgroundImageMimeType = image.ContentType;
|
||
|
||
_logger.LogInformation("从页眉中提取背景图片,类型: {Type}, 大小: {Size} bytes",
|
||
documentData.BackgroundImageMimeType, documentData.BackgroundImage.Length);
|
||
break;
|
||
}
|
||
if (documentData.BackgroundImage != null) break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "提取背景图片失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落字体大小
|
||
/// </summary>
|
||
private double? GetParagraphFontSize(Paragraph paragraph)
|
||
{
|
||
var runProperties = paragraph.Elements<Run>()
|
||
.SelectMany(r => r.Elements<RunProperties>())
|
||
.FirstOrDefault();
|
||
|
||
if (runProperties?.FontSize != null)
|
||
{
|
||
var fontSize = runProperties.FontSize.Val;
|
||
if (double.TryParse(fontSize, out double size))
|
||
{
|
||
return size / 2.0; // OpenXML 中字体大小是半磅为单位
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落对齐方式
|
||
/// </summary>
|
||
private string GetParagraphAlignment(Paragraph paragraph)
|
||
{
|
||
var paragraphProperties = paragraph.ParagraphProperties;
|
||
if (paragraphProperties?.Justification != null)
|
||
{
|
||
var justification = paragraphProperties.Justification.Val?.ToString().ToLower();
|
||
return justification switch
|
||
{
|
||
"center" => "center",
|
||
"right" => "right",
|
||
"both" => "justify",
|
||
_ => "left"
|
||
};
|
||
}
|
||
|
||
return "left";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查段落是否为粗体
|
||
/// </summary>
|
||
private bool IsParagraphBold(Paragraph paragraph)
|
||
{
|
||
var runProperties = paragraph.Elements<Run>()
|
||
.SelectMany(r => r.Elements<RunProperties>())
|
||
.FirstOrDefault();
|
||
|
||
return runProperties?.Bold != null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落字体颜色
|
||
/// </summary>
|
||
private string GetParagraphColor(Paragraph paragraph)
|
||
{
|
||
var runProperties = paragraph.Elements<Run>()
|
||
.SelectMany(r => r.Elements<RunProperties>())
|
||
.FirstOrDefault();
|
||
|
||
if (runProperties?.Color != null)
|
||
{
|
||
var color = runProperties.Color.Val;
|
||
if (!string.IsNullOrEmpty(color))
|
||
{
|
||
return color;
|
||
}
|
||
}
|
||
|
||
return "black";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取段落位置信息
|
||
/// </summary>
|
||
private void GetParagraphPosition(Paragraph paragraph, ParagraphData paragraphData)
|
||
{
|
||
try
|
||
{
|
||
var paragraphProperties = paragraph.ParagraphProperties;
|
||
if (paragraphProperties != null)
|
||
{
|
||
_logger.LogDebug("处理段落: {Text}", paragraphData.Text);
|
||
|
||
// 检查是否有绝对定位
|
||
var frameProperties = paragraphProperties.Elements<FrameProperties>().FirstOrDefault();
|
||
if (frameProperties != null)
|
||
{
|
||
_logger.LogDebug("找到FrameProperties");
|
||
|
||
// 提取位置信息
|
||
if (frameProperties.Width != null)
|
||
{
|
||
var widthStr = frameProperties.Width.Value.ToString();
|
||
_logger.LogDebug("Width: {Width}", widthStr);
|
||
if (uint.TryParse(widthStr, out uint width))
|
||
{
|
||
paragraphData.PositionX = width;
|
||
_logger.LogDebug("设置PositionX: {X}", width);
|
||
}
|
||
}
|
||
|
||
if (frameProperties.Height != null)
|
||
{
|
||
var heightStr = frameProperties.Height.Value.ToString();
|
||
_logger.LogDebug("Height: {Height}", heightStr);
|
||
if (uint.TryParse(heightStr, out uint height))
|
||
{
|
||
paragraphData.PositionY = height;
|
||
_logger.LogDebug("设置PositionY: {Y}", height);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_logger.LogDebug("未找到FrameProperties");
|
||
}
|
||
|
||
// 如果没有绝对定位,检查段落缩进
|
||
if (paragraphData.PositionX == null)
|
||
{
|
||
var indentation = paragraphProperties.Elements<Indentation>().FirstOrDefault();
|
||
if (indentation != null && indentation.Left != null)
|
||
{
|
||
var leftStr = indentation.Left.Value.ToString();
|
||
_logger.LogDebug("Left Indentation: {Indent}", leftStr);
|
||
if (uint.TryParse(leftStr, out uint leftIndent))
|
||
{
|
||
paragraphData.PositionX = leftIndent / 20; // 转换为点
|
||
_logger.LogDebug("通过缩进设置PositionX: {X}", paragraphData.PositionX);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查段落间距
|
||
var spacingBetweenLines = paragraphProperties.SpacingBetweenLines;
|
||
if (spacingBetweenLines != null)
|
||
{
|
||
_logger.LogDebug("SpacingBetweenLines: {Spacing}", spacingBetweenLines.ToString());
|
||
}
|
||
}
|
||
|
||
_logger.LogDebug("最终位置 - X: {X}, Y: {Y}", paragraphData.PositionX, paragraphData.PositionY);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "获取段落位置信息失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用QuestPDF生成PDF
|
||
/// </summary>
|
||
private async Task GeneratePdfWithQuestPDF(Stream pdfStream, DocumentData documentData)
|
||
{
|
||
var questDocument = QuestPDF.Fluent.Document.Create(container =>
|
||
{
|
||
container.Page(page =>
|
||
{
|
||
page.Margin(0);
|
||
page.Size(PageSizes.A4);
|
||
page.DefaultTextStyle(x => x.FontSize(12).FontFamily(QuestPDF.Helpers.Fonts.Calibri));
|
||
|
||
// 添加背景图片
|
||
if (documentData.BackgroundImage != null)
|
||
{
|
||
page.Background()
|
||
.Image(documentData.BackgroundImage)
|
||
.FitArea();
|
||
}
|
||
|
||
// 使用相对定位来放置文本
|
||
page.Content().Column(column =>
|
||
{
|
||
// 根据段落信息进行精确定位
|
||
foreach (var paragraph in documentData.Paragraphs)
|
||
{
|
||
var fontSize = paragraph.FontSize ?? 12;
|
||
var isBold = paragraph.IsBold;
|
||
|
||
// 使用从DOCX中提取的实际位置信息
|
||
var positionX = paragraph.PositionX ?? 50; // 默认左边距
|
||
var positionY = paragraph.PositionY ?? 50; // 默认顶部边距
|
||
|
||
// 跳过无效的文本内容
|
||
if (string.IsNullOrWhiteSpace(paragraph.Text) ||
|
||
paragraph.Text.Contains("left-") ||
|
||
paragraph.Text.Contains("right-") ||
|
||
paragraph.Text.Contains("top-") ||
|
||
paragraph.Text.Contains("bottom-"))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 创建文本元素,使用原始位置
|
||
var textElement = column.Item().PaddingTop((float)positionY).PaddingLeft((float)positionX).Text(paragraph.Text)
|
||
.FontSize((float)fontSize)
|
||
.FontColor(QuestPDF.Helpers.Colors.Black);
|
||
|
||
// 如果需要粗体,应用到同一个元素
|
||
if (isBold)
|
||
{
|
||
textElement.Bold();
|
||
}
|
||
}
|
||
|
||
// 添加二维码到右下角
|
||
if (documentData.QRCodeImage != null)
|
||
{
|
||
column.Item().PaddingTop(500).PaddingRight(50).AlignRight().Width(80).Height(80).Image(documentData.QRCodeImage);
|
||
}
|
||
});
|
||
|
||
page.Footer()
|
||
.AlignCenter()
|
||
.Text(x =>
|
||
{
|
||
x.Span("生成时间: ").FontSize(8).FontColor(QuestPDF.Helpers.Colors.Grey.Darken1);
|
||
x.CurrentPageNumber().FontSize(8).FontColor(QuestPDF.Helpers.Colors.Grey.Darken1);
|
||
});
|
||
});
|
||
});
|
||
|
||
// 生成PDF到流
|
||
questDocument.GeneratePdf(pdfStream);
|
||
await Task.CompletedTask; // 为了保持异步签名
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据段落内容确定文本位置(基于A4页面210x297mm,坐标单位为点)
|
||
/// </summary>
|
||
private (double X, double Y) GetTextPosition(ParagraphData paragraph)
|
||
{
|
||
// 根据文本内容和样式判断位置
|
||
var text = paragraph.Text;
|
||
|
||
// 根据证书模板的布局进行定位(A4页面,左上角为原点)
|
||
// 调整坐标以更好地匹配证书模板
|
||
if (text.Contains("第九届上海国际交互绳大奖赛"))
|
||
{
|
||
return (150, 80); // 顶部标题位置(居中)
|
||
}
|
||
else if (text.Contains("Double Dutch Contest Shanghai"))
|
||
{
|
||
return (150, 120); // 英文标题位置(居中)
|
||
}
|
||
else if (text.Contains("CERTIFICATE"))
|
||
{
|
||
return (250, 200); // 证书标题位置(居中)
|
||
}
|
||
else if (text.Contains("比赛证书"))
|
||
{
|
||
return (250, 240); // 中文证书标题位置(居中)
|
||
}
|
||
else if (text.Contains("参赛单位"))
|
||
{
|
||
return (100, 320); // 参赛单位标签位置
|
||
}
|
||
else if (text.Contains("{{danwei}}"))
|
||
{
|
||
return (200, 320); // 参赛单位值位置
|
||
}
|
||
else if (text.Contains("参赛运动员"))
|
||
{
|
||
return (100, 370); // 参赛运动员标签位置
|
||
}
|
||
else if (text.Contains("{{name}}"))
|
||
{
|
||
return (200, 370); // 参赛运动员值位置
|
||
}
|
||
else if (text.Contains("项目组别"))
|
||
{
|
||
return (100, 420); // 项目组别标签位置
|
||
}
|
||
else if (text.Contains("名次"))
|
||
{
|
||
return (100, 470); // 名次标签位置
|
||
}
|
||
else if (text.Contains("第九届上海国际交互绳大奖赛组委会"))
|
||
{
|
||
return (150, 550); // 底部组织机构位置(居中)
|
||
}
|
||
else if (text.Contains("二零二五年十一月"))
|
||
{
|
||
return (250, 590); // 日期位置(居中)
|
||
}
|
||
|
||
// 默认位置
|
||
return (50, 50);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 确定段落样式
|
||
/// </summary>
|
||
private string DetermineParagraphStyle(Paragraph paragraph)
|
||
{
|
||
// 检查是否为标题
|
||
var paragraphProperties = paragraph.ParagraphProperties;
|
||
if (paragraphProperties != null)
|
||
{
|
||
var style = paragraphProperties.ParagraphStyleId?.Val?.Value?.ToLower();
|
||
if (style != null)
|
||
{
|
||
if (style.Contains("heading") || style.Contains("title"))
|
||
{
|
||
return "heading";
|
||
}
|
||
}
|
||
}
|
||
|
||
// 根据文本特征判断
|
||
var text = paragraph.InnerText?.Trim() ?? string.Empty;
|
||
if (text.Length < 50 && text.Length > 0)
|
||
{
|
||
return "subheading";
|
||
}
|
||
|
||
return "normal";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取LibreOffice可执行文件路径
|
||
/// </summary>
|
||
private string GetLibreOfficePath()
|
||
{
|
||
var possiblePaths = new[]
|
||
{
|
||
// Windows
|
||
@"C:\Program Files\LibreOffice\program\soffice.exe",
|
||
@"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
|
||
@"C:\Program Files\LibreOffice\program\libreoffice.exe",
|
||
@"C:\Program Files (x86)\LibreOffice\program\libreoffice.exe",
|
||
|
||
// Linux
|
||
"/usr/bin/libreoffice",
|
||
"/usr/bin/soffice",
|
||
"/opt/libreoffice/program/soffice",
|
||
|
||
// macOS
|
||
"/Applications/LibreOffice.app/Contents/MacOS/soffice"
|
||
};
|
||
|
||
foreach (var path in possiblePaths)
|
||
{
|
||
if (System.IO.File.Exists(path))
|
||
{
|
||
_logger.LogInformation("找到LibreOffice: {Path}", path);
|
||
return path;
|
||
}
|
||
}
|
||
|
||
// 如果找不到,尝试使用系统PATH中的命令
|
||
return OperatingSystem.IsWindows() ? "soffice" : "libreoffice";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存PDF文件
|
||
/// </summary>
|
||
private async Task<string> SavePdfFile(Stream pdfStream, YD_Event.Core.Entities.Certificate certificate, CertificateTemplate template)
|
||
{
|
||
var options = _ossOptionsMonitor.CurrentValue;
|
||
var now = DateTime.Today;
|
||
string name = $"certificate_{_idGenerator.Encode(_idGenerator.NewLong())}.pdf";
|
||
string filePath = $"/certificates/{now.Year}/{now.Month:D2}/{now.Day:D2}/";
|
||
string finalUrl = string.Empty;
|
||
|
||
pdfStream.Position = 0;
|
||
|
||
if (!options.Enable)
|
||
{
|
||
// 本地存储
|
||
string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath);
|
||
string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir.TrimStart('/'));
|
||
|
||
if (!Directory.Exists(absoluteDir))
|
||
{
|
||
Directory.CreateDirectory(absoluteDir);
|
||
}
|
||
|
||
string fullPath = Path.Combine(absoluteDir, name);
|
||
|
||
// 预测最终 URL
|
||
var request = _httpContextAccessor.HttpContext?.Request;
|
||
if (request != null)
|
||
{
|
||
finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}";
|
||
}
|
||
|
||
using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
|
||
await pdfStream.CopyToAsync(fileStream);
|
||
|
||
_logger.LogInformation("PDF文件保存到本地: {FullPath}", fullPath);
|
||
}
|
||
else
|
||
{
|
||
// OSS 上传
|
||
string fileName = $"{filePath.TrimStart('/')}{name}";
|
||
finalUrl = await _fileService.UploadFileStreamAsync(fileName, pdfStream);
|
||
|
||
_logger.LogInformation("PDF文件上传到OSS: {FileName}", fileName);
|
||
}
|
||
|
||
return finalUrl;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成二维码图片
|
||
/// </summary>
|
||
private async Task<string> GenerateQRCodeImage(long certificateId, int size)
|
||
{
|
||
try
|
||
{
|
||
var certificateUrl = GenerateCertificateUrl(certificateId);
|
||
using var qrStream = _qrCodeService.GenerateQRCode(certificateUrl, size);
|
||
return await SaveQRCodeImage(qrStream, certificateId, size);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "生成二维码图片失败. CertificateId: {CertificateId}", certificateId);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存二维码图片
|
||
/// </summary>
|
||
private async Task<string> SaveQRCodeImage(Stream qrStream, long certificateId, int size)
|
||
{
|
||
var options = _ossOptionsMonitor.CurrentValue;
|
||
var now = DateTime.Today;
|
||
string name = $"qr_{certificateId}_{_idGenerator.Encode(_idGenerator.NewLong())}";
|
||
string filePath = $"/{now.Year}/{now.Month:D2}/{now.Day:D2}/qrcodes/";
|
||
string extension = ".png";
|
||
string finalUrl = string.Empty;
|
||
|
||
qrStream.Position = 0;
|
||
|
||
if (!options.Enable)
|
||
{
|
||
string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath);
|
||
string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir.TrimStart('/'));
|
||
|
||
if (!Directory.Exists(absoluteDir))
|
||
{
|
||
Directory.CreateDirectory(absoluteDir);
|
||
}
|
||
|
||
string fullPath = Path.Combine(absoluteDir, $"{name}{extension}");
|
||
using var fileStream2 = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
|
||
await qrStream.CopyToAsync(fileStream2);
|
||
|
||
var request = _httpContextAccessor.HttpContext?.Request;
|
||
if (request != null)
|
||
{
|
||
finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}{extension}";
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// OSS 上传
|
||
string fileName = $"{filePath.TrimStart('/')}{name}{extension}";
|
||
finalUrl = await _fileService.UploadFileStreamAsync(fileName, qrStream);
|
||
}
|
||
|
||
return finalUrl;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成证书URL
|
||
/// </summary>
|
||
private string GenerateCertificateUrl(long certificateId)
|
||
{
|
||
var request = _httpContextAccessor.HttpContext?.Request;
|
||
if (request == null) return string.Empty;
|
||
|
||
var baseUrl = $"{request.Scheme}://{request.Host.Value}";
|
||
return _qrCodeService.GenerateCertificateUrl(certificateId, baseUrl);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成PDF保存地址
|
||
/// </summary>
|
||
private string GeneratePdfUrl(long certificateId, YD_Event.Core.Entities.Certificate certificate, CertificateTemplate template)
|
||
{
|
||
var options = _ossOptionsMonitor.CurrentValue;
|
||
var now = DateTime.Today;
|
||
string name = $"certificate_{_idGenerator.Encode(_idGenerator.NewLong())}.pdf";
|
||
string filePath = $"/certificates/{now.Year}/{now.Month:D2}/{now.Day:D2}/";
|
||
string finalUrl = string.Empty;
|
||
|
||
if (!options.Enable)
|
||
{
|
||
// 本地存储
|
||
string localDir = string.Concat(options.Bucket?.TrimEnd('/') ?? "", filePath);
|
||
|
||
// 预测最终 URL
|
||
var request = _httpContextAccessor.HttpContext?.Request;
|
||
if (request != null)
|
||
{
|
||
finalUrl = $"{request.Scheme}://{request.Host.Value}/{localDir.TrimStart('/')}{name}";
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// OSS 上传
|
||
string fileName = $"{filePath.TrimStart('/')}{name}";
|
||
finalUrl = $"{options.Domain.TrimEnd('/')}/{fileName}";
|
||
}
|
||
|
||
return finalUrl;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存PDF文件到指定URL
|
||
/// </summary>
|
||
private async Task SavePdfFileToUrl(Stream pdfStream, string targetUrl)
|
||
{
|
||
var options = _ossOptionsMonitor.CurrentValue;
|
||
|
||
// 从URL中解析文件路径
|
||
var uri = new Uri(targetUrl);
|
||
var path = uri.AbsolutePath;
|
||
|
||
// 移除域名部分,获取相对路径
|
||
string relativePath = path;
|
||
if (path.StartsWith("/"))
|
||
{
|
||
relativePath = path.Substring(1);
|
||
}
|
||
|
||
pdfStream.Position = 0;
|
||
|
||
if (!options.Enable)
|
||
{
|
||
// 本地存储
|
||
string localDir = Path.GetDirectoryName(relativePath);
|
||
string fileName = Path.GetFileName(relativePath);
|
||
string absoluteDir = Path.Combine(_webHostEnvironment.WebRootPath, localDir);
|
||
|
||
if (!Directory.Exists(absoluteDir))
|
||
{
|
||
Directory.CreateDirectory(absoluteDir);
|
||
}
|
||
|
||
string fullPath = Path.Combine(absoluteDir, fileName);
|
||
|
||
using var fileStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
|
||
await pdfStream.CopyToAsync(fileStream);
|
||
|
||
_logger.LogInformation("PDF文件保存到本地: {FullPath}", fullPath);
|
||
}
|
||
else
|
||
{
|
||
// OSS 上传
|
||
await _fileService.UploadFileStreamAsync(relativePath, pdfStream);
|
||
|
||
_logger.LogInformation("PDF文件上传到OSS: {FileName}", relativePath);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 文档数据模型
|
||
/// </summary>
|
||
public class DocumentData
|
||
{
|
||
public List<ParagraphData> Paragraphs { get; set; } = new List<ParagraphData>();
|
||
public List<TableData> Tables { get; set; } = new List<TableData>();
|
||
public byte[] BackgroundImage { get; set; } = null;
|
||
public string BackgroundImageMimeType { get; set; } = string.Empty;
|
||
public byte[] QRCodeImage { get; set; } = null;
|
||
public string QRCodeMimeType { get; set; } = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 段落数据模型
|
||
/// </summary>
|
||
public class ParagraphData
|
||
{
|
||
public string Text { get; set; } = string.Empty;
|
||
public string Style { get; set; } = "normal"; // normal, heading, subheading
|
||
public double? PositionX { get; set; } = null;
|
||
public double? PositionY { get; set; } = null;
|
||
public double? FontSize { get; set; } = null;
|
||
public string Alignment { get; set; } = "left"; // left, center, right
|
||
public bool IsBold { get; set; } = false;
|
||
public string FontColor { get; set; } = "black";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 表格数据模型
|
||
/// </summary>
|
||
public class TableData
|
||
{
|
||
public List<List<string>> Rows { get; set; } = new List<List<string>>();
|
||
}
|