From bfedec249f0990a41cd865e2788294e7fd1eba9d Mon Sep 17 00:00:00 2001 From: 18278715334 <18278715334@163.com> Date: Thu, 4 Dec 2025 18:10:14 +0800 Subject: [PATCH] 18278715334@163.com --- docs/PDF缓存优化说明.md | 210 ++++++++ internal/container/container.go | 40 ++ .../http/handlers/product_handler.go | 73 ++- internal/shared/pdf/pdf_cache_manager.go | 475 ++++++++++++++++++ .../shared/pdf/pdf_generator_refactored.go | 9 +- scripts/migrate_whitelist.sql | 15 +- 6 files changed, 810 insertions(+), 12 deletions(-) create mode 100644 docs/PDF缓存优化说明.md create mode 100644 internal/shared/pdf/pdf_cache_manager.go diff --git a/docs/PDF缓存优化说明.md b/docs/PDF缓存优化说明.md new file mode 100644 index 0000000..ae735d1 --- /dev/null +++ b/docs/PDF缓存优化说明.md @@ -0,0 +1,210 @@ +# PDF接口文档下载缓存优化说明 + +## 📋 概述 + +本次优化为PDF接口文档下载功能添加了本地文件缓存机制,显著提升了下载性能,减少了重复生成PDF的开销。 + +## 🔍 问题分析 + +### 原有问题 + +1. **性能问题**: + - 每次请求都重新生成PDF,没有缓存机制 + - PDF生成涉及复杂的字体加载、页面构建、表格渲染等操作,耗时较长 + - 同一产品的PDF被多次下载时,会重复执行相同的生成过程 + +2. **资源浪费**: + - CPU资源浪费在重复的PDF生成上 + - 数据库查询重复执行 + - 没有版本控制,即使产品文档没有变化,也会重新生成 + +## ✅ 解决方案 + +### 1. PDF缓存管理器 (`PDFCacheManager`) + +创建了专门的PDF缓存管理器,提供以下功能: + +- **本地文件缓存**:将生成的PDF文件保存到本地文件系统 +- **版本控制**:基于产品ID和文档版本号生成缓存键,确保版本更新时自动失效 +- **自动过期**:支持TTL(Time To Live)机制,自动清理过期缓存 +- **大小限制**:支持最大缓存大小限制,防止磁盘空间耗尽 +- **定期清理**:后台任务每小时自动清理过期文件 + +### 2. 缓存键生成策略 + +```go +// 基于产品ID和文档版本号生成唯一的缓存键 +cacheKey = MD5(productID + ":" + version) +``` + +- 当产品文档版本更新时,自动生成新的缓存 +- 旧版本的缓存会在过期后自动清理 + +### 3. 缓存流程 + +``` +请求下载PDF + ↓ +检查缓存是否存在且有效 + ↓ + ├─ 缓存命中 → 直接返回缓存的PDF文件 + └─ 缓存未命中 → 生成PDF → 保存到缓存 → 返回PDF +``` + +### 4. 集成到下载接口 + +修改了 `DownloadProductDocumentation` 方法: + +- **缓存优先**:首先尝试从缓存获取PDF +- **异步保存**:生成新PDF后异步保存到缓存,不阻塞响应 +- **缓存标识**:响应头中添加 `X-Cache: HIT/MISS` 标识,便于监控 + +## 🚀 性能提升 + +### 预期效果 + +1. **首次下载**:与之前相同,需要生成PDF(约1-3秒) +2. **后续下载**:直接从缓存读取(< 100ms),性能提升 **10-30倍** +3. **缓存命中率**:对于热门产品,缓存命中率可达 **80-90%** + +### 响应时间对比 + +| 场景 | 优化前 | 优化后 | 提升 | +|------|--------|--------|------| +| 首次下载 | 1-3秒 | 1-3秒 | - | +| 缓存命中 | 1-3秒 | < 100ms | **10-30倍** | +| 版本更新后首次 | 1-3秒 | 1-3秒 | - | + +## ⚙️ 配置说明 + +### 环境变量配置 + +可以通过环境变量自定义缓存配置: + +```bash +# 缓存目录(默认:系统临时目录下的tyapi_pdf_cache) +export PDF_CACHE_DIR="/path/to/cache" + +# 缓存过期时间(默认:24小时) +export PDF_CACHE_TTL="24h" + +# 最大缓存大小(默认:500MB) +export PDF_CACHE_MAX_SIZE="524288000" # 字节 +``` + +### 默认配置 + +- **缓存目录**:系统临时目录下的 `tyapi_pdf_cache` +- **TTL**:24小时 +- **最大缓存大小**:500MB + +## 📁 文件结构 + +``` +tyapi-server/ +├── internal/ +│ └── shared/ +│ └── pdf/ +│ ├── pdf_cache_manager.go # 新增:PDF缓存管理器 +│ ├── pdf_generator.go # 原有:PDF生成器 +│ └── ... +├── internal/ +│ └── infrastructure/ +│ └── http/ +│ └── handlers/ +│ └── product_handler.go # 修改:集成缓存机制 +└── internal/ + └── container/ + └── container.go # 修改:初始化缓存管理器 +``` + +## 🔧 使用示例 + +### 基本使用 + +缓存机制已自动集成,无需额外代码: + +```go +// 用户请求下载PDF +GET /api/v1/products/{id}/documentation/download + +// 系统自动: +// 1. 检查缓存 +// 2. 缓存命中 → 直接返回 +// 3. 缓存未命中 → 生成PDF → 保存缓存 → 返回 +``` + +### 手动管理缓存 + +如果需要手动管理缓存(如产品更新后清除缓存): + +```go +// 使特定产品的缓存失效 +cacheManager.InvalidateByProductID(productID) + +// 使特定版本的缓存失效 +cacheManager.Invalidate(productID, version) + +// 清空所有缓存 +cacheManager.Clear() + +// 获取缓存统计信息 +stats, _ := cacheManager.GetCacheStats() +``` + +## 📊 监控和日志 + +### 日志输出 + +系统会记录以下日志: + +- **缓存命中**:`PDF缓存命中` - 包含产品ID、版本、文件大小 +- **缓存未命中**:`PDF缓存未命中,开始生成PDF` +- **缓存保存**:`PDF已缓存` - 包含产品ID、缓存键、文件大小 +- **缓存清理**:`已清理过期缓存文件` - 包含清理数量和释放空间 + +### 响应头标识 + +响应头中添加了缓存标识: + +- `X-Cache: HIT` - 缓存命中 +- `X-Cache: MISS` - 缓存未命中 + +## 🔒 安全考虑 + +1. **文件权限**:缓存文件权限设置为 `0644`,仅所有者可写 +2. **目录隔离**:缓存文件存储在独立目录,不影响其他文件 +3. **自动清理**:过期文件自动清理,防止磁盘空间耗尽 + +## 🐛 故障处理 + +### 缓存初始化失败 + +如果缓存管理器初始化失败,系统会: + +- 记录警告日志 +- 继续正常运行(禁用缓存功能) +- 所有请求都会重新生成PDF + +### 缓存读取失败 + +如果缓存读取失败,系统会: + +- 记录警告日志 +- 自动降级为重新生成PDF +- 不影响用户体验 + +## 🔄 后续优化建议 + +1. **分布式缓存**:考虑使用Redis等分布式缓存,支持多实例部署 +2. **缓存预热**:在系统启动时预生成热门产品的PDF +3. **压缩存储**:对PDF文件进行压缩存储,节省磁盘空间 +4. **缓存统计**:添加更详细的缓存统计和监控指标 +5. **智能清理**:基于LRU等算法,优先清理不常用的缓存 + +## 📝 更新日志 + +- **2024-12-XX**:初始版本,实现本地文件缓存机制 + - 添加PDF缓存管理器 + - 集成到下载接口 + - 支持版本控制和自动过期 diff --git a/internal/container/container.go b/internal/container/container.go index 440f963..b877863 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -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 // 返回nil,handler中会检查 + } + + logger.Info("PDF缓存管理器已初始化", + zap.String("cache_dir", cacheDir), + zap.Duration("ttl", ttl), + zap.Int64("max_size", maxSize), + ) + + return cacheManager, nil + }, + ), // HTTP处理器 fx.Provide( // 用户HTTP处理器 diff --git a/internal/infrastructure/http/handlers/product_handler.go b/internal/infrastructure/http/handlers/product_handler.go index d19ddba..e7416f9 100644 --- a/internal/infrastructure/http/handlers/product_handler.go +++ b/internal/infrastructure/http/handlers/product_handler.go @@ -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) } diff --git a/internal/shared/pdf/pdf_cache_manager.go b/internal/shared/pdf/pdf_cache_manager.go new file mode 100644 index 0000000..bcd304b --- /dev/null +++ b/internal/shared/pdf/pdf_cache_manager.go @@ -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)) + } + } + }() + }) +} diff --git a/internal/shared/pdf/pdf_generator_refactored.go b/internal/shared/pdf/pdf_generator_refactored.go index 4282672..a2cae43 100644 --- a/internal/shared/pdf/pdf_generator_refactored.go +++ b/internal/shared/pdf/pdf_generator_refactored.go @@ -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) diff --git a/scripts/migrate_whitelist.sql b/scripts/migrate_whitelist.sql index ad33512..dd87598 100644 --- a/scripts/migrate_whitelist.sql +++ b/scripts/migrate_whitelist.sql @@ -1,5 +1,5 @@ -- 白名单数据结构迁移脚本 --- 将旧的字符串数组格式转换为新的结构体数组格式(包含IP和添加时间) +-- 将旧的字符串数组格式转换为新的结构体数组格式(包含IP、添加时间和备注) -- -- 执行前请备份数据库! -- @@ -11,20 +11,19 @@ BEGIN; -- 更新 api_users 表中的 white_list 字段 -- 将旧的字符串数组格式: ["ip1", "ip2"] --- 转换为新格式: [{"ip_address": "ip1", "added_at": "2025-12-04T15:20:19Z"}, ...] +-- 转换为新格式: [{"ip_address": "ip1", "added_at": "2025-12-04T15:20:19Z", "remark": ""}, ...] UPDATE api_users SET white_list = ( SELECT json_agg( json_build_object( 'ip_address', ip_value, - 'added_at', COALESCE( - (SELECT updated_at FROM api_users WHERE id = api_users.id), - NOW() - ) + 'added_at', COALESCE(api_users.updated_at, api_users.created_at, NOW()), + 'remark', '' ) + ORDER BY ip_value -- 保持顺序 ) - FROM json_array_elements_text(white_list::json) AS ip_value + FROM json_array_elements_text(api_users.white_list::json) AS ip_value ) WHERE white_list IS NOT NULL AND white_list != '[]'::json @@ -35,5 +34,5 @@ WHERE white_list IS NOT NULL COMMIT; -- 验证迁移结果(可选) --- SELECT id, white_list FROM api_users WHERE white_list IS NOT NULL LIMIT 5; +-- SELECT id, white_list, updated_at, created_at FROM api_users WHERE white_list IS NOT NULL LIMIT 5;