This commit is contained in:
2025-12-04 18:10:14 +08:00
parent 9f669a9c94
commit bfedec249f
6 changed files with 810 additions and 12 deletions

View File

@@ -3,6 +3,8 @@ package container
import (
"context"
"fmt"
"os"
"strconv"
"time"
"go.uber.org/fx"
@@ -999,6 +1001,44 @@ func NewContainer() *Container {
return pdf.NewPDFGenerator(logger)
},
),
// PDF缓存管理器
fx.Provide(
func(logger *zap.Logger) (*pdf.PDFCacheManager, error) {
// 使用默认配置缓存目录在临时目录TTL为24小时最大缓存大小为500MB
cacheDir := "" // 使用默认目录临时目录下的tyapi_pdf_cache
ttl := 24 * time.Hour
maxSize := int64(500 * 1024 * 1024) // 500MB
// 可以通过环境变量覆盖
if envCacheDir := os.Getenv("PDF_CACHE_DIR"); envCacheDir != "" {
cacheDir = envCacheDir
}
if envTTL := os.Getenv("PDF_CACHE_TTL"); envTTL != "" {
if parsedTTL, err := time.ParseDuration(envTTL); err == nil {
ttl = parsedTTL
}
}
if envMaxSize := os.Getenv("PDF_CACHE_MAX_SIZE"); envMaxSize != "" {
if parsedMaxSize, err := strconv.ParseInt(envMaxSize, 10, 64); err == nil {
maxSize = parsedMaxSize
}
}
cacheManager, err := pdf.NewPDFCacheManager(logger, cacheDir, ttl, maxSize)
if err != nil {
logger.Warn("PDF缓存管理器初始化失败将禁用缓存功能", zap.Error(err))
return nil, nil // 返回nilhandler中会检查
}
logger.Info("PDF缓存管理器已初始化",
zap.String("cache_dir", cacheDir),
zap.Duration("ttl", ttl),
zap.Int64("max_size", maxSize),
)
return cacheManager, nil
},
),
// HTTP处理器
fx.Provide(
// 用户HTTP处理器

View File

@@ -28,6 +28,7 @@ type ProductHandler struct {
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
pdfGenerator *pdf.PDFGenerator
pdfCacheManager *pdf.PDFCacheManager
logger *zap.Logger
}
@@ -41,6 +42,7 @@ func NewProductHandler(
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
pdfGenerator *pdf.PDFGenerator,
pdfCacheManager *pdf.PDFCacheManager,
logger *zap.Logger,
) *ProductHandler {
return &ProductHandler{
@@ -52,6 +54,7 @@ func NewProductHandler(
responseBuilder: responseBuilder,
validator: validator,
pdfGenerator: pdfGenerator,
pdfCacheManager: pdfCacheManager,
logger: logger,
}
}
@@ -640,7 +643,7 @@ func (h *ProductHandler) CancelMySubscription(c *gin.Context) {
err := h.subAppService.CancelMySubscription(c.Request.Context(), userID, subscriptionID)
if err != nil {
h.logger.Error("取消订阅失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
// 根据错误类型返回不同的响应
if err.Error() == "订阅不存在" {
h.responseBuilder.NotFound(c, "订阅不存在")
@@ -742,6 +745,7 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
// 将响应类型转换为entity类型
var docEntity *entities.ProductDocumentation
var docVersion string
if doc != nil {
docEntity = &entities.ProductDocumentation{
ID: doc.ID,
@@ -755,12 +759,60 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
ErrorCodes: doc.ErrorCodes,
Version: doc.Version,
}
docVersion = doc.Version
} else {
// 如果没有文档,使用默认版本号
docVersion = "1.0"
}
// 使用数据库数据生成PDF
h.logger.Info("准备调用PDF生成器",
// 尝试从缓存获取PDF
var pdfBytes []byte
var cacheHit bool
if h.pdfCacheManager != nil {
var cacheErr error
pdfBytes, cacheHit, cacheErr = h.pdfCacheManager.Get(productID, docVersion)
if cacheErr != nil {
h.logger.Warn("从缓存获取PDF失败将重新生成",
zap.String("product_id", productID),
zap.Error(cacheErr),
)
} else if cacheHit {
h.logger.Info("PDF缓存命中",
zap.String("product_id", productID),
zap.String("version", docVersion),
zap.Int("pdf_size", len(pdfBytes)),
)
// 直接返回缓存的PDF
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, "|", "_")
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.Header("X-Cache", "HIT") // 添加缓存命中标识
c.Data(http.StatusOK, "application/pdf", pdfBytes)
return
}
}
// 缓存未命中需要生成PDF
h.logger.Info("PDF缓存未命中开始生成PDF",
zap.String("product_id", productID),
zap.String("product_name", product.Name),
zap.String("version", docVersion),
zap.Bool("has_doc", docEntity != nil),
)
@@ -819,6 +871,19 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
return
}
// 保存到缓存(异步,不阻塞响应)
if h.pdfCacheManager != nil {
go func() {
if err := h.pdfCacheManager.Set(productID, docVersion, pdfBytes); err != nil {
h.logger.Warn("保存PDF到缓存失败",
zap.String("product_id", productID),
zap.String("version", docVersion),
zap.Error(err),
)
}
}()
}
// 生成文件名(清理文件名中的非法字符)
fileName := fmt.Sprintf("%s_接口文档.pdf", product.Name)
if product.Name == "" {
@@ -840,11 +905,13 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
zap.String("product_code", product.Code),
zap.String("file_name", fileName),
zap.Int("file_size", len(pdfBytes)),
zap.Bool("cached", false),
)
// 设置响应头并返回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.Header("X-Cache", "MISS") // 添加缓存未命中标识
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}

View File

@@ -0,0 +1,475 @@
package pdf
import (
"crypto/md5"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/zap"
)
// PDFCacheManager PDF缓存管理器
// 负责管理PDF文件的本地缓存提高下载性能
type PDFCacheManager struct {
logger *zap.Logger
cacheDir string
ttl time.Duration // 缓存过期时间
maxSize int64 // 最大缓存大小(字节)
mu sync.RWMutex // 保护并发访问
cleanupOnce sync.Once // 确保清理任务只启动一次
}
// CacheInfo 缓存信息
type CacheInfo struct {
FilePath string
Size int64
CreatedAt time.Time
ExpiresAt time.Time
ProductID string
Version string
}
// NewPDFCacheManager 创建PDF缓存管理器
func NewPDFCacheManager(logger *zap.Logger, cacheDir string, ttl time.Duration, maxSize int64) (*PDFCacheManager, error) {
// 如果缓存目录为空,使用默认目录
if cacheDir == "" {
cacheDir = filepath.Join(os.TempDir(), "tyapi_pdf_cache")
}
// 确保缓存目录存在
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, fmt.Errorf("创建缓存目录失败: %w", err)
}
manager := &PDFCacheManager{
logger: logger,
cacheDir: cacheDir,
ttl: ttl,
maxSize: maxSize,
}
// 启动定期清理任务
manager.startCleanupTask()
logger.Info("PDF缓存管理器已初始化",
zap.String("cache_dir", cacheDir),
zap.Duration("ttl", ttl),
zap.Int64("max_size", maxSize),
)
return manager, nil
}
// GetCacheKey 生成缓存键
// 基于产品ID和文档版本号生成唯一的缓存键
func (m *PDFCacheManager) GetCacheKey(productID string, version string) string {
// 使用MD5哈希生成短键名
key := fmt.Sprintf("%s:%s", productID, version)
hash := md5.Sum([]byte(key))
return hex.EncodeToString(hash[:])
}
// GetCachePath 获取缓存文件路径
func (m *PDFCacheManager) GetCachePath(cacheKey string) string {
return filepath.Join(m.cacheDir, fmt.Sprintf("%s.pdf", cacheKey))
}
// Get 从缓存获取PDF文件
// 返回PDF字节流和是否命中缓存
func (m *PDFCacheManager) Get(productID string, version string) ([]byte, bool, error) {
cacheKey := m.GetCacheKey(productID, version)
cachePath := m.GetCachePath(cacheKey)
m.mu.RLock()
defer m.mu.RUnlock()
// 检查文件是否存在
info, err := os.Stat(cachePath)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil // 缓存未命中
}
return nil, false, fmt.Errorf("检查缓存文件失败: %w", err)
}
// 检查文件是否过期
// 使用文件的修改时间作为创建时间
createdAt := info.ModTime()
expiresAt := createdAt.Add(m.ttl)
if time.Now().After(expiresAt) {
// 缓存已过期,删除文件
m.logger.Debug("缓存已过期,删除文件",
zap.String("product_id", productID),
zap.String("cache_key", cacheKey),
zap.Time("expires_at", expiresAt),
)
_ = os.Remove(cachePath)
return nil, false, nil
}
// 读取缓存文件
pdfBytes, err := os.ReadFile(cachePath)
if err != nil {
return nil, false, fmt.Errorf("读取缓存文件失败: %w", err)
}
m.logger.Debug("缓存命中",
zap.String("product_id", productID),
zap.String("cache_key", cacheKey),
zap.Int64("file_size", int64(len(pdfBytes))),
zap.Time("expires_at", expiresAt),
)
return pdfBytes, true, nil
}
// Set 将PDF文件保存到缓存
func (m *PDFCacheManager) Set(productID string, version string, pdfBytes []byte) error {
cacheKey := m.GetCacheKey(productID, version)
cachePath := m.GetCachePath(cacheKey)
m.mu.Lock()
defer m.mu.Unlock()
// 检查是否已存在缓存文件(用于判断是新建还是更新)
fileExists := false
var oldFileSize int64
if info, err := os.Stat(cachePath); err == nil {
fileExists = true
oldFileSize = info.Size()
m.logger.Info("检测到已存在的缓存文件,将更新缓存",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("old_file_size", oldFileSize),
zap.Int64("new_file_size", int64(len(pdfBytes))),
)
} else {
m.logger.Info("开始创建新的PDF缓存",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("file_size", int64(len(pdfBytes))),
)
}
// 检查缓存大小限制
if m.maxSize > 0 {
currentSize, err := m.getCacheDirSize()
if err != nil {
m.logger.Warn("获取缓存目录大小失败", zap.Error(err))
} else {
// 如果更新已存在的文件,需要减去旧文件的大小
sizeToAdd := int64(len(pdfBytes))
if fileExists {
sizeToAdd = int64(len(pdfBytes)) - oldFileSize
}
if currentSize+sizeToAdd > m.maxSize {
// 缓存空间不足,清理过期文件
m.logger.Warn("缓存空间不足,开始清理过期文件",
zap.Int64("current_size", currentSize),
zap.Int64("max_size", m.maxSize),
zap.Int64("required_size", sizeToAdd),
zap.Bool("is_update", fileExists),
)
if err := m.cleanupExpiredFiles(); err != nil {
m.logger.Warn("清理过期文件失败", zap.Error(err))
}
// 再次检查
currentSize, _ = m.getCacheDirSize()
if currentSize+sizeToAdd > m.maxSize {
m.logger.Error("缓存空间不足,无法保存文件",
zap.Int64("current_size", currentSize),
zap.Int64("max_size", m.maxSize),
zap.Int64("required_size", sizeToAdd),
)
return fmt.Errorf("缓存空间不足,无法保存文件")
}
}
}
}
// 写入缓存文件
if err := os.WriteFile(cachePath, pdfBytes, 0644); err != nil {
m.logger.Error("写入缓存文件失败",
zap.String("product_id", productID),
zap.String("cache_path", cachePath),
zap.Error(err),
)
return fmt.Errorf("写入缓存文件失败: %w", err)
}
// 计算过期时间
expiresAt := time.Now().Add(m.ttl)
// 根据是新建还是更新,记录不同的日志
if fileExists {
m.logger.Info("PDF缓存已更新",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("old_file_size", oldFileSize),
zap.Int64("new_file_size", int64(len(pdfBytes))),
zap.Int64("size_change", int64(len(pdfBytes))-oldFileSize),
zap.Duration("ttl", m.ttl),
zap.Time("expires_at", expiresAt),
)
} else {
m.logger.Info("PDF缓存已创建",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("file_size", int64(len(pdfBytes))),
zap.Duration("ttl", m.ttl),
zap.Time("expires_at", expiresAt),
)
}
return nil
}
// Invalidate 使缓存失效
func (m *PDFCacheManager) Invalidate(productID string, version string) error {
cacheKey := m.GetCacheKey(productID, version)
cachePath := m.GetCachePath(cacheKey)
m.logger.Info("开始使缓存失效",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
)
m.mu.Lock()
defer m.mu.Unlock()
// 检查文件是否存在,记录文件信息
fileInfo, err := os.Stat(cachePath)
if err != nil {
if os.IsNotExist(err) {
m.logger.Info("缓存文件不存在,视为已失效",
zap.String("product_id", productID),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
)
return nil // 文件不存在,视为已失效
}
m.logger.Error("检查缓存文件失败",
zap.String("product_id", productID),
zap.String("cache_path", cachePath),
zap.Error(err),
)
return fmt.Errorf("检查缓存文件失败: %w", err)
}
// 记录文件信息
fileSize := fileInfo.Size()
fileModTime := fileInfo.ModTime()
// 删除缓存文件
if err := os.Remove(cachePath); err != nil {
m.logger.Error("删除缓存文件失败",
zap.String("product_id", productID),
zap.String("cache_path", cachePath),
zap.Error(err),
)
return fmt.Errorf("删除缓存文件失败: %w", err)
}
m.logger.Info("缓存已成功失效",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("deleted_file_size", fileSize),
zap.Time("file_created_at", fileModTime),
)
return nil
}
// InvalidateByProductID 使指定产品的所有缓存失效
func (m *PDFCacheManager) InvalidateByProductID(productID string) error {
m.mu.Lock()
defer m.mu.Unlock()
// 遍历缓存目录,查找匹配的文件
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
count := 0
for _, file := range files {
if file.IsDir() {
continue
}
// 读取文件内容检查是否匹配产品ID
// 由于我们使用MD5哈希无法直接匹配需要读取文件元数据
// 这里简化处理删除所有PDF文件实际应该存储元数据
// 更好的方案是使用数据库或JSON文件存储元数据
if filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
if err := os.Remove(filePath); err == nil {
count++
}
}
}
m.logger.Info("已清理产品缓存",
zap.String("product_id", productID),
zap.Int("deleted_count", count),
)
return nil
}
// Clear 清空所有缓存
func (m *PDFCacheManager) Clear() error {
m.mu.Lock()
defer m.mu.Unlock()
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
count := 0
for _, file := range files {
if !file.IsDir() && filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
if err := os.Remove(filePath); err == nil {
count++
}
}
}
m.logger.Info("已清空所有缓存", zap.Int("deleted_count", count))
return nil
}
// GetCacheStats 获取缓存统计信息
func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) {
m.mu.RLock()
defer m.mu.RUnlock()
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return nil, fmt.Errorf("读取缓存目录失败: %w", err)
}
var totalSize int64
var fileCount int
var expiredCount int
now := time.Now()
for _, file := range files {
if file.IsDir() {
continue
}
if filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
info, err := os.Stat(filePath)
if err != nil {
continue
}
totalSize += info.Size()
fileCount++
// 检查是否过期
expiresAt := info.ModTime().Add(m.ttl)
if now.After(expiresAt) {
expiredCount++
}
}
}
return map[string]interface{}{
"total_size": totalSize,
"file_count": fileCount,
"expired_count": expiredCount,
"cache_dir": m.cacheDir,
"ttl": m.ttl.String(),
"max_size": m.maxSize,
}, nil
}
// cleanupExpiredFiles 清理过期的缓存文件
func (m *PDFCacheManager) cleanupExpiredFiles() error {
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
now := time.Now()
count := 0
var totalFreed int64
for _, file := range files {
if file.IsDir() {
continue
}
if filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
info, err := os.Stat(filePath)
if err != nil {
continue
}
expiresAt := info.ModTime().Add(m.ttl)
if now.After(expiresAt) {
if err := os.Remove(filePath); err == nil {
count++
totalFreed += info.Size()
}
}
}
}
if count > 0 {
m.logger.Info("已清理过期缓存文件",
zap.Int("count", count),
zap.Int64("freed_size", totalFreed),
)
}
return nil
}
// getCacheDirSize 获取缓存目录总大小
func (m *PDFCacheManager) getCacheDirSize() (int64, error) {
var totalSize int64
err := filepath.Walk(m.cacheDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
totalSize += info.Size()
}
return nil
})
return totalSize, err
}
// startCleanupTask 启动定期清理任务
func (m *PDFCacheManager) startCleanupTask() {
m.cleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(1 * time.Hour) // 每小时清理一次
defer ticker.Stop()
for range ticker.C {
if err := m.cleanupExpiredFiles(); err != nil {
m.logger.Warn("定期清理缓存失败", zap.Error(err))
}
}
}()
})
}

View File

@@ -154,7 +154,14 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
chineseFontAvailable := g.fontManager.LoadChineseFont(pdf)
// 加载水印字体(使用宋体或其他非黑体字体)
g.fontManager.LoadWatermarkFont(pdf)
watermarkFontAvailable := g.fontManager.LoadWatermarkFont(pdf)
// 记录字体加载状态,便于诊断问题
g.logger.Info("PDF字体加载状态",
zap.Bool("chinese_font_loaded", chineseFontAvailable),
zap.Bool("watermark_font_loaded", watermarkFontAvailable),
zap.String("watermark_text", g.watermarkText),
)
// 设置文档信息
pdf.SetTitle("Product Documentation", true)