This commit is contained in:
1
go.mod
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 // 返回nil,handler中会检查
|
||||||
|
}
|
||||||
|
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处理器
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
// 其他系统错误
|
// 其他系统错误
|
||||||
|
|||||||
@@ -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:"产品"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,11 +92,33 @@ 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 != "" {
|
||||||
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
|
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
|
||||||
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
|
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
|
||||||
}
|
}
|
||||||
return count, query.Count(&count).Error
|
return count, query.Count(&count).Error
|
||||||
@@ -109,7 +133,7 @@ func (r *GormRechargeRecordRepository) Exists(ctx context.Context, id string) (b
|
|||||||
func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.RechargeRecord, error) {
|
func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.RechargeRecord, error) {
|
||||||
var records []entities.RechargeRecord
|
var records []entities.RechargeRecord
|
||||||
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 {
|
||||||
// 特殊处理 user_ids 过滤器
|
// 特殊处理 user_ids 过滤器
|
||||||
@@ -117,17 +141,38 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Search != "" {
|
if options.Search != "" {
|
||||||
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
|
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
|
||||||
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
|
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Sort != "" {
|
if options.Sort != "" {
|
||||||
order := "ASC"
|
order := "ASC"
|
||||||
if options.Order == "desc" || options.Order == "DESC" {
|
if options.Order == "desc" || options.Order == "DESC" {
|
||||||
@@ -137,12 +182,12 @@ func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfa
|
|||||||
} else {
|
} else {
|
||||||
query = query.Order("created_at DESC")
|
query = query.Order("created_at DESC")
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Page > 0 && options.PageSize > 0 {
|
if options.Page > 0 && options.PageSize > 0 {
|
||||||
offset := (options.Page - 1) * options.PageSize
|
offset := (options.Page - 1) * options.PageSize
|
||||||
query = query.Offset(offset).Limit(options.PageSize)
|
query = query.Offset(offset).Limit(options.PageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := query.Find(&records).Error
|
err := query.Find(&records).Error
|
||||||
return records, err
|
return records, err
|
||||||
}
|
}
|
||||||
@@ -209,7 +254,7 @@ func (r *GormRechargeRecordRepository) GetTotalAmountByUserIdAndDateRange(ctx co
|
|||||||
// GetDailyStatsByUserId 获取用户每日充值统计(排除赠送)
|
// GetDailyStatsByUserId 获取用户每日充值统计(排除赠送)
|
||||||
func (r *GormRechargeRecordRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
func (r *GormRechargeRecordRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||||
var results []map[string]interface{}
|
var results []map[string]interface{}
|
||||||
|
|
||||||
// 构建SQL查询 - 使用PostgreSQL语法,使用具体的日期范围
|
// 构建SQL查询 - 使用PostgreSQL语法,使用具体的日期范围
|
||||||
sql := `
|
sql := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -224,19 +269,19 @@ func (r *GormRechargeRecordRepository) GetDailyStatsByUserId(ctx context.Context
|
|||||||
GROUP BY DATE(created_at)
|
GROUP BY DATE(created_at)
|
||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
|
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMonthlyStatsByUserId 获取用户每月充值统计(排除赠送)
|
// GetMonthlyStatsByUserId 获取用户每月充值统计(排除赠送)
|
||||||
func (r *GormRechargeRecordRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
func (r *GormRechargeRecordRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||||
var results []map[string]interface{}
|
var results []map[string]interface{}
|
||||||
|
|
||||||
// 构建SQL查询 - 使用PostgreSQL语法,使用具体的日期范围
|
// 构建SQL查询 - 使用PostgreSQL语法,使用具体的日期范围
|
||||||
sql := `
|
sql := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -251,12 +296,12 @@ func (r *GormRechargeRecordRepository) GetMonthlyStatsByUserId(ctx context.Conte
|
|||||||
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
|
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
|
||||||
ORDER BY month ASC
|
ORDER BY month ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
|
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +328,7 @@ func (r *GormRechargeRecordRepository) GetSystemAmountByDateRange(ctx context.Co
|
|||||||
// GetSystemDailyStats 获取系统每日充值统计(排除赠送)
|
// GetSystemDailyStats 获取系统每日充值统计(排除赠送)
|
||||||
func (r *GormRechargeRecordRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
func (r *GormRechargeRecordRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||||
var results []map[string]interface{}
|
var results []map[string]interface{}
|
||||||
|
|
||||||
sql := `
|
sql := `
|
||||||
SELECT
|
SELECT
|
||||||
DATE(created_at) as date,
|
DATE(created_at) as date,
|
||||||
@@ -296,19 +341,19 @@ func (r *GormRechargeRecordRepository) GetSystemDailyStats(ctx context.Context,
|
|||||||
GROUP BY DATE(created_at)
|
GROUP BY DATE(created_at)
|
||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
|
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemMonthlyStats 获取系统每月充值统计(排除赠送)
|
// GetSystemMonthlyStats 获取系统每月充值统计(排除赠送)
|
||||||
func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
|
||||||
var results []map[string]interface{}
|
var results []map[string]interface{}
|
||||||
|
|
||||||
sql := `
|
sql := `
|
||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(created_at, 'YYYY-MM') as month,
|
TO_CHAR(created_at, 'YYYY-MM') as month,
|
||||||
@@ -321,11 +366,32 @@ func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context
|
|||||||
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
|
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
|
||||||
ORDER BY month ASC
|
ORDER BY month ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
|
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
168
internal/shared/pdf/LOG_VIEWING_GUIDE.md
Normal file
168
internal/shared/pdf/LOG_VIEWING_GUIDE.md
Normal 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日志
|
||||||
|
- 日志文件按日期自动分包,便于管理和查找
|
||||||
|
|
||||||
618
internal/shared/pdf/database_table_reader.go
Normal file
618
internal/shared/pdf/database_table_reader.go
Normal 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, " ", " ")
|
||||||
|
text = strings.ReplaceAll(text, "&", "&")
|
||||||
|
text = strings.ReplaceAll(text, "<", "<")
|
||||||
|
text = strings.ReplaceAll(text, ">", ">")
|
||||||
|
text = strings.ReplaceAll(text, """, "\"")
|
||||||
|
text = strings.ReplaceAll(text, "'", "'")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
535
internal/shared/pdf/database_table_renderer.go
Normal file
535
internal/shared/pdf/database_table_renderer.go
Normal 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
|
||||||
|
}
|
||||||
217
internal/shared/pdf/font_manager.go
Normal file
217
internal/shared/pdf/font_manager.go
Normal 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
|
||||||
|
}
|
||||||
155
internal/shared/pdf/json_processor.go
Normal file
155
internal/shared/pdf/json_processor.go
Normal 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)
|
||||||
|
}
|
||||||
658
internal/shared/pdf/markdown_converter.go
Normal file
658
internal/shared/pdf/markdown_converter.go
Normal 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]
|
||||||
|
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")
|
||||||
|
}
|
||||||
355
internal/shared/pdf/markdown_processor.go
Normal file
355
internal/shared/pdf/markdown_processor.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
733
internal/shared/pdf/page_builder.go
Normal file
733
internal/shared/pdf/page_builder.go
Normal 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] + "..."
|
||||||
|
}
|
||||||
242
internal/shared/pdf/pdf_finder.go
Normal file
242
internal/shared/pdf/pdf_finder.go
Normal 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
|
||||||
|
}
|
||||||
2197
internal/shared/pdf/pdf_generator.go
Normal file
2197
internal/shared/pdf/pdf_generator.go
Normal file
File diff suppressed because it is too large
Load Diff
185
internal/shared/pdf/pdf_generator_refactored.go
Normal file
185
internal/shared/pdf/pdf_generator_refactored.go
Normal 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
|
||||||
|
}
|
||||||
208
internal/shared/pdf/table_parser.go
Normal file
208
internal/shared/pdf/table_parser.go
Normal 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
|
||||||
|
}
|
||||||
340
internal/shared/pdf/table_renderer.go
Normal file
340
internal/shared/pdf/table_renderer.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
131
internal/shared/pdf/text_processor.go
Normal file
131
internal/shared/pdf/text_processor.go
Normal 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
|
||||||
|
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()
|
||||||
|
}
|
||||||
BIN
internal/shared/pdf/天远数据.png
Normal file
BIN
internal/shared/pdf/天远数据.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Reference in New Issue
Block a user