Files
tyapi-server/internal/shared/pdf/pdf_cache_manager.go
2025-12-04 18:10:14 +08:00

476 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))
}
}
}()
})
}