package pdf import ( "crypto/md5" "encoding/hex" "fmt" "os" "path/filepath" "sync" "time" "go.uber.org/zap" ) // PDFCacheManager PDF缓存管理器(统一实现) // 支持两种缓存键生成方式: // 1. 基于姓名+身份证(用于PDFG报告) // 2. 基于产品ID+版本(用于产品文档) type PDFCacheManager struct { logger *zap.Logger cacheDir string ttl time.Duration // 缓存过期时间 maxSize int64 // 最大缓存大小(字节,0表示不限制) mu sync.RWMutex // 保护并发访问 cleanupOnce sync.Once // 确保清理任务只启动一次 } // NewPDFCacheManager 创建PDF缓存管理器 // cacheDir: 缓存目录(空则使用默认目录) // ttl: 缓存过期时间 // maxSize: 最大缓存大小(字节,0表示不限制) func NewPDFCacheManager(logger *zap.Logger, cacheDir string, ttl time.Duration, maxSize int64) (*PDFCacheManager, error) { // 如果缓存目录为空,使用项目根目录的storage/pdfg-cache目录 if cacheDir == "" { wd, err := os.Getwd() if err != nil { cacheDir = filepath.Join(os.TempDir(), "tyapi_pdfg_cache") } else { cacheDir = filepath.Join(wd, "storage", "pdfg-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 生成缓存键(基于姓名+身份证) func (m *PDFCacheManager) GetCacheKey(name, idCard string) string { key := fmt.Sprintf("%s:%s", name, idCard) hash := md5.Sum([]byte(key)) return hex.EncodeToString(hash[:]) } // GetCacheKeyByProduct 生成缓存键(基于产品ID+版本) func (m *PDFCacheManager) GetCacheKeyByProduct(productID, version string) string { key := fmt.Sprintf("%s:%s", productID, version) hash := md5.Sum([]byte(key)) return hex.EncodeToString(hash[:]) } // GetCacheKeyByReportID 生成缓存键(基于报告ID) // 文件名格式:MD5(report_id).pdf // report_id 本身已经包含时间戳和随机数,所以 MD5 后就是唯一的 func (m *PDFCacheManager) GetCacheKeyByReportID(reportID string) string { hash := md5.Sum([]byte(reportID)) 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(name, idCard string) ([]byte, bool, time.Time, error) { cacheKey := m.GetCacheKey(name, idCard) return m.getByKey(cacheKey, name, idCard) } // GetByProduct 从缓存获取PDF文件(基于产品ID+版本) // 返回PDF字节流、是否命中缓存、错误 func (m *PDFCacheManager) GetByProduct(productID, version string) ([]byte, bool, error) { cacheKey := m.GetCacheKeyByProduct(productID, version) pdfBytes, hit, _, err := m.getByKey(cacheKey, productID, version) return pdfBytes, hit, err } // GetByCacheKey 通过缓存键直接获取PDF文件 // 适用于已经持久化了缓存键(例如作为报告ID)的场景 // 返回PDF字节流、是否命中缓存、文件创建时间、错误 func (m *PDFCacheManager) GetByCacheKey(cacheKey string) ([]byte, bool, time.Time, error) { return m.getByKey(cacheKey, "", "") } // SetByReportID 将PDF文件保存到缓存(基于报告ID) func (m *PDFCacheManager) SetByReportID(reportID string, pdfBytes []byte) error { cacheKey := m.GetCacheKeyByReportID(reportID) return m.setByKey(cacheKey, pdfBytes, reportID, "") } // GetByReportID 从缓存获取PDF文件(基于报告ID) // 直接通过 report_id 的 MD5 计算文件名,无需遍历 // 返回PDF字节流、是否命中缓存、文件创建时间、错误 func (m *PDFCacheManager) GetByReportID(reportID string) ([]byte, bool, time.Time, error) { cacheKey := m.GetCacheKeyByReportID(reportID) return m.getByKey(cacheKey, reportID, "") } // getByKey 内部方法:根据缓存键获取文件 func (m *PDFCacheManager) getByKey(cacheKey string, key1, key2 string) ([]byte, bool, time.Time, error) { 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, time.Time{}, nil // 缓存未命中 } return nil, false, time.Time{}, fmt.Errorf("检查缓存文件失败: %w", err) } // 检查文件是否过期(从文件生成时间开始算24小时) createdAt := info.ModTime() expiresAt := createdAt.Add(m.ttl) if time.Now().After(expiresAt) { // 缓存已过期,删除文件 m.logger.Debug("缓存已过期,删除文件", zap.String("key1", key1), zap.String("key2", key2), zap.String("cache_key", cacheKey), zap.Time("expires_at", expiresAt), ) _ = os.Remove(cachePath) return nil, false, time.Time{}, nil } // 读取缓存文件 pdfBytes, err := os.ReadFile(cachePath) if err != nil { return nil, false, time.Time{}, fmt.Errorf("读取缓存文件失败: %w", err) } m.logger.Debug("缓存命中", zap.String("key1", key1), zap.String("key2", key2), zap.String("cache_key", cacheKey), zap.Int64("file_size", int64(len(pdfBytes))), zap.Time("expires_at", expiresAt), ) return pdfBytes, true, createdAt, nil } // Set 将PDF文件保存到缓存(基于姓名+身份证) func (m *PDFCacheManager) Set(name, idCard string, pdfBytes []byte) error { cacheKey := m.GetCacheKey(name, idCard) return m.setByKey(cacheKey, pdfBytes, name, idCard) } // SetByProduct 将PDF文件保存到缓存(基于产品ID+版本) func (m *PDFCacheManager) SetByProduct(productID, version string, pdfBytes []byte) error { cacheKey := m.GetCacheKeyByProduct(productID, version) return m.setByKey(cacheKey, pdfBytes, productID, version) } // setByKey 内部方法:根据缓存键保存文件 func (m *PDFCacheManager) setByKey(cacheKey string, pdfBytes []byte, key1, key2 string) error { cachePath := m.GetCachePath(cacheKey) m.mu.Lock() defer m.mu.Unlock() // 检查缓存大小限制 if m.maxSize > 0 { currentSize, err := m.getCacheDirSize() if err != nil { m.logger.Warn("获取缓存目录大小失败", zap.Error(err)) } else { // 检查是否已存在文件 var oldFileSize int64 if info, err := os.Stat(cachePath); err == nil { oldFileSize = info.Size() } 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), ) if err := m.cleanExpiredFiles(); 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 { return fmt.Errorf("写入缓存文件失败: %w", err) } m.logger.Debug("PDF已保存到缓存", zap.String("key1", key1), zap.String("key2", key2), zap.String("cache_key", cacheKey), zap.Int64("file_size", int64(len(pdfBytes))), ) 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.cleanExpiredFiles(); err != nil { m.logger.Warn("清理过期缓存文件失败", zap.Error(err)) } } }() }) } // cleanExpiredFiles 清理过期的缓存文件 func (m *PDFCacheManager) cleanExpiredFiles() error { m.mu.Lock() defer m.mu.Unlock() entries, err := os.ReadDir(m.cacheDir) if err != nil { return fmt.Errorf("读取缓存目录失败: %w", err) } now := time.Now() cleanedCount := 0 for _, entry := range entries { if entry.IsDir() { continue } // 只处理PDF文件 if filepath.Ext(entry.Name()) != ".pdf" { continue } filePath := filepath.Join(m.cacheDir, entry.Name()) info, err := entry.Info() if err != nil { continue } // 检查文件是否过期 createdAt := info.ModTime() expiresAt := createdAt.Add(m.ttl) if now.After(expiresAt) { if err := os.Remove(filePath); err != nil { m.logger.Warn("删除过期缓存文件失败", zap.String("file_path", filePath), zap.Error(err), ) } else { cleanedCount++ } } } if cleanedCount > 0 { m.logger.Info("清理过期缓存文件完成", zap.Int("cleaned_count", cleanedCount), ) } return nil } // Invalidate 使缓存失效(基于产品ID+版本) func (m *PDFCacheManager) Invalidate(productID, version string) error { cacheKey := m.GetCacheKeyByProduct(productID, version) cachePath := m.GetCachePath(cacheKey) m.mu.Lock() defer m.mu.Unlock() if err := os.Remove(cachePath); err != nil { if os.IsNotExist(err) { return nil // 文件不存在,视为已失效 } return fmt.Errorf("删除缓存文件失败: %w", err) } return nil } // InvalidateByNameIDCard 使缓存失效(基于姓名+身份证) func (m *PDFCacheManager) InvalidateByNameIDCard(name, idCard string) error { cacheKey := m.GetCacheKey(name, idCard) cachePath := m.GetCachePath(cacheKey) m.mu.Lock() defer m.mu.Unlock() if err := os.Remove(cachePath); err != nil { if os.IsNotExist(err) { return nil // 文件不存在,视为已失效 } return fmt.Errorf("删除缓存文件失败: %w", err) } return nil } // Clear 清空所有缓存 func (m *PDFCacheManager) Clear() error { m.mu.Lock() defer m.mu.Unlock() entries, err := os.ReadDir(m.cacheDir) if err != nil { return fmt.Errorf("读取缓存目录失败: %w", err) } count := 0 for _, entry := range entries { if !entry.IsDir() && filepath.Ext(entry.Name()) == ".pdf" { filePath := filepath.Join(m.cacheDir, entry.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() entries, 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 _, entry := range entries { if entry.IsDir() { continue } if filepath.Ext(entry.Name()) == ".pdf" { filePath := filepath.Join(m.cacheDir, entry.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 }