File size: 28,707 Bytes
6aecb2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
"""
图片缓存管理器
"""

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

        # 如果 image_id 已经是缓存键,直接复用
        if image_info.image_id and image_info.image_id in self._cache_index:
            return image_info.image_id

        # 如果没有图片数据,使用图片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(用于隔离图床数据)
            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)

            # 以 cache_key 作为对外暴露的 image_id,避免跨用户 ID 冲突
            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)

                    # 更新该 key 的元数据(幂等)
                    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]

            # Enforce per-user storage quota (only when writing a new cache entry)
            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

                # Multi-worker processes: refresh filesystem index so new uploads/deletes are visible across workers.
                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,
                    )

            # 选择存储路径 - 优先使用AI生成或网络搜索的路径
            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:
                # Fallback: multi-worker processes may have stale in-memory index.
                # Discover the cached file on disk by cache_key and rebuild index entry on demand.
                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:
                # 如果没有元数据文件,创建一个基础的ImageInfo对象
                # 这在PDF转换等场景下是正常的,只需要图片文件本身
                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
                )

                # 创建基础ImageInfo
                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]:
        """检查图片是否已缓存"""
        # 如果 image_id 本身就是缓存键,直接校验
        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):
        """保存图片元数据引用 - 为同一内容的图片保存多个引用"""
        # 创建引用文件名:content_hash_image_id.json
        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():
            # 降低日志级别,避免在PDF转换等场景下产生不必要的警告
            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
        
        # 转换为API需要的格式
        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