package pdf import ( "bytes" "context" "fmt" "os" "path/filepath" "strings" "tyapi-server/internal/domains/product/entities" "github.com/jung-kurt/gofpdf/v2" "github.com/shopspring/decimal" "go.uber.org/zap" ) // PDFGeneratorRefactored 重构后的PDF生成器 type PDFGeneratorRefactored struct { logger *zap.Logger fontManager *FontManager textProcessor *TextProcessor markdownProc *MarkdownProcessor tableParser *TableParser tableRenderer *TableRenderer jsonProcessor *JSONProcessor logoPath string watermarkText string } // NewPDFGeneratorRefactored 创建重构后的PDF生成器 func NewPDFGeneratorRefactored(logger *zap.Logger) *PDFGeneratorRefactored { // 设置全局logger(用于资源路径查找) SetGlobalLogger(logger) // 初始化各个模块 textProcessor := NewTextProcessor() fontManager := NewFontManager(logger) markdownProc := NewMarkdownProcessor(textProcessor) tableParser := NewTableParser(logger, fontManager) tableRenderer := NewTableRenderer(logger, fontManager, textProcessor) jsonProcessor := NewJSONProcessor() gen := &PDFGeneratorRefactored{ logger: logger, fontManager: fontManager, textProcessor: textProcessor, markdownProc: markdownProc, tableParser: tableParser, tableRenderer: tableRenderer, jsonProcessor: jsonProcessor, watermarkText: "海南海宇大数据有限公司", } // 查找logo文件 gen.findLogo() return gen } // findLogo 查找logo文件(仅从resources/pdf加载) func (g *PDFGeneratorRefactored) findLogo() { // 获取resources/pdf目录(使用统一的资源路径查找函数) resourcesPDFDir := GetResourcesPDFDir() logoPath := filepath.Join(resourcesPDFDir, "logo.png") // 检查文件是否存在 if _, err := os.Stat(logoPath); err == nil { g.logoPath = logoPath return } // 只记录关键错误 g.logger.Warn("未找到logo文件", zap.String("path", logoPath)) } // GenerateProductPDF 为产品生成PDF文档(接受响应类型,内部转换) func (g *PDFGeneratorRefactored) GenerateProductPDF(ctx context.Context, productID string, productName, productCode, description, content string, price float64, doc *entities.ProductDocumentation) ([]byte, error) { // 构建临时的 Product entity(仅用于PDF生成) product := &entities.Product{ ID: productID, Name: productName, Code: productCode, Description: description, Content: content, } // 如果有价格信息,设置价格 if price > 0 { product.Price = decimal.NewFromFloat(price) } return g.generatePDF(product, doc, nil) } // GenerateProductPDFFromEntity 从entity类型生成PDF(推荐使用) func (g *PDFGeneratorRefactored) GenerateProductPDFFromEntity(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation) ([]byte, error) { return g.generatePDF(product, doc, nil) } // GenerateProductPDFWithSubProducts 从entity类型生成PDF,支持组合包子产品文档 func (g *PDFGeneratorRefactored) GenerateProductPDFWithSubProducts(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation, subProductDocs []*entities.ProductDocumentation) ([]byte, error) { return g.generatePDF(product, doc, subProductDocs) } // generatePDF 内部PDF生成方法 func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *entities.ProductDocumentation, subProductDocs []*entities.ProductDocumentation) (result []byte, err error) { defer func() { if r := recover(); r != nil { // 将panic转换为error,而不是重新抛出 if e, ok := r.(error); ok { err = fmt.Errorf("PDF生成panic: %w", e) } else { err = fmt.Errorf("PDF生成panic: %v", r) } result = nil } }() // 保存当前工作目录(用于后续恢复) originalWorkDir, _ := os.Getwd() // 关键修复:gofpdf在AddUTF8Font和Output时都会处理字体路径 // 如果路径是绝对路径 /app/resources/pdf/fonts/simhei.ttf,gofpdf会去掉开头的/ // 变成相对路径 app/resources/pdf/fonts/simhei.ttf // 解决方案:在AddUTF8Font之前就切换工作目录到根目录(/) resourcesDir := GetResourcesPDFDir() workDirChanged := false if resourcesDir != "" && len(resourcesDir) > 0 && resourcesDir[0] == '/' { // 切换到根目录,这样 gofpdf 转换后的相对路径 app/resources 就能解析为 /app/resources if err := os.Chdir("/"); err == nil { workDirChanged = true g.logger.Info("切换工作目录到根目录(在AddUTF8Font之前)以修复gofpdf路径问题", zap.String("reason", "gofpdf在AddUTF8Font时就会处理路径,需要在加载字体前切换工作目录"), zap.String("original_work_dir", originalWorkDir), zap.String("new_work_dir", "/"), zap.String("resources_dir", resourcesDir), ) defer func() { // 恢复原始工作目录 if originalWorkDir != "" { if err := os.Chdir(originalWorkDir); err == nil { g.logger.Debug("已恢复原始工作目录", zap.String("work_dir", originalWorkDir)) } } }() } else { g.logger.Warn("无法切换到根目录", zap.Error(err)) } } // 创建PDF文档 (A4大小,gofpdf v2 默认支持UTF-8) pdf := gofpdf.New("P", "mm", "A4", "") // 优化边距,减少空白 pdf.SetMargins(15, 25, 15) // 加载黑体字体(用于所有内容,除了水印) // 注意:此时工作目录应该是根目录(/),这样gofpdf处理路径时就能正确解析 chineseFontAvailable := g.fontManager.LoadChineseFont(pdf) // 加载水印字体(使用宋体或其他非黑体字体) watermarkFontAvailable := g.fontManager.LoadWatermarkFont(pdf) // 记录字体加载状态,便于诊断问题 g.logger.Info("PDF字体加载状态", zap.Bool("chinese_font_loaded", chineseFontAvailable), zap.Bool("watermark_font_loaded", watermarkFontAvailable), zap.String("watermark_text", g.watermarkText), ) // 设置文档信息 pdf.SetTitle("Product Documentation", true) pdf.SetAuthor("TYAPI Server", true) pdf.SetCreator("TYAPI Server", true) // 创建页面构建器 pageBuilder := NewPageBuilder(g.logger, g.fontManager, g.textProcessor, g.markdownProc, g.tableParser, g.tableRenderer, g.jsonProcessor, g.logoPath, g.watermarkText) // 添加第一页(产品信息) pageBuilder.AddFirstPage(pdf, product, doc, chineseFontAvailable) // 如果是组合包,需要特殊处理:先渲染所有文档,最后统一添加二维码 if product.IsPackage { // 如果有关联的文档,添加接口文档页面(但不包含二维码和说明,后面统一添加) if doc != nil { pageBuilder.AddDocumentationPagesWithoutAdditionalInfo(pdf, doc, chineseFontAvailable) } // 如果有子产品文档,为每个子产品添加接口文档页面 if len(subProductDocs) > 0 { for i, subDoc := range subProductDocs { // 获取子产品信息(从文档中获取ProductID,然后查找对应的产品信息) // 注意:这里我们需要从product.PackageItems中查找对应的子产品信息 var subProduct *entities.Product if product.PackageItems != nil && i < len(product.PackageItems) { if product.PackageItems[i].Product != nil { subProduct = product.PackageItems[i].Product } } // 如果找不到子产品信息,创建一个基本的子产品实体 if subProduct == nil { subProduct = &entities.Product{ ID: subDoc.ProductID, Code: subDoc.ProductID, // 使用ProductID作为临时Code Name: fmt.Sprintf("子产品 %d", i+1), } } pageBuilder.AddSubProductDocumentationPages(pdf, subProduct, subDoc, chineseFontAvailable, false) } } // 在所有接口文档渲染完成后,统一添加二维码和后勤服务说明 // 使用主产品文档(如果存在),否则使用第一个子产品文档,如果都没有则创建一个空的文档对象 var finalDoc *entities.ProductDocumentation if doc != nil { finalDoc = doc } else if len(subProductDocs) > 0 { finalDoc = subProductDocs[0] } else { // 如果没有文档,创建一个空的文档对象,用于添加二维码和说明 finalDoc = &entities.ProductDocumentation{ ProductID: product.ID, RequestMethod: "POST", Version: "1.0", } } // 始终添加二维码和后勤服务说明 pageBuilder.AddAdditionalInfo(pdf, finalDoc, chineseFontAvailable) } else { // 普通产品:使用原来的方法(包含二维码和说明) if doc != nil { pageBuilder.AddDocumentationPages(pdf, doc, chineseFontAvailable) } } // 生成PDF字节流 // 注意:工作目录已经在AddUTF8Font之前切换到了根目录(/) // 这样gofpdf在Output时使用相对路径 app/resources/pdf/fonts 就能正确解析 var buf bytes.Buffer // 在Output前验证字体文件路径(此时工作目录应该是根目录/) if workDir, err := os.Getwd(); err == nil { // 验证绝对路径 fontAbsPath := filepath.Join(resourcesDir, "fonts", "simhei.ttf") if _, err := os.Stat(fontAbsPath); err == nil { g.logger.Debug("Output前验证字体文件存在(绝对路径)", zap.String("font_abs_path", fontAbsPath), zap.String("work_dir", workDir), ) } // 验证相对路径(gofpdf可能使用的路径) fontRelPath := "app/resources/pdf/fonts/simhei.ttf" if relAbsPath, err := filepath.Abs(fontRelPath); err == nil { if _, err := os.Stat(relAbsPath); err == nil { g.logger.Debug("Output前验证字体文件存在(相对路径解析)", zap.String("font_rel_path", fontRelPath), zap.String("resolved_abs_path", relAbsPath), zap.String("work_dir", workDir), ) } else { g.logger.Warn("Output前相对路径解析的字体文件不存在", zap.String("font_rel_path", fontRelPath), zap.String("resolved_abs_path", relAbsPath), zap.String("work_dir", workDir), zap.Error(err), ) } } g.logger.Debug("准备生成PDF", zap.String("work_dir", workDir), zap.String("resources_pdf_dir", resourcesDir), zap.Bool("work_dir_changed", workDirChanged), ) } err = pdf.Output(&buf) if err != nil { // 记录详细的错误信息 currentWorkDir := "" if wd, e := os.Getwd(); e == nil { currentWorkDir = wd } // 尝试分析错误:如果是路径问题,记录更多信息 errStr := err.Error() if strings.Contains(errStr, "stat ") && strings.Contains(errStr, ": no such file") { g.logger.Error("PDF Output失败(字体文件路径问题)", zap.Error(err), zap.String("current_work_dir", currentWorkDir), zap.String("resources_pdf_dir", resourcesDir), zap.String("error_message", errStr), ) } else { g.logger.Error("PDF Output失败", zap.Error(err), zap.String("current_work_dir", currentWorkDir), zap.String("resources_pdf_dir", resourcesDir), ) } return nil, fmt.Errorf("生成PDF失败: %w", err) } pdfBytes := buf.Bytes() return pdfBytes, nil }