Files
frontend/src/infrastructure/cache/MediaCacheManager.ts
lafay 25194313ae
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 15m38s
Frontend CI / ota-android (push) Successful in 16m54s
Frontend CI / build-android-apk (push) Failing after 8m29s
feat(profile): add data storage settings screen with cache management
Add DataStorageScreen for managing media cache and storage:
- Display cache statistics (image/video/audio counts and sizes)
- Show AsyncStorage usage information
- Add clear all cache functionality
- Add cleanup expired cache option (7 days threshold)
- Add storage usage breakdown by category
- Add "Last cleaned" timestamp display

Also fix MediaCacheManager to skip native cache operations on web platform:
- Return original URI directly on web without caching
- Skip directory creation on web
- Skip file existence checks on web
- Skip startup and periodic cleanup on web
2026-04-07 16:27:02 +08:00

738 lines
19 KiB
TypeScript
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.
/**
* 媒体缓存管理器
* @module src/infrastructure/cache/MediaCacheManager
* @description 管理图片、视频、音频缓存,支持多种清理策略
*/
import { File, Paths } from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
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();
// 启动时清理 (非web平台)
if (this.policy.cleanupOnStart && Platform.OS !== 'web') {
await this.cleanup(CleanupTrigger.STARTUP);
}
// 启动定期清理 (非web平台)
if (Platform.OS !== 'web') {
this.startPeriodicCleanup();
}
this.isInitialized = true;
} catch (error) {
console.error('[MediaCacheManager] 初始化失败:', error);
throw error;
}
}
/**
* 确保缓存目录存在
*/
private async ensureDirectories(): Promise<void> {
if (Platform.OS === 'web') return;
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> {
if (Platform.OS === 'web') return false;
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> {
if (Platform.OS === 'web') {
return uri;
}
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();