2026-03-18 23:05:28 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 媒体缓存管理器
|
|
|
|
|
|
* @module src/infrastructure/cache/MediaCacheManager
|
|
|
|
|
|
* @description 管理图片、视频、音频缓存,支持多种清理策略
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { File, Paths } from 'expo-file-system';
|
|
|
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
2026-04-07 16:27:02 +08:00
|
|
|
|
import { Platform } from 'react-native';
|
2026-03-18 23:05:28 +08:00
|
|
|
|
import {
|
|
|
|
|
|
MediaType,
|
|
|
|
|
|
CleanupTrigger,
|
|
|
|
|
|
CacheEntry,
|
|
|
|
|
|
CacheStats,
|
|
|
|
|
|
CleanupPolicy,
|
|
|
|
|
|
CleanupResult,
|
|
|
|
|
|
MediaCacheConfig,
|
|
|
|
|
|
DEFAULT_MEDIA_CACHE_CONFIG,
|
|
|
|
|
|
DEFAULT_CLEANUP_POLICY,
|
|
|
|
|
|
} from './types';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 缓存记录存储键前缀
|
|
|
|
|
|
*/
|
|
|
|
|
|
const CACHE_RECORD_PREFIX = 'media_cache_record_';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取缓存目录路径
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getCacheDir(): string {
|
|
|
|
|
|
const cachePath = Paths.cache;
|
|
|
|
|
|
return `${cachePath}media_cache/`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取图片缓存目录
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getImageDir(): string {
|
|
|
|
|
|
return `${getCacheDir()}images/`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取视频缓存目录
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getVideoDir(): string {
|
|
|
|
|
|
return `${getCacheDir()}videos/`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取音频缓存目录
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getAudioDir(): string {
|
|
|
|
|
|
return `${getCacheDir()}audios/`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 媒体缓存管理器类
|
|
|
|
|
|
* @class MediaCacheManager
|
|
|
|
|
|
* @description 单例模式,管理所有媒体文件的缓存
|
|
|
|
|
|
*/
|
|
|
|
|
|
export class MediaCacheManager {
|
|
|
|
|
|
private static instance: MediaCacheManager;
|
|
|
|
|
|
|
|
|
|
|
|
private cache: Map<string, CacheEntry> = new Map();
|
|
|
|
|
|
private config: MediaCacheConfig;
|
|
|
|
|
|
private policy: CleanupPolicy;
|
|
|
|
|
|
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
private lastCleanupTime: number = 0;
|
|
|
|
|
|
private isInitialized: boolean = false;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取单例实例
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static getInstance(): MediaCacheManager {
|
|
|
|
|
|
if (!MediaCacheManager.instance) {
|
|
|
|
|
|
MediaCacheManager.instance = new MediaCacheManager();
|
|
|
|
|
|
}
|
|
|
|
|
|
return MediaCacheManager.instance;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 私有构造函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
private constructor(
|
|
|
|
|
|
config?: Partial<MediaCacheConfig>,
|
|
|
|
|
|
policy?: Partial<CleanupPolicy>
|
|
|
|
|
|
) {
|
|
|
|
|
|
this.config = { ...DEFAULT_MEDIA_CACHE_CONFIG, ...config };
|
|
|
|
|
|
this.policy = { ...DEFAULT_CLEANUP_POLICY, ...policy };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 初始化缓存管理器
|
|
|
|
|
|
* 确保缓存目录存在并加载缓存记录
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async initialize(): Promise<void> {
|
|
|
|
|
|
if (this.isInitialized) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 确保缓存目录存在
|
|
|
|
|
|
await this.ensureDirectories();
|
|
|
|
|
|
|
|
|
|
|
|
// 加载缓存记录
|
|
|
|
|
|
await this.loadCacheRecords();
|
|
|
|
|
|
|
2026-04-07 16:27:02 +08:00
|
|
|
|
// 启动时清理 (非web平台)
|
|
|
|
|
|
if (this.policy.cleanupOnStart && Platform.OS !== 'web') {
|
2026-03-18 23:05:28 +08:00
|
|
|
|
await this.cleanup(CleanupTrigger.STARTUP);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 16:27:02 +08:00
|
|
|
|
// 启动定期清理 (非web平台)
|
|
|
|
|
|
if (Platform.OS !== 'web') {
|
|
|
|
|
|
this.startPeriodicCleanup();
|
|
|
|
|
|
}
|
2026-03-18 23:05:28 +08:00
|
|
|
|
|
|
|
|
|
|
this.isInitialized = true;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[MediaCacheManager] 初始化失败:', error);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 确保缓存目录存在
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async ensureDirectories(): Promise<void> {
|
2026-04-07 16:27:02 +08:00
|
|
|
|
if (Platform.OS === 'web') return;
|
|
|
|
|
|
|
2026-03-18 23:05:28 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
|
|
|
|
|
|
for (const dir of dirs) {
|
|
|
|
|
|
const dirFile = new File(dir);
|
|
|
|
|
|
if (!await dirFile.exists) {
|
|
|
|
|
|
await dirFile.create();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[MediaCacheManager] 创建缓存目录失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从持久化存储加载缓存记录
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async loadCacheRecords(): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const keys = await AsyncStorage.getAllKeys();
|
|
|
|
|
|
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
|
|
|
|
|
|
|
|
|
|
|
|
if (recordKeys.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const records = await AsyncStorage.multiGet(recordKeys);
|
|
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of records) {
|
|
|
|
|
|
if (value) {
|
|
|
|
|
|
const entry: CacheEntry = JSON.parse(value);
|
|
|
|
|
|
// 验证文件是否存在
|
|
|
|
|
|
const exists = await this.checkFileExists(entry.localPath);
|
|
|
|
|
|
if (exists) {
|
|
|
|
|
|
this.cache.set(entry.key, entry);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 文件不存在,删除记录
|
|
|
|
|
|
await AsyncStorage.removeItem(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[MediaCacheManager] 加载缓存记录失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 检查文件是否存在
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async checkFileExists(path: string): Promise<boolean> {
|
2026-04-07 16:27:02 +08:00
|
|
|
|
if (Platform.OS === 'web') return false;
|
|
|
|
|
|
|
2026-03-18 23:05:28 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const file = new File(path);
|
|
|
|
|
|
return await file.exists;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 记录缓存访问(用于LRU)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async recordAccess(key: string): Promise<void> {
|
|
|
|
|
|
const entry = this.cache.get(key);
|
|
|
|
|
|
if (entry) {
|
|
|
|
|
|
entry.lastAccessedAt = Date.now();
|
|
|
|
|
|
await this.saveCacheRecord(entry);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 缓存媒体文件
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async cacheMedia(
|
|
|
|
|
|
uri: string,
|
|
|
|
|
|
type: MediaType,
|
|
|
|
|
|
options: {
|
|
|
|
|
|
conversationId?: string;
|
|
|
|
|
|
messageId?: string;
|
|
|
|
|
|
size?: number;
|
|
|
|
|
|
} = {}
|
|
|
|
|
|
): Promise<string> {
|
2026-04-07 16:27:02 +08:00
|
|
|
|
if (Platform.OS === 'web') {
|
|
|
|
|
|
return uri;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 23:05:28 +08:00
|
|
|
|
const { conversationId, messageId, size = 0 } = options;
|
|
|
|
|
|
|
|
|
|
|
|
// 生成唯一键
|
|
|
|
|
|
const key = this.generateCacheKey(uri, type);
|
|
|
|
|
|
const localPath = this.getMediaPath(type, key);
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否已缓存
|
|
|
|
|
|
const existingEntry = this.cache.get(key);
|
|
|
|
|
|
if (existingEntry) {
|
|
|
|
|
|
await this.recordAccess(key);
|
|
|
|
|
|
return existingEntry.localPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 下载文件
|
|
|
|
|
|
const destination = new File(localPath);
|
|
|
|
|
|
const downloaded = await File.downloadFileAsync(uri, destination);
|
|
|
|
|
|
|
|
|
|
|
|
// 获取实际文件大小
|
|
|
|
|
|
let fileSize = size;
|
|
|
|
|
|
// fileSize 为 0 时使用默认大小,实际应用中可以通过其他方式获取
|
|
|
|
|
|
|
|
|
|
|
|
// 创建缓存条目
|
|
|
|
|
|
const entry: CacheEntry = {
|
|
|
|
|
|
key,
|
|
|
|
|
|
type,
|
|
|
|
|
|
uri,
|
|
|
|
|
|
localPath: downloaded.uri,
|
|
|
|
|
|
size: fileSize,
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
messageId,
|
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
|
lastAccessedAt: Date.now(),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 保存记录
|
|
|
|
|
|
this.cache.set(key, entry);
|
|
|
|
|
|
await this.saveCacheRecord(entry);
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否需要清理
|
|
|
|
|
|
await this.checkAndCleanup();
|
|
|
|
|
|
|
|
|
|
|
|
return downloaded.uri;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[MediaCacheManager] 缓存媒体失败:', error);
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取缓存的媒体路径
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async getCachedMedia(uri: string, type: MediaType): Promise<string | null> {
|
|
|
|
|
|
const key = this.generateCacheKey(uri, type);
|
|
|
|
|
|
const entry = this.cache.get(key);
|
|
|
|
|
|
|
|
|
|
|
|
if (entry) {
|
|
|
|
|
|
const exists = await this.checkFileExists(entry.localPath);
|
|
|
|
|
|
if (exists) {
|
|
|
|
|
|
await this.recordAccess(key);
|
|
|
|
|
|
return entry.localPath;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 文件不存在,删除记录
|
|
|
|
|
|
this.cache.delete(key);
|
|
|
|
|
|
await this.removeCacheRecord(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取缓存统计信息
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getCacheStats(): CacheStats {
|
|
|
|
|
|
const stats: CacheStats = {
|
|
|
|
|
|
totalSize: 0,
|
|
|
|
|
|
imageCount: 0,
|
|
|
|
|
|
videoCount: 0,
|
|
|
|
|
|
audioCount: 0,
|
|
|
|
|
|
totalEntries: this.cache.size,
|
|
|
|
|
|
oldestItem: Date.now(),
|
|
|
|
|
|
newestItem: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
for (const entry of this.cache.values()) {
|
|
|
|
|
|
stats.totalSize += entry.size;
|
|
|
|
|
|
stats.oldestItem = Math.min(stats.oldestItem, entry.createdAt);
|
|
|
|
|
|
stats.newestItem = Math.max(stats.newestItem, entry.createdAt);
|
|
|
|
|
|
|
|
|
|
|
|
switch (entry.type) {
|
|
|
|
|
|
case MediaType.IMAGE:
|
|
|
|
|
|
stats.imageCount++;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case MediaType.VIDEO:
|
|
|
|
|
|
stats.videoCount++;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case MediaType.AUDIO:
|
|
|
|
|
|
stats.audioCount++;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return stats;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理操作入口
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async cleanup(trigger: CleanupTrigger): Promise<CleanupResult> {
|
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
let deletedCount = 0;
|
|
|
|
|
|
let freedSize = 0;
|
|
|
|
|
|
const deletedItems: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 1. 清理过期缓存
|
|
|
|
|
|
const expiredResult = await this.cleanupExpired();
|
|
|
|
|
|
deletedCount += expiredResult.deletedCount;
|
|
|
|
|
|
freedSize += expiredResult.freedSize;
|
|
|
|
|
|
deletedItems.push(...expiredResult.deletedItems || []);
|
|
|
|
|
|
errors.push(...expiredResult.errors);
|
|
|
|
|
|
|
|
|
|
|
|
// 2. LRU清理(如果启用)
|
|
|
|
|
|
if (this.policy.enableLRU) {
|
|
|
|
|
|
const lruResult = await this.cleanupLRU();
|
|
|
|
|
|
deletedCount += lruResult.deletedCount;
|
|
|
|
|
|
freedSize += lruResult.freedSize;
|
|
|
|
|
|
deletedItems.push(...lruResult.deletedItems || []);
|
|
|
|
|
|
errors.push(...lruResult.errors);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 阈值清理
|
|
|
|
|
|
const thresholdResult = await this.cleanupByThreshold();
|
|
|
|
|
|
deletedCount += thresholdResult.deletedCount;
|
|
|
|
|
|
freedSize += thresholdResult.freedSize;
|
|
|
|
|
|
deletedItems.push(...thresholdResult.deletedItems || []);
|
|
|
|
|
|
errors.push(...thresholdResult.errors);
|
|
|
|
|
|
|
|
|
|
|
|
this.lastCleanupTime = Date.now();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
errors.push(`清理失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
trigger,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
deletedCount,
|
|
|
|
|
|
freedSize,
|
|
|
|
|
|
errors,
|
|
|
|
|
|
deletedItems,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理过期缓存
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async cleanupExpired(): Promise<CleanupResult> {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const maxAge = this.policy.maxAge;
|
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
let deletedCount = 0;
|
|
|
|
|
|
let freedSize = 0;
|
|
|
|
|
|
const deletedItems: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
const entriesToDelete: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const [key, entry] of this.cache.entries()) {
|
|
|
|
|
|
if (now - entry.lastAccessedAt > maxAge) {
|
|
|
|
|
|
entriesToDelete.push(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of entriesToDelete) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const entry = this.cache.get(key);
|
|
|
|
|
|
if (entry) {
|
|
|
|
|
|
const file = new File(entry.localPath);
|
|
|
|
|
|
if (await file.exists) {
|
|
|
|
|
|
await file.delete();
|
|
|
|
|
|
}
|
|
|
|
|
|
this.cache.delete(key);
|
|
|
|
|
|
await this.removeCacheRecord(key);
|
|
|
|
|
|
|
|
|
|
|
|
deletedCount++;
|
|
|
|
|
|
freedSize += entry.size;
|
|
|
|
|
|
deletedItems.push(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
trigger: CleanupTrigger.MANUAL,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
deletedCount,
|
|
|
|
|
|
freedSize,
|
|
|
|
|
|
errors,
|
|
|
|
|
|
deletedItems,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* LRU清理 - 删除最近最少使用的缓存
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async cleanupLRU(targetSize?: number): Promise<CleanupResult> {
|
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
let deletedCount = 0;
|
|
|
|
|
|
let freedSize = 0;
|
|
|
|
|
|
const deletedItems: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 如果没有指定目标大小,使用最大缓存大小的一半
|
|
|
|
|
|
const target = targetSize || Math.floor(this.policy.maxSize / 2);
|
|
|
|
|
|
const currentStats = this.getCacheStats();
|
|
|
|
|
|
|
|
|
|
|
|
if (currentStats.totalSize <= target) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
trigger: CleanupTrigger.MANUAL,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
deletedCount: 0,
|
|
|
|
|
|
freedSize: 0,
|
|
|
|
|
|
errors: [],
|
|
|
|
|
|
deletedItems: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按最后访问时间排序
|
|
|
|
|
|
const sortedEntries = Array.from(this.cache.entries())
|
|
|
|
|
|
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
|
|
|
|
|
|
|
|
|
|
|
|
let currentSize = currentStats.totalSize;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [key, entry] of sortedEntries) {
|
|
|
|
|
|
if (currentSize <= target) break;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const file = new File(entry.localPath);
|
|
|
|
|
|
if (await file.exists) {
|
|
|
|
|
|
await file.delete();
|
|
|
|
|
|
}
|
|
|
|
|
|
this.cache.delete(key);
|
|
|
|
|
|
await this.removeCacheRecord(key);
|
|
|
|
|
|
|
|
|
|
|
|
deletedCount++;
|
|
|
|
|
|
freedSize += entry.size;
|
|
|
|
|
|
deletedItems.push(key);
|
|
|
|
|
|
currentSize -= entry.size;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
trigger: CleanupTrigger.MANUAL,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
deletedCount,
|
|
|
|
|
|
freedSize,
|
|
|
|
|
|
errors,
|
|
|
|
|
|
deletedItems,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 按阈值清理 - 超过最大大小时清理
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async cleanupByThreshold(): Promise<CleanupResult> {
|
|
|
|
|
|
const currentStats = this.getCacheStats();
|
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
let deletedCount = 0;
|
|
|
|
|
|
let freedSize = 0;
|
|
|
|
|
|
const deletedItems: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 检查总大小是否超过限制
|
|
|
|
|
|
if (currentStats.totalSize <= this.policy.maxSize) {
|
|
|
|
|
|
// 检查条目数是否超过限制
|
|
|
|
|
|
if (currentStats.totalEntries <= this.policy.maxEntries) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
trigger: CleanupTrigger.MANUAL,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
deletedCount: 0,
|
|
|
|
|
|
freedSize: 0,
|
|
|
|
|
|
errors: [],
|
|
|
|
|
|
deletedItems: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 需要清理
|
|
|
|
|
|
const targetSize = Math.floor(this.policy.maxSize * 0.8); // 清理到80%
|
|
|
|
|
|
const targetEntries = Math.floor(this.policy.maxEntries * 0.8);
|
|
|
|
|
|
|
|
|
|
|
|
// 按LRU排序
|
|
|
|
|
|
const sortedEntries = Array.from(this.cache.entries())
|
|
|
|
|
|
.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
|
|
|
|
|
|
|
|
|
|
|
|
let currentSize = currentStats.totalSize;
|
|
|
|
|
|
let currentEntries = currentStats.totalEntries;
|
|
|
|
|
|
|
|
|
|
|
|
for (const [key, entry] of sortedEntries) {
|
|
|
|
|
|
// 两个条件:大小或条目数
|
|
|
|
|
|
if (currentSize <= targetSize && currentEntries <= targetEntries) break;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const file = new File(entry.localPath);
|
|
|
|
|
|
if (await file.exists) {
|
|
|
|
|
|
await file.delete();
|
|
|
|
|
|
}
|
|
|
|
|
|
this.cache.delete(key);
|
|
|
|
|
|
await this.removeCacheRecord(key);
|
|
|
|
|
|
|
|
|
|
|
|
deletedCount++;
|
|
|
|
|
|
freedSize += entry.size;
|
|
|
|
|
|
deletedItems.push(key);
|
|
|
|
|
|
currentSize -= entry.size;
|
|
|
|
|
|
currentEntries--;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
trigger: CleanupTrigger.MANUAL,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
deletedCount,
|
|
|
|
|
|
freedSize,
|
|
|
|
|
|
errors,
|
|
|
|
|
|
deletedItems,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 删除指定会话的所有媒体缓存
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async clearConversationMedia(conversationId: string): Promise<CleanupResult> {
|
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
let deletedCount = 0;
|
|
|
|
|
|
let freedSize = 0;
|
|
|
|
|
|
const deletedItems: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
const entriesToDelete: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const [key, entry] of this.cache.entries()) {
|
|
|
|
|
|
if (entry.conversationId === conversationId) {
|
|
|
|
|
|
entriesToDelete.push(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const key of entriesToDelete) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const entry = this.cache.get(key);
|
|
|
|
|
|
if (entry) {
|
|
|
|
|
|
const file = new File(entry.localPath);
|
|
|
|
|
|
if (await file.exists) {
|
|
|
|
|
|
await file.delete();
|
|
|
|
|
|
}
|
|
|
|
|
|
this.cache.delete(key);
|
|
|
|
|
|
await this.removeCacheRecord(key);
|
|
|
|
|
|
|
|
|
|
|
|
deletedCount++;
|
|
|
|
|
|
freedSize += entry.size;
|
|
|
|
|
|
deletedItems.push(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
errors.push(`删除 ${key} 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
trigger: CleanupTrigger.CONVERSATION_DELETED,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
deletedCount,
|
|
|
|
|
|
freedSize,
|
|
|
|
|
|
errors,
|
|
|
|
|
|
deletedItems,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清空所有缓存
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async clearAll(): Promise<void> {
|
|
|
|
|
|
for (const entry of this.cache.values()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const file = new File(entry.localPath);
|
|
|
|
|
|
if (await file.exists) {
|
|
|
|
|
|
await file.delete();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(`[MediaCacheManager] 删除文件失败: ${entry.localPath}`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.cache.clear();
|
|
|
|
|
|
|
|
|
|
|
|
// 清除所有记录
|
|
|
|
|
|
const keys = await AsyncStorage.getAllKeys();
|
|
|
|
|
|
const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX));
|
|
|
|
|
|
if (recordKeys.length > 0) {
|
|
|
|
|
|
await AsyncStorage.multiRemove(recordKeys);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 启动定期清理
|
|
|
|
|
|
*/
|
|
|
|
|
|
public startPeriodicCleanup(): void {
|
|
|
|
|
|
if (this.cleanupTimer) {
|
|
|
|
|
|
clearInterval(this.cleanupTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.cleanupTimer = setInterval(async () => {
|
|
|
|
|
|
// 检查是否需要清理
|
|
|
|
|
|
if (Date.now() - this.lastCleanupTime >= this.policy.cleanupInterval) {
|
|
|
|
|
|
await this.cleanup(CleanupTrigger.SCHEDULED);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, this.policy.cleanupInterval);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 停止定期清理
|
|
|
|
|
|
*/
|
|
|
|
|
|
public stopPeriodicCleanup(): void {
|
|
|
|
|
|
if (this.cleanupTimer) {
|
|
|
|
|
|
clearInterval(this.cleanupTimer);
|
|
|
|
|
|
this.cleanupTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 检查是否需要清理
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async checkAndCleanup(): Promise<void> {
|
|
|
|
|
|
const stats = this.getCacheStats();
|
|
|
|
|
|
|
|
|
|
|
|
// 检查大小
|
|
|
|
|
|
if (stats.totalSize > this.policy.maxSize) {
|
|
|
|
|
|
await this.cleanupLRU();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查条目数
|
|
|
|
|
|
if (stats.totalEntries > this.policy.maxEntries) {
|
|
|
|
|
|
await this.cleanupLRU();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 保存缓存记录到持久化存储
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async saveCacheRecord(entry: CacheEntry): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const key = `${CACHE_RECORD_PREFIX}${entry.key}`;
|
|
|
|
|
|
await AsyncStorage.setItem(key, JSON.stringify(entry));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[MediaCacheManager] 保存缓存记录失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 删除缓存记录
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async removeCacheRecord(key: string): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const storageKey = `${CACHE_RECORD_PREFIX}${key}`;
|
|
|
|
|
|
await AsyncStorage.removeItem(storageKey);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[MediaCacheManager] 删除缓存记录失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 生成缓存键
|
|
|
|
|
|
*/
|
|
|
|
|
|
private generateCacheKey(uri: string, type: MediaType): string {
|
|
|
|
|
|
const hash = this.hashString(uri);
|
|
|
|
|
|
return `${type.toLowerCase()}_${hash}_${Date.now()}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取媒体文件存储路径
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getMediaPath(type: MediaType, key: string): string {
|
|
|
|
|
|
const ext = this.getExtension(key, type);
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case MediaType.IMAGE:
|
|
|
|
|
|
return `${getImageDir()}${key}.${ext}`;
|
|
|
|
|
|
case MediaType.VIDEO:
|
|
|
|
|
|
return `${getVideoDir()}${key}.${ext}`;
|
|
|
|
|
|
case MediaType.AUDIO:
|
|
|
|
|
|
return `${getAudioDir()}${key}.${ext}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 根据类型获取文件扩展名
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getExtension(key: string, type: MediaType): string {
|
|
|
|
|
|
if (type === MediaType.IMAGE) {
|
|
|
|
|
|
if (key.includes('gif')) return 'gif';
|
|
|
|
|
|
if (key.includes('webp')) return 'webp';
|
|
|
|
|
|
return 'jpg';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === MediaType.VIDEO) return 'mp4';
|
|
|
|
|
|
if (type === MediaType.AUDIO) return 'mp3';
|
|
|
|
|
|
return 'bin';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 字符串哈希函数
|
|
|
|
|
|
*/
|
|
|
|
|
|
private hashString(str: string): string {
|
|
|
|
|
|
let hash = 0;
|
|
|
|
|
|
for (let i = 0; i < str.length; i++) {
|
|
|
|
|
|
const char = str.charCodeAt(i);
|
|
|
|
|
|
hash = ((hash << 5) - hash) + char;
|
|
|
|
|
|
hash = hash & hash;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Math.abs(hash).toString(16);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 导出单例实例
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const mediaCacheManager = MediaCacheManager.getInstance();
|