Files
frontend/src/infrastructure/cache/MediaCacheManager.ts
lafay be8f6de8cf
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m16s
Frontend CI / ota-android (push) Successful in 11m42s
Frontend CI / build-android-apk (push) Successful in 59m5s
<think>Let me analyze the staged changes to generate a proper conventional commit message.
Looking at the changes:

1. **New files**:
   - `app/(app)/(tabs)/profile/account-deletion.tsx` - account deletion screen
   - `app/(app)/(tabs)/profile/privacy-settings.tsx` - privacy settings screen
   - `src/screens/profile/AccountDeletionScreen.tsx` - account deletion implementation
   - `src/screens/profile/PrivacySettingsScreen.tsx` - privacy settings implementation

2. **Modified files**:
   - Database changes: `LocalDataSource.ts`, `DatabaseManager.ts`, `DatabaseConfig.ts`, `MigrationManager.ts` - refactoring database layer (removing LockManager, simplified initialization, added retry logic)
   - Auth store: `authStore.ts` - changed `initDatabase` to `switchDatabase`
   - Auth service: `authService.ts` - added privacy settings and account deletion methods
   - DTOs: `dto.ts` - added PrivacySettingsDTO, DeletionStatusDTO, VoteOptionDTO, VoteResultDTO types
   - Navigation: `hrefs.ts` - added new href functions for privacy settings and account deletion
   - Profile screens: `SettingsScreen.tsx`, `index.ts`, `_layout.tsx` - added new menu items and routes
   - Various message screens - added keyboard handling props
   - `app/_layout.tsx` - added CSS for mobile touch handling
   - `CreatePostScreen.tsx` - fixed vote_options format
   - `PostDetailScreen.tsx` - added type annotations for vote handling
   - `MediaCacheManager.ts` - updated to use new expo-file-system API
   - `VerificationSettingsScreen.tsx` - fixed type safety
   - `AccountSecurityScreen.tsx` - styling refactoring

This is a multi-faceted change involving:

Database refactoring, new privacy/account deletion features, bug fixes, and UI improvements. The database changes appear to be refactoring without new functionality. The most significant changes are the privacy settings and account deletion features, plus bug fixes like the vote options format and mobile touch handling.
</think>

feat(profile): add privacy settings and account deletion screens

Add privacy settings and account deletion functionality with new screens and APIs. Refactor database initialization to use switchDatabase for user switching. Fix vote options format in post creation and add type safety to vote handling. Improve mobile touch handling in layout and message screens. Update media cache to use new expo-file-system API.

BREAKING CHANGE: Database initialization now uses switchDatabase instead of initDatabase for user switching
2026-04-08 16:48:19 +08:00

743 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, Directory, 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.uri;
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 cacheDir = new Directory(Paths.cache, 'media_cache');
if (!await cacheDir.exists) {
await cacheDir.create();
}
const subDirs = ['images', 'videos', 'audios'];
for (const subDir of subDirs) {
const dir = new Directory(cacheDir, subDir);
if (!await dir.exists) {
await dir.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();