| """ |
| 图片缓存管理器 |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| import shutil |
| import time |
| from pathlib import Path |
| from typing import Dict, List, Optional, Tuple, Any |
| import hashlib |
| from datetime import datetime, timedelta |
|
|
| from ..models import ( |
| ImageInfo, ImageCacheInfo, ImageSourceType, ImageProvider |
| ) |
| from ....auth.request_context import current_user_id |
|
|
| logger = logging.getLogger(__name__) |
|
|
| class StorageQuotaExceededError(RuntimeError): |
| """Raised when a user exceeds their image hosting storage quota.""" |
|
|
| def __init__(self, *, user_id: int, used_bytes: int, quota_bytes: int): |
| super().__init__( |
| f"Storage quota exceeded for user {user_id}: used={used_bytes}B quota={quota_bytes}B" |
| ) |
| self.user_id = user_id |
| self.used_bytes = used_bytes |
| self.quota_bytes = quota_bytes |
|
|
|
|
| class ImageCacheManager: |
| """图片缓存管理器""" |
| |
| def __init__(self, config: Dict[str, Any]): |
| self.config = config |
|
|
| |
| self.cache_root = Path(config.get('base_dir', config.get('cache_root', 'temp/images_cache'))) |
|
|
| |
| self.max_size_gb = config.get('max_size_gb', 100.0) |
| self.cleanup_interval_hours = config.get('cleanup_interval_hours', 240000) |
|
|
| |
| self.ai_generated_dir = self.cache_root / 'ai_generated' |
| self.web_search_dir = self.cache_root / 'web_search' |
| self.local_storage_dir = self.cache_root / 'local_storage' |
| self.metadata_dir = self.cache_root / 'metadata' |
| self.thumbnails_dir = self.cache_root / 'thumbnails' |
|
|
| |
| self._create_cache_directories() |
|
|
| |
| self._cache_index: Dict[str, ImageCacheInfo] = {} |
| self._load_cache_index() |
| |
| def _create_cache_directories(self): |
| """创建缓存目录结构""" |
| directories = [ |
| self.cache_root, |
| self.ai_generated_dir, |
| self.ai_generated_dir / 'dalle', |
| self.ai_generated_dir / 'stable_diffusion', |
| self.ai_generated_dir / 'siliconflow', |
| self.ai_generated_dir / 'pollinations', |
| self.web_search_dir, |
| self.web_search_dir / 'unsplash', |
| self.web_search_dir / 'pixabay', |
| self.local_storage_dir, |
| self.local_storage_dir / 'user_uploads', |
| self.local_storage_dir / 'processed', |
| self.metadata_dir, |
| self.metadata_dir / 'references', |
| self.thumbnails_dir |
| ] |
|
|
| for directory in directories: |
| directory.mkdir(parents=True, exist_ok=True) |
| |
| def _get_cache_path(self, source_type: ImageSourceType, provider: ImageProvider) -> Path: |
| """获取缓存路径""" |
| if source_type == ImageSourceType.AI_GENERATED: |
| if provider == ImageProvider.DALLE: |
| return self.ai_generated_dir / 'dalle' |
| elif provider == ImageProvider.STABLE_DIFFUSION: |
| return self.ai_generated_dir / 'stable_diffusion' |
| elif provider == ImageProvider.POLLINATIONS: |
| return self.ai_generated_dir / 'pollinations' |
| else: |
| return self.ai_generated_dir |
| |
| elif source_type == ImageSourceType.WEB_SEARCH: |
| if provider == ImageProvider.UNSPLASH: |
| return self.web_search_dir / 'unsplash' |
| elif provider == ImageProvider.PIXABAY: |
| return self.web_search_dir / 'pixabay' |
| else: |
| return self.web_search_dir |
| |
| elif source_type == ImageSourceType.LOCAL_STORAGE: |
| if provider == ImageProvider.USER_UPLOAD: |
| return self.local_storage_dir / 'user_uploads' |
| else: |
| return self.local_storage_dir / 'processed' |
| |
| return self.cache_root |
| |
| def _generate_content_hash(self, image_data: bytes) -> str: |
| """生成图片内容哈希值""" |
| return hashlib.sha256(image_data).hexdigest() |
|
|
| async def get_user_storage_usage_bytes(self, user_id: int, *, refresh_index: bool = True) -> int: |
| """Return total cached bytes for a user scope (best-effort, file-exists checked).""" |
| if refresh_index: |
| await asyncio.get_event_loop().run_in_executor(None, self._load_cache_index) |
|
|
| prefix = f"u{user_id}_" |
| total_size = 0 |
| for cache_key, cache_info in list(self._cache_index.items()): |
| if not cache_key.startswith(prefix): |
| continue |
| try: |
| if Path(cache_info.file_path).exists(): |
| total_size += int(cache_info.file_size or 0) |
| except Exception: |
| continue |
| return total_size |
|
|
| def _get_owner_user_id(self, image_info: ImageInfo) -> Optional[int]: |
| owner = image_info.owner_user_id |
| if owner is None: |
| owner = current_user_id.get() |
| return owner |
|
|
| def _scoped_cache_key(self, owner_user_id: Optional[int], content_hash: str) -> str: |
| scope = f"u{owner_user_id}" if owner_user_id is not None else "public" |
| return f"{scope}_{content_hash}" |
|
|
| def _generate_cache_key(self, image_info: ImageInfo, image_data: bytes = None) -> str: |
| """生成缓存键 - 用户隔离 + 内容哈希。 |
| |
| 新的缓存键为 `{scope}_{sha256}`,其中 scope 为 `u{user_id}` 或 `public`。 |
| """ |
| owner_user_id = self._get_owner_user_id(image_info) |
| if image_data: |
| return self._scoped_cache_key(owner_user_id, self._generate_content_hash(image_data)) |
|
|
| |
| if image_info.image_id and image_info.image_id in self._cache_index: |
| return image_info.image_id |
|
|
| |
| logger.warning(f"Generating cache key without image data for {image_info.image_id}") |
| return hashlib.md5(f"temp_{image_info.image_id}".encode()).hexdigest() |
| |
| async def cache_image(self, image_info: ImageInfo, image_data: bytes) -> str: |
| """缓存图片 - 基于内容去重""" |
| try: |
| |
| owner_user_id = self._get_owner_user_id(image_info) |
| image_info.owner_user_id = owner_user_id |
| if image_info.source_image_id is None: |
| image_info.source_image_id = image_info.image_id |
|
|
| |
| content_hash = self._generate_content_hash(image_data) |
| cache_key = self._scoped_cache_key(owner_user_id, content_hash) |
|
|
| |
| image_info.image_id = cache_key |
|
|
| |
| if cache_key in self._cache_index: |
| existing_cache_info = self._cache_index[cache_key] |
| existing_file_path = Path(existing_cache_info.file_path) |
|
|
| |
| if existing_file_path.exists(): |
| existing_cache_info.update_access() |
|
|
| |
| image_info.local_path = str(existing_file_path) |
|
|
| |
| await self._save_image_metadata(cache_key, image_info) |
|
|
| asyncio.create_task(self._save_cache_index()) |
| logger.debug(f"Image content already cached for user scope: {cache_key}") |
| return cache_key |
| else: |
| |
| del self._cache_index[cache_key] |
|
|
| |
| quota_mb = self.config.get("user_quota_mb", 0) if isinstance(self.config, dict) else 0 |
| try: |
| quota_mb = int(float(quota_mb)) |
| except Exception: |
| quota_mb = 0 |
|
|
| if owner_user_id is not None and quota_mb > 0: |
| quota_bytes = quota_mb * 1024 * 1024 |
|
|
| |
| await asyncio.get_event_loop().run_in_executor(None, self._load_cache_index) |
|
|
| used_bytes = await self.get_user_storage_usage_bytes(owner_user_id, refresh_index=False) |
| if used_bytes + len(image_data) > quota_bytes: |
| raise StorageQuotaExceededError( |
| user_id=owner_user_id, |
| used_bytes=used_bytes, |
| quota_bytes=quota_bytes, |
| ) |
|
|
| |
| if image_info.source_type.value in ['ai_generated', 'web_search']: |
| cache_path = self._get_cache_path(image_info.source_type, image_info.provider) |
| else: |
| |
| cache_path = self.local_storage_dir / 'user_uploads' |
| |
| |
| file_extension = Path(image_info.filename).suffix |
| if not file_extension: |
| file_extension = f".{image_info.metadata.format.value}" |
|
|
| file_path = cache_path / f"{cache_key}{file_extension}" |
|
|
| |
| await asyncio.get_event_loop().run_in_executor( |
| None, self._save_image_file, file_path, image_data |
| ) |
|
|
| |
| image_info.local_path = str(file_path) |
|
|
| |
| cache_info = ImageCacheInfo( |
| cache_key=cache_key, |
| file_path=str(file_path), |
| file_size=len(image_data), |
| created_at=time.time(), |
| last_accessed=time.time(), |
| access_count=1, |
| expires_at=None |
| ) |
|
|
| |
| self._cache_index[cache_key] = cache_info |
|
|
| |
| await self._save_image_metadata(cache_key, image_info) |
|
|
| |
| asyncio.create_task(self._save_cache_index()) |
|
|
| logger.debug(f"Image cached successfully: {cache_key}") |
| return cache_key |
| |
| except Exception as e: |
| logger.error(f"Failed to cache image {image_info.image_id}: {e}") |
| raise |
| |
| def _save_image_file(self, file_path: Path, image_data: bytes): |
| """保存图片文件""" |
| with open(file_path, 'wb') as f: |
| f.write(image_data) |
| |
| async def get_cached_image(self, cache_key: str) -> Optional[Tuple[ImageInfo, Path]]: |
| """获取缓存的图片""" |
| try: |
| cache_info = self._cache_index.get(cache_key) |
| if not cache_info: |
| |
| |
| cache_info = await self._discover_cache_info(cache_key) |
| if not cache_info: |
| return None |
|
|
| |
| file_path = Path(cache_info.file_path) |
| if not file_path.exists(): |
| await self.remove_from_cache(cache_key) |
| return None |
|
|
| |
| image_info = await self._load_image_metadata(cache_key) |
| if not image_info: |
| |
| |
| logger.debug(f"No metadata found for {cache_key}, creating basic ImageInfo") |
| from ..models import ImageInfo, ImageMetadata, ImageFormat, ImageSourceType, ImageProvider |
|
|
| |
| file_extension = file_path.suffix.lower() |
| format_map = { |
| '.jpg': ImageFormat.JPEG, |
| '.jpeg': ImageFormat.JPEG, |
| '.png': ImageFormat.PNG, |
| '.webp': ImageFormat.WEBP, |
| '.gif': ImageFormat.GIF, |
| '.svg': ImageFormat.SVG, |
| } |
|
|
| image_format = format_map.get(file_extension, ImageFormat.JPEG) |
|
|
| |
| metadata = ImageMetadata( |
| format=image_format, |
| width=0, |
| height=0, |
| file_size=cache_info.file_size |
| ) |
|
|
| |
| image_info = ImageInfo( |
| image_id=cache_key, |
| filename=file_path.name, |
| local_path=str(file_path), |
| source_type=ImageSourceType.LOCAL_STORAGE, |
| provider=ImageProvider.LOCAL_STORAGE, |
| metadata=metadata |
| ) |
|
|
| |
| cache_info.update_access() |
| asyncio.create_task(self._save_cache_index()) |
|
|
| logger.debug(f"Cache hit: {cache_key}") |
| return image_info, file_path |
|
|
| except Exception as e: |
| logger.error(f"Failed to get cached image {cache_key}: {e}") |
| return None |
|
|
| async def _discover_cache_info(self, cache_key: str) -> Optional[ImageCacheInfo]: |
| """Discover a cached image file on disk and rebuild a minimal cache index entry.""" |
| cache_dirs = [self.ai_generated_dir, self.web_search_dir, self.local_storage_dir] |
| exts = [".webp", ".jpg", ".jpeg", ".png", ".gif", ".svg", ".bmp", ".tiff", ".tif"] |
|
|
| def _find_file() -> Optional[Path]: |
| for base_dir in cache_dirs: |
| if not base_dir.exists(): |
| continue |
| for ext in exts: |
| matches = list(base_dir.glob(f"**/{cache_key}{ext}")) |
| if matches: |
| matches.sort(key=lambda p: p.stat().st_mtime, reverse=True) |
| return matches[0] |
| return None |
|
|
| try: |
| found = await asyncio.get_event_loop().run_in_executor(None, _find_file) |
| if not found or not found.exists(): |
| return None |
|
|
| stat = found.stat() |
| cache_info = ImageCacheInfo( |
| cache_key=cache_key, |
| file_path=str(found), |
| file_size=stat.st_size, |
| created_at=stat.st_ctime, |
| last_accessed=stat.st_atime, |
| access_count=1, |
| expires_at=None, |
| ) |
| self._cache_index[cache_key] = cache_info |
| logger.debug(f"Discovered cache entry from filesystem: {cache_key} -> {found}") |
| return cache_info |
| except Exception as e: |
| logger.warning(f"Failed to discover cache entry for {cache_key}: {e}") |
| return None |
| |
| async def is_cached(self, image_info: ImageInfo, image_data: bytes = None) -> Optional[str]: |
| """检查图片是否已缓存""" |
| |
| if image_info.image_id: |
| cache_info = self._cache_index.get(image_info.image_id) |
| if cache_info and Path(cache_info.file_path).exists(): |
| return image_info.image_id |
|
|
| |
| if image_data: |
| cache_key = self._generate_cache_key(image_info, image_data) |
| cache_info = self._cache_index.get(cache_key) |
| if cache_info: |
| file_path = Path(cache_info.file_path) |
| if file_path.exists(): |
| return cache_key |
|
|
| |
| cache_key = self._generate_cache_key(image_info) |
| cache_info = self._cache_index.get(cache_key) |
|
|
| if cache_info: |
| file_path = Path(cache_info.file_path) |
| if file_path.exists(): |
| return cache_key |
|
|
| return None |
| |
| async def remove_from_cache(self, cache_key: str) -> bool: |
| """从缓存中移除图片""" |
| try: |
| cache_info = self._cache_index.get(cache_key) |
| if not cache_info: |
| return False |
| |
| |
| file_path = Path(cache_info.file_path) |
| if file_path.exists(): |
| await asyncio.get_event_loop().run_in_executor(None, file_path.unlink) |
| |
| |
| thumbnail_path = self.thumbnails_dir / f"{cache_key}.jpg" |
| if thumbnail_path.exists(): |
| await asyncio.get_event_loop().run_in_executor(None, thumbnail_path.unlink) |
| |
| |
| metadata_path = self.metadata_dir / f"{cache_key}.json" |
| if metadata_path.exists(): |
| await asyncio.get_event_loop().run_in_executor(None, metadata_path.unlink) |
| |
| |
| del self._cache_index[cache_key] |
| |
| |
| await self._save_cache_index() |
| |
| logger.info(f"Removed from cache: {cache_key}") |
| return True |
| |
| except Exception as e: |
| logger.error(f"Failed to remove from cache {cache_key}: {e}") |
| return False |
| |
| async def _save_image_metadata(self, cache_key: str, image_info: ImageInfo): |
| """保存图片元数据""" |
| metadata_path = self.metadata_dir / f"{cache_key}.json" |
| metadata = image_info.model_dump() |
|
|
| def _save(): |
| with open(metadata_path, 'w', encoding='utf-8') as f: |
| json.dump(metadata, f, ensure_ascii=False, indent=2) |
|
|
| await asyncio.get_event_loop().run_in_executor(None, _save) |
|
|
| async def _save_image_metadata_reference(self, content_hash: str, image_info: ImageInfo): |
| """保存图片元数据引用 - 为同一内容的图片保存多个引用""" |
| |
| reference_filename = f"{content_hash}_{image_info.image_id}.json" |
| reference_path = self.metadata_dir / "references" / reference_filename |
|
|
| |
| reference_path.parent.mkdir(exist_ok=True) |
|
|
| metadata = image_info.model_dump() |
|
|
| def _save(): |
| with open(reference_path, 'w', encoding='utf-8') as f: |
| json.dump(metadata, f, ensure_ascii=False, indent=2) |
|
|
| await asyncio.get_event_loop().run_in_executor(None, _save) |
| logger.debug(f"Saved metadata reference: {reference_filename}") |
| |
| async def _load_image_metadata(self, cache_key: str) -> Optional[ImageInfo]: |
| """加载图片元数据""" |
| metadata_path = self.metadata_dir / f"{cache_key}.json" |
| if not metadata_path.exists(): |
| |
| logger.debug(f"Metadata file not found: {metadata_path}") |
| return None |
|
|
| def _load(): |
| with open(metadata_path, 'r', encoding='utf-8') as f: |
| return json.load(f) |
|
|
| try: |
| metadata = await asyncio.get_event_loop().run_in_executor(None, _load) |
| image_info = ImageInfo(**metadata) |
| logger.debug(f"Successfully loaded metadata for cache key: {cache_key}") |
| return image_info |
| except Exception as e: |
| logger.error(f"Failed to load image metadata {cache_key}: {e}") |
| logger.error(f"Metadata file path: {metadata_path}") |
| return None |
| |
| def _load_cache_index(self): |
| """加载缓存索引 - 简化版本,直接扫描文件系统""" |
| try: |
| |
| self._cache_index.clear() |
|
|
| |
| cache_dirs = [ |
| self.ai_generated_dir, |
| self.web_search_dir, |
| self.local_storage_dir |
| ] |
|
|
| total_files = 0 |
| for cache_dir in cache_dirs: |
| if cache_dir.exists(): |
| |
| for file_path in cache_dir.rglob('*'): |
| if file_path.is_file() and file_path.suffix.lower() in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.bmp', '.tiff', '.tif']: |
| try: |
| |
| cache_key = file_path.stem |
|
|
| |
| stat = file_path.stat() |
| cache_info = ImageCacheInfo( |
| cache_key=cache_key, |
| file_path=str(file_path), |
| file_size=stat.st_size, |
| created_at=stat.st_ctime, |
| last_accessed=stat.st_atime, |
| access_count=1, |
| expires_at=None |
| ) |
|
|
| self._cache_index[cache_key] = cache_info |
| total_files += 1 |
|
|
| except Exception as e: |
| logger.warning(f"Failed to process cache file {file_path}: {e}") |
|
|
| logger.debug(f"Loaded {total_files} cached images from filesystem") |
|
|
| except Exception as e: |
| logger.error(f"Failed to load cache index: {e}") |
| |
| if not self._cache_index: |
| self._cache_index = {} |
| |
| async def _save_cache_index(self): |
| """保存缓存索引 - 简化版本,不再保存JSON文件""" |
| |
| logger.debug("Cache index save skipped - using filesystem-based indexing") |
| |
| |
| |
| async def get_cache_size(self) -> int: |
| """获取缓存总大小""" |
| total_size = 0 |
| for cache_info in self._cache_index.values(): |
| total_size += cache_info.file_size |
| return total_size |
| |
| async def get_cache_stats(self) -> Dict[str, Any]: |
| """获取缓存统计信息""" |
| total_entries = len(self._cache_index) |
| total_size = await self.get_cache_size() |
| |
| |
| source_stats = {} |
| for cache_info in self._cache_index.values(): |
| file_path = Path(cache_info.file_path) |
| source_type = file_path.parent.parent.name if file_path.parent.parent.name in ['ai_generated', 'web_search', 'local_storage'] else 'unknown' |
| |
| if source_type not in source_stats: |
| source_stats[source_type] = {'count': 0, 'size': 0} |
| |
| source_stats[source_type]['count'] += 1 |
| source_stats[source_type]['size'] += cache_info.file_size |
| |
| |
| categories = {} |
| for source_type, stats in source_stats.items(): |
| categories[source_type] = stats['count'] |
|
|
| return { |
| 'total_entries': total_entries, |
| 'total_size_bytes': total_size, |
| 'total_size_mb': total_size / (1024 * 1024), |
| 'categories': categories, |
| 'source_stats': source_stats |
| } |
| |
| async def clear_cache(self, source_type: Optional[ImageSourceType] = None) -> int: |
| """清空缓存""" |
| if source_type: |
| |
| keys_to_remove = [] |
| for cache_key, cache_info in self._cache_index.items(): |
| file_path = Path(cache_info.file_path) |
| if source_type.value in str(file_path): |
| keys_to_remove.append(cache_key) |
|
|
| for cache_key in keys_to_remove: |
| await self.remove_from_cache(cache_key) |
|
|
| return len(keys_to_remove) |
| else: |
| |
| keys_to_remove = list(self._cache_index.keys()) |
|
|
| |
| for cache_key in keys_to_remove: |
| await self.remove_from_cache(cache_key) |
|
|
| |
| await self._clear_orphaned_files() |
|
|
| return len(keys_to_remove) |
|
|
| async def _clear_orphaned_files(self): |
| """清理孤立的缓存文件""" |
| try: |
| def _clear_directory_contents(directory: Path): |
| """清理目录中的所有文件,但保留目录结构""" |
| if not directory.exists(): |
| return |
|
|
| for item in directory.iterdir(): |
| if item.is_file(): |
| item.unlink() |
| elif item.is_dir(): |
| |
| _clear_directory_contents(item) |
|
|
| |
| directories_to_clear = [ |
| self.ai_generated_dir, |
| self.web_search_dir, |
| self.local_storage_dir, |
| self.metadata_dir, |
| self.thumbnails_dir |
| ] |
|
|
| for directory in directories_to_clear: |
| await asyncio.get_event_loop().run_in_executor(None, _clear_directory_contents, directory) |
|
|
| logger.info("Cleared all orphaned cache files") |
|
|
| except Exception as e: |
| logger.error(f"Failed to clear orphaned files: {e}") |
| |
|
|
| async def deduplicate_cache(self) -> int: |
| """去重缓存中的重复图片""" |
| try: |
| content_hash_to_keys = {} |
| keys_to_remove = [] |
|
|
| |
| for cache_key, cache_info in self._cache_index.items(): |
| file_path = Path(cache_info.file_path) |
| if not file_path.exists(): |
| keys_to_remove.append(cache_key) |
| continue |
|
|
| try: |
| |
| with open(file_path, 'rb') as f: |
| content = f.read() |
| content_hash = hashlib.md5(content).hexdigest() |
|
|
| if content_hash not in content_hash_to_keys: |
| content_hash_to_keys[content_hash] = [] |
| content_hash_to_keys[content_hash].append((cache_key, cache_info)) |
|
|
| except Exception as e: |
| logger.warning(f"Failed to read file for deduplication {file_path}: {e}") |
| keys_to_remove.append(cache_key) |
|
|
| |
| for content_hash, key_info_list in content_hash_to_keys.items(): |
| if len(key_info_list) > 1: |
| |
| key_info_list.sort(key=lambda x: x[1].created_at, reverse=True) |
|
|
| |
| for cache_key, cache_info in key_info_list[1:]: |
| keys_to_remove.append(cache_key) |
| logger.info(f"Marking duplicate cache entry for removal: {cache_key}") |
|
|
| |
| for cache_key in keys_to_remove: |
| await self.remove_from_cache(cache_key) |
|
|
| logger.info(f"Removed {len(keys_to_remove)} duplicate/invalid cache entries") |
| return len(keys_to_remove) |
|
|
| except Exception as e: |
| logger.error(f"Failed to deduplicate cache: {e}") |
| return 0 |
|
|