This commit is contained in:
2025-12-03 12:03:42 +08:00
parent 1cf64e831c
commit 63252fa30f
27 changed files with 7167 additions and 36 deletions

1
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hibiken/asynq v0.25.1 github.com/hibiken/asynq v0.25.1
github.com/jung-kurt/gofpdf/v2 v2.17.3
github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_golang v1.22.0
github.com/qiniu/go-sdk/v7 v7.25.4 github.com/qiniu/go-sdk/v7 v7.25.4
github.com/redis/go-redis/v9 v9.11.0 github.com/redis/go-redis/v9 v9.11.0

2
go.sum
View File

@@ -133,6 +133,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=

View File

@@ -2,6 +2,8 @@ package product
import ( import (
"context" "context"
"fmt"
"strings"
"tyapi-server/internal/application/product/dto/commands" "tyapi-server/internal/application/product/dto/commands"
"tyapi-server/internal/application/product/dto/responses" "tyapi-server/internal/application/product/dto/responses"
@@ -28,6 +30,9 @@ type DocumentationApplicationServiceInterface interface {
// GetDocumentationsByProductIDs 批量获取文档 // GetDocumentationsByProductIDs 批量获取文档
GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]responses.DocumentationResponse, error) GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]responses.DocumentationResponse, error)
// GenerateFullDocumentation 生成完整的接口文档Markdown格式
GenerateFullDocumentation(ctx context.Context, productID string) (string, error)
} }
// DocumentationApplicationService 文档应用服务 // DocumentationApplicationService 文档应用服务
@@ -53,6 +58,7 @@ func (s *DocumentationApplicationService) CreateDocumentation(ctx context.Contex
ResponseFields: cmd.ResponseFields, ResponseFields: cmd.ResponseFields,
ResponseExample: cmd.ResponseExample, ResponseExample: cmd.ResponseExample,
ErrorCodes: cmd.ErrorCodes, ErrorCodes: cmd.ErrorCodes,
PDFFilePath: cmd.PDFFilePath,
} }
// 调用领域服务创建文档 // 调用领域服务创建文档
@@ -88,6 +94,20 @@ func (s *DocumentationApplicationService) UpdateDocumentation(ctx context.Contex
return nil, err return nil, err
} }
// 更新PDF文件路径如果提供
if cmd.PDFFilePath != "" {
doc.PDFFilePath = cmd.PDFFilePath
err = s.docService.UpdateDocumentationEntity(ctx, doc)
if err != nil {
return nil, fmt.Errorf("更新PDF文件路径失败: %w", err)
}
// 重新获取更新后的文档以确保获取最新数据
doc, err = s.docService.GetDocumentation(ctx, id)
if err != nil {
return nil, err
}
}
// 返回响应 // 返回响应
resp := responses.NewDocumentationResponse(doc) resp := responses.NewDocumentationResponse(doc)
return &resp, nil return &resp, nil
@@ -136,3 +156,93 @@ func (s *DocumentationApplicationService) GetDocumentationsByProductIDs(ctx cont
return docResponses, nil return docResponses, nil
} }
// GenerateFullDocumentation 生成完整的接口文档Markdown格式
func (s *DocumentationApplicationService) GenerateFullDocumentation(ctx context.Context, productID string) (string, error) {
// 通过产品ID获取文档
doc, err := s.docService.GetDocumentationByProductID(ctx, productID)
if err != nil {
return "", fmt.Errorf("获取文档失败: %w", err)
}
// 获取文档时已经包含了产品信息通过GetDocumentationWithProduct
// 如果没有产品信息通过文档ID获取
if doc.Product == nil && doc.ID != "" {
docWithProduct, err := s.docService.GetDocumentationWithProduct(ctx, doc.ID)
if err == nil && docWithProduct != nil {
doc = docWithProduct
}
}
var markdown strings.Builder
// 添加文档标题
productName := "产品"
if doc.Product != nil {
productName = doc.Product.Name
}
markdown.WriteString(fmt.Sprintf("# %s 接口文档\n\n", productName))
// 添加产品基本信息
if doc.Product != nil {
markdown.WriteString("## 产品信息\n\n")
markdown.WriteString(fmt.Sprintf("- **产品名称**: %s\n", doc.Product.Name))
markdown.WriteString(fmt.Sprintf("- **产品编号**: %s\n", doc.Product.Code))
if doc.Product.Description != "" {
markdown.WriteString(fmt.Sprintf("- **产品描述**: %s\n", doc.Product.Description))
}
markdown.WriteString("\n")
}
// 添加请求方式
markdown.WriteString("## 请求方式\n\n")
if doc.RequestURL != "" {
markdown.WriteString(fmt.Sprintf("- **请求方法**: %s\n", doc.RequestMethod))
markdown.WriteString(fmt.Sprintf("- **请求地址**: %s\n", doc.RequestURL))
markdown.WriteString("\n")
}
// 添加请求方式详细说明
if doc.BasicInfo != "" {
markdown.WriteString("### 请求方式说明\n\n")
markdown.WriteString(doc.BasicInfo)
markdown.WriteString("\n\n")
}
// 添加请求参数
if doc.RequestParams != "" {
markdown.WriteString("## 请求参数\n\n")
markdown.WriteString(doc.RequestParams)
markdown.WriteString("\n\n")
}
// 添加返回字段说明
if doc.ResponseFields != "" {
markdown.WriteString("## 返回字段说明\n\n")
markdown.WriteString(doc.ResponseFields)
markdown.WriteString("\n\n")
}
// 添加响应示例
if doc.ResponseExample != "" {
markdown.WriteString("## 响应示例\n\n")
markdown.WriteString(doc.ResponseExample)
markdown.WriteString("\n\n")
}
// 添加错误代码
if doc.ErrorCodes != "" {
markdown.WriteString("## 错误代码\n\n")
markdown.WriteString(doc.ErrorCodes)
markdown.WriteString("\n\n")
}
// 添加文档版本信息
markdown.WriteString("---\n\n")
markdown.WriteString(fmt.Sprintf("**文档版本**: %s\n\n", doc.Version))
if doc.UpdatedAt.Year() > 1900 {
markdown.WriteString(fmt.Sprintf("**更新时间**: %s\n", doc.UpdatedAt.Format("2006-01-02 15:04:05")))
}
return markdown.String(), nil
}

View File

@@ -10,6 +10,7 @@ type CreateDocumentationCommand struct {
ResponseFields string `json:"response_fields"` ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"` ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"` ErrorCodes string `json:"error_codes"`
PDFFilePath string `json:"pdf_file_path,omitempty"`
} }
// UpdateDocumentationCommand 更新文档命令 // UpdateDocumentationCommand 更新文档命令
@@ -21,4 +22,5 @@ type UpdateDocumentationCommand struct {
ResponseFields string `json:"response_fields"` ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"` ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"` ErrorCodes string `json:"error_codes"`
PDFFilePath string `json:"pdf_file_path,omitempty"`
} }

View File

@@ -18,6 +18,7 @@ type DocumentationResponse struct {
ResponseExample string `json:"response_example"` ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"` ErrorCodes string `json:"error_codes"`
Version string `json:"version"` Version string `json:"version"`
PDFFilePath string `json:"pdf_file_path,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -35,6 +36,7 @@ func NewDocumentationResponse(doc *entities.ProductDocumentation) DocumentationR
ResponseExample: doc.ResponseExample, ResponseExample: doc.ResponseExample,
ErrorCodes: doc.ErrorCodes, ErrorCodes: doc.ErrorCodes,
Version: doc.Version, Version: doc.Version,
PDFFilePath: doc.PDFFilePath,
CreatedAt: doc.CreatedAt, CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt, UpdatedAt: doc.UpdatedAt,
} }

View File

@@ -66,6 +66,7 @@ import (
"tyapi-server/internal/shared/middleware" "tyapi-server/internal/shared/middleware"
sharedOCR "tyapi-server/internal/shared/ocr" sharedOCR "tyapi-server/internal/shared/ocr"
"tyapi-server/internal/shared/payment" "tyapi-server/internal/shared/payment"
"tyapi-server/internal/shared/pdf"
"tyapi-server/internal/shared/resilience" "tyapi-server/internal/shared/resilience"
"tyapi-server/internal/shared/saga" "tyapi-server/internal/shared/saga"
"tyapi-server/internal/shared/tracing" "tyapi-server/internal/shared/tracing"
@@ -980,6 +981,24 @@ func NewContainer() *Container {
), ),
), ),
// PDF查找服务
fx.Provide(
func(logger *zap.Logger) (*pdf.PDFFinder, error) {
docDir, err := pdf.GetDocumentationDir()
if err != nil {
logger.Warn("未找到接口文档文件夹PDF自动查找功能将不可用", zap.Error(err))
return nil, nil // 返回nilhandler中会检查
}
logger.Info("PDF查找服务已初始化", zap.String("documentation_dir", docDir))
return pdf.NewPDFFinder(docDir, logger), nil
},
),
// PDF生成器
fx.Provide(
func(logger *zap.Logger) *pdf.PDFGenerator {
return pdf.NewPDFGenerator(logger)
},
),
// HTTP处理器 // HTTP处理器
fx.Provide( fx.Provide(
// 用户HTTP处理器 // 用户HTTP处理器

View File

@@ -34,7 +34,7 @@ func ProcessQYGLB4C0Request(ctx context.Context, params []byte, deps *processors
// 数据源错误 // 数据源错误
if errors.Is(err, westdex.ErrDatasource) { if errors.Is(err, westdex.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err) return nil, errors.Join(processors.ErrDatasource, err)
}else if errors.Is(err, westdex.ErrNotFound) { } else if errors.Is(err, westdex.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err) return nil, errors.Join(processors.ErrNotFound, err)
} }
// 其他系统错误 // 其他系统错误

View File

@@ -22,6 +22,7 @@ type ProductDocumentation struct {
ResponseExample string `gorm:"type:text" comment:"响应示例"` ResponseExample string `gorm:"type:text" comment:"响应示例"`
ErrorCodes string `gorm:"type:text" comment:"错误代码"` ErrorCodes string `gorm:"type:text" comment:"错误代码"`
Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"` Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"`
PDFFilePath string `gorm:"type:varchar(500)" comment:"PDF文档文件路径或URL"`
// 关联关系 // 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"` Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`

View File

@@ -114,3 +114,15 @@ func (s *ProductDocumentationService) GetDocumentationWithProduct(ctx context.Co
func (s *ProductDocumentationService) GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]*entities.ProductDocumentation, error) { func (s *ProductDocumentationService) GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]*entities.ProductDocumentation, error) {
return s.docRepo.FindByProductIDs(ctx, productIDs) return s.docRepo.FindByProductIDs(ctx, productIDs)
} }
// UpdateDocumentationEntity 更新文档实体用于更新PDFFilePath等字段
func (s *ProductDocumentationService) UpdateDocumentationEntity(ctx context.Context, doc *entities.ProductDocumentation) error {
// 验证文档是否存在
_, err := s.docRepo.FindByID(ctx, doc.ID)
if err != nil {
return fmt.Errorf("文档不存在: %w", err)
}
// 保存更新
return s.docRepo.Update(ctx, doc)
}

View File

@@ -3,6 +3,7 @@ package repositories
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
"tyapi-server/internal/domains/finance/entities" "tyapi-server/internal/domains/finance/entities"
@@ -10,6 +11,7 @@ import (
"tyapi-server/internal/shared/database" "tyapi-server/internal/shared/database"
"tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/interfaces"
"github.com/shopspring/decimal"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -90,7 +92,29 @@ func (r *GormRechargeRecordRepository) Count(ctx context.Context, options interf
query := r.GetDB(ctx).Model(&entities.RechargeRecord{}) query := r.GetDB(ctx).Model(&entities.RechargeRecord{})
if options.Filters != nil { if options.Filters != nil {
for key, value := range options.Filters { for key, value := range options.Filters {
query = query.Where(key+" = ?", value) // 特殊处理时间范围过滤器
if key == "start_time" {
if startTime, ok := value.(time.Time); ok {
query = query.Where("created_at >= ?", startTime)
}
} else if key == "end_time" {
if endTime, ok := value.(time.Time); ok {
query = query.Where("created_at <= ?", endTime)
}
} else if key == "min_amount" {
// 处理最小金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
query = query.Where("amount >= ?", amount)
}
} else if key == "max_amount" {
// 处理最大金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
query = query.Where("amount <= ?", amount)
}
} else {
// 其他过滤器使用等值查询
query = query.Where(key+" = ?", value)
}
} }
} }
if options.Search != "" { if options.Search != "" {
@@ -117,7 +141,28 @@ func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfa
if userIds, ok := value.(string); ok && userIds != "" { if userIds, ok := value.(string); ok && userIds != "" {
query = query.Where("user_id IN ?", strings.Split(userIds, ",")) query = query.Where("user_id IN ?", strings.Split(userIds, ","))
} }
} else if key == "start_time" {
// 处理开始时间范围
if startTime, ok := value.(time.Time); ok {
query = query.Where("created_at >= ?", startTime)
}
} else if key == "end_time" {
// 处理结束时间范围
if endTime, ok := value.(time.Time); ok {
query = query.Where("created_at <= ?", endTime)
}
} else if key == "min_amount" {
// 处理最小金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
query = query.Where("amount >= ?", amount)
}
} else if key == "max_amount" {
// 处理最大金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
query = query.Where("amount <= ?", amount)
}
} else { } else {
// 其他过滤器使用等值查询
query = query.Where(key+" = ?", value) query = query.Where(key+" = ?", value)
} }
} }
@@ -329,3 +374,24 @@ func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context
return results, nil return results, nil
} }
// parseAmount 解析金额值支持string、int、int64类型转换为decimal.Decimal
func (r *GormRechargeRecordRepository) parseAmount(value interface{}) (decimal.Decimal, error) {
switch v := value.(type) {
case string:
if v == "" {
return decimal.Zero, fmt.Errorf("empty string")
}
return decimal.NewFromString(v)
case int:
return decimal.NewFromInt(int64(v)), nil
case int64:
return decimal.NewFromInt(v), nil
case float64:
return decimal.NewFromFloat(v), nil
case decimal.Decimal:
return v, nil
default:
return decimal.Zero, fmt.Errorf("unsupported type: %T", value)
}
}

View File

@@ -2,12 +2,17 @@
package handlers package handlers
import ( import (
"fmt"
"net/http"
"strconv" "strconv"
"strings"
"tyapi-server/internal/application/product" "tyapi-server/internal/application/product"
"tyapi-server/internal/application/product/dto/commands" "tyapi-server/internal/application/product/dto/commands"
"tyapi-server/internal/application/product/dto/queries" "tyapi-server/internal/application/product/dto/queries"
_ "tyapi-server/internal/application/product/dto/responses" _ "tyapi-server/internal/application/product/dto/responses"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/interfaces"
"tyapi-server/internal/shared/pdf"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
@@ -15,14 +20,15 @@ import (
// ProductHandler 产品相关HTTP处理器 // ProductHandler 产品相关HTTP处理器
type ProductHandler struct { type ProductHandler struct {
appService product.ProductApplicationService appService product.ProductApplicationService
apiConfigService product.ProductApiConfigApplicationService apiConfigService product.ProductApiConfigApplicationService
categoryService product.CategoryApplicationService categoryService product.CategoryApplicationService
subAppService product.SubscriptionApplicationService subAppService product.SubscriptionApplicationService
documentationAppService product.DocumentationApplicationServiceInterface documentationAppService product.DocumentationApplicationServiceInterface
responseBuilder interfaces.ResponseBuilder responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator validator interfaces.RequestValidator
logger *zap.Logger pdfGenerator *pdf.PDFGenerator
logger *zap.Logger
} }
// NewProductHandler 创建产品HTTP处理器 // NewProductHandler 创建产品HTTP处理器
@@ -34,17 +40,19 @@ func NewProductHandler(
documentationAppService product.DocumentationApplicationServiceInterface, documentationAppService product.DocumentationApplicationServiceInterface,
responseBuilder interfaces.ResponseBuilder, responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
pdfGenerator *pdf.PDFGenerator,
logger *zap.Logger, logger *zap.Logger,
) *ProductHandler { ) *ProductHandler {
return &ProductHandler{ return &ProductHandler{
appService: appService, appService: appService,
apiConfigService: apiConfigService, apiConfigService: apiConfigService,
categoryService: categoryService, categoryService: categoryService,
subAppService: subAppService, subAppService: subAppService,
documentationAppService: documentationAppService, documentationAppService: documentationAppService,
responseBuilder: responseBuilder, responseBuilder: responseBuilder,
validator: validator, validator: validator,
logger: logger, pdfGenerator: pdfGenerator,
logger: logger,
} }
} }
@@ -630,3 +638,168 @@ func (h *ProductHandler) GetProductDocumentation(c *gin.Context) {
h.responseBuilder.Success(c, doc, "获取文档成功") h.responseBuilder.Success(c, doc, "获取文档成功")
} }
// DownloadProductDocumentation 下载产品接口文档PDF文件
// @Summary 下载产品接口文档
// @Description 根据产品ID从数据库获取产品信息和文档信息动态生成PDF文档并下载。
// @Tags 数据大厅
// @Accept json
// @Produce application/pdf
// @Param id path string true "产品ID"
// @Success 200 {file} file "PDF文档文件"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "产品不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/products/{id}/documentation/download [get]
func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
productID := c.Param("id")
if productID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
return
}
// 检查PDF生成器是否可用
if h.pdfGenerator == nil {
h.logger.Error("PDF生成器未初始化")
h.responseBuilder.InternalError(c, "PDF生成器未初始化")
return
}
// 获取产品信息
product, err := h.appService.GetProductByID(c.Request.Context(), &queries.GetProductQuery{ID: productID})
if err != nil {
h.logger.Error("获取产品信息失败", zap.Error(err))
h.responseBuilder.NotFound(c, "产品不存在")
return
}
// 检查产品编码是否存在
if product.Code == "" {
h.logger.Warn("产品编码为空", zap.String("product_id", productID))
h.responseBuilder.BadRequest(c, "产品编码不存在")
return
}
h.logger.Info("开始生成PDF文档",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
zap.String("product_name", product.Name),
)
// 获取产品文档信息
doc, docErr := h.documentationAppService.GetDocumentationByProductID(c.Request.Context(), productID)
if docErr != nil {
h.logger.Warn("获取产品文档失败,将只生成产品基本信息",
zap.String("product_id", productID),
zap.Error(docErr),
)
}
// 将响应类型转换为entity类型
var docEntity *entities.ProductDocumentation
if doc != nil {
docEntity = &entities.ProductDocumentation{
ID: doc.ID,
ProductID: doc.ProductID,
RequestURL: doc.RequestURL,
RequestMethod: doc.RequestMethod,
BasicInfo: doc.BasicInfo,
RequestParams: doc.RequestParams,
ResponseFields: doc.ResponseFields,
ResponseExample: doc.ResponseExample,
ErrorCodes: doc.ErrorCodes,
Version: doc.Version,
}
}
// 使用数据库数据生成PDF
h.logger.Info("准备调用PDF生成器",
zap.String("product_id", productID),
zap.String("product_name", product.Name),
zap.Bool("has_doc", docEntity != nil),
)
defer func() {
if r := recover(); r != nil {
h.logger.Error("PDF生成过程中发生panic",
zap.String("product_id", productID),
zap.Any("panic_value", r),
)
// 确保在panic时也能返回响应
if !c.Writer.Written() {
h.responseBuilder.InternalError(c, fmt.Sprintf("生成PDF文档时发生错误: %v", r))
}
}
}()
// 直接调用PDF生成器简化版本不使用goroutine
h.logger.Info("开始调用PDF生成器")
pdfBytes, genErr := h.pdfGenerator.GenerateProductPDF(
c.Request.Context(),
product.ID,
product.Name,
product.Code,
product.Description,
product.Content,
product.Price,
docEntity,
)
h.logger.Info("PDF生成器调用返回",
zap.String("product_id", productID),
zap.Bool("has_error", genErr != nil),
zap.Int("pdf_size", len(pdfBytes)),
)
if genErr != nil {
h.logger.Error("生成PDF文档失败",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
zap.Error(genErr),
)
h.responseBuilder.InternalError(c, fmt.Sprintf("生成PDF文档失败: %s", genErr.Error()))
return
}
h.logger.Info("PDF生成器调用完成",
zap.String("product_id", productID),
zap.Int("pdf_size", len(pdfBytes)),
)
if len(pdfBytes) == 0 {
h.logger.Error("生成的PDF文档为空",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
)
h.responseBuilder.InternalError(c, "生成的PDF文档为空")
return
}
// 生成文件名(清理文件名中的非法字符)
fileName := fmt.Sprintf("%s_接口文档.pdf", product.Name)
if product.Name == "" {
fileName = fmt.Sprintf("%s_接口文档.pdf", product.Code)
}
// 清理文件名中的非法字符
fileName = strings.ReplaceAll(fileName, "/", "_")
fileName = strings.ReplaceAll(fileName, "\\", "_")
fileName = strings.ReplaceAll(fileName, ":", "_")
fileName = strings.ReplaceAll(fileName, "*", "_")
fileName = strings.ReplaceAll(fileName, "?", "_")
fileName = strings.ReplaceAll(fileName, "\"", "_")
fileName = strings.ReplaceAll(fileName, "<", "_")
fileName = strings.ReplaceAll(fileName, ">", "_")
fileName = strings.ReplaceAll(fileName, "|", "_")
h.logger.Info("成功生成PDF文档",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
zap.String("file_name", fileName),
zap.Int("file_size", len(pdfBytes)),
)
// 设置响应头并返回PDF文件
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}

View File

@@ -51,6 +51,7 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
products.GET("/:id", r.productHandler.GetProductDetail) products.GET("/:id", r.productHandler.GetProductDetail)
products.GET("/:id/api-config", r.productHandler.GetProductApiConfig) products.GET("/:id/api-config", r.productHandler.GetProductApiConfig)
products.GET("/:id/documentation", r.productHandler.GetProductDocumentation) products.GET("/:id/documentation", r.productHandler.GetProductDocumentation)
products.GET("/:id/documentation/download", r.productHandler.DownloadProductDocumentation)
// 订阅产品(需要认证) // 订阅产品(需要认证)
products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct) products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct)

View File

@@ -0,0 +1,168 @@
# 📋 PDF表格转换日志查看指南
## 📍 日志文件位置
### 1. 开发环境
日志文件存储在项目根目录的 `logs/` 目录下:
```
tyapi-server/
└── logs/
├── 2024-12-02/ # 按日期分包(如果启用)
│ ├── debug.log # Debug级别日志包含JSON转换详情
│ ├── info.log # Info级别日志包含转换流程
│ └── error.log # Error级别日志包含错误信息
└── app.log # 传统模式(如果未启用按日分包)
```
### 2. 生产环境Docker
日志文件存储在容器的 `/app/logs/` 目录,映射到宿主机的 `./logs/` 目录:
```bash
# 查看宿主机日志
./logs/2024-12-02/info.log
./logs/2024-12-02/debug.log
```
## 🔍 如何查看转换日志
### 方法1实时查看日志推荐
```bash
# 查看Info级别日志转换流程
tail -f logs/2024-12-02/info.log | grep "表格\|JSON\|markdown"
# 查看Debug级别日志详细JSON数据
tail -f logs/2024-12-02/debug.log | grep "JSON\|表格"
# 查看所有PDF相关日志
tail -f logs/2024-12-02/*.log | grep -i "pdf\|table\|json"
```
### 方法2使用Docker查看
```bash
# 查看容器实时日志
docker logs -f tyapi-app-prod | grep -i "表格\|json\|markdown"
# 查看最近的100行日志
docker logs --tail 100 tyapi-app-prod | grep -i "表格\|json"
```
### 方法3搜索特定字段类型
```bash
# 查看请求参数的转换日志
grep "request_params" logs/2024-12-02/info.log
# 查看响应字段的转换日志
grep "response_fields" logs/2024-12-02/info.log
# 查看错误代码的转换日志
grep "error_codes" logs/2024-12-02/info.log
```
## 📊 日志级别说明
### Info级别日志info.log
包含转换流程的关键步骤:
- ✅ 数据格式检测JSON/Markdown
- ✅ Markdown表格解析开始
- ✅ 表格解析成功(表头数量、行数)
- ✅ JSON转换完成
**示例日志:**
```
2024-12-02T10:30:15Z INFO 开始解析markdown表格并转换为JSON {"field_type": "request_params", "content_length": 1234}
2024-12-02T10:30:15Z INFO markdown表格解析成功 {"field_type": "request_params", "header_count": 3, "row_count": 5}
2024-12-02T10:30:15Z INFO 表格数据已转换为JSON格式 {"field_type": "request_params", "json_array_length": 5}
```
### Debug级别日志debug.log
包含详细的转换数据:
- 🔍 原始内容预览
- 🔍 解析后的表头列表
- 🔍 转换后的完整JSON数据前1000字符
- 🔍 每行的转换详情
**示例日志:**
```
2024-12-02T10:30:15Z DEBUG 转换后的JSON数据预览 {"field_type": "request_params", "json_length": 2345, "json_preview": "[{\"字段名\":\"name\",\"类型\":\"string\",\"说明\":\"姓名\"}...]"}
```
### Error级别日志error.log
包含转换过程中的错误:
- ❌ Markdown解析失败
- ❌ JSON序列化失败
- ❌ 数据格式错误
**示例日志:**
```
2024-12-02T10:30:15Z ERROR 解析markdown表格失败 {"field_type": "request_params", "error": "无法解析表格:未找到表头", "content_preview": "..."}
```
## 🔎 日志关键词搜索
### 转换流程关键词
- `开始解析markdown表格` - 转换开始
- `markdown表格解析成功` - 解析完成
- `表格数据已转换为JSON格式` - JSON转换完成
- `转换后的JSON数据预览` - JSON数据详情
### 数据格式关键词
- `数据已经是JSON格式` - 数据源是JSON
- `从JSON对象中提取数组数据` - 从JSON对象提取
- `解析markdown表格并转换为JSON` - Markdown转JSON
### 错误关键词
- `解析markdown表格失败` - 解析错误
- `JSON序列化失败` - JSON错误
- `字段内容为空` - 空数据
## 📝 日志配置
确保日志级别设置为 `debug` 才能看到详细的JSON转换日志
```yaml
# config.yaml 或 configs/env.development.yaml
logger:
level: "debug" # 开发环境使用debug级别
format: "console" # 或 "json"
output: "file" # 输出到文件
log_dir: "logs" # 日志目录
use_daily: true # 启用按日分包
```
## 🛠️ 常用命令
```bash
# 查看今天的Info日志
cat logs/$(date +%Y-%m-%d)/info.log | grep "表格\|JSON"
# 查看最近的转换日志最后50行
tail -n 50 logs/$(date +%Y-%m-%d)/info.log
# 搜索特定产品的转换日志
grep "product_id.*xxx" logs/$(date +%Y-%m-%d)/info.log
# 查看所有错误
grep "ERROR" logs/$(date +%Y-%m-%d)/error.log
# 统计转换次数
grep "表格数据已转换为JSON格式" logs/$(date +%Y-%m-%d)/info.log | wc -l
```
## 💡 调试技巧
1. **查看完整JSON数据**如果JSON数据超过1000字符查看debug.log获取完整内容
2. **追踪转换流程**:使用 `field_type` 字段过滤特定字段的转换日志
3. **定位错误**查看error.log中的 `content_preview` 字段了解原始数据
4. **性能监控**:统计转换次数和耗时,优化转换逻辑
## 📌 注意事项
- Debug级别日志可能包含大量数据注意日志文件大小
- 生产环境建议使用 `info` 级别,减少日志量
- JSON预览限制在1000字符完整数据请查看debug日志
- 日志文件按日期自动分包,便于管理和查找

View File

@@ -0,0 +1,618 @@
package pdf
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"tyapi-server/internal/domains/product/entities"
"go.uber.org/zap"
)
// DatabaseTableReader 数据库表格数据读取器
type DatabaseTableReader struct {
logger *zap.Logger
}
// NewDatabaseTableReader 创建数据库表格数据读取器
func NewDatabaseTableReader(logger *zap.Logger) *DatabaseTableReader {
return &DatabaseTableReader{
logger: logger,
}
}
// TableData 表格数据
type TableData struct {
Headers []string
Rows [][]string
}
// TableWithTitle 带标题的表格
type TableWithTitle struct {
Title string // 表格标题markdown标题
Table *TableData // 表格数据
}
// ReadTableFromDocumentation 从产品文档中读取表格数据
// 先将markdown表格转换为JSON格式然后再转换为表格数据
func (r *DatabaseTableReader) ReadTableFromDocumentation(ctx context.Context, doc *entities.ProductDocumentation, fieldType string) (*TableData, error) {
var content string
switch fieldType {
case "request_params":
content = doc.RequestParams
case "response_fields":
content = doc.ResponseFields
case "response_example":
content = doc.ResponseExample
case "error_codes":
content = doc.ErrorCodes
default:
return nil, fmt.Errorf("未知的字段类型: %s", fieldType)
}
// 检查内容是否为空(去除空白字符后)
trimmedContent := strings.TrimSpace(content)
if trimmedContent == "" {
return nil, fmt.Errorf("字段 %s 内容为空", fieldType)
}
// 先尝试解析为JSON数组如果已经是JSON格式
var jsonArray []map[string]interface{}
if err := json.Unmarshal([]byte(content), &jsonArray); err == nil && len(jsonArray) > 0 {
r.logger.Info("数据已经是JSON格式直接使用",
zap.String("field_type", fieldType),
zap.Int("json_array_length", len(jsonArray)))
return r.convertJSONArrayToTable(jsonArray), nil
}
// 尝试解析为单个JSON对象包含数组字段
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(content), &jsonObj); err == nil {
// 查找包含数组的字段
for _, value := range jsonObj {
if arr, ok := value.([]interface{}); ok && len(arr) > 0 {
// 转换为map数组
mapArray := make([]map[string]interface{}, 0, len(arr))
for _, item := range arr {
if itemMap, ok := item.(map[string]interface{}); ok {
mapArray = append(mapArray, itemMap)
}
}
if len(mapArray) > 0 {
r.logger.Info("从JSON对象中提取数组数据", zap.String("field_type", fieldType))
return r.convertJSONArrayToTable(mapArray), nil
}
}
}
}
// 如果不是JSON格式先解析为markdown表格然后转换为JSON格式
r.logger.Info("开始解析markdown表格并转换为JSON",
zap.String("field_type", fieldType),
zap.Int("content_length", len(content)),
zap.String("content_preview", r.getContentPreview(content, 200)))
tableData, err := r.parseMarkdownTable(content)
if err != nil {
r.logger.Error("解析markdown表格失败",
zap.String("field_type", fieldType),
zap.Error(err),
zap.String("content_preview", r.getContentPreview(content, 500)))
return nil, fmt.Errorf("解析markdown表格失败: %w", err)
}
r.logger.Info("markdown表格解析成功",
zap.String("field_type", fieldType),
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Strings("headers", tableData.Headers))
// 将markdown表格数据转换为JSON格式保持列顺序
r.logger.Debug("开始将表格数据转换为JSON格式", zap.String("field_type", fieldType))
jsonArray = r.convertTableDataToJSON(tableData)
r.logger.Info("表格数据已转换为JSON格式",
zap.String("field_type", fieldType),
zap.Int("json_array_length", len(jsonArray)))
// 记录转换后的JSON用于调试
jsonBytes, marshalErr := json.MarshalIndent(jsonArray, "", " ")
if marshalErr != nil {
r.logger.Warn("JSON序列化失败",
zap.String("field_type", fieldType),
zap.Error(marshalErr))
} else {
previewLen := len(jsonBytes)
if previewLen > 1000 {
previewLen = 1000
}
r.logger.Debug("转换后的JSON数据预览",
zap.String("field_type", fieldType),
zap.Int("json_length", len(jsonBytes)),
zap.String("json_preview", string(jsonBytes[:previewLen])))
// 如果JSON数据较大记录完整路径提示
if len(jsonBytes) > 1000 {
r.logger.Info("JSON数据较大完整内容请查看debug级别日志",
zap.String("field_type", fieldType),
zap.Int("json_length", len(jsonBytes)))
}
}
// 将JSON数据转换回表格数据用于渲染使用原始表头顺序保持列顺序
return r.convertJSONArrayToTableWithOrder(jsonArray, tableData.Headers), nil
}
// convertJSONArrayToTable 将JSON数组转换为表格数据用于已经是JSON格式的数据
func (r *DatabaseTableReader) convertJSONArrayToTable(data []map[string]interface{}) *TableData {
if len(data) == 0 {
return &TableData{
Headers: []string{},
Rows: [][]string{},
}
}
// 收集所有列名(按第一次出现的顺序)
columnSet := make(map[string]bool)
columns := make([]string, 0)
// 从第一行开始收集列名,保持第一次出现的顺序
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
// 只从第一行收集,保持顺序
if len(columns) > 0 {
break
}
}
// 如果第一行没有收集到所有列,继续收集(但顺序可能不稳定)
if len(columns) == 0 {
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
}
}
// 构建表头
headers := make([]string, len(columns))
copy(headers, columns)
// 构建数据行
rows := make([][]string, 0, len(data))
for _, row := range data {
rowData := make([]string, len(columns))
for i, col := range columns {
value := row[col]
rowData[i] = r.formatValue(value)
}
rows = append(rows, rowData)
}
return &TableData{
Headers: headers,
Rows: rows,
}
}
// convertJSONArrayToTableWithOrder 将JSON数组转换为表格数据使用指定的列顺序
func (r *DatabaseTableReader) convertJSONArrayToTableWithOrder(data []map[string]interface{}, originalHeaders []string) *TableData {
if len(data) == 0 {
return &TableData{
Headers: originalHeaders,
Rows: [][]string{},
}
}
// 使用原始表头顺序
headers := make([]string, len(originalHeaders))
copy(headers, originalHeaders)
// 构建数据行,按照原始表头顺序
rows := make([][]string, 0, len(data))
for _, row := range data {
rowData := make([]string, len(headers))
for i, header := range headers {
value := row[header]
rowData[i] = r.formatValue(value)
}
rows = append(rows, rowData)
}
r.logger.Debug("JSON转表格完成保持列顺序",
zap.Int("header_count", len(headers)),
zap.Int("row_count", len(rows)),
zap.Strings("headers", headers))
return &TableData{
Headers: headers,
Rows: rows,
}
}
// parseMarkdownTablesWithTitles 解析markdown格式的表格支持多个表格保留标题
func (r *DatabaseTableReader) parseMarkdownTablesWithTitles(content string) ([]TableWithTitle, error) {
lines := strings.Split(content, "\n")
var result []TableWithTitle
var currentTitle string
var currentHeaders []string
var currentRows [][]string
inTable := false
hasValidHeader := false
nonTableLineCount := 0
maxNonTableLines := 3 // 允许最多3个连续非表格行
for _, line := range lines {
line = strings.TrimSpace(line)
// 处理markdown标题行以#开头)- 保存标题
if strings.HasPrefix(line, "#") {
// 如果当前有表格,先保存
if inTable && len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
currentHeaders = nil
currentRows = nil
inTable = false
hasValidHeader = false
}
// 提取标题(移除#和空格)
currentTitle = strings.TrimSpace(strings.TrimPrefix(line, "#"))
currentTitle = strings.TrimSpace(strings.TrimPrefix(currentTitle, "#"))
currentTitle = strings.TrimSpace(strings.TrimPrefix(currentTitle, "#"))
nonTableLineCount = 0
continue
}
// 跳过空行
if line == "" {
if inTable {
nonTableLineCount++
if nonTableLineCount > maxNonTableLines {
// 当前表格结束,保存并重置
if len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
currentHeaders = nil
currentRows = nil
currentTitle = ""
}
inTable = false
hasValidHeader = false
nonTableLineCount = 0
}
}
continue
}
// 检查是否是markdown表格行
if !strings.Contains(line, "|") {
// 如果已经在表格中,遇到非表格行则计数
if inTable {
nonTableLineCount++
// 如果连续非表格行过多,表格结束
if nonTableLineCount > maxNonTableLines {
// 当前表格结束,保存并重置
if len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
currentHeaders = nil
currentRows = nil
currentTitle = ""
}
inTable = false
hasValidHeader = false
nonTableLineCount = 0
}
}
continue
}
// 重置非表格行计数(遇到表格行了)
nonTableLineCount = 0
// 跳过分隔行
if r.isSeparatorLine(line) {
// 分隔行后应该开始数据行
if hasValidHeader {
continue
}
// 如果还没有表头,跳过分隔行
continue
}
// 解析表格行
cells := strings.Split(line, "|")
// 清理首尾空元素
if len(cells) > 0 && strings.TrimSpace(cells[0]) == "" {
cells = cells[1:]
}
if len(cells) > 0 && strings.TrimSpace(cells[len(cells)-1]) == "" {
cells = cells[:len(cells)-1]
}
// 清理每个单元格,过滤空字符
cleanedCells := make([]string, 0, len(cells))
for _, cell := range cells {
cleaned := strings.TrimSpace(cell)
// 移除HTML标签如<br>
cleaned = r.removeHTMLTags(cleaned)
cleanedCells = append(cleanedCells, cleaned)
}
// 检查这一行是否有有效内容
hasContent := false
for _, cell := range cleanedCells {
if strings.TrimSpace(cell) != "" {
hasContent = true
break
}
}
if !hasContent || len(cleanedCells) == 0 {
continue
}
if !inTable {
// 第一行作为表头
currentHeaders = cleanedCells
inTable = true
hasValidHeader = true
} else {
// 数据行,确保列数与表头一致
row := make([]string, len(currentHeaders))
for i := range row {
if i < len(cleanedCells) {
row[i] = cleanedCells[i]
} else {
row[i] = ""
}
}
// 检查数据行是否有有效内容(至少有一个非空单元格)
hasData := false
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
hasData = true
break
}
}
// 只添加有有效内容的数据行
if hasData {
currentRows = append(currentRows, row)
}
}
}
// 处理最后一个表格
if len(currentHeaders) > 0 {
result = append(result, TableWithTitle{
Title: currentTitle,
Table: &TableData{
Headers: currentHeaders,
Rows: currentRows,
},
})
}
if len(result) == 0 {
return nil, fmt.Errorf("无法解析表格:未找到表头")
}
r.logger.Info("解析多个表格完成",
zap.Int("table_count", len(result)))
return result, nil
}
// parseMarkdownTable 解析markdown格式的表格兼容方法调用新方法
func (r *DatabaseTableReader) parseMarkdownTable(content string) (*TableData, error) {
tablesWithTitles, err := r.parseMarkdownTablesWithTitles(content)
if err != nil {
return nil, err
}
if len(tablesWithTitles) == 0 {
return nil, fmt.Errorf("未找到任何表格")
}
// 返回第一个表格(向后兼容)
return tablesWithTitles[0].Table, nil
}
// mergeTables 合并多个表格(使用最宽的表头)
func (r *DatabaseTableReader) mergeTables(existingHeaders []string, existingRows [][]string, newHeaders []string, newRows [][]string) ([]string, [][]string) {
// 如果这是第一个表格,直接返回
if len(existingHeaders) == 0 {
return newHeaders, newRows
}
// 使用最宽的表头(列数最多的)
var finalHeaders []string
if len(newHeaders) > len(existingHeaders) {
finalHeaders = make([]string, len(newHeaders))
copy(finalHeaders, newHeaders)
} else {
finalHeaders = make([]string, len(existingHeaders))
copy(finalHeaders, existingHeaders)
}
// 合并所有行,确保列数与最终表头一致
mergedRows := make([][]string, 0, len(existingRows)+len(newRows))
// 添加已有行
for _, row := range existingRows {
adjustedRow := make([]string, len(finalHeaders))
copy(adjustedRow, row)
mergedRows = append(mergedRows, adjustedRow)
}
// 添加新行
for _, row := range newRows {
adjustedRow := make([]string, len(finalHeaders))
for i := range adjustedRow {
if i < len(row) {
adjustedRow[i] = row[i]
} else {
adjustedRow[i] = ""
}
}
mergedRows = append(mergedRows, adjustedRow)
}
return finalHeaders, mergedRows
}
// removeHTMLTags 移除HTML标签如<br>)和样式信息
func (r *DatabaseTableReader) removeHTMLTags(text string) string {
// 先移除所有HTML标签包括带样式的标签如 <span style="color:red">
// 使用正则表达式移除所有HTML标签及其内容
re := regexp.MustCompile(`<[^>]+>`)
text = re.ReplaceAllString(text, "")
// 替换常见的HTML换行标签为空格
text = strings.ReplaceAll(text, "<br>", " ")
text = strings.ReplaceAll(text, "<br/>", " ")
text = strings.ReplaceAll(text, "<br />", " ")
text = strings.ReplaceAll(text, "\n", " ")
// 移除HTML实体
text = strings.ReplaceAll(text, "&nbsp;", " ")
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
return strings.TrimSpace(text)
}
// isSeparatorLine 检查是否是markdown表格的分隔行
func (r *DatabaseTableReader) isSeparatorLine(line string) bool {
if !strings.Contains(line, "-") {
return false
}
for _, r := range line {
if r != '|' && r != '-' && r != ':' && r != ' ' {
return false
}
}
return true
}
// convertTableDataToJSON 将表格数据转换为JSON数组格式
func (r *DatabaseTableReader) convertTableDataToJSON(tableData *TableData) []map[string]interface{} {
if tableData == nil || len(tableData.Headers) == 0 {
r.logger.Warn("表格数据为空无法转换为JSON")
return []map[string]interface{}{}
}
jsonArray := make([]map[string]interface{}, 0, len(tableData.Rows))
validRowCount := 0
for rowIndex, row := range tableData.Rows {
rowObj := make(map[string]interface{})
for i, header := range tableData.Headers {
// 获取对应的单元格值
var cellValue string
if i < len(row) {
cellValue = strings.TrimSpace(row[i])
}
// 将表头作为key单元格值作为value
header = strings.TrimSpace(header)
if header != "" {
rowObj[header] = cellValue
}
}
// 只添加有有效数据的行
if len(rowObj) > 0 {
jsonArray = append(jsonArray, rowObj)
validRowCount++
} else {
r.logger.Debug("跳过空行",
zap.Int("row_index", rowIndex))
}
}
r.logger.Debug("表格转JSON完成",
zap.Int("total_rows", len(tableData.Rows)),
zap.Int("valid_rows", validRowCount),
zap.Int("json_array_length", len(jsonArray)))
return jsonArray
}
// getContentPreview 获取内容预览(用于日志记录)
func (r *DatabaseTableReader) getContentPreview(content string, maxLen int) string {
content = strings.TrimSpace(content)
if len(content) <= maxLen {
return content
}
return content[:maxLen] + "..."
}
// formatValue 格式化值为字符串
func (r *DatabaseTableReader) formatValue(value interface{}) string {
if value == nil {
return ""
}
var result string
switch v := value.(type) {
case string:
result = strings.TrimSpace(v)
// 如果去除空白后为空,返回空字符串
if result == "" {
return ""
}
// 移除HTML标签和样式确保数据干净
result = r.removeHTMLTags(result)
return result
case bool:
if v {
return "是"
}
return "否"
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%g", v)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
default:
result = fmt.Sprintf("%v", v)
// 去除空白字符
result = strings.TrimSpace(result)
if result == "" {
return ""
}
return result
}
}

View File

@@ -0,0 +1,535 @@
package pdf
import (
"math"
"strings"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// DatabaseTableRenderer 数据库表格渲染器
type DatabaseTableRenderer struct {
logger *zap.Logger
fontManager *FontManager
}
// NewDatabaseTableRenderer 创建数据库表格渲染器
func NewDatabaseTableRenderer(logger *zap.Logger, fontManager *FontManager) *DatabaseTableRenderer {
return &DatabaseTableRenderer{
logger: logger,
fontManager: fontManager,
}
}
// RenderTable 渲染表格到PDF
func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableData) error {
if tableData == nil || len(tableData.Headers) == 0 {
r.logger.Warn("表格数据为空,跳过渲染")
return nil
}
r.logger.Info("开始渲染表格",
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Strings("headers", tableData.Headers))
// 检查表头是否有有效内容
hasValidHeader := false
for _, header := range tableData.Headers {
if strings.TrimSpace(header) != "" {
hasValidHeader = true
break
}
}
if !hasValidHeader {
r.logger.Warn("表头内容为空,跳过渲染")
return nil
}
// 检查是否有有效的数据行
hasValidRows := false
for _, row := range tableData.Rows {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
hasValidRows = true
break
}
}
if hasValidRows {
break
}
}
r.logger.Debug("表格验证通过",
zap.Bool("has_valid_header", hasValidHeader),
zap.Bool("has_valid_rows", hasValidRows),
zap.Int("row_count", len(tableData.Rows)))
// 即使没有数据行,也渲染表头(单行表格)
// 但如果没有表头也没有数据,则不渲染
// 设置字体
r.fontManager.SetFont(pdf, "", 9)
_, lineHt := pdf.GetFontSize()
// 计算页面可用宽度
pageWidth, _ := pdf.GetPageSize()
availableWidth := pageWidth - 30 // 减去左右边距15mm * 2
// 计算每列宽度
colWidths := r.calculateColumnWidths(pdf, tableData, availableWidth)
// 检查是否需要分页(在绘制表头前)
_, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
estimatedHeaderHeight := lineHt * 2.5 // 估算表头高度
if currentY+estimatedHeaderHeight > pageHeight-bottomMargin {
r.logger.Debug("表头前需要分页", zap.Float64("current_y", currentY))
pdf.AddPage()
}
// 绘制表头
headerStartY := pdf.GetY()
headerHeight := r.renderHeader(pdf, tableData.Headers, colWidths, headerStartY)
// 移动到表头下方
pdf.SetXY(15.0, headerStartY+headerHeight)
// 绘制数据行(如果有数据行)
if len(tableData.Rows) > 0 {
r.logger.Debug("开始渲染数据行", zap.Int("row_count", len(tableData.Rows)))
r.renderRows(pdf, tableData.Rows, colWidths, lineHt, tableData.Headers, colWidths)
} else {
r.logger.Debug("没有数据行,只渲染表头")
}
r.logger.Info("表格渲染完成",
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Float64("current_y", pdf.GetY()))
return nil
}
// calculateColumnWidths 计算每列的宽度
// 策略:先确保每列最短内容能完整显示(不换行),然后根据内容长度分配剩余空间
func (r *DatabaseTableRenderer) calculateColumnWidths(pdf *gofpdf.Fpdf, tableData *TableData, availableWidth float64) []float64 {
numCols := len(tableData.Headers)
r.fontManager.SetFont(pdf, "", 9)
// 第一步:找到每列中最短的内容(包括表头),计算其完整显示所需的最小宽度
colMinWidths := make([]float64, numCols)
colMaxWidths := make([]float64, numCols)
colContentLengths := make([]float64, numCols) // 用于记录内容总长度,用于分配剩余空间
for i := 0; i < numCols; i++ {
minWidth := math.MaxFloat64
maxWidth := 0.0
totalLength := 0.0
count := 0
// 检查表头
if i < len(tableData.Headers) {
header := tableData.Headers[i]
textWidth := r.getTextWidth(pdf, header)
cellWidth := textWidth + 8 // 加上内边距
if cellWidth < minWidth {
minWidth = cellWidth
}
if cellWidth > maxWidth {
maxWidth = cellWidth
}
totalLength += textWidth
count++
}
// 检查所有数据行
for _, row := range tableData.Rows {
if i < len(row) {
cell := row[i]
textWidth := r.getTextWidth(pdf, cell)
cellWidth := textWidth + 8 // 加上内边距
if cellWidth < minWidth {
minWidth = cellWidth
}
if cellWidth > maxWidth {
maxWidth = cellWidth
}
totalLength += textWidth
count++
}
}
// 设置最小宽度(确保最短内容能完整显示)
if minWidth == math.MaxFloat64 {
colMinWidths[i] = 30.0 // 默认最小宽度
} else {
colMinWidths[i] = math.Max(minWidth, 25.0) // 至少25mm
}
colMaxWidths[i] = maxWidth
if count > 0 {
colContentLengths[i] = totalLength / float64(count) // 平均内容长度
} else {
colContentLengths[i] = colMinWidths[i]
}
}
// 第二步:计算总的最小宽度(确保所有最短内容都能显示)
totalMinWidth := 0.0
for _, w := range colMinWidths {
totalMinWidth += w
}
// 第三步:分配宽度
colWidths := make([]float64, numCols)
if totalMinWidth >= availableWidth {
// 如果最小宽度已经超过可用宽度,按比例缩放,但确保每列至少能显示最短内容
scale := availableWidth / totalMinWidth
for i := range colWidths {
colWidths[i] = colMinWidths[i] * scale
// 确保最小宽度,但允许稍微压缩
if colWidths[i] < colMinWidths[i]*0.8 {
colWidths[i] = colMinWidths[i] * 0.8
}
}
} else {
// 如果最小宽度小于可用宽度,先分配最小宽度,然后根据内容长度分配剩余空间
extraWidth := availableWidth - totalMinWidth
// 计算总的内容长度(用于按比例分配)
totalContentLength := 0.0
for _, length := range colContentLengths {
totalContentLength += length
}
// 如果总内容长度为0平均分配
if totalContentLength < 0.1 {
extraPerCol := extraWidth / float64(numCols)
for i := range colWidths {
colWidths[i] = colMinWidths[i] + extraPerCol
}
} else {
// 根据内容长度按比例分配剩余空间
for i := range colWidths {
// 计算这一列应该分配多少额外空间(基于内容长度)
ratio := colContentLengths[i] / totalContentLength
extraForCol := extraWidth * ratio
colWidths[i] = colMinWidths[i] + extraForCol
}
}
}
return colWidths
}
// renderHeader 渲染表头
func (r *DatabaseTableRenderer) renderHeader(pdf *gofpdf.Fpdf, headers []string, colWidths []float64, startY float64) float64 {
_, lineHt := pdf.GetFontSize()
// 计算表头的最大高度
maxHeaderHeight := lineHt * 2.0 // 使用合理的表头高度
for i, header := range headers {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
headerLines := pdf.SplitText(header, colW-6) // 增加边距从4增加到6
headerHeight := float64(len(headerLines)) * lineHt
// 添加上下内边距
headerHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
if headerHeight < lineHt*2.0 {
headerHeight = lineHt * 2.0
}
if headerHeight > maxHeaderHeight {
maxHeaderHeight = headerHeight
}
}
// 绘制表头背景和文本
pdf.SetFillColor(74, 144, 226) // 蓝色背景
pdf.SetTextColor(0, 0, 0) // 黑色文字
r.fontManager.SetFont(pdf, "B", 9)
currentX := 15.0
for i, header := range headers {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
// 清理表头数据移除任何残留的HTML标签和样式
header = strings.TrimSpace(header)
// 移除HTML标签使用简单的替换
header = strings.ReplaceAll(header, "<br>", " ")
header = strings.ReplaceAll(header, "<br/>", " ")
header = strings.ReplaceAll(header, "<br />", " ")
// 绘制表头背景
pdf.Rect(currentX, startY, colW, maxHeaderHeight, "FD")
// 绘制表头文本
if header != "" {
// 计算文本的实际高度(减少内边距,给文本更多空间)
headerLines := pdf.SplitText(header, colW-4)
textHeight := float64(len(headerLines)) * lineHt
if textHeight < lineHt {
textHeight = lineHt
}
// 计算垂直居中的Y位置
cellCenterY := startY + maxHeaderHeight/2
textStartY := cellCenterY - textHeight/2
// 设置文本位置(水平居中,垂直居中,减少左边距)
pdf.SetXY(currentX+2, textStartY)
// 确保颜色为深黑色(在渲染前再次设置,防止被覆盖)
pdf.SetTextColor(0, 0, 0) // 表头是黑色文字
// 设置字体,确保颜色不会变淡
r.fontManager.SetFont(pdf, "B", 9)
// 再次确保颜色为深黑色(在渲染前最后一次设置)
pdf.SetTextColor(0, 0, 0)
// 使用正常的行高,文本已经垂直居中(减少内边距,给文本更多空间)
pdf.MultiCell(colW-4, lineHt, header, "", "C", false)
}
// 重置Y坐标
pdf.SetXY(currentX+colW, startY)
currentX += colW
}
return maxHeaderHeight
}
// renderRows 渲染数据行(支持自动分页)
func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, colWidths []float64, lineHt float64, headers []string, headerColWidths []float64) {
numCols := len(colWidths)
pdf.SetFillColor(245, 245, 220) // 米色背景
pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰
r.fontManager.SetFont(pdf, "", 9)
// 获取页面尺寸和边距
_, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
minSpaceForRow := lineHt * 3 // 至少需要3倍行高的空间
validRowIndex := 0 // 用于交替填充的有效行索引
for rowIndex, row := range rows {
// 检查这一行是否有有效内容
hasContent := false
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
hasContent = true
break
}
}
// 跳过完全为空的行
if !hasContent {
continue
}
// 检查是否需要分页
currentY := pdf.GetY()
if currentY+minSpaceForRow > pageHeight-bottomMargin {
// 需要分页
r.logger.Debug("表格需要分页",
zap.Int("row_index", rowIndex),
zap.Float64("current_y", currentY),
zap.Float64("page_height", pageHeight))
pdf.AddPage()
// 在新页面上重新绘制表头
if len(headers) > 0 && len(headerColWidths) > 0 {
newHeaderStartY := pdf.GetY()
headerHeight := r.renderHeader(pdf, headers, headerColWidths, newHeaderStartY)
pdf.SetXY(15.0, newHeaderStartY+headerHeight)
}
}
startY := pdf.GetY()
fill := (validRowIndex % 2) == 0 // 交替填充
validRowIndex++
// 计算这一行的最大高度
maxCellHeight := lineHt * 2.0 // 使用合理的最小高度
for j := 0; j < numCols && j < len(row); j++ {
cell := row[j]
cellWidth := colWidths[j] - 4 // 减少内边距到4mm给文本更多空间
// 计算文本实际宽度,判断是否需要换行
textWidth := r.getTextWidth(pdf, cell)
var lines []string
// 只有当文本宽度超过单元格宽度时才换行
if textWidth > cellWidth {
// 文本需要换行
func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Warn("SplitText失败使用估算",
zap.Any("error", rec),
zap.Int("row_index", rowIndex),
zap.Int("col_index", j))
// 使用估算值
charCount := len([]rune(cell))
estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
lines = make([]string, int(estimatedLines))
for i := range lines {
lines[i] = ""
}
}
}()
lines = pdf.SplitText(cell, cellWidth)
}()
} else {
// 文本不需要换行,单行显示
lines = []string{cell}
}
cellHeight := float64(len(lines)) * lineHt
// 添加上下内边距
cellHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
if cellHeight < lineHt*2.0 {
cellHeight = lineHt * 2.0
}
if cellHeight > maxCellHeight {
maxCellHeight = cellHeight
}
}
// 再次检查分页(在计算完行高后)
if startY+maxCellHeight > pageHeight-bottomMargin {
r.logger.Debug("行高度超出页面,需要分页",
zap.Int("row_index", rowIndex),
zap.Float64("row_height", maxCellHeight))
pdf.AddPage()
startY = pdf.GetY()
}
// 绘制这一行的所有单元格
currentX := 15.0
for j := 0; j < numCols && j < len(row); j++ {
colW := colWidths[j]
cell := row[j]
// 清理单元格数据移除任何残留的HTML标签和样式
cell = strings.TrimSpace(cell)
// 移除HTML标签使用简单的正则表达式
cell = strings.ReplaceAll(cell, "<br>", " ")
cell = strings.ReplaceAll(cell, "<br/>", " ")
cell = strings.ReplaceAll(cell, "<br />", " ")
// 绘制单元格背景
if fill {
pdf.SetFillColor(250, 250, 235) // 稍深的米色
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.Rect(currentX, startY, colW, maxCellHeight, "FD")
// 绘制单元格文本(只绘制非空内容)
if cell != "" {
// 计算文本的实际宽度和单元格可用宽度
cellWidth := colW - 4
textWidth := r.getTextWidth(pdf, cell)
var textLines []string
// 只有当文本宽度超过单元格宽度时才换行
if textWidth > cellWidth {
// 文本需要换行
func() {
defer func() {
if rec := recover(); rec != nil {
// 如果SplitText失败使用估算
charCount := len([]rune(cell))
estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
textLines = make([]string, int(estimatedLines))
for i := range textLines {
textLines[i] = ""
}
}
}()
textLines = pdf.SplitText(cell, cellWidth)
}()
} else {
// 文本不需要换行,单行显示
textLines = []string{cell}
}
textHeight := float64(len(textLines)) * lineHt
if textHeight < lineHt {
textHeight = lineHt
}
// 计算垂直居中的Y位置
cellCenterY := startY + maxCellHeight/2
textStartY := cellCenterY - textHeight/2
// 设置文本位置(水平左对齐,垂直居中,减少左边距)
pdf.SetXY(currentX+2, textStartY)
// 再次确保颜色为深黑色(防止被其他设置覆盖)
pdf.SetTextColor(0, 0, 0)
// 设置字体,确保颜色不会变淡
r.fontManager.SetFont(pdf, "", 9)
// 再次确保颜色为深黑色(在渲染前最后一次设置)
pdf.SetTextColor(0, 0, 0)
// 安全地渲染文本,使用正常的行高
func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Warn("MultiCell渲染失败",
zap.Any("error", rec),
zap.Int("row_index", rowIndex),
zap.Int("col_index", j))
}
}()
// 使用正常的行高,文本已经垂直居中
pdf.MultiCell(cellWidth, lineHt, cell, "", "L", false)
}()
}
// 重置Y坐标
pdf.SetXY(currentX+colW, startY)
currentX += colW
}
// 移动到下一行
pdf.SetXY(15.0, startY+maxCellHeight)
}
}
// getTextWidth 获取文本宽度
func (r *DatabaseTableRenderer) getTextWidth(pdf *gofpdf.Fpdf, text string) float64 {
if r.fontManager.IsChineseFontAvailable() {
width := pdf.GetStringWidth(text)
// 如果宽度为0或太小使用更准确的估算
if width < 0.1 {
return r.estimateTextWidth(text)
}
return width
}
// 估算宽度
return r.estimateTextWidth(text)
}
// estimateTextWidth 估算文本宽度(处理中英文混合)
func (r *DatabaseTableRenderer) estimateTextWidth(text string) float64 {
charCount := 0.0
for _, r := range text {
// 中文字符通常比英文字符宽
if r >= 0x4E00 && r <= 0x9FFF {
charCount += 1.8 // 中文字符约1.8倍宽度
} else if r >= 0x3400 && r <= 0x4DBF {
charCount += 1.8 // 扩展A
} else if r >= 0x20000 && r <= 0x2A6DF {
charCount += 1.8 // 扩展B
} else {
charCount += 1.0 // 英文字符和数字
}
}
return charCount * 3.0 // 基础宽度3mm
}

View File

@@ -0,0 +1,217 @@
package pdf
import (
"os"
"runtime"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// FontManager 字体管理器
type FontManager struct {
logger *zap.Logger
chineseFontName string
chineseFontPath string
chineseFontLoaded bool
watermarkFontName string
watermarkFontPath string
watermarkFontLoaded bool
}
// NewFontManager 创建字体管理器
func NewFontManager(logger *zap.Logger) *FontManager {
return &FontManager{
logger: logger,
chineseFontName: "ChineseFont",
watermarkFontName: "WatermarkFont",
}
}
// LoadChineseFont 加载中文字体到PDF只使用黑体
func (fm *FontManager) LoadChineseFont(pdf *gofpdf.Fpdf) bool {
if fm.chineseFontLoaded {
return true
}
fontPaths := fm.getHeiFontPaths() // 只获取黑体路径
if len(fontPaths) == 0 {
fm.logger.Warn("未找到黑体字体路径PDF中的中文可能显示为空白")
return false
}
// 尝试添加黑体字体
for _, fontPath := range fontPaths {
if fm.tryAddFont(pdf, fontPath) {
fm.chineseFontPath = fontPath
fm.chineseFontLoaded = true
fm.logger.Info("成功添加黑体字体(常规和粗体)", zap.String("font_path", fontPath))
return true
}
}
fm.logger.Warn("未找到可用的黑体字体文件PDF中的中文可能显示为空白")
return false
}
// LoadWatermarkFont 加载水印字体到PDF使用宋体或其他非黑体字体
func (fm *FontManager) LoadWatermarkFont(pdf *gofpdf.Fpdf) bool {
if fm.watermarkFontLoaded {
return true
}
fontPaths := fm.getWatermarkFontPaths() // 获取水印字体路径(宋体等)
if len(fontPaths) == 0 {
// 如果找不到水印字体,使用主字体(黑体)
fm.logger.Warn("未找到水印字体,将使用主字体(黑体)")
return false
}
// 尝试添加水印字体
for _, fontPath := range fontPaths {
if fm.tryAddWatermarkFont(pdf, fontPath) {
fm.watermarkFontPath = fontPath
fm.watermarkFontLoaded = true
fm.logger.Info("成功添加水印字体", zap.String("font_path", fontPath))
return true
}
}
fm.logger.Warn("未找到可用的水印字体文件,将使用主字体(黑体)")
return false
}
// tryAddFont 尝试添加字体捕获panic
func (fm *FontManager) tryAddFont(pdf *gofpdf.Fpdf, fontPath string) bool {
defer func() {
if r := recover(); r != nil {
fm.logger.Error("添加字体时发生panic", zap.Any("panic", r), zap.String("font_path", fontPath))
}
}()
// gofpdf v2使用AddUTF8Font添加支持UTF-8的字体
pdf.AddUTF8Font(fm.chineseFontName, "", fontPath) // 常规样式
pdf.AddUTF8Font(fm.chineseFontName, "B", fontPath) // 粗体样式
return true
}
// tryAddWatermarkFont 尝试添加水印字体捕获panic
func (fm *FontManager) tryAddWatermarkFont(pdf *gofpdf.Fpdf, fontPath string) bool {
defer func() {
if r := recover(); r != nil {
fm.logger.Error("添加水印字体时发生panic", zap.Any("panic", r), zap.String("font_path", fontPath))
}
}()
// gofpdf v2使用AddUTF8Font添加支持UTF-8的字体
pdf.AddUTF8Font(fm.watermarkFontName, "", fontPath) // 常规样式
pdf.AddUTF8Font(fm.watermarkFontName, "B", fontPath) // 粗体样式
return true
}
// getHeiFontPaths 获取黑体字体路径(支持跨平台,只返回黑体)
func (fm *FontManager) getHeiFontPaths() []string {
var fontPaths []string
// Windows系统
if runtime.GOOS == "windows" {
fontPaths = []string{
`C:\Windows\Fonts\simhei.ttf`, // 黑体(优先)
}
} else if runtime.GOOS == "linux" {
// Linux系统黑体字体路径
fontPaths = []string{
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", // 文泉驿正黑(黑体)
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", // 文泉驿微米黑(黑体)
}
} else if runtime.GOOS == "darwin" {
// macOS系统黑体字体路径
fontPaths = []string{
"/Library/Fonts/Microsoft/SimHei.ttf", // 黑体
"/System/Library/Fonts/STHeiti Light.ttc", // 黑体
}
}
// 过滤出实际存在的字体文件
var existingFonts []string
for _, fontPath := range fontPaths {
if _, err := os.Stat(fontPath); err == nil {
existingFonts = append(existingFonts, fontPath)
}
}
return existingFonts
}
// getWatermarkFontPaths 获取水印字体路径(支持跨平台,使用宋体或其他非黑体字体)
func (fm *FontManager) getWatermarkFontPaths() []string {
var fontPaths []string
// Windows系统
if runtime.GOOS == "windows" {
fontPaths = []string{
`C:\Windows\Fonts\simsun.ttf`, // 宋体(优先用于水印)
`C:\Windows\Fonts\simkai.ttf`, // 楷体(备选)
}
} else if runtime.GOOS == "linux" {
// Linux系统宋体字体路径
fontPaths = []string{
"/usr/share/fonts/truetype/arphic/uming.ttc", // 文鼎PL UMing宋体
}
} else if runtime.GOOS == "darwin" {
// macOS系统宋体字体路径
fontPaths = []string{
"/System/Library/Fonts/STSong.ttc", // 宋体
}
}
// 过滤出实际存在的字体文件
var existingFonts []string
for _, fontPath := range fontPaths {
if _, err := os.Stat(fontPath); err == nil {
existingFonts = append(existingFonts, fontPath)
}
}
return existingFonts
}
// SetFont 设置字体(使用黑体)
func (fm *FontManager) SetFont(pdf *gofpdf.Fpdf, style string, size float64) {
if fm.chineseFontLoaded {
pdf.SetFont(fm.chineseFontName, style, size)
} else {
// 如果没有黑体字体使用Arial作为后备
pdf.SetFont("Arial", style, size)
}
}
// SetWatermarkFont 设置水印字体(使用宋体或其他非黑体字体)
func (fm *FontManager) SetWatermarkFont(pdf *gofpdf.Fpdf, style string, size float64) {
if fm.watermarkFontLoaded {
pdf.SetFont(fm.watermarkFontName, style, size)
} else {
// 如果水印字体不可用,使用主字体(黑体)作为后备
fm.SetFont(pdf, style, size)
}
}
// IsChineseFontAvailable 检查中文字体是否可用
func (fm *FontManager) IsChineseFontAvailable() bool {
return fm.chineseFontLoaded
}
// GetChineseFontName 获取中文字体名称
func (fm *FontManager) GetChineseFontName() string {
return fm.chineseFontName
}
// GetWatermarkFontName 获取水印字体名称
func (fm *FontManager) GetWatermarkFontName() string {
return fm.watermarkFontName
}
// IsWatermarkFontAvailable 检查水印字体是否可用
func (fm *FontManager) IsWatermarkFontAvailable() bool {
return fm.watermarkFontLoaded
}

View File

@@ -0,0 +1,155 @@
package pdf
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// JSONProcessor JSON处理器
type JSONProcessor struct{}
// NewJSONProcessor 创建JSON处理器
func NewJSONProcessor() *JSONProcessor {
return &JSONProcessor{}
}
// FormatJSON 格式化JSON字符串以便更好地显示
func (jp *JSONProcessor) FormatJSON(jsonStr string) (string, error) {
var jsonObj interface{}
if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err != nil {
return jsonStr, err // 如果解析失败,返回原始字符串
}
// 重新格式化JSON使用缩进
formatted, err := json.MarshalIndent(jsonObj, "", " ")
if err != nil {
return jsonStr, err
}
return string(formatted), nil
}
// ExtractJSON 从文本中提取JSON
func (jp *JSONProcessor) ExtractJSON(text string) string {
// 查找 ```json 代码块
re := regexp.MustCompile("(?s)```json\\s*\n(.*?)\n```")
matches := re.FindStringSubmatch(text)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
// 查找普通代码块
re = regexp.MustCompile("(?s)```\\s*\n(.*?)\n```")
matches = re.FindStringSubmatch(text)
if len(matches) > 1 {
content := strings.TrimSpace(matches[1])
// 检查是否是JSON
if strings.HasPrefix(content, "{") || strings.HasPrefix(content, "[") {
return content
}
}
return ""
}
// GenerateJSONExample 从请求参数表格生成JSON示例
func (jp *JSONProcessor) GenerateJSONExample(requestParams string, tableParser *TableParser) string {
tableData := tableParser.ParseMarkdownTable(requestParams)
if len(tableData) < 2 {
return ""
}
// 查找字段名列和类型列
var fieldCol, typeCol int = -1, -1
header := tableData[0]
for i, h := range header {
hLower := strings.ToLower(h)
if strings.Contains(hLower, "字段") || strings.Contains(hLower, "参数") || strings.Contains(hLower, "field") {
fieldCol = i
}
if strings.Contains(hLower, "类型") || strings.Contains(hLower, "type") {
typeCol = i
}
}
if fieldCol == -1 {
return ""
}
// 生成JSON结构
jsonMap := make(map[string]interface{})
for i := 1; i < len(tableData); i++ {
row := tableData[i]
if fieldCol >= len(row) {
continue
}
fieldName := strings.TrimSpace(row[fieldCol])
if fieldName == "" {
continue
}
// 跳过表头行
if strings.Contains(strings.ToLower(fieldName), "字段") || strings.Contains(strings.ToLower(fieldName), "参数") {
continue
}
// 获取类型
fieldType := "string"
if typeCol >= 0 && typeCol < len(row) {
fieldType = strings.ToLower(strings.TrimSpace(row[typeCol]))
}
// 设置示例值
var value interface{}
if strings.Contains(fieldType, "int") || strings.Contains(fieldType, "number") {
value = 0
} else if strings.Contains(fieldType, "bool") {
value = true
} else if strings.Contains(fieldType, "array") || strings.Contains(fieldType, "list") {
value = []interface{}{}
} else if strings.Contains(fieldType, "object") || strings.Contains(fieldType, "dict") {
value = map[string]interface{}{}
} else {
// 根据字段名设置合理的示例值
fieldLower := strings.ToLower(fieldName)
if strings.Contains(fieldLower, "name") || strings.Contains(fieldName, "姓名") {
value = "张三"
} else if strings.Contains(fieldLower, "id_card") || strings.Contains(fieldLower, "idcard") || strings.Contains(fieldName, "身份证") {
value = "110101199001011234"
} else if strings.Contains(fieldLower, "phone") || strings.Contains(fieldLower, "mobile") || strings.Contains(fieldName, "手机") {
value = "13800138000"
} else if strings.Contains(fieldLower, "card") || strings.Contains(fieldName, "银行卡") {
value = "6222021234567890123"
} else {
value = "string"
}
}
// 处理嵌套字段(如 baseInfo.phone
if strings.Contains(fieldName, ".") {
parts := strings.Split(fieldName, ".")
current := jsonMap
for j := 0; j < len(parts)-1; j++ {
if _, ok := current[parts[j]].(map[string]interface{}); !ok {
current[parts[j]] = make(map[string]interface{})
}
current = current[parts[j]].(map[string]interface{})
}
current[parts[len(parts)-1]] = value
} else {
jsonMap[fieldName] = value
}
}
// 使用encoding/json正确格式化JSON
jsonBytes, err := json.MarshalIndent(jsonMap, "", " ")
if err != nil {
// 如果JSON序列化失败返回简单的字符串表示
return fmt.Sprintf("%v", jsonMap)
}
return string(jsonBytes)
}

View File

@@ -0,0 +1,658 @@
package pdf
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// MarkdownConverter Markdown转换器 - 将各种格式的markdown内容标准化
type MarkdownConverter struct {
textProcessor *TextProcessor
}
// NewMarkdownConverter 创建Markdown转换器
func NewMarkdownConverter(textProcessor *TextProcessor) *MarkdownConverter {
return &MarkdownConverter{
textProcessor: textProcessor,
}
}
// ConvertToStandardMarkdown 将各种格式的内容转换为标准的markdown格式
// 这是第一步:预处理和标准化
func (mc *MarkdownConverter) ConvertToStandardMarkdown(content string) string {
if strings.TrimSpace(content) == "" {
return content
}
// 1. 先清理HTML标签保留内容
content = mc.textProcessor.StripHTML(content)
// 2. 处理代码块 - 确保代码块格式正确
content = mc.normalizeCodeBlocks(content)
// 3. 处理表格 - 确保表格格式正确
content = mc.normalizeTables(content)
// 4. 处理列表 - 统一列表格式
content = mc.normalizeLists(content)
// 5. 处理JSON内容 - 尝试识别并格式化JSON
content = mc.normalizeJSONContent(content)
// 6. 处理链接和图片 - 转换为文本
content = mc.convertLinksToText(content)
content = mc.convertImagesToText(content)
// 7. 处理引用块
content = mc.normalizeBlockquotes(content)
// 8. 处理水平线
content = mc.normalizeHorizontalRules(content)
// 9. 清理多余空行(保留代码块内的空行)
content = mc.cleanupExtraBlankLines(content)
return content
}
// normalizeCodeBlocks 规范化代码块
func (mc *MarkdownConverter) normalizeCodeBlocks(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
codeBlockLang := ""
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否是代码块开始
if strings.HasPrefix(trimmed, "```") {
if inCodeBlock {
// 代码块结束
result = append(result, line)
inCodeBlock = false
codeBlockLang = ""
} else {
// 代码块开始
inCodeBlock = true
// 提取语言标识
if len(trimmed) > 3 {
codeBlockLang = strings.TrimSpace(trimmed[3:])
if codeBlockLang != "" {
result = append(result, fmt.Sprintf("```%s", codeBlockLang))
} else {
result = append(result, "```")
}
} else {
result = append(result, "```")
}
}
} else if inCodeBlock {
// 在代码块中,保留原样
result = append(result, line)
} else {
// 不在代码块中,处理其他内容
result = append(result, line)
}
// 如果代码块没有正确关闭,在文件末尾自动关闭
if i == len(lines)-1 && inCodeBlock {
result = append(result, "```")
}
}
return strings.Join(result, "\n")
}
// normalizeTables 规范化表格格式
func (mc *MarkdownConverter) normalizeTables(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
// 代码块中的内容不处理
result = append(result, line)
continue
}
// 检查是否是表格行
if strings.Contains(trimmed, "|") {
// 检查是否是分隔行
isSeparator := mc.isTableSeparator(trimmed)
if isSeparator {
// 确保分隔行格式正确
cells := strings.Split(trimmed, "|")
// 清理首尾空元素
if len(cells) > 0 && cells[0] == "" {
cells = cells[1:]
}
if len(cells) > 0 && cells[len(cells)-1] == "" {
cells = cells[:len(cells)-1]
}
// 构建标准分隔行
separator := "|"
for range cells {
separator += " --- |"
}
result = append(result, separator)
} else {
// 普通表格行,确保格式正确
normalizedLine := mc.normalizeTableRow(line)
result = append(result, normalizedLine)
}
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// isTableSeparator 检查是否是表格分隔行
func (mc *MarkdownConverter) isTableSeparator(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "-") {
return false
}
// 检查是否只包含 |、-、:、空格
for _, r := range trimmed {
if r != '|' && r != '-' && r != ':' && r != ' ' {
return false
}
}
return true
}
// normalizeTableRow 规范化表格行
func (mc *MarkdownConverter) normalizeTableRow(line string) string {
trimmed := strings.TrimSpace(line)
if !strings.Contains(trimmed, "|") {
return line
}
cells := strings.Split(trimmed, "|")
// 清理首尾空元素
if len(cells) > 0 && cells[0] == "" {
cells = cells[1:]
}
if len(cells) > 0 && cells[len(cells)-1] == "" {
cells = cells[:len(cells)-1]
}
// 清理每个单元格
normalizedCells := make([]string, 0, len(cells))
for _, cell := range cells {
cell = strings.TrimSpace(cell)
// 移除markdown格式但保留内容
cell = mc.textProcessor.RemoveMarkdownSyntax(cell)
normalizedCells = append(normalizedCells, cell)
}
// 重新构建表格行
return "| " + strings.Join(normalizedCells, " | ") + " |"
}
// normalizeLists 规范化列表格式
func (mc *MarkdownConverter) normalizeLists(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
result = append(result, line)
continue
}
// 处理有序列表
if matched, _ := regexp.MatchString(`^\d+\.\s+`, trimmed); matched {
// 确保格式统一:数字. 空格
re := regexp.MustCompile(`^(\d+)\.\s*`)
trimmed = re.ReplaceAllString(trimmed, "$1. ")
result = append(result, trimmed)
} else if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") {
// 处理无序列表,统一使用 -
re := regexp.MustCompile(`^[-*+]\s*`)
trimmed = re.ReplaceAllString(trimmed, "- ")
result = append(result, trimmed)
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// normalizeJSONContent 规范化JSON内容
func (mc *MarkdownConverter) normalizeJSONContent(content string) string {
// 尝试识别并格式化JSON代码块
jsonBlockRegex := regexp.MustCompile("(?s)```(?:json)?\\s*\n(.*?)\n```")
content = jsonBlockRegex.ReplaceAllStringFunc(content, func(match string) string {
// 提取JSON内容
submatch := jsonBlockRegex.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
jsonStr := strings.TrimSpace(submatch[1])
// 尝试格式化JSON
var jsonObj interface{}
if err := json.Unmarshal([]byte(jsonStr), &jsonObj); err == nil {
// 格式化成功
formatted, err := json.MarshalIndent(jsonObj, "", " ")
if err == nil {
return fmt.Sprintf("```json\n%s\n```", string(formatted))
}
}
return match
})
return content
}
// convertLinksToText 将链接转换为文本
func (mc *MarkdownConverter) convertLinksToText(content string) string {
// [text](url) -> text (url)
linkRegex := regexp.MustCompile(`\[([^\]]+)\]\(([^\)]+)\)`)
content = linkRegex.ReplaceAllString(content, "$1 ($2)")
// [text][ref] -> text
refLinkRegex := regexp.MustCompile(`\[([^\]]+)\]\[[^\]]+\]`)
content = refLinkRegex.ReplaceAllString(content, "$1")
return content
}
// convertImagesToText 将图片转换为文本
func (mc *MarkdownConverter) convertImagesToText(content string) string {
// ![alt](url) -> [图片: alt]
imageRegex := regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`)
content = imageRegex.ReplaceAllString(content, "[图片: $1]")
return content
}
// normalizeBlockquotes 规范化引用块
func (mc *MarkdownConverter) normalizeBlockquotes(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
result = append(result, line)
continue
}
// 处理引用块 > text -> > text
if strings.HasPrefix(trimmed, ">") {
// 确保格式统一
quoteText := strings.TrimSpace(trimmed[1:])
if quoteText != "" {
result = append(result, "> "+quoteText)
} else {
result = append(result, ">")
}
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// normalizeHorizontalRules 规范化水平线
func (mc *MarkdownConverter) normalizeHorizontalRules(content string) string {
// 统一水平线格式为 ---
hrRegex := regexp.MustCompile(`^[-*_]{3,}\s*$`)
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
continue
}
if inCodeBlock {
result = append(result, line)
continue
}
// 如果是水平线,统一格式
if hrRegex.MatchString(trimmed) {
result = append(result, "---")
} else {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// cleanupExtraBlankLines 清理多余空行(保留代码块内的空行)
func (mc *MarkdownConverter) cleanupExtraBlankLines(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
lastWasBlank := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
lastWasBlank = false
continue
}
if inCodeBlock {
// 代码块中的内容全部保留
result = append(result, line)
lastWasBlank = (trimmed == "")
continue
}
// 不在代码块中
if trimmed == "" {
// 空行:最多保留一个连续空行
if !lastWasBlank {
result = append(result, "")
lastWasBlank = true
}
} else {
result = append(result, line)
lastWasBlank = false
}
}
return strings.Join(result, "\n")
}
// PreprocessContent 预处理内容 - 这是主要的转换入口
// 先转换,再解析
func (mc *MarkdownConverter) PreprocessContent(content string) string {
if strings.TrimSpace(content) == "" {
return content
}
// 第一步转换为标准markdown
content = mc.ConvertToStandardMarkdown(content)
// 第二步尝试识别并转换JSON数组为表格
content = mc.convertJSONArrayToTable(content)
// 第三步:确保所有表格都有正确的分隔行
content = mc.ensureTableSeparators(content)
return content
}
// convertJSONArrayToTable 将JSON数组转换为markdown表格
func (mc *MarkdownConverter) convertJSONArrayToTable(content string) string {
// 如果内容已经是表格格式,不处理
if strings.Contains(content, "|") {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "```") {
// 已经有表格,不转换
return content
}
}
}
// 尝试解析为JSON数组
trimmedContent := strings.TrimSpace(content)
if strings.HasPrefix(trimmedContent, "[") {
var jsonArray []map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &jsonArray); err == nil && len(jsonArray) > 0 {
// 转换为markdown表格
return mc.jsonArrayToMarkdownTable(jsonArray)
}
}
// 尝试解析为JSON对象包含params或fields字段
if strings.HasPrefix(trimmedContent, "{") {
var jsonObj map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &jsonObj); err == nil {
// 检查是否有params字段
if params, ok := jsonObj["params"].([]interface{}); ok {
paramMaps := make([]map[string]interface{}, 0, len(params))
for _, p := range params {
if pm, ok := p.(map[string]interface{}); ok {
paramMaps = append(paramMaps, pm)
}
}
if len(paramMaps) > 0 {
return mc.jsonArrayToMarkdownTable(paramMaps)
}
}
// 检查是否有fields字段
if fields, ok := jsonObj["fields"].([]interface{}); ok {
fieldMaps := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
if fm, ok := f.(map[string]interface{}); ok {
fieldMaps = append(fieldMaps, fm)
}
}
if len(fieldMaps) > 0 {
return mc.jsonArrayToMarkdownTable(fieldMaps)
}
}
}
}
return content
}
// jsonArrayToMarkdownTable 将JSON数组转换为markdown表格
func (mc *MarkdownConverter) jsonArrayToMarkdownTable(data []map[string]interface{}) string {
if len(data) == 0 {
return ""
}
var result strings.Builder
// 收集所有可能的列名(保持原始顺序)
// 使用map记录是否已添加使用slice保持顺序
columnSet := make(map[string]bool)
columns := make([]string, 0)
// 遍历所有数据行,按第一次出现的顺序收集列名
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
}
if len(columns) == 0 {
return ""
}
// 构建表头(直接使用原始列名,不做映射)
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
result.WriteString(col) // 直接使用原始列名
result.WriteString(" |")
}
result.WriteString("\n")
// 构建分隔行
result.WriteString("|")
for range columns {
result.WriteString(" --- |")
}
result.WriteString("\n")
// 构建数据行
for _, row := range data {
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
value := mc.formatCellValue(row[col])
result.WriteString(value)
result.WriteString(" |")
}
result.WriteString("\n")
}
return result.String()
}
// formatColumnName 格式化列名(直接返回原始列名,不做映射)
// 保持数据库原始数据的列名,不进行转换
func (mc *MarkdownConverter) formatColumnName(name string) string {
// 直接返回原始列名,保持数据库数据的原始格式
return name
}
// formatCellValue 格式化单元格值
func (mc *MarkdownConverter) formatCellValue(value interface{}) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
v = strings.ReplaceAll(v, "\n", " ")
v = strings.ReplaceAll(v, "\r", " ")
v = strings.TrimSpace(v)
v = strings.ReplaceAll(v, "|", "\\|")
return v
case bool:
if v {
return "是"
}
return "否"
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%g", v)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
default:
str := fmt.Sprintf("%v", v)
str = strings.ReplaceAll(str, "\n", " ")
str = strings.ReplaceAll(str, "\r", " ")
str = strings.ReplaceAll(str, "|", "\\|")
return strings.TrimSpace(str)
}
}
// ensureTableSeparators 确保所有表格都有正确的分隔行
func (mc *MarkdownConverter) ensureTableSeparators(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
lastLineWasTableHeader := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否在代码块中
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
result = append(result, line)
lastLineWasTableHeader = false
continue
}
if inCodeBlock {
result = append(result, line)
lastLineWasTableHeader = false
continue
}
// 检查是否是表格行
if strings.Contains(trimmed, "|") {
// 检查是否是分隔行
if mc.isTableSeparator(trimmed) {
result = append(result, line)
lastLineWasTableHeader = false
} else {
// 普通表格行
result = append(result, line)
// 检查上一行是否是表头
if lastLineWasTableHeader {
// 在表头后插入分隔行
cells := strings.Split(trimmed, "|")
if len(cells) > 0 && cells[0] == "" {
cells = cells[1:]
}
if len(cells) > 0 && cells[len(cells)-1] == "" {
cells = cells[:len(cells)-1]
}
separator := "|"
for range cells {
separator += " --- |"
}
// 在当前位置插入分隔行
result = append(result[:len(result)-1], separator, line)
} else {
// 检查是否是表头(第一行表格)
if i > 0 {
prevLine := strings.TrimSpace(lines[i-1])
if !strings.Contains(prevLine, "|") || mc.isTableSeparator(prevLine) {
// 这可能是表头
lastLineWasTableHeader = true
}
} else {
lastLineWasTableHeader = true
}
}
}
} else {
result = append(result, line)
lastLineWasTableHeader = false
}
}
return strings.Join(result, "\n")
}

View File

@@ -0,0 +1,355 @@
package pdf
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// MarkdownProcessor Markdown处理器
type MarkdownProcessor struct {
textProcessor *TextProcessor
markdownConverter *MarkdownConverter
}
// NewMarkdownProcessor 创建Markdown处理器
func NewMarkdownProcessor(textProcessor *TextProcessor) *MarkdownProcessor {
converter := NewMarkdownConverter(textProcessor)
return &MarkdownProcessor{
textProcessor: textProcessor,
markdownConverter: converter,
}
}
// MarkdownSection 表示一个markdown章节
type MarkdownSection struct {
Title string // 标题(包含#号)
Level int // 标题级别(## 是2, ### 是3, #### 是4
Content string // 该章节的内容
}
// SplitByMarkdownHeaders 按markdown标题分割内容
func (mp *MarkdownProcessor) SplitByMarkdownHeaders(content string) []MarkdownSection {
lines := strings.Split(content, "\n")
var sections []MarkdownSection
var currentSection MarkdownSection
var currentContent []string
// 标题正则:匹配 #, ##, ###, #### 等
headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
// 检查是否是标题行
if matches := headerRegex.FindStringSubmatch(trimmedLine); matches != nil {
// 如果之前有内容,先保存之前的章节
if currentSection.Title != "" || len(currentContent) > 0 {
if currentSection.Title != "" {
currentSection.Content = strings.Join(currentContent, "\n")
sections = append(sections, currentSection)
}
}
// 开始新章节
level := len(matches[1]) // #号的数量
currentSection = MarkdownSection{
Title: trimmedLine,
Level: level,
Content: "",
}
currentContent = []string{}
} else {
// 普通内容行,添加到当前章节
currentContent = append(currentContent, line)
}
}
// 保存最后一个章节
if currentSection.Title != "" || len(currentContent) > 0 {
if currentSection.Title != "" {
currentSection.Content = strings.Join(currentContent, "\n")
sections = append(sections, currentSection)
} else if len(currentContent) > 0 {
// 如果没有标题,但开头有内容,作为第一个章节
sections = append(sections, MarkdownSection{
Title: "",
Level: 0,
Content: strings.Join(currentContent, "\n"),
})
}
}
return sections
}
// FormatContentAsMarkdownTable 将数据库中的数据格式化为标准的markdown表格格式
// 先进行预处理转换,再进行解析
func (mp *MarkdownProcessor) FormatContentAsMarkdownTable(content string) string {
if strings.TrimSpace(content) == "" {
return content
}
// 第一步预处理和转换标准化markdown格式
content = mp.markdownConverter.PreprocessContent(content)
// 如果内容已经是markdown表格格式包含|符号),检查格式是否正确
if strings.Contains(content, "|") {
// 检查是否已经是有效的markdown表格
lines := strings.Split(content, "\n")
hasTableFormat := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 跳过代码块中的内容
if strings.HasPrefix(trimmed, "```") {
continue
}
if strings.Contains(trimmed, "|") && !strings.HasPrefix(trimmed, "#") {
hasTableFormat = true
break
}
}
if hasTableFormat {
return content
}
}
// 提取代码块(保留代码块不变)
codeBlocks := mp.ExtractCodeBlocks(content)
// 移除代码块,只处理非代码块部分
contentWithoutCodeBlocks := mp.RemoveCodeBlocks(content)
// 如果移除代码块后内容为空,说明只有代码块,直接返回原始内容
if strings.TrimSpace(contentWithoutCodeBlocks) == "" {
return content
}
// 尝试解析非代码块部分为JSON数组仅当内容看起来像JSON时
trimmedContent := strings.TrimSpace(contentWithoutCodeBlocks)
// 检查是否看起来像JSON以[或{开头)
if strings.HasPrefix(trimmedContent, "[") || strings.HasPrefix(trimmedContent, "{") {
// 尝试解析为JSON数组
var requestParams []map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &requestParams); err == nil && len(requestParams) > 0 {
// 成功解析为JSON数组转换为markdown表格
tableContent := mp.jsonArrayToMarkdownTable(requestParams)
// 如果有代码块,在表格后添加代码块
if len(codeBlocks) > 0 {
return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
}
return tableContent
}
// 尝试解析为单个JSON对象
var singleObj map[string]interface{}
if err := json.Unmarshal([]byte(trimmedContent), &singleObj); err == nil {
// 检查是否是包含数组字段的对象
if params, ok := singleObj["params"].([]interface{}); ok {
// 转换为map数组
paramMaps := make([]map[string]interface{}, 0, len(params))
for _, p := range params {
if pm, ok := p.(map[string]interface{}); ok {
paramMaps = append(paramMaps, pm)
}
}
if len(paramMaps) > 0 {
tableContent := mp.jsonArrayToMarkdownTable(paramMaps)
// 如果有代码块,在表格后添加代码块
if len(codeBlocks) > 0 {
return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
}
return tableContent
}
}
if fields, ok := singleObj["fields"].([]interface{}); ok {
// 转换为map数组
fieldMaps := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
if fm, ok := f.(map[string]interface{}); ok {
fieldMaps = append(fieldMaps, fm)
}
}
if len(fieldMaps) > 0 {
tableContent := mp.jsonArrayToMarkdownTable(fieldMaps)
// 如果有代码块,在表格后添加代码块
if len(codeBlocks) > 0 {
return tableContent + "\n\n" + strings.Join(codeBlocks, "\n\n")
}
return tableContent
}
}
}
}
// 如果无法解析为JSON返回原始内容保留代码块
return content
}
// ExtractCodeBlocks 提取内容中的所有代码块
func (mp *MarkdownProcessor) ExtractCodeBlocks(content string) []string {
var codeBlocks []string
lines := strings.Split(content, "\n")
inCodeBlock := false
var currentBlock []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否是代码块开始
if strings.HasPrefix(trimmed, "```") {
if inCodeBlock {
// 代码块结束
currentBlock = append(currentBlock, line)
codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
currentBlock = []string{}
inCodeBlock = false
} else {
// 代码块开始
inCodeBlock = true
currentBlock = []string{line}
}
} else if inCodeBlock {
// 在代码块中
currentBlock = append(currentBlock, line)
}
}
// 如果代码块没有正确关闭,也添加进去
if inCodeBlock && len(currentBlock) > 0 {
codeBlocks = append(codeBlocks, strings.Join(currentBlock, "\n"))
}
return codeBlocks
}
// RemoveCodeBlocks 移除内容中的所有代码块
func (mp *MarkdownProcessor) RemoveCodeBlocks(content string) string {
lines := strings.Split(content, "\n")
var result []string
inCodeBlock := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检查是否是代码块开始或结束
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
continue // 跳过代码块的标记行
}
// 如果不在代码块中,保留这一行
if !inCodeBlock {
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// jsonArrayToMarkdownTable 将JSON数组转换为标准的markdown表格
func (mp *MarkdownProcessor) jsonArrayToMarkdownTable(data []map[string]interface{}) string {
if len(data) == 0 {
return ""
}
var result strings.Builder
// 收集所有可能的列名(保持原始顺序)
// 使用map记录是否已添加使用slice保持顺序
columnSet := make(map[string]bool)
columns := make([]string, 0)
// 遍历所有数据行,按第一次出现的顺序收集列名
for _, row := range data {
for key := range row {
if !columnSet[key] {
columns = append(columns, key)
columnSet[key] = true
}
}
}
if len(columns) == 0 {
return ""
}
// 构建表头(直接使用原始列名,不做映射)
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
result.WriteString(col) // 直接使用原始列名
result.WriteString(" |")
}
result.WriteString("\n")
// 构建分隔行
result.WriteString("|")
for range columns {
result.WriteString(" --- |")
}
result.WriteString("\n")
// 构建数据行
for _, row := range data {
result.WriteString("|")
for _, col := range columns {
result.WriteString(" ")
value := mp.formatCellValue(row[col])
result.WriteString(value)
result.WriteString(" |")
}
result.WriteString("\n")
}
return result.String()
}
// formatColumnName 格式化列名(直接返回原始列名,不做映射)
// 保持数据库原始数据的列名,不进行转换
func (mp *MarkdownProcessor) formatColumnName(name string) string {
// 直接返回原始列名,保持数据库数据的原始格式
return name
}
// formatCellValue 格式化单元格值
func (mp *MarkdownProcessor) formatCellValue(value interface{}) string {
if value == nil {
return ""
}
switch v := value.(type) {
case string:
// 清理字符串,移除换行符和多余空格
v = strings.ReplaceAll(v, "\n", " ")
v = strings.ReplaceAll(v, "\r", " ")
v = strings.TrimSpace(v)
// 转义markdown特殊字符
v = strings.ReplaceAll(v, "|", "\\|")
return v
case bool:
if v {
return "是"
}
return "否"
case float64:
// 如果是整数,不显示小数点
if v == float64(int64(v)) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%g", v)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", v)
default:
// 对于其他类型,转换为字符串
str := fmt.Sprintf("%v", v)
str = strings.ReplaceAll(str, "\n", " ")
str = strings.ReplaceAll(str, "\r", " ")
str = strings.ReplaceAll(str, "|", "\\|")
return strings.TrimSpace(str)
}
}

View File

@@ -0,0 +1,733 @@
package pdf
import (
"context"
"fmt"
"math"
"os"
"strings"
"tyapi-server/internal/domains/product/entities"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// PageBuilder 页面构建器
type PageBuilder struct {
logger *zap.Logger
fontManager *FontManager
textProcessor *TextProcessor
markdownProc *MarkdownProcessor
markdownConverter *MarkdownConverter
tableParser *TableParser
tableRenderer *TableRenderer
jsonProcessor *JSONProcessor
logoPath string
watermarkText string
}
// NewPageBuilder 创建页面构建器
func NewPageBuilder(
logger *zap.Logger,
fontManager *FontManager,
textProcessor *TextProcessor,
markdownProc *MarkdownProcessor,
tableParser *TableParser,
tableRenderer *TableRenderer,
jsonProcessor *JSONProcessor,
logoPath string,
watermarkText string,
) *PageBuilder {
markdownConverter := NewMarkdownConverter(textProcessor)
return &PageBuilder{
logger: logger,
fontManager: fontManager,
textProcessor: textProcessor,
markdownProc: markdownProc,
markdownConverter: markdownConverter,
tableParser: tableParser,
tableRenderer: tableRenderer,
jsonProcessor: jsonProcessor,
logoPath: logoPath,
watermarkText: watermarkText,
}
}
// AddFirstPage 添加第一页(封面页 - 产品功能简述)
func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pdf.AddPage()
// 添加页眉logo和文字
pb.addHeader(pdf, chineseFontAvailable)
// 添加水印
pb.addWatermark(pdf, chineseFontAvailable)
// 封面页布局 - 居中显示
pageWidth, pageHeight := pdf.GetPageSize()
// 标题区域(页面中上部)
pdf.SetY(80)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 32)
_, lineHt := pdf.GetFontSize()
// 清理产品名称中的无效字符
cleanName := pb.textProcessor.CleanText(product.Name)
pdf.CellFormat(0, lineHt*1.5, cleanName, "", 1, "C", false, 0, "")
// 添加"接口文档"副标题
pdf.Ln(10)
pb.fontManager.SetFont(pdf, "", 18)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "")
// 分隔线
pdf.Ln(20)
pdf.SetLineWidth(0.5)
pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY())
// 产品编码(居中)
pdf.Ln(30)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, fmt.Sprintf("产品编码:%s", product.Code), "", 1, "C", false, 0, "")
// 产品描述(居中显示,段落格式)
if product.Description != "" {
pdf.Ln(25)
desc := pb.textProcessor.StripHTML(product.Description)
desc = pb.textProcessor.CleanText(desc)
pb.fontManager.SetFont(pdf, "", 14)
_, lineHt = pdf.GetFontSize()
// 居中对齐的MultiCell通过计算宽度实现
descWidth := pageWidth * 0.7
descLines := pdf.SplitText(desc, descWidth)
currentX := (pageWidth - descWidth) / 2
for _, line := range descLines {
pdf.SetX(currentX)
pdf.CellFormat(descWidth, lineHt*1.5, line, "", 1, "C", false, 0, "")
}
}
// 产品详情(如果存在)
if product.Content != "" {
pdf.Ln(20)
content := pb.textProcessor.StripHTML(product.Content)
content = pb.textProcessor.CleanText(content)
pb.fontManager.SetFont(pdf, "", 12)
_, lineHt = pdf.GetFontSize()
contentWidth := pageWidth * 0.7
contentLines := pdf.SplitText(content, contentWidth)
currentX := (pageWidth - contentWidth) / 2
for _, line := range contentLines {
pdf.SetX(currentX)
pdf.CellFormat(contentWidth, lineHt*1.4, line, "", 1, "C", false, 0, "")
}
}
// 底部信息(价格等)
if !product.Price.IsZero() {
pdf.SetY(pageHeight - 60)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 12)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, fmt.Sprintf("价格:%s 元", product.Price.String()), "", 1, "C", false, 0, "")
}
}
// AddDocumentationPages 添加接口文档页面
func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
// 创建自定义的AddPage函数确保每页都有水印
addPageWithWatermark := func() {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
}
addPageWithWatermark()
pdf.SetY(45)
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
// 请求URL
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, "请求URL", "", 1, "L", false, 0, "")
// URL使用黑体字体可能包含中文字符
// 先清理URL中的乱码
cleanURL := pb.textProcessor.CleanText(doc.RequestURL)
pb.fontManager.SetFont(pdf, "", 10) // 使用黑体
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
// 请求方法
pdf.Ln(5)
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
// 基本信息
if doc.BasicInfo != "" {
pdf.Ln(8)
pb.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
}
// 请求参数
if doc.RequestParams != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理请求参数
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "request_params"); err != nil {
pb.logger.Warn("渲染请求参数表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.RequestParams)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
// 生成JSON示例
if jsonExample := pb.jsonProcessor.GenerateJSONExample(doc.RequestParams, pb.tableParser); jsonExample != "" {
pdf.Ln(5)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
// JSON中可能包含中文值使用黑体字体
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON支持中文
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
}
}
// 响应示例
if doc.ResponseExample != "" {
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
// 优先尝试提取和格式化JSON
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON支持中文
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
pb.logger.Warn("渲染响应示例表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseExample)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
// 返回字段说明
if doc.ResponseFields != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "返回字段说明:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理返回字段(支持多个表格,带标题)
if err := pb.tableParser.ParseAndRenderTablesWithTitles(context.Background(), pdf, doc, "response_fields"); err != nil {
pb.logger.Warn("渲染返回字段表格失败,回退到文本显示",
zap.Error(err),
zap.String("content_preview", pb.getContentPreview(doc.ResponseFields, 200)))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseFields)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
} else {
pb.logger.Info("返回字段说明表格渲染成功")
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "错误代码:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理错误代码
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "error_codes"); err != nil {
pb.logger.Warn("渲染错误代码表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ErrorCodes)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
// addSection 添加章节
func (pb *PageBuilder) addSection(pdf *gofpdf.Fpdf, title, content string, chineseFontAvailable bool) {
_, lineHt := pdf.GetFontSize()
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, title+"", "", 1, "L", false, 0, "")
// 第一步预处理和转换标准化markdown格式
content = pb.markdownConverter.PreprocessContent(content)
// 第二步将内容格式化为标准的markdown表格格式如果还不是
content = pb.markdownProc.FormatContentAsMarkdownTable(content)
// 先尝试提取JSON如果是代码块格式
if jsonContent := pb.jsonProcessor.ExtractJSON(content); jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体
pdf.MultiCell(0, lineHt*1.2, jsonContent, "", "L", false)
} else {
// 按#号标题分割内容,每个标题下的内容单独处理
sections := pb.markdownProc.SplitByMarkdownHeaders(content)
if len(sections) > 0 {
// 如果有多个章节,逐个处理
for i, section := range sections {
if i > 0 {
pdf.Ln(5) // 章节之间的间距
}
// 如果有标题,先显示标题
if section.Title != "" {
titleLevel := section.Level
fontSize := 14.0 - float64(titleLevel-2)*2 // ## 是14, ### 是12, #### 是10
if fontSize < 10 {
fontSize = 10
}
pb.fontManager.SetFont(pdf, "B", fontSize)
pdf.SetTextColor(0, 0, 0)
// 清理标题中的#号
cleanTitle := strings.TrimSpace(strings.TrimLeft(section.Title, "#"))
pdf.CellFormat(0, lineHt*1.2, cleanTitle, "", 1, "L", false, 0, "")
pdf.Ln(3)
}
// 处理该章节的内容(可能是表格或文本)
pb.processSectionContent(pdf, section.Content, chineseFontAvailable, lineHt)
}
} else {
// 如果没有标题分割,直接处理整个内容
pb.processSectionContent(pdf, content, chineseFontAvailable, lineHt)
}
}
}
// processRequestParams 处理请求参数:直接解析所有表格,确保表格能够正确渲染
func (pb *PageBuilder) processRequestParams(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
// 第一步预处理和转换标准化markdown格式
content = pb.markdownConverter.PreprocessContent(content)
// 第二步将数据格式化为标准的markdown表格格式
processedContent := pb.markdownProc.FormatContentAsMarkdownTable(content)
// 解析并显示所有表格(不按标题分组)
// 将内容按表格分割,找到所有表格块
allTables := pb.tableParser.ExtractAllTables(processedContent)
if len(allTables) > 0 {
// 有表格,逐个渲染
for i, tableBlock := range allTables {
if i > 0 {
pdf.Ln(5) // 表格之间的间距
}
// 渲染表格前的说明文字(包括标题)
if tableBlock.BeforeText != "" {
beforeText := tableBlock.BeforeText
// 处理标题和文本
pb.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
pdf.Ln(3)
}
// 渲染表格
if len(tableBlock.TableData) > 0 && pb.tableParser.IsValidTable(tableBlock.TableData) {
pb.tableRenderer.RenderTable(pdf, tableBlock.TableData)
}
// 渲染表格后的说明文字
if tableBlock.AfterText != "" {
afterText := pb.textProcessor.StripHTML(tableBlock.AfterText)
afterText = pb.textProcessor.CleanText(afterText)
if strings.TrimSpace(afterText) != "" {
pdf.Ln(3)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
}
}
}
} else {
// 没有表格,显示为文本
text := pb.textProcessor.StripHTML(processedContent)
text = pb.textProcessor.CleanText(text)
if strings.TrimSpace(text) != "" {
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// processResponseExample 处理响应示例不按markdown标题分级直接解析所有表格但保留标题显示
func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
// 第一步预处理和转换标准化markdown格式
content = pb.markdownConverter.PreprocessContent(content)
// 第二步将数据格式化为标准的markdown表格格式
processedContent := pb.markdownProc.FormatContentAsMarkdownTable(content)
// 尝试提取JSON内容如果存在代码块
jsonContent := pb.jsonProcessor.ExtractJSON(processedContent)
if jsonContent != "" {
pdf.SetTextColor(0, 0, 0)
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
pdf.Ln(5)
}
// 解析并显示所有表格(不按标题分组)
// 将内容按表格分割,找到所有表格块
allTables := pb.tableParser.ExtractAllTables(processedContent)
if len(allTables) > 0 {
// 有表格,逐个渲染
for i, tableBlock := range allTables {
if i > 0 {
pdf.Ln(5) // 表格之间的间距
}
// 渲染表格前的说明文字(包括标题)
if tableBlock.BeforeText != "" {
beforeText := tableBlock.BeforeText
// 处理标题和文本
pb.renderTextWithTitles(pdf, beforeText, chineseFontAvailable, lineHt)
pdf.Ln(3)
}
// 渲染表格
if len(tableBlock.TableData) > 0 && pb.tableParser.IsValidTable(tableBlock.TableData) {
pb.tableRenderer.RenderTable(pdf, tableBlock.TableData)
}
// 渲染表格后的说明文字
if tableBlock.AfterText != "" {
afterText := pb.textProcessor.StripHTML(tableBlock.AfterText)
afterText = pb.textProcessor.CleanText(afterText)
if strings.TrimSpace(afterText) != "" {
pdf.Ln(3)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
}
}
}
} else {
// 没有表格,显示为文本
text := pb.textProcessor.StripHTML(processedContent)
text = pb.textProcessor.CleanText(text)
if strings.TrimSpace(text) != "" {
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// processSectionContent 处理单个章节的内容(解析表格或显示文本)
func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool, lineHt float64) {
// 尝试解析markdown表格
tableData := pb.tableParser.ParseMarkdownTable(content)
// 检查内容是否包含表格标记(|符号)
hasTableMarkers := strings.Contains(content, "|")
// 如果解析出了表格数据(即使验证失败),或者内容包含表格标记,都尝试渲染表格
// 放宽条件:支持只有表头的表格(单行表格)
if len(tableData) >= 1 && hasTableMarkers {
// 如果表格有效,或者至少有表头,都尝试渲染
if pb.tableParser.IsValidTable(tableData) {
// 如果是有效的表格,先检查表格前后是否有说明文字
// 提取表格前后的文本(用于显示说明)
lines := strings.Split(content, "\n")
var beforeTable []string
var afterTable []string
inTable := false
tableStartLine := -1
tableEndLine := -1
// 找到表格的起始和结束行
usePipeDelimiter := false
for _, line := range lines {
if strings.Contains(strings.TrimSpace(line), "|") {
usePipeDelimiter = true
break
}
}
for i, line := range lines {
trimmedLine := strings.TrimSpace(line)
if usePipeDelimiter && strings.Contains(trimmedLine, "|") {
if !inTable {
tableStartLine = i
inTable = true
}
tableEndLine = i
} else if inTable && usePipeDelimiter && !strings.Contains(trimmedLine, "|") {
// 表格可能结束了
if strings.HasPrefix(trimmedLine, "```") {
tableEndLine = i - 1
break
}
}
}
// 提取表格前的文本
if tableStartLine > 0 {
beforeTable = lines[0:tableStartLine]
}
// 提取表格后的文本
if tableEndLine >= 0 && tableEndLine < len(lines)-1 {
afterTable = lines[tableEndLine+1:]
}
// 显示表格前的说明文字
if len(beforeTable) > 0 {
beforeText := strings.Join(beforeTable, "\n")
beforeText = pb.textProcessor.StripHTML(beforeText)
beforeText = pb.textProcessor.CleanText(beforeText)
if strings.TrimSpace(beforeText) != "" {
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, beforeText, "", "L", false)
pdf.Ln(3)
}
}
// 渲染表格
pb.tableRenderer.RenderTable(pdf, tableData)
// 显示表格后的说明文字
if len(afterTable) > 0 {
afterText := strings.Join(afterTable, "\n")
afterText = pb.textProcessor.StripHTML(afterText)
afterText = pb.textProcessor.CleanText(afterText)
if strings.TrimSpace(afterText) != "" {
pdf.Ln(3)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false)
}
}
}
} else {
// 如果不是有效表格显示为文本完整显示markdown内容
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
text := pb.textProcessor.StripHTML(content)
text = pb.textProcessor.CleanText(text) // 清理无效字符,保留中文
// 如果文本不为空,显示它
if strings.TrimSpace(text) != "" {
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// renderTextWithTitles 渲染包含markdown标题的文本
func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chineseFontAvailable bool, lineHt float64) {
lines := strings.Split(text, "\n")
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
// 检查是否是标题行
if strings.HasPrefix(trimmedLine, "#") {
// 计算标题级别
level := 0
for _, r := range trimmedLine {
if r == '#' {
level++
} else {
break
}
}
// 提取标题文本(移除#号)
titleText := strings.TrimSpace(trimmedLine[level:])
if titleText == "" {
continue
}
// 根据级别设置字体大小
fontSize := 14.0 - float64(level-2)*2
if fontSize < 10 {
fontSize = 10
}
if fontSize > 16 {
fontSize = 16
}
// 渲染标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", fontSize)
_, titleLineHt := pdf.GetFontSize()
pdf.CellFormat(0, titleLineHt*1.2, titleText, "", 1, "L", false, 0, "")
pdf.Ln(2)
} else if strings.TrimSpace(line) != "" {
// 普通文本行只去除HTML标签保留markdown格式
cleanText := pb.textProcessor.StripHTML(line)
cleanText = pb.textProcessor.CleanTextPreservingMarkdown(cleanText)
if strings.TrimSpace(cleanText) != "" {
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, cleanText, "", "L", false)
}
} else {
// 空行,添加间距
pdf.Ln(2)
}
}
}
// addHeader 添加页眉logo和文字
func (pb *PageBuilder) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
pdf.SetY(5)
// 绘制logo如果存在
if pb.logoPath != "" {
if _, err := os.Stat(pb.logoPath); err == nil {
// gofpdf的ImageOptions方法调整位置和大小左边距是15mm
pdf.ImageOptions(pb.logoPath, 15, 5, 15, 15, false, gofpdf.ImageOptions{}, 0, "")
pb.logger.Info("已添加logo", zap.String("path", pb.logoPath))
} else {
pb.logger.Warn("logo文件不存在", zap.String("path", pb.logoPath), zap.Error(err))
}
} else {
pb.logger.Warn("logo路径为空")
}
// 绘制"天远数据"文字(使用中文字体如果可用)
pdf.SetXY(33, 8)
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, 10, "天远数据", "", 0, "L", false, 0, "")
// 绘制下横线优化位置左边距是15mm
pdf.Line(15, 22, 75, 22)
}
// addWatermark 添加水印从左边开始向上倾斜45度考虑可用区域
func (pb *PageBuilder) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
// 如果中文字体不可用,跳过水印(避免显示乱码)
if !chineseFontAvailable {
return
}
// 保存当前图形状态
pdf.TransformBegin()
defer pdf.TransformEnd()
// 获取页面尺寸和边距
_, pageHeight := pdf.GetPageSize()
leftMargin, topMargin, _, bottomMargin := pdf.GetMargins()
// 计算实际可用区域高度
usableHeight := pageHeight - topMargin - bottomMargin
// 设置水印样式(使用水印字体,非黑体)
fontSize := 45.0
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
// 设置灰色和透明度(加深水印,使其更明显)
pdf.SetTextColor(180, 180, 180) // 深一点的灰色
pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显
// 计算文字宽度
textWidth := pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
// 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
// 从左边开始,计算起始位置
// 起始X左边距
// 起始Y考虑水印文字长度和旋转后需要的空间
startX := leftMargin
startY := topMargin + textWidth*0.5 // 为旋转留出空间
// 移动到起始位置
pdf.TransformTranslate(startX, startY)
// 向上倾斜45度顺时针旋转45度即-45度或逆时针315度
pdf.TransformRotate(-45, 0, 0)
// 检查文字是否会超出可用区域(旋转后的对角线长度)
rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize)
if rotatedDiagonal > usableHeight*0.8 {
// 如果太大,缩小字体
fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal
pb.fontManager.SetWatermarkFont(pdf, "", fontSize)
textWidth = pdf.GetStringWidth(pb.watermarkText)
if textWidth == 0 {
textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0
}
}
// 从左边开始绘制水印文字
pdf.SetXY(0, 0)
pdf.CellFormat(textWidth, fontSize, pb.watermarkText, "", 0, "L", false, 0, "")
// 恢复透明度和颜色
pdf.SetAlpha(1.0, "Normal")
pdf.SetTextColor(0, 0, 0) // 恢复为黑色
}
// getContentPreview 获取内容预览(用于日志记录)
func (pb *PageBuilder) getContentPreview(content string, maxLen int) string {
content = strings.TrimSpace(content)
if len(content) <= maxLen {
return content
}
return content[:maxLen] + "..."
}

View File

@@ -0,0 +1,242 @@
package pdf
import (
"fmt"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
)
// GetDocumentationDir 获取接口文档文件夹路径
// 会在当前目录及其父目录中查找"接口文档"文件夹
func GetDocumentationDir() (string, error) {
// 获取当前工作目录
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("获取工作目录失败: %w", err)
}
// 搜索策略:从当前目录开始,向上查找"接口文档"文件夹
currentDir := wd
maxDepth := 10 // 增加搜索深度,确保能找到
var checkedDirs []string
for i := 0; i < maxDepth; i++ {
docDir := filepath.Join(currentDir, "接口文档")
checkedDirs = append(checkedDirs, docDir)
if info, err := os.Stat(docDir); err == nil && info.IsDir() {
absPath, err := filepath.Abs(docDir)
if err != nil {
return "", fmt.Errorf("获取绝对路径失败: %w", err)
}
return absPath, nil
}
// 尝试父目录
parentDir := filepath.Dir(currentDir)
if parentDir == currentDir {
break // 已到达根目录
}
currentDir = parentDir
}
return "", fmt.Errorf("未找到接口文档文件夹。已检查的路径: %v当前工作目录: %s", checkedDirs, wd)
}
// PDFFinder PDF文件查找服务
type PDFFinder struct {
documentationDir string
logger *zap.Logger
}
// NewPDFFinder 创建PDF查找服务
func NewPDFFinder(documentationDir string, logger *zap.Logger) *PDFFinder {
return &PDFFinder{
documentationDir: documentationDir,
logger: logger,
}
}
// FindPDFByProductCode 根据产品代码查找PDF文件
// 会在接口文档文件夹中递归搜索匹配的PDF文件
// 文件名格式应为: *_{产品代码}.pdf
func (f *PDFFinder) FindPDFByProductCode(productCode string) (string, error) {
if productCode == "" {
return "", fmt.Errorf("产品代码不能为空")
}
// 构建搜索模式:文件名以 _{产品代码}.pdf 结尾
searchPattern := fmt.Sprintf("*_%s.pdf", productCode)
f.logger.Info("开始搜索PDF文件",
zap.String("product_code", productCode),
zap.String("search_pattern", searchPattern),
zap.String("documentation_dir", f.documentationDir),
)
// 验证接口文档文件夹是否存在
if info, err := os.Stat(f.documentationDir); err != nil || !info.IsDir() {
f.logger.Error("接口文档文件夹不存在或无法访问",
zap.String("documentation_dir", f.documentationDir),
zap.Error(err),
)
return "", fmt.Errorf("接口文档文件夹不存在或无法访问: %s", f.documentationDir)
}
var foundPath string
var checkedFiles []string
err := filepath.Walk(f.documentationDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
f.logger.Debug("访问文件/目录时出错,跳过",
zap.String("path", path),
zap.Error(err),
)
return nil // 忽略访问错误,继续搜索
}
// 只处理PDF文件
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".pdf") {
return nil
}
// 获取文件名(不包含路径)
fileName := info.Name()
checkedFiles = append(checkedFiles, fileName)
// 转换为小写进行大小写不敏感匹配
fileNameLower := strings.ToLower(fileName)
productCodeLower := strings.ToLower(productCode)
// 方式1: 检查文件名是否以 _{产品代码}.pdf 结尾(大小写不敏感)
suffixPattern := fmt.Sprintf("_%s.pdf", productCodeLower)
if strings.HasSuffix(fileNameLower, suffixPattern) {
foundPath = path
f.logger.Info("找到匹配的PDF文件后缀匹配",
zap.String("product_code", productCode),
zap.String("file_name", fileName),
zap.String("file_path", path),
)
return filepath.SkipAll // 找到后停止搜索
}
// 方式2: 使用filepath.Match进行模式匹配作为备用
matched, matchErr := filepath.Match(searchPattern, fileName)
if matchErr == nil && matched {
foundPath = path
f.logger.Info("找到匹配的PDF文件模式匹配",
zap.String("product_code", productCode),
zap.String("file_name", fileName),
zap.String("file_path", path),
)
return filepath.SkipAll // 找到后停止搜索
}
return nil
})
if err != nil {
f.logger.Error("搜索PDF文件时出错",
zap.String("product_code", productCode),
zap.Error(err),
)
return "", fmt.Errorf("搜索PDF文件时出错: %w", err)
}
if foundPath == "" {
// 查找包含产品编码前缀的类似文件,用于调试
var similarFiles []string
if len(productCode) >= 4 {
productCodePrefix := productCode[:4] // 取前4个字符作为前缀如JRZQ
for _, fileName := range checkedFiles {
fileNameLower := strings.ToLower(fileName)
if strings.Contains(fileNameLower, strings.ToLower(productCodePrefix)) {
similarFiles = append(similarFiles, fileName)
if len(similarFiles) >= 5 {
break // 只显示最多5个类似文件
}
}
}
}
f.logger.Warn("未找到匹配的PDF文件",
zap.String("product_code", productCode),
zap.String("search_pattern", searchPattern),
zap.String("documentation_dir", f.documentationDir),
zap.Int("checked_files_count", len(checkedFiles)),
zap.Strings("similar_files_with_same_prefix", similarFiles),
zap.Strings("sample_files", func() []string {
if len(checkedFiles) > 10 {
return checkedFiles[:10]
}
return checkedFiles
}()),
)
return "", fmt.Errorf("未找到产品代码为 %s 的PDF文档", productCode)
}
// 转换为绝对路径
absPath, err := filepath.Abs(foundPath)
if err != nil {
f.logger.Error("获取文件绝对路径失败",
zap.String("file_path", foundPath),
zap.Error(err),
)
return "", fmt.Errorf("获取文件绝对路径失败: %w", err)
}
f.logger.Info("成功找到PDF文件",
zap.String("product_code", productCode),
zap.String("file_path", absPath),
)
return absPath, nil
}
// FindPDFByProductCodeWithFallback 根据产品代码查找PDF文件支持多个可能的命名格式
func (f *PDFFinder) FindPDFByProductCodeWithFallback(productCode string) (string, error) {
// 尝试多种可能的文件命名格式
patterns := []string{
fmt.Sprintf("*_%s.pdf", productCode), // 标准格式: 产品名称_{代码}.pdf
fmt.Sprintf("%s*.pdf", productCode), // 以代码开头
fmt.Sprintf("*%s*.pdf", productCode), // 包含代码
}
var foundPath string
for _, pattern := range patterns {
err := filepath.Walk(f.documentationDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".pdf") {
return nil
}
fileName := info.Name()
if matched, _ := filepath.Match(pattern, fileName); matched {
foundPath = path
return filepath.SkipAll
}
return nil
})
if err == nil && foundPath != "" {
break
}
}
if foundPath == "" {
return "", fmt.Errorf("未找到产品代码为 %s 的PDF文档", productCode)
}
absPath, err := filepath.Abs(foundPath)
if err != nil {
return "", fmt.Errorf("获取文件绝对路径失败: %w", err)
}
return absPath, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,185 @@
package pdf
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"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 {
// 初始化各个模块
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文件
func (g *PDFGeneratorRefactored) findLogo() {
// 获取当前文件所在目录
_, filename, _, _ := runtime.Caller(0)
baseDir := filepath.Dir(filename)
logoPaths := []string{
filepath.Join(baseDir, "天远数据.png"), // 相对当前文件
"天远数据.png", // 当前目录
filepath.Join(baseDir, "..", "天远数据.png"), // 上一级目录
filepath.Join(baseDir, "..", "..", "天远数据.png"), // 上两级目录
}
for _, logoPath := range logoPaths {
if _, err := os.Stat(logoPath); err == nil {
absPath, err := filepath.Abs(logoPath)
if err == nil {
g.logoPath = absPath
g.logger.Info("找到logo文件", zap.String("logo_path", absPath))
return
}
}
}
g.logger.Warn("未找到logo文件", zap.Strings("尝试的路径", logoPaths))
}
// 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)
}
// GenerateProductPDFFromEntity 从entity类型生成PDF推荐使用
func (g *PDFGeneratorRefactored) GenerateProductPDFFromEntity(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation) ([]byte, error) {
return g.generatePDF(product, doc)
}
// generatePDF 内部PDF生成方法
func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *entities.ProductDocumentation) (result []byte, err error) {
defer func() {
if r := recover(); r != nil {
g.logger.Error("PDF生成过程中发生panic",
zap.String("product_id", product.ID),
zap.String("product_name", product.Name),
zap.Any("panic_value", r),
)
// 将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
}
}()
g.logger.Info("开始生成PDF",
zap.String("product_id", product.ID),
zap.String("product_name", product.Name),
zap.Bool("has_doc", doc != nil),
)
// 创建PDF文档 (A4大小gofpdf v2 默认支持UTF-8)
pdf := gofpdf.New("P", "mm", "A4", "")
// 优化边距,减少空白
pdf.SetMargins(15, 25, 15)
// 加载黑体字体(用于所有内容,除了水印)
chineseFontAvailable := g.fontManager.LoadChineseFont(pdf)
// 加载水印字体(使用宋体或其他非黑体字体)
g.fontManager.LoadWatermarkFont(pdf)
// 设置文档信息
pdf.SetTitle("Product Documentation", true)
pdf.SetAuthor("TYAPI Server", true)
pdf.SetCreator("TYAPI Server", true)
g.logger.Info("PDF文档基本信息设置完成")
// 创建页面构建器
pageBuilder := NewPageBuilder(g.logger, g.fontManager, g.textProcessor, g.markdownProc, g.tableParser, g.tableRenderer, g.jsonProcessor, g.logoPath, g.watermarkText)
// 添加第一页(产品信息)
g.logger.Info("开始添加第一页")
pageBuilder.AddFirstPage(pdf, product, doc, chineseFontAvailable)
g.logger.Info("第一页添加完成")
// 如果有关联的文档,添加接口文档页面
if doc != nil {
g.logger.Info("开始添加文档页面")
pageBuilder.AddDocumentationPages(pdf, doc, chineseFontAvailable)
g.logger.Info("文档页面添加完成")
} else {
g.logger.Info("没有文档信息,跳过文档页面")
}
// 生成PDF字节流
g.logger.Info("开始生成PDF字节流")
var buf bytes.Buffer
err = pdf.Output(&buf)
if err != nil {
g.logger.Error("PDF输出失败", zap.Error(err))
return nil, fmt.Errorf("生成PDF失败: %w", err)
}
pdfBytes := buf.Bytes()
g.logger.Info("PDF生成成功",
zap.String("product_id", product.ID),
zap.Int("pdf_size", len(pdfBytes)),
)
return pdfBytes, nil
}

View File

@@ -0,0 +1,208 @@
package pdf
import (
"context"
"fmt"
"strings"
"tyapi-server/internal/domains/product/entities"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// TableBlock 表格块(用于向后兼容)
type TableBlock struct {
BeforeText string
TableData [][]string
AfterText string
}
// TableParser 表格解析器
// 从数据库读取数据并转换为表格格式
type TableParser struct {
logger *zap.Logger
fontManager *FontManager
databaseReader *DatabaseTableReader
databaseRenderer *DatabaseTableRenderer
}
// NewTableParser 创建表格解析器
func NewTableParser(logger *zap.Logger, fontManager *FontManager) *TableParser {
reader := NewDatabaseTableReader(logger)
renderer := NewDatabaseTableRenderer(logger, fontManager)
return &TableParser{
logger: logger,
fontManager: fontManager,
databaseReader: reader,
databaseRenderer: renderer,
}
}
// ParseAndRenderTable 从产品文档中解析并渲染表格(支持多个表格,带标题)
func (tp *TableParser) ParseAndRenderTable(ctx context.Context, pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, fieldType string) error {
// 从数据库读取表格数据(支持多个表格)
tableData, err := tp.databaseReader.ReadTableFromDocumentation(ctx, doc, fieldType)
if err != nil {
// 如果内容为空,不渲染,也不报错(静默跳过)
if strings.Contains(err.Error(), "内容为空") {
tp.logger.Debug("表格内容为空,跳过渲染", zap.String("field_type", fieldType))
return nil
}
return fmt.Errorf("读取表格数据失败: %w", err)
}
// 检查表格数据是否有效
if tableData == nil || len(tableData.Headers) == 0 {
tp.logger.Warn("表格数据无效,跳过渲染",
zap.String("field_type", fieldType),
zap.Bool("is_nil", tableData == nil))
return nil
}
tp.logger.Info("准备渲染表格",
zap.String("field_type", fieldType),
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Float64("pdf_current_y", pdf.GetY()))
// 渲染表格到PDF
if err := tp.databaseRenderer.RenderTable(pdf, tableData); err != nil {
tp.logger.Error("渲染表格失败",
zap.String("field_type", fieldType),
zap.Error(err))
return fmt.Errorf("渲染表格失败: %w", err)
}
tp.logger.Info("表格渲染成功",
zap.String("field_type", fieldType),
zap.Float64("pdf_final_y", pdf.GetY()))
return nil
}
// ParseAndRenderTablesWithTitles 从产品文档中解析并渲染多个表格(带标题)
func (tp *TableParser) ParseAndRenderTablesWithTitles(ctx context.Context, pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, fieldType string) error {
var content string
switch fieldType {
case "request_params":
content = doc.RequestParams
case "response_fields":
content = doc.ResponseFields
case "response_example":
content = doc.ResponseExample
case "error_codes":
content = doc.ErrorCodes
default:
return fmt.Errorf("未知的字段类型: %s", fieldType)
}
if strings.TrimSpace(content) == "" {
return nil
}
// 解析多个表格(带标题)
tablesWithTitles, err := tp.databaseReader.parseMarkdownTablesWithTitles(content)
if err != nil {
tp.logger.Warn("解析表格失败,回退到单个表格", zap.Error(err))
// 回退到单个表格渲染
return tp.ParseAndRenderTable(ctx, pdf, doc, fieldType)
}
if len(tablesWithTitles) == 0 {
return nil
}
// 分别渲染每个表格,并在表格前显示标题
_, lineHt := pdf.GetFontSize()
for i, twt := range tablesWithTitles {
if twt.Table == nil || len(twt.Table.Headers) == 0 {
continue
}
// 如果不是第一个表格,添加间距
if i > 0 {
pdf.Ln(5)
}
// 如果有标题,显示标题
if strings.TrimSpace(twt.Title) != "" {
pdf.SetTextColor(0, 0, 0)
tp.fontManager.SetFont(pdf, "B", 12)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, twt.Title, "", 1, "L", false, 0, "")
pdf.Ln(2)
}
// 渲染表格
if err := tp.databaseRenderer.RenderTable(pdf, twt.Table); err != nil {
tp.logger.Warn("渲染表格失败", zap.Error(err), zap.String("title", twt.Title))
continue
}
}
return nil
}
// ParseTableData 仅解析表格数据,不渲染
func (tp *TableParser) ParseTableData(ctx context.Context, doc *entities.ProductDocumentation, fieldType string) (*TableData, error) {
return tp.databaseReader.ReadTableFromDocumentation(ctx, doc, fieldType)
}
// ParseMarkdownTable 解析Markdown表格兼容方法
func (tp *TableParser) ParseMarkdownTable(text string) [][]string {
// 使用数据库读取器的markdown解析功能
tableData, err := tp.databaseReader.parseMarkdownTable(text)
if err != nil {
tp.logger.Warn("解析markdown表格失败", zap.Error(err))
return nil
}
// 转换为旧格式 [][]string
result := make([][]string, 0, len(tableData.Rows)+1)
result = append(result, tableData.Headers)
result = append(result, tableData.Rows...)
return result
}
// ExtractAllTables 提取所有表格块(兼容方法)
func (tp *TableParser) ExtractAllTables(content string) []TableBlock {
// 使用数据库读取器解析markdown表格
tableData, err := tp.databaseReader.parseMarkdownTable(content)
if err != nil {
return []TableBlock{}
}
// 转换为TableBlock格式
if len(tableData.Headers) > 0 {
rows := make([][]string, 0, len(tableData.Rows)+1)
rows = append(rows, tableData.Headers)
rows = append(rows, tableData.Rows...)
return []TableBlock{
{
BeforeText: "",
TableData: rows,
AfterText: "",
},
}
}
return []TableBlock{}
}
// IsValidTable 验证表格是否有效(兼容方法)
func (tp *TableParser) IsValidTable(tableData [][]string) bool {
if len(tableData) == 0 {
return false
}
if len(tableData[0]) == 0 {
return false
}
// 检查表头是否有有效内容
for _, cell := range tableData[0] {
if strings.TrimSpace(cell) != "" {
return true
}
}
return false
}

View File

@@ -0,0 +1,340 @@
package pdf
import (
"math"
"strings"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
)
// min 返回两个整数中的较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
// TableRenderer 表格渲染器
type TableRenderer struct {
logger *zap.Logger
fontManager *FontManager
textProcessor *TextProcessor
}
// NewTableRenderer 创建表格渲染器
func NewTableRenderer(logger *zap.Logger, fontManager *FontManager, textProcessor *TextProcessor) *TableRenderer {
return &TableRenderer{
logger: logger,
fontManager: fontManager,
textProcessor: textProcessor,
}
}
// RenderTable 渲染表格
func (tr *TableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData [][]string) {
if len(tableData) == 0 {
return
}
// 支持只有表头的表格(单行表格)
if len(tableData) == 1 {
tr.logger.Info("渲染单行表格(只有表头)", zap.Int("cols", len(tableData[0])))
}
_, lineHt := pdf.GetFontSize()
tr.fontManager.SetFont(pdf, "", 9)
// 计算列宽(根据内容动态计算,确保所有列都能显示)
pageWidth, _ := pdf.GetPageSize()
availableWidth := pageWidth - 30 // 减去左右边距15mm * 2
numCols := len(tableData[0])
// 计算每列的最小宽度(根据内容)
colMinWidths := make([]float64, numCols)
tr.fontManager.SetFont(pdf, "", 9)
// 遍历所有行,计算每列的最大内容宽度
for i, row := range tableData {
for j := 0; j < numCols && j < len(row); j++ {
cell := tr.textProcessor.CleanTextPreservingMarkdown(row[j])
// 计算文本宽度
var textWidth float64
if tr.fontManager.IsChineseFontAvailable() {
textWidth = pdf.GetStringWidth(cell)
} else {
// 估算宽度
charCount := len([]rune(cell))
textWidth = float64(charCount) * 3.0 // 估算每个字符3mm
}
// 加上边距左右各4mm进一步增加边距让内容更舒适
cellWidth := textWidth + 8
// 最小宽度(表头可能需要更多空间)
if i == 0 {
cellWidth = math.Max(cellWidth, 30) // 表头最小30mm从25mm增加
} else {
cellWidth = math.Max(cellWidth, 25) // 数据行最小25mm从20mm增加
}
if cellWidth > colMinWidths[j] {
colMinWidths[j] = cellWidth
}
}
}
// 确保所有列的最小宽度一致(避免宽度差异过大)
minColWidth := 25.0
for i := range colMinWidths {
if colMinWidths[i] < minColWidth {
colMinWidths[i] = minColWidth
}
}
// 计算总的最小宽度
totalMinWidth := 0.0
for _, w := range colMinWidths {
totalMinWidth += w
}
// 计算每列的实际宽度
colWidths := make([]float64, numCols)
if totalMinWidth <= availableWidth {
// 如果总宽度不超过可用宽度,使用计算的最小宽度,剩余空间平均分配
extraWidth := availableWidth - totalMinWidth
extraPerCol := extraWidth / float64(numCols)
for i := range colWidths {
colWidths[i] = colMinWidths[i] + extraPerCol
}
} else {
// 如果总宽度超过可用宽度,按比例缩放
scale := availableWidth / totalMinWidth
for i := range colWidths {
colWidths[i] = colMinWidths[i] * scale
// 确保最小宽度
if colWidths[i] < 10 {
colWidths[i] = 10
}
}
// 重新调整以确保总宽度不超过可用宽度
actualTotal := 0.0
for _, w := range colWidths {
actualTotal += w
}
if actualTotal > availableWidth {
scale = availableWidth / actualTotal
for i := range colWidths {
colWidths[i] *= scale
}
}
}
// 绘制表头
header := tableData[0]
pdf.SetFillColor(74, 144, 226) // 蓝色背景
pdf.SetTextColor(0, 0, 0) // 黑色文字
tr.fontManager.SetFont(pdf, "B", 9)
// 清理表头文本只清理无效字符保留markdown格式
for i, cell := range header {
header[i] = tr.textProcessor.CleanTextPreservingMarkdown(cell)
}
// 先计算表头的最大高度
headerStartY := pdf.GetY()
maxHeaderHeight := lineHt * 2.5 // 进一步增加表头高度从2.0倍增加到2.5倍
for i, cell := range header {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
headerLines := pdf.SplitText(cell, colW-6) // 增加边距从4增加到6
headerHeight := float64(len(headerLines)) * lineHt * 2.5 // 进一步增加表头行高
if headerHeight < lineHt*2.5 {
headerHeight = lineHt * 2.5
}
if headerHeight > maxHeaderHeight {
maxHeaderHeight = headerHeight
}
}
// 绘制表头(使用动态计算的列宽)
currentX := 15.0
for i, cell := range header {
if i >= len(colWidths) {
break
}
colW := colWidths[i]
// 绘制表头背景
pdf.Rect(currentX, headerStartY, colW, maxHeaderHeight, "FD")
// 绘制表头文本不使用ClipRect直接使用MultiCell它会自动处理换行
// 确保文本不为空
if strings.TrimSpace(cell) != "" {
// 增加内边距从2增加到3
pdf.SetXY(currentX+3, headerStartY+3)
// 确保表头文字为黑色
pdf.SetTextColor(0, 0, 0)
// 进一步增加表头行高从2.0倍增加到2.5倍
pdf.MultiCell(colW-6, lineHt*2.5, cell, "", "C", false)
} else {
// 如果单元格为空,记录警告
tr.logger.Warn("表头单元格为空", zap.Int("col_index", i), zap.String("header", strings.Join(header, ",")))
}
// 重置Y坐标确保下一列从同一行开始
pdf.SetXY(currentX+colW, headerStartY)
currentX += colW
}
// 移动到下一行(使用计算好的最大表头高度)
pdf.SetXY(15.0, headerStartY+maxHeaderHeight)
// 绘制数据行
pdf.SetFillColor(245, 245, 220) // 米色背景
pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰
tr.fontManager.SetFont(pdf, "", 9)
_, lineHt = pdf.GetFontSize()
for i := 1; i < len(tableData); i++ {
row := tableData[i]
fill := (i % 2) == 0 // 交替填充
// 计算这一行的起始Y坐标
startY := pdf.GetY()
// 设置字体以计算文本宽度和高度
tr.fontManager.SetFont(pdf, "", 9)
_, cellLineHt := pdf.GetFontSize()
// 先遍历一次,计算每列需要的最大高度
maxCellHeight := cellLineHt * 2.5 // 进一步增加最小高度从2.0倍增加到2.5倍
for j, cell := range row {
if j >= numCols || j >= len(colWidths) {
break
}
// 清理单元格文本只清理无效字符保留markdown格式
cleanCell := tr.textProcessor.CleanTextPreservingMarkdown(cell)
cellWidth := colWidths[j] - 6 // 使用动态计算的列宽减去左右边距从4增加到6
// 使用SplitText准确计算需要的行数
var lines []string
if tr.fontManager.IsChineseFontAvailable() {
// 对于中文字体使用SplitText
lines = pdf.SplitText(cleanCell, cellWidth)
} else {
// 对于Arial字体如果包含中文可能失败使用估算
charCount := len([]rune(cleanCell))
if charCount == 0 {
lines = []string{""}
} else {
// 中文字符宽度大约是英文字符的2倍
estimatedWidth := 0.0
for _, r := range cleanCell {
if r >= 0x4E00 && r <= 0x9FFF {
estimatedWidth += 6.0 // 中文字符宽度
} else {
estimatedWidth += 3.0 // 英文字符宽度
}
}
estimatedLines := math.Ceil(estimatedWidth / cellWidth)
if estimatedLines < 1 {
estimatedLines = 1
}
lines = make([]string, int(estimatedLines))
// 简单分割文本
charsPerLine := int(math.Ceil(float64(charCount) / estimatedLines))
for k := 0; k < int(estimatedLines); k++ {
start := k * charsPerLine
end := start + charsPerLine
if end > charCount {
end = charCount
}
if start < charCount {
runes := []rune(cleanCell)
if start < len(runes) {
if end > len(runes) {
end = len(runes)
}
lines[k] = string(runes[start:end])
}
}
}
}
}
// 计算单元格高度
numLines := float64(len(lines))
if numLines == 0 {
numLines = 1
}
cellHeight := numLines * cellLineHt * 2.5 // 进一步增加行高从2.0倍增加到2.5倍
if cellHeight < cellLineHt*2.5 {
cellHeight = cellLineHt * 2.5
}
// 为多行内容添加额外间距
if len(lines) > 1 {
cellHeight += cellLineHt * 0.5 // 多行时额外增加0.5倍行高
}
if cellHeight > maxCellHeight {
maxCellHeight = cellHeight
}
}
// 绘制这一行的所有单元格左边距是15mm
currentX := 15.0
for j, cell := range row {
if j >= numCols || j >= len(colWidths) {
break
}
colW := colWidths[j] // 使用动态计算的列宽
// 绘制单元格边框和背景
if fill {
pdf.SetFillColor(250, 250, 235) // 稍深的米色
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.Rect(currentX, startY, colW, maxCellHeight, "FD")
// 绘制文本使用MultiCell支持换行并限制在单元格内
pdf.SetTextColor(0, 0, 0) // 确保深黑色
// 只清理无效字符保留markdown格式
cleanCell := tr.textProcessor.CleanTextPreservingMarkdown(cell)
// 确保文本不为空才渲染
if strings.TrimSpace(cleanCell) != "" {
// 设置到单元格内增加边距从2增加到3让内容更舒适
pdf.SetXY(currentX+3, startY+3)
// 使用MultiCell自动换行左对齐
tr.fontManager.SetFont(pdf, "", 9)
// 再次确保颜色为深黑色(防止被其他设置覆盖)
pdf.SetTextColor(0, 0, 0)
// 设置字体后再次确保颜色
pdf.SetTextColor(0, 0, 0)
// 使用MultiCell会自动处理换行使用统一的行高
// MultiCell会自动处理换行不需要ClipRect
// 进一步增加行高从2.0倍增加到2.5倍,让内容更舒适
pdf.MultiCell(colW-6, cellLineHt*2.5, cleanCell, "", "L", false)
} else if strings.TrimSpace(cell) != "" {
// 如果原始单元格不为空但清理后为空,记录警告
tr.logger.Warn("单元格文本清理后为空",
zap.Int("row", i),
zap.Int("col", j),
zap.String("original", cell[:min(len(cell), 50)]))
}
// MultiCell后Y坐标已经改变必须重置以便下一列从同一行开始
// 这是关键确保所有列都从同一个startY开始
pdf.SetXY(currentX+colW, startY)
// 移动到下一列
currentX += colW
}
// 移动到下一行的起始位置(使用计算好的最大高度)
pdf.SetXY(15.0, startY+maxCellHeight)
}
}

View File

@@ -0,0 +1,131 @@
package pdf
import (
"html"
"regexp"
"strings"
)
// TextProcessor 文本处理器
type TextProcessor struct{}
// NewTextProcessor 创建文本处理器
func NewTextProcessor() *TextProcessor {
return &TextProcessor{}
}
// CleanText 清理文本中的无效字符和乱码
func (tp *TextProcessor) CleanText(text string) string {
// 先解码HTML实体
text = html.UnescapeString(text)
// 移除或替换无效的UTF-8字符
var result strings.Builder
for _, r := range text {
// 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等
if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
(r >= 0x3400 && r <= 0x4DBF) || // 扩展A
(r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
(r >= 'A' && r <= 'Z') || // 大写字母
(r >= 'a' && r <= 'z') || // 小写字母
(r >= '0' && r <= '9') || // 数字
(r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符
(r == '\n' || r == '\r' || r == '\t') || // 换行和制表符
(r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
(r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
result.WriteRune(r)
} else if r > 0x007F && r < 0x00A0 {
// 无效的控制字符,替换为空格
result.WriteRune(' ')
}
// 其他字符(如乱码)直接跳过
}
return result.String()
}
// CleanTextPreservingMarkdown 清理文本但保留markdown语法字符
func (tp *TextProcessor) CleanTextPreservingMarkdown(text string) string {
// 先解码HTML实体
text = html.UnescapeString(text)
// 移除或替换无效的UTF-8字符但保留markdown语法字符
var result strings.Builder
for _, r := range text {
// 保留:中文字符、英文字母、数字、常见标点符号、空格、换行符等
// 特别保留markdown语法字符* _ ` [ ] ( ) # - | : !
if (r >= 0x4E00 && r <= 0x9FFF) || // 中文字符范围
(r >= 0x3400 && r <= 0x4DBF) || // 扩展A
(r >= 0x20000 && r <= 0x2A6DF) || // 扩展B
(r >= 'A' && r <= 'Z') || // 大写字母
(r >= 'a' && r <= 'z') || // 小写字母
(r >= '0' && r <= '9') || // 数字
(r >= 0x0020 && r <= 0x007E) || // ASCII可打印字符包括markdown语法字符
(r == '\n' || r == '\r' || r == '\t') || // 换行和制表符
(r >= 0x3000 && r <= 0x303F) || // CJK符号和标点
(r >= 0xFF00 && r <= 0xFFEF) { // 全角字符
result.WriteRune(r)
} else if r > 0x007F && r < 0x00A0 {
// 无效的控制字符,替换为空格
result.WriteRune(' ')
}
// 其他字符(如乱码)直接跳过
}
return result.String()
}
// StripHTML 去除HTML标签不转换换行直接移除标签
func (tp *TextProcessor) StripHTML(text string) string {
// 解码HTML实体
text = html.UnescapeString(text)
// 直接移除所有HTML标签不进行换行转换
re := regexp.MustCompile(`<[^>]+>`)
text = re.ReplaceAllString(text, "")
// 清理多余空白
text = strings.TrimSpace(text)
return text
}
// RemoveMarkdownSyntax 移除markdown语法保留纯文本
func (tp *TextProcessor) RemoveMarkdownSyntax(text string) string {
// 移除粗体标记 **text** 或 __text__
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1")
// 移除斜体标记 *text* 或 _text_
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1")
// 移除代码标记 `code`
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
// 移除链接标记 [text](url) -> text
text = regexp.MustCompile(`\[([^\]]+)\]\([^\)]+\)`).ReplaceAllString(text, "$1")
// 移除图片标记 ![alt](url) -> alt
text = regexp.MustCompile(`!\[([^\]]*)\]\([^\)]+\)`).ReplaceAllString(text, "$1")
// 移除标题标记 # text -> text
text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
return text
}
// RemoveNonASCII 移除非ASCII字符保留ASCII字符和常见符号
func (tp *TextProcessor) RemoveNonASCII(text string) string {
var result strings.Builder
for _, r := range text {
// 保留ASCII字符0-127
if r < 128 {
result.WriteRune(r)
} else {
// 中文字符替换为空格或跳过
result.WriteRune(' ')
}
}
return result.String()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB