diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index aebad5d..b966310 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -98,12 +98,12 @@ jobs: container: image: reactnativecommunity/react-native-android:latest env: - GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1 -Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m" - _JAVA_OPTIONS: "-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m" - NODE_OPTIONS: "--max-old-space-size=2048" + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4 -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m" + _JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m" + NODE_OPTIONS: "--max-old-space-size=8192" NODE_ENV: "production" - NDK_NUM_JOBS: "1" - CMAKE_BUILD_PARALLEL_LEVEL: "1" + NDK_NUM_JOBS: "4" + CMAKE_BUILD_PARALLEL_LEVEL: "4" permissions: contents: read steps: @@ -222,10 +222,10 @@ jobs: # Update gradle.properties cat > gradle.properties << 'PROPS_EOF' org.gradle.daemon=false - org.gradle.parallel=false - org.gradle.configureondemand=false - org.gradle.workers.max=1 - org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=128m -XX:+HeapDumpOnOutOfMemoryError + org.gradle.parallel=true + org.gradle.configureondemand=true + org.gradle.workers.max=4 + org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.enableJetifier=false android.useAndroidX=true hermesEnabled=true @@ -242,7 +242,7 @@ jobs: run: | cd android chmod +x gradlew - ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=1 + ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --max-workers=4 --parallel - name: Upload APK artifact uses: actions/upload-artifact@v3 diff --git a/ARCHITECTURE_REFACTOR_PLAN.md b/ARCHITECTURE_REFACTOR_PLAN.md new file mode 100644 index 0000000..1daaf2f --- /dev/null +++ b/ARCHITECTURE_REFACTOR_PLAN.md @@ -0,0 +1,231 @@ +# 架构修复方案文档 + +## 1. 重构目标 + +### 1.1 核心原则 +- **单一职责**:每个模块只负责一个明确的功能 +- **依赖倒置**:高层模块不依赖低层模块,都依赖抽象 +- **开闭原则**:对扩展开放,对修改关闭 + +### 1.2 目标架构 +``` +src/ +├── core/ # 核心业务逻辑(框架无关) +│ ├── entities/ # 业务实体 +│ ├── repositories/ # 仓库接口 +│ └── usecases/ # 业务用例 +├── data/ # 数据层 +│ ├── datasources/ # 数据源实现(API、DB、Cache) +│ ├── repositories/ # 仓库实现 +│ └── mappers/ # 数据映射 +├── presentation/ # 表现层 +│ ├── components/ # UI组件 +│ ├── screens/ # 屏幕组件 +│ ├── stores/ # 状态管理 +│ └── hooks/ # 自定义hooks +└── infrastructure/ # 基础设施 + ├── navigation/ # 导航 + ├── theme/ # 主题 + └── services/ # 外部服务 +``` + +## 2. 具体修复方案 + +### 2.1 messageManager.ts 重构方案 + +#### 现状问题 +- 1500+行,上帝对象 +- 混合了 WebSocket、数据库、状态管理、UI通知 +- 与SQLite深度耦合 + +#### 目标结构 +``` +src/data/ +├── datasources/ +│ ├── WebSocketClient.ts # WebSocket连接管理 +│ └── MessageDataSource.ts # 消息数据源 +├── repositories/ +│ └── MessageRepository.ts # 消息仓库实现 +└── mappers/ + └── MessageMapper.ts # 消息数据映射 + +src/core/ +├── entities/ +│ └── Message.ts # 消息实体 +├── repositories/ +│ └── IMessageRepository.ts # 仓库接口 +└── usecases/ + ├── ProcessMessageUseCase.ts + ├── SyncMessagesUseCase.ts + └── UpdateReadReceiptUseCase.ts + +src/presentation/stores/ +└── messageStore.ts # 简化的消息状态管理 +``` + +#### 重构步骤 +1. 提取 WebSocketClient - 纯连接管理 +2. 创建 MessageRepository - 数据访问抽象 +3. 创建 UseCases - 业务逻辑 +4. 重写 messageStore - 仅负责UI状态 +5. 删除旧的 messageManager.ts + +### 2.2 乐观更新工具函数方案 + +#### 现状问题 +- userStore.ts 中重复6+次相同模式 +- 代码冗余,难以维护 + +#### 目标API +```typescript +// utils/optimisticUpdate.ts +export interface OptimisticUpdateOptions { + store: StoreApi; + getCurrentState: () => T; + optimisticUpdate: (current: T) => T; + apiCall: () => Promise; + onSuccess?: (result: R, current: T) => T; + onError?: (error: any, original: T) => void; +} + +export async function optimisticUpdate( + options: OptimisticUpdateOptions +): Promise +``` + +#### 使用示例 +```typescript +// 重构前(重复代码) +set(state => ({ posts: updatedPosts })); +try { + const result = await postService.like(postId); + // ... +} catch (error) { + set(state => ({ posts: originalPosts })); +} + +// 重构后(简洁) +await optimisticUpdate({ + store, + getCurrentState: () => store.getState().posts, + optimisticUpdate: (posts) => updatePostLikes(posts, postId, true), + apiCall: () => postService.like(postId), + onSuccess: (result, posts) => updatePostWithServerData(posts, result), +}); +``` + +### 2.3 useResponsive.ts 拆分方案 + +#### 现状问题 +- 485行,43个导出 +- 功能混杂:断点检测、响应式值计算、平台检测 + +#### 目标结构 +``` +src/presentation/hooks/responsive/ +├── useBreakpoint.ts # 断点检测 +├── useResponsiveValue.ts # 响应式值映射 +├── useOrientation.ts # 方向检测 +├── usePlatform.ts # 平台检测 +├── useScreenSize.ts # 屏幕尺寸 +└── index.ts # 统一导出 +``` + +#### 各Hook职责 +- `useBreakpoint`: 返回当前断点 (mobile/tablet/desktop/wide) +- `useResponsiveValue`: 根据断点返回不同值 +- `useOrientation`: 返回 portrait/landscape +- `usePlatform`: 返回 ios/android/web +- `useScreenSize`: 返回 width/height + +### 2.4 MainNavigator 解耦方案 + +#### 现状问题 +- 1118行,直接依赖store +- 混合了导航配置和业务逻辑 + +#### 目标结构 +``` +src/infrastructure/navigation/ +├── MainNavigator.tsx # 简化后的导航器 +├── AuthNavigator.tsx # 认证导航 +├── TabNavigator.tsx # 标签导航 +├── navigationService.ts # 导航服务(解耦层) +├── navigationTypes.ts # 类型定义 +└── hooks/ + └── useNavigationState.ts # 导航状态hook +``` + +#### 解耦策略 +1. 创建 NavigationService - 提供导航操作的抽象 +2. Store通过NavigationService触发导航,而非直接操作 +3. MainNavigator只负责渲染,不处理业务逻辑 + +### 2.5 Repository 层创建方案 + +#### 现状问题 +- 服务层直接操作数据库 +- 没有明确的数据访问边界 + +#### 目标结构 +``` +src/data/repositories/ +├── MessageRepository.ts # 消息数据访问 +├── PostRepository.ts # 帖子数据访问 +├── UserRepository.ts # 用户数据访问 +└── interfaces/ + ├── IMessageRepository.ts + ├── IPostRepository.ts + └── IUserRepository.ts +``` + +#### 职责划分 +- **Repository**: 数据访问抽象,定义接口 +- **DataSource**: 具体数据源实现(API、SQLite、Cache) +- **Service**: 业务逻辑编排,使用Repository + +## 3. 实施计划 + +### 阶段1:基础设施(第1-2天) +- [ ] 创建目录结构 +- [ ] 实现乐观更新工具函数 +- [ ] 拆分 useResponsive hooks + +### 阶段2:数据层重构(第3-5天) +- [ ] 创建 Repository 接口和实现 +- [ ] 创建 DataSource 层 +- [ ] 迁移数据库操作到 Repository + +### 阶段3:核心业务重构(第6-10天) +- [ ] 重构 messageManager → UseCases + Store +- [ ] 创建 WebSocketClient +- [ ] 实现消息同步逻辑 + +### 阶段4:表现层重构(第11-12天) +- [ ] 解耦 MainNavigator +- [ ] 重构 userStore 使用乐观更新工具 +- [ ] 更新所有屏幕组件 + +### 阶段5:测试与优化(第13-14天) +- [ ] 单元测试 +- [ ] 集成测试 +- [ ] 性能优化 + +## 4. 风险与应对 + +| 风险 | 影响 | 应对措施 | +|-----|------|---------| +| 重构引入bug | 高 | 每个模块重构后必须测试,保持API兼容 | +| 开发时间超期 | 中 | 分阶段交付,优先核心功能 | +| 团队成员不熟悉新架构 | 中 | 编写详细文档,代码审查 | +| 性能下降 | 低 | 添加性能监控,及时优化 | + +## 5. 验收标准 + +- [ ] messageManager.ts 被删除,功能拆分到多个小模块 +- [ ] userStore.ts 中无重复乐观更新代码 +- [ ] useResponsive.ts 拆分为5+个专注hook +- [ ] MainNavigator.tsx 行数 < 300 +- [ ] 所有单元测试通过 +- [ ] 无TypeScript错误 +- [ ] 性能不劣化(启动时间、内存占用) diff --git a/docs/OPTIMIZATION_DESIGN.md b/docs/OPTIMIZATION_DESIGN.md new file mode 100644 index 0000000..5b1a7e8 --- /dev/null +++ b/docs/OPTIMIZATION_DESIGN.md @@ -0,0 +1,1725 @@ +# 聊天系统性能优化设计方案 + +## 文档信息 + +- **创建日期**: 2026-03-18 +- **项目**: 胡萝卜 BBS (Carrot BBS) 前端 +- **范围**: 消息系统性能优化 + +--- + +## 目录 + +1. [架构概述](#架构概述) +2. [P0 分页状态管理](#p0-分页状态管理) +3. [P0 同步状态机](#p0-同步状态机) +4. [P1 差异更新](#p1-差异更新) +5. [P1 媒体缓存清理](#p1-媒体缓存清理) +6. [实现优先级与依赖关系](#实现优先级与依赖关系) + +--- + +## 架构概述 + +### 当前消息系统架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ UI Layer │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ChatScreen │ │ MessageList │ │ +│ │ useChatScreen │ │ Screen │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +└───────────┼─────────────────────┼───────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Store Layer (Zustand) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ messageManager (MessageManager) ││ +│ │ - messages Map ││ +│ │ - conversations: Conversation[] ││ +│ │ - activeConversation: string | null ││ +│ │ - isConnected: boolean ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ messageManagerHooks (React Hooks) ││ +│ │ - useChat(conversationId) ││ +│ │ - useMessages(conversationId) ││ +│ │ - useConversations() ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ UseCase Layer │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ ProcessMessageUseCase ││ +│ │ - WebSocket 事件监听与处理 ││ +│ │ - 消息去重 (processedMessageIds) ││ +│ │ - 已读状态管理 (pendingReadMap) ││ +│ │ - 消息持久化到 Repository ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DataSource Layer │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐│ +│ │WebSocketClient│ │CacheDataSource│ │LocalDataSource (SQLite)││ +│ │ │ │ (Memory+Async) │ │ ││ +│ │ - 事件订阅 │ │ - get/set │ │ - messages 表 ││ +│ │ - emit │ │ - delete │ │ - conversations 表 ││ +│ │ - 连接状态 │ │ - clear │ │ - users 表 ││ +│ └───────────────┘ └───────────────┘ └───────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Infrastructure Layer │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ sseService (SSE Client) ││ +│ │ - EventSource 连接管理 ││ +│ │ - 重连逻辑 (maxReconnectAttempts=20) ││ +│ │ - AppState 监听 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 问题分析 + +基于代码分析,当前系统存在以下问题: + +| 问题 | 影响 | 优先级 | +|------|------|--------| +| 分页状态管理不完善 | 用户滚动加载时可能重复请求同一页数据 | P0 | +| 连接状态反馈不清晰 | 用户不知道连接是否正常 | P0 | +| 全量数据更新 | 大房间每次更新都刷新整个消息列表 | P1 | +| 媒体缓存无清理机制 | 长期使用后存储空间爆炸 | P1 | + +--- + +## P0 分页状态管理 + +### 现有代码分析 + +#### 问题点 + +1. **`useChatScreen.ts` (第388-408行)** + ```typescript + const loadMoreHistory = useCallback(async () => { + if (!conversationId || !hasMoreHistory || loadingMore) { + return; + } + // 缺少分页游标管理,可能导致重复加载 + setLoadingMore(true); + try { + await loadMoreMessages(); + // 没有追踪当前加载到了哪一页 + } finally { + setLoadingMore(false); + } + }, [...]); + ``` + +2. **`messageManagerHooks.ts` (第156-158行)** + ```typescript + setIsLoading(true); + const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); + // 直接使用 minSeq,但没有缓存 minSeq 的状态 + ``` + +3. **`MessageRepository.ts` (第125-137行)** + ```typescript + async getMessagesBeforeSeq(conversationId, beforeSeq, limit = 20) { + // 依赖 beforeSeq 参数,但没有分页偏移量管理 + } + ``` + +#### 架构缺陷 + +- 没有独立的分页状态存储 +- `hasMoreMessages` 状态与实际数据可能不同步 +- 并发加载时缺少互斥机制 +- 没有加载中的分页信息缓存 + +### 实现方案 + +#### 1. 新增分页状态管理器 + +```typescript +// src/stores/pagination/PaginationStateManager.ts + +interface PaginationState { + conversationId: string; + currentMinSeq: number; // 当前已加载的最小seq + currentMaxSeq: number; // 当前已加载的最大seq + pages: Map; // pageNumber -> messageIds + loadingPageNumbers: Set; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + totalLoaded: number; +} + +class PaginationStateManager { + private states: Map = new Map(); + + // 获取或创建分页状态 + getOrCreate(conversationId: string): PaginationState { ... } + + // 标记页面加载中 + markPageLoading(conversationId: string, pageNumber: number): void { ... } + + // 标记页面加载完成 + markPageLoaded(conversationId: string, pageNumber: number, messageIds: string[], hasMore: boolean): void { ... } + + // 检查页面是否已加载 + isPageLoaded(conversationId: string, pageNumber: number): boolean { ... } + + // 检查页面是否正在加载 + isPageLoading(conversationId: string, pageNumber: number): boolean { ... } + + // 重置分页状态 + reset(conversationId: string): void { ... } +} +``` + +#### 2. 修改 useMessages Hook + +```typescript +// src/stores/messageManagerHooks.ts + +interface UseMessagesOptions { + pageSize?: number; + prefetchThreshold?: number; // 预加载阈值 +} + +function useMessages( + conversationId: string | null, + options: UseMessagesOptions = {} +) { + const { + pageSize = 20, + prefetchThreshold = 5 + } = options; + + // 分页状态 + const [paginationState, setPaginationState] = useState(null); + + // 计算当前页码 + const currentPage = useMemo(() => { + if (!paginationState) return 1; + return Math.ceil( + (paginationState.totalLoaded - messages.length) / pageSize + ) + 1; + }, [paginationState, messages.length]); + + // 加载更多(带分页保护) + const loadMoreMessages = useCallback(async () => { + if (!conversationId || !paginationState) return; + + // 计算下一页 + const nextPage = Math.floor(paginationState.totalLoaded / pageSize) + 1; + + // 检查是否正在加载 + if (paginationState.loadingPageNumbers.has(nextPage)) { + return; + } + + // 检查是否已加载 + if (paginationState.pages.has(nextPage)) { + return; + } + + await messageManager.loadMoreMessages( + conversationId, + paginationState.currentMinSeq, + pageSize + ); + }, [conversationId, paginationState, pageSize]); + + // 预加载检测 + useEffect(() => { + if (!paginationState || !hasMore) return; + + const currentPageLoaded = messages.length; + const threshold = pageSize - prefetchThreshold; + + if (currentPageLoaded <= threshold && !paginationState.loadingPageNumbers.has(1)) { + loadMoreMessages(); + } + }, [messages.length, hasMore, loadMoreMessages]); +} +``` + +#### 3. 修改 MessageManager + +```typescript +// src/stores/message/MessageManager.ts + +class MessageManager { + private paginationManager = new PaginationStateManager(); + + async loadMoreMessages( + conversationId: string, + beforeSeq: number, + limit: number + ): Promise { + // 检查分页状态 + const pageNumber = this.calculatePageNumber(conversationId, beforeSeq); + + if (this.paginationManager.isPageLoading(conversationId, pageNumber)) { + return []; // 避免重复加载 + } + + if (this.paginationManager.isPageLoaded(conversationId, pageNumber)) { + return []; // 已加载,直接返回 + } + + this.paginationManager.markPageLoading(conversationId, pageNumber); + + try { + // 调用 API + const response = await messageService.getMessages(conversationId, { + before_seq: beforeSeq, + limit + }); + + const messageIds = response.messages.map(m => m.id); + + this.paginationManager.markPageLoaded( + conversationId, + pageNumber, + messageIds, + response.has_more + ); + + // 更新消息存储 + this.appendMessages(conversationId, response.messages); + + return response.messages; + } catch (error) { + // 加载失败,清除分页状态 + this.paginationManager.markPageFailed(conversationId, pageNumber); + throw error; + } + } +} +``` + +#### 4. 修改数据库查询 + +```typescript +// src/services/database.ts (或 MessageRepository) + +export async function getMessagesBeforeSeq( + conversationId: string, + beforeSeq: number, + limit: number +): Promise { + // 确保使用索引优化查询 + const result = await db.getAllAsync(` + SELECT * FROM messages + WHERE conversationId = ? AND seq < ? + ORDER BY seq DESC + LIMIT ? + `, [conversationId, beforeSeq, limit]); + + return result; +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/stores/pagination/PaginationStateManager.ts` | 新增 | 分页状态管理器 | +| `src/stores/pagination/index.ts` | 新增 | 导出文件 | +| `src/stores/messageManagerHooks.ts` | 修改 | 集成分页状态管理 | +| `src/stores/message/MessageManager.ts` | 修改 | 添加分页保护逻辑 | +| `src/data/repositories/MessageRepository.ts` | 修改 | 优化分页查询 | + +### 关键设计决策 + +1. **分页粒度**: 以 `beforeSeq` 为游标,而非 offset,避免消息新增导致的分页漂移 +2. **页面缓存**: 使用 `Map` 缓存已加载页面,避免重复请求 +3. **加载锁**: 使用 `Set` 追踪正在加载的页面,防止并发重复加载 +4. **预加载**: 当滚动到距离底部 `prefetchThreshold` 条消息时,提前加载下一页 + +--- + +## P0 同步状态机 + +### 现有代码分析 + +#### 问题点 + +1. **`sseService.ts` (第406-408行)** + ```typescript + isConnected(): boolean { + return this.source != null; + } + // 状态判断过于简单,source != null 不代表真正连接成功 + ``` + +2. **`WebSocketClient.ts` (第23-35行)** + ```typescript + export interface WebSocketEvents { + 'connected': void; + 'disconnected': void; + 'error': Error; + } + // 只有 connected/disconnected 两种状态 + ``` + +3. **`sseService.ts` (第190-228行)** + ```typescript + async connect(): Promise { + // isConnecting 状态没有暴露给外部 + // 外部无法区分"正在连接"和"连接失败" + } + ``` + +4. **`messageManagerHooks.ts` (第390-405行)** + ```typescript + const [isConnected, setIsConnected] = useState(() => messageManager.isConnected()); + // 连接状态变化没有详细的状态分类 + ``` + +#### 状态定义缺失 + +当前没有区分以下状态: +- **Connecting**: 正在建立连接 +- **Connected**: 连接已建立 +- **Reconnecting**: 连接断开,正在重连 +- **Disconnected**: 连接已断开 +- **Error**: 连接错误 + +### 实现方案 + +#### 1. 定义连接状态机 + +```typescript +// src/services/connection/ConnectionState.ts + +export enum ConnectionStateType { + IDLE = 'idle', + CONNECTING = 'connecting', + CONNECTED = 'connected', + RECONNECTING = 'reconnecting', + DISCONNECTED = 'disconnected', + ERROR = 'error', +} + +export interface ConnectionState { + type: ConnectionStateType; + timestamp: number; + error?: Error; + retryCount?: number; + lastConnectedAt?: number; +} + +export interface ConnectionStateChangeEvent { + previousState: ConnectionState; + currentState: ConnectionState; + reason?: string; +} +``` + +#### 2. 创建连接状态管理器 + +```typescript +// src/services/connection/ConnectionStateManager.ts + +type ConnectionStateListener = (event: ConnectionStateChangeEvent) => void; + +class ConnectionStateManager { + private state: ConnectionState = { + type: ConnectionStateType.IDLE, + timestamp: Date.now(), + }; + + private listeners: Set = new Set(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 20; + + // 获取当前状态 + getState(): ConnectionState { ... } + + // 状态转换 + transition(newType: ConnectionStateType, reason?: string, error?: Error): void { + const previousState = this.state; + this.state = { + type: newType, + timestamp: Date.now(), + error, + retryCount: newType === ConnectionStateType.RECONNECTING + ? this.reconnectAttempts + : undefined, + lastConnectedAt: newType === ConnectionStateType.CONNECTED + ? Date.now() + : this.state.lastConnectedAt, + }; + + this.notifyListeners({ previousState, currentState: this.state, reason }); + } + + // 增加重试计数 + incrementRetry(): number { + this.reconnectAttempts++; + return this.reconnectAttempts; + } + + // 重置重试计数 + resetRetry(): void { + this.reconnectAttempts = 0; + } + + // 订阅状态变化 + subscribe(listener: ConnectionStateListener): () => void { ... } + + // 获取可读的状态描述 + getStatusDescription(): string { + switch (this.state.type) { + case ConnectionStateType.IDLE: + return '未连接'; + case ConnectionStateType.CONNECTING: + return '正在连接...'; + case ConnectionStateType.CONNECTED: + return '已连接'; + case ConnectionStateType.RECONNECTING: + return `正在重连 (${this.state.retryCount}/${this.maxReconnectAttempts})`; + case ConnectionStateType.DISCONNECTED: + return '已断开'; + case ConnectionStateType.ERROR: + return `连接错误: ${this.state.error?.message || '未知错误'}`; + } + } +} + +export const connectionStateManager = new ConnectionStateManager(); +``` + +#### 3. 修改 SSEService + +```typescript +// src/services/sseService.ts + +class SSEService { + private connectionStateManager = connectionStateManager; + + async connect(): Promise { + if (this.isConnecting || this.isConnected()) { + return true; + } + + this.connectionStateManager.transition( + ConnectionStateType.CONNECTING, + 'initiated' + ); + + try { + const token = await api.getToken(); + if (!token) { + throw new Error('No auth token'); + } + + // ... 建立连接 ... + + this.source.addEventListener('open', () => { + this.connectionStateManager.resetRetry(); + this.connectionStateManager.transition( + ConnectionStateType.CONNECTED, + 'connection_opened' + ); + }); + + this.source.addEventListener('error', (error) => { + this.connectionStateManager.transition( + ConnectionStateType.ERROR, + 'connection_error', + error + ); + this.scheduleReconnect(); + }); + + return true; + } catch (error) { + this.connectionStateManager.transition( + ConnectionStateType.ERROR, + 'connection_failed', + error + ); + this.scheduleReconnect(); + return false; + } + } + + private scheduleReconnect(): void { + const retryCount = this.connectionStateManager.incrementRetry(); + + if (retryCount >= this.maxReconnectAttempts) { + this.connectionStateManager.transition( + ConnectionStateType.DISCONNECTED, + 'max_reconnect_exceeded' + ); + return; + } + + this.connectionStateManager.transition( + ConnectionStateType.RECONNECTING, + 'scheduling_reconnect' + ); + + this.reconnectTimer = setTimeout(() => { + this.connect(); + }, this.reconnectDelay); + } +} +``` + +#### 4. 创建连接状态 Hook + +```typescript +// src/hooks/useConnectionState.ts + +export function useConnectionState() { + const [state, setState] = useState( + () => connectionStateManager.getState() + ); + + useEffect(() => { + const unsubscribe = connectionStateManager.subscribe((event) => { + setState(event.currentState); + }); + + return unsubscribe; + }, []); + + return { + state, + status: connectionStateManager.getStatusDescription(), + isConnected: state.type === ConnectionStateType.CONNECTED, + isConnecting: state.type === ConnectionStateType.CONNECTING, + isReconnecting: state.type === ConnectionStateType.RECONNECTING, + isDisconnected: state.type === ConnectionStateType.DISCONNECTED, + isError: state.type === ConnectionStateType.ERROR, + retryCount: state.retryCount, + error: state.error, + reconnect: () => sseService.connect(), + disconnect: () => sseService.disconnect(), + }; +} +``` + +#### 5. UI 集成示例 + +```typescript +// src/screens/message/components/ChatScreen/ConnectionStatusBadge.tsx + +export function ConnectionStatusBadge() { + const { status, isConnected, isReconnecting, isError, retryCount } = useConnectionState(); + + if (isConnected) { + return null; // 已连接不显示 + } + + return ( + + {isReconnecting && ( + + )} + + {isReconnecting + ? `连接中断,正在重连 (${retryCount}/20)` + : status} + + + ); +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/services/connection/ConnectionState.ts` | 新增 | 连接状态定义 | +| `src/services/connection/ConnectionStateManager.ts` | 新增 | 连接状态管理器 | +| `src/services/connection/index.ts` | 新增 | 导出文件 | +| `src/services/sseService.ts` | 修改 | 集成状态管理器 | +| `src/hooks/useConnectionState.ts` | 新增 | 连接状态 Hook | +| `src/hooks/index.ts` | 修改 | 导出新 Hook | + +### Mermaid 状态图 + +```mermaid +stateDiagram-v2 + [*] --> IDLE + IDLE --> CONNECTING: 调用 connect() + CONNECTING --> CONNECTED: 连接成功 + CONNECTING --> ERROR: 连接失败 + CONNECTED --> RECONNECTING: 连接断开 + RECONNECTING --> CONNECTED: 重连成功 + RECONNECTING --> DISCONNECTED: 超过最大重试次数 + RECONNECTING --> ERROR: 重连失败 + ERROR --> RECONNECTING: 自动重连 + ERROR --> DISCONNECTED: 停止重连 + DISCONNECTED --> CONNECTING: 手动重连 + IDLE --> CONNECTING: 手动重连 + CONNECTED --> DISCONNECTED: 调用 disconnect() +``` + +--- + +## P1 差异更新 + +### 现有代码分析 + +#### 问题点 + +1. **`MessageManager` 消息更新机制** + - 每当有新消息时,通过 `setMessages` 更新整个消息数组 + - 大房间场景下,频繁的全量更新会导致 UI 卡顿 + - 没有区分不同类型的消息更新(新增、删除、修改) + +2. **`messageManagerHooks.ts` (第33-46行)** + ```typescript + const unsubscribe = messageManager.subscribe((event: MessageEvent) => { + switch (event.type) { + case 'conversations_updated': + setConversations(messageManager.getConversations()); + break; + // ... 其他都是全量更新 + } + }); + ``` + +3. **`useChatScreen.ts` (第148-162行)** + ```typescript + const messages = useMemo(() => { + return messageManagerMessages.map(m => ({ + // 全量映射 + })); + }, [messageManagerMessages]); + ``` + +4. **`ProcessMessageUseCase.ts` (第159-199行)** + ```typescript + private async handleNewMessage(message: any): Promise { + // 每条消息都触发完整流程 + this.notifySubscribers({ + type: 'message_received', + payload: { message, ... } + }); + } + ``` + +#### 性能瓶颈 + +- 1000人群聊,每秒10条消息 = 每秒10000次 UI 更新操作 +- 全量 `setMessages` 触发 FlatList 完整重渲染 +- 没有虚拟列表优化 + +### 实现方案 + +#### 1. 定义消息更新类型 + +```typescript +// src/stores/message/MessageUpdateTypes.ts + +export enum MessageUpdateType { + APPEND = 'append', // 追加新消息到末尾 + PREPEND = 'prepend', // 预置历史消息到开头 + UPDATE = 'update', // 更新单条消息 + DELETE = 'delete', // 删除单条消息 + BATCH_APPEND = 'batch_append', // 批量追加 + BATCH_PREPEND = 'batch_prepend', // 批量预置 + RECALL = 'recall', // 撤回消息 + CLEAR = 'clear', // 清空会话 +} + +export interface MessageUpdate { + type: MessageUpdateType; + conversationId: string; + payload: T; + timestamp: number; +} + +export interface AppendPayload { + messages: Message[]; +} + +export interface PrependPayload { + messages: Message[]; + hasMoreBefore: boolean; +} + +export interface UpdatePayload { + messageId: string; + updates: Partial; +} + +export interface DeletePayload { + messageId: string; +} + +export interface BatchAppendPayload { + messages: Message[]; +} + +export interface RecallPayload { + messageId: string; +} +``` + +#### 2. 创建差异更新 Hook + +```typescript +// src/hooks/useDifferentialMessages.ts + +export function useDifferentialMessages(conversationId: string | null) { + const [messages, setMessages] = useState([]); + const pendingUpdatesRef = useRef([]); + const flushScheduledRef = useRef(false); + + // 批量处理更新 + const flushUpdates = useCallback(() => { + if (pendingUpdatesRef.current.length === 0) return; + + const updates = pendingUpdatesRef.current; + pendingUpdatesRef.current = []; + flushScheduledRef.current = false; + + setMessages(currentMessages => { + const newMessages = [...currentMessages]; + + for (const update of updates) { + switch (update.type) { + case MessageUpdateType.APPEND: + for (const msg of update.payload.messages) { + if (!newMessages.find(m => m.id === msg.id)) { + newMessages.push(msg); + } + } + break; + + case MessageUpdateType.PREPEND: + const prependMessages = update.payload.messages + .filter(m => !newMessages.find(n => n.id === m.id)); + newMessages.unshift(...prependMessages.reverse()); + break; + + case MessageUpdateType.UPDATE: + const updateIdx = newMessages.findIndex(m => m.id === update.payload.messageId); + if (updateIdx !== -1) { + newMessages[updateIdx] = { + ...newMessages[updateIdx], + ...update.payload.updates + }; + } + break; + + case MessageUpdateType.DELETE: + newMessages = newMessages.filter(m => m.id !== update.payload.messageId); + break; + + case MessageUpdateType.RECALL: + const recallIdx = newMessages.findIndex(m => m.id === update.payload.messageId); + if (recallIdx !== -1) { + newMessages[recallIdx] = { + ...newMessages[recallIdx], + status: 'recalled', + segments: [], + }; + } + break; + + case MessageUpdateType.BATCH_APPEND: + for (const msg of update.payload.messages) { + if (!newMessages.find(m => m.id === msg.id)) { + newMessages.push(msg); + } + } + break; + } + } + + return newMessages; + }); + }, []); + + // 调度批量更新(16ms 内合并) + const scheduleFlush = useCallback(() => { + if (flushScheduledRef.current) return; + flushScheduledRef.current = true; + requestAnimationFrame(flushUpdates); + }, [flushUpdates]); + + // 处理消息更新 + const handleMessageUpdate = useCallback((update: MessageUpdate) => { + pendingUpdatesRef.current.push(update); + scheduleFlush(); + }, [scheduleFlush]); + + // 订阅 MessageManager + useEffect(() => { + if (!conversationId) return; + + const unsubscribe = messageManager.subscribe((event) => { + if (event.type === 'message_received') { + const { message, isCurrentUser } = event.payload; + handleMessageUpdate({ + type: isCurrentUser + ? MessageUpdateType.APPEND + : MessageUpdateType.PREPEND, + conversationId, + payload: { messages: [message] }, + timestamp: Date.now(), + }); + } + + if (event.type === 'messages_loaded') { + const { messages, direction } = event.payload; + handleMessageUpdate({ + type: direction === 'before' + ? MessageUpdateType.PREPEND + : MessageUpdateType.BATCH_APPEND, + conversationId, + payload: { messages }, + timestamp: Date.now(), + }); + } + + if (event.type === 'message_recalled') { + handleMessageUpdate({ + type: MessageUpdateType.RECALL, + conversationId, + payload: { messageId: event.payload.messageId }, + timestamp: Date.now(), + }); + } + }); + + return unsubscribe; + }, [conversationId, handleMessageUpdate]); + + return { messages }; +} +``` + +#### 3. 优化 FlatList 渲染 + +```typescript +// src/screens/message/components/ChatScreen/OptimizedMessageList.tsx + +export function OptimizedMessageList({ + messages, + onLoadMore, + hasMore, +}: { + messages: Message[]; + onLoadMore: () => void; + hasMore: boolean; +}) { + const viewabilityConfig = useRef({ + itemVisiblePercentThreshold: 50, + minimumViewTime: 100, + }); + + // 关键消息路径优化 + const keyExtractor = useCallback((item: Message) => item.id, []); + + const getItemLayout = useCallback((data: Message[] | null, index: number) => { + // 估算每条消息高度(可以根据类型调整) + const BASE_HEIGHT = 60; + const IMAGE_EXTRA = 200; + const EXTRA_PER_SEGMENT = 30; + + let length = BASE_HEIGHT; + if (data) { + const message = data[index]; + if (message.segments) { + length += message.segments.length * EXTRA_PER_SEGMENT; + } + if (message.segments?.some(s => s.type === 'image')) { + length += IMAGE_EXTRA; + } + } + + return { + length, + offset: data.slice(0, index).reduce((sum, m) => { + // 计算累计高度 + return sum + BASE_HEIGHT + + (m.segments?.length || 0) * EXTRA_PER_SEGMENT + + (m.segments?.some(s => s.type === 'image') ? IMAGE_EXTRA : 0); + }, 0), + index, + }; + }, []); + + // 消息类型判断 + const isSameDay = useCallback((prev: Message, next: Message) => { + const prevDate = new Date(prev.created_at).toDateString(); + const nextDate = new Date(next.created_at).toDateString(); + return prevDate === nextDate; + }, []); + + const renderItem = useCallback(({ item, index }: { item: Message; index: number }) => { + const prevItem = messages[index - 1]; + + return ( + 50} + /> + ); + }, [isSameDay]); + + return ( + + ); +} +``` + +#### 4. 修改 MessageManager 支持差异事件 + +```typescript +// src/stores/message/MessageManager.ts + +class MessageManager { + // 发送差异更新事件 + private emitMessageUpdate(update: MessageUpdate): void { + this.subscribers.forEach(subscriber => { + if (subscriber.types.includes(update.type) || subscriber.types.includes('*')) { + subscriber.callback(update); + } + }); + } + + async handleIncomingMessage(message: WSChatMessage): Promise { + // 检查消息是否已存在 + const existingMessages = this.messages.get(message.conversation_id) || []; + if (existingMessages.find(m => m.id === message.id)) { + return; // 避免重复 + } + + const newMessage = this.createMessageObject(message); + + // 直接发送差异更新,而不是全量更新 + this.emitMessageUpdate({ + type: MessageUpdateType.APPEND, + conversationId: message.conversation_id, + payload: { messages: [newMessage] }, + timestamp: Date.now(), + }); + + // 异步保存到数据库 + this.persistMessage(newMessage); + } +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/stores/message/MessageUpdateTypes.ts` | 新增 | 消息更新类型定义 | +| `src/hooks/useDifferentialMessages.ts` | 新增 | 差异更新 Hook | +| `src/stores/messageManagerHooks.ts` | 修改 | 集成差异更新 | +| `src/stores/message/MessageManager.ts` | 修改 | 添加差异事件支持 | +| `src/screens/message/components/ChatScreen/OptimizedMessageList.tsx` | 新增 | 优化的消息列表组件 | + +### 性能收益 + +| 场景 | 优化前 | 优化后 | +|------|--------|--------| +| 1000人群,每秒10条消息 | 10000次/秒 UI 更新 | 60次/秒 UI 更新 | +| 滚动加载100条历史消息 | 全量重渲染100条 | 仅渲染可见区域~20条 | +| 消息撤回 | 全量更新 + UI 重建 | 单条更新 | + +--- + +## P1 媒体缓存清理 + +### 现有代码分析 + +#### 问题点 + +1. **`CacheDataSource.ts` (第14-28行)** + ```typescript + export class CacheDataSource implements ICacheDataSource { + private memoryCache: Map> = new Map(); + private maxSize: number; // 默认 100 条 + private defaultTtl: number; // 默认 5 分钟 + + // 没有媒体文件专用缓存管理 + // 没有磁盘空间监控 + } + ``` + +2. **缺少媒体缓存机制** + - 图片、视频、音频没有独立的缓存策略 + - 没有按会话清理过期媒体的功能 + - 没有用户手动清理入口 + +3. **`LocalDataSource.ts` (第73-86行)** + ```typescript + // 消息表结构 + `CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY NOT NULL, + conversationId TEXT NOT NULL, + segments TEXT + // 没有存储媒体文件路径 + )` + ``` + - 消息表没有记录媒体缓存路径 + - 无法追踪哪些媒体文件属于哪个会话 + +#### 存储风险 + +- 图片缓存:无限制增长 +- 视频缓存:无限制增长 +- 音频缓存:无限制增长 +- 总计:可能导致存储空间耗尽 + +### 实现方案 + +#### 1. 定义媒体缓存配置 + +```typescript +// src/services/media/MediaCacheConfig.ts + +export interface MediaCacheConfig { + // 图片配置 + image: { + maxMemoryCacheSize: number; // 内存缓存数量,默认 50 + maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 500 + maxAge: number; // 缓存有效期(小时),默认 168 (7天) + maxAgeForConversation: number; // 会话内媒体保留时间(天),默认 30 + }; + + // 视频配置 + video: { + maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 1000 + maxAge: number; // 缓存有效期(小时),默认 72 (3天) + autoPreload: boolean; // 是否自动预加载,默认 false + }; + + // 音频配置 + audio: { + maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 200 + maxAge: number; // 缓存有效期(天),默认 7 + }; + + // 全局配置 + global: { + checkOnStartup: boolean; // 启动时检查清理,默认 true + checkInterval: number; // 定期检查间隔(小时),默认 24 + lowStorageThreshold: number; // 低存储空间阈值(MB),默认 500 + }; +} + +export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = { + image: { + maxMemoryCacheSize: 50, + maxDiskCacheSize: 500, + maxAge: 168, // 7 days + maxAgeForConversation: 30, + }, + video: { + maxDiskCacheSize: 1000, + maxAge: 72, // 3 days + autoPreload: false, + }, + audio: { + maxDiskCacheSize: 200, + maxAge: 168, // 7 days + }, + global: { + checkOnStartup: true, + checkInterval: 24, + lowStorageThreshold: 500, + }, +}; +``` + +#### 2. 创建媒体缓存管理器 + +```typescript +// src/services/media/MediaCacheManager.ts + +import * as FileSystem from 'expo-file-system'; +import { cacheDataSource } from '../data/datasources/CacheDataSource'; + +export enum MediaType { + IMAGE = 'image', + VIDEO = 'video', + AUDIO = 'audio', +} + +export interface MediaCacheInfo { + type: MediaType; + uri: string; + localPath: string; + conversationId?: string; + messageId?: string; + size: number; + createdAt: number; + lastAccessedAt: number; +} + +class MediaCacheManager { + private config: MediaCacheConfig; + private cacheDirectory: string; + private mediaRecords: Map = new Map(); + + // 缓存目录 + private readonly CACHE_DIR = `${FileSystem.documentDirectory}media_cache/`; + private readonly IMAGE_DIR = `${this.CACHE_DIR}images/`; + private readonly VIDEO_DIR = `${this.CACHE_DIR}videos/`; + private readonly AUDIO_DIR = `${this.CACHE_DIR}audios/`; + + constructor(config: MediaCacheConfig = DEFAULT_MEDIA_CACHE_CONFIG) { + this.config = config; + this.cacheDirectory = this.CACHE_DIR; + } + + // 初始化 + async initialize(): Promise { + await this.ensureDirectories(); + await this.loadMediaRecords(); + await this.cleanupIfNeeded(); + } + + // 确保缓存目录存在 + private async ensureDirectories(): Promise { + await FileSystem.makeDirectoryAsync(this.IMAGE_DIR, { intermediates: true }); + await FileSystem.makeDirectoryAsync(this.VIDEO_DIR, { intermediates: true }); + await FileSystem.makeDirectoryAsync(this.AUDIO_DIR, { intermediates: true }); + } + + // 缓存媒体文件 + async cacheMedia( + uri: string, + type: MediaType, + options: { + conversationId?: string; + messageId?: string; + } = {} + ): Promise { + const { conversationId, messageId } = options; + + // 生成唯一文件名 + const filename = this.generateFilename(uri, type); + const localPath = this.getMediaPath(type, filename); + + // 检查是否已缓存 + if (await this.exists(localPath)) { + await this.updateAccessTime(localPath); + return localPath; + } + + try { + // 下载文件 + const downloadResult = await FileSystem.downloadAsync(uri, localPath); + + // 记录缓存信息 + const cacheInfo: MediaCacheInfo = { + type, + uri, + localPath, + conversationId, + messageId, + size: downloadResult.headers['content-length'] + ? parseInt(downloadResult.headers['content-length']) + : 0, + createdAt: Date.now(), + lastAccessedAt: Date.now(), + }; + + await this.saveMediaRecord(localPath, cacheInfo); + await this.cleanupIfNeeded(); + + return localPath; + } catch (error) { + console.error('[MediaCacheManager] 缓存失败:', error); + throw error; + } + } + + // 获取缓存的媒体文件路径 + async getCachedMedia(uri: string, type: MediaType): Promise { + const record = this.findByUri(uri); + if (record && await this.exists(record.localPath)) { + await this.updateAccessTime(record.localPath); + return record.localPath; + } + return null; + } + + // 删除单个会话的所有媒体缓存 + async clearConversationMedia(conversationId: string): Promise { + const toDelete: string[] = []; + + for (const [path, info] of this.mediaRecords) { + if (info.conversationId === conversationId) { + toDelete.push(path); + } + } + + await Promise.all(toDelete.map(p => this.deleteMedia(p))); + } + + // 删除过期媒体 + async clearExpiredMedia(): Promise { + const now = Date.now(); + const maxAge = this.config.image.maxAge * 60 * 60 * 1000; // 转换为毫秒 + let deletedCount = 0; + + for (const [path, info] of this.mediaRecords) { + if (now - info.lastAccessedAt > maxAge) { + await this.deleteMedia(path); + deletedCount++; + } + } + + return deletedCount; + } + + // 清理超过会话保留期的媒体 + async clearOldConversationMedia(): Promise { + const now = Date.now(); + const maxAge = this.config.image.maxAgeForConversation * 24 * 60 * 60 * 1000; + let deletedCount = 0; + + for (const [path, info] of this.mediaRecords) { + if (info.conversationId && now - info.createdAt > maxAge) { + await this.deleteMedia(path); + deletedCount++; + } + } + + return deletedCount; + } + + // 按大小清理(LRU) + async clearBySizeLimit(): Promise { + const totalSize = await this.getTotalCacheSize(); + const limit = this.config.image.maxDiskCacheSize * 1024 * 1024; + + if (totalSize <= limit) { + return 0; + } + + // 按最后访问时间排序 + const sorted = Array.from(this.mediaRecords.entries()) + .sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt); + + let currentSize = totalSize; + let deletedCount = 0; + + for (const [path, info] of sorted) { + if (currentSize <= limit) break; + + await this.deleteMedia(path); + currentSize -= info.size; + deletedCount++; + } + + return deletedCount; + } + + // 获取缓存统计 + async getCacheStats(): Promise<{ + totalSize: number; + imageCount: number; + videoCount: number; + audioCount: number; + oldestItem: number; + newestItem: number; + }> { + const stats = { + totalSize: 0, + imageCount: 0, + videoCount: 0, + audioCount: 0, + oldestItem: Date.now(), + newestItem: 0, + }; + + for (const info of this.mediaRecords.values()) { + stats.totalSize += info.size; + stats.oldestItem = Math.min(stats.oldestItem, info.createdAt); + stats.newestItem = Math.max(stats.newestItem, info.createdAt); + + switch (info.type) { + case MediaType.IMAGE: stats.imageCount++; break; + case MediaType.VIDEO: stats.videoCount++; break; + case MediaType.AUDIO: stats.audioCount++; break; + } + } + + return stats; + } + + // 清理入口 + async cleanupIfNeeded(): Promise { + await this.clearExpiredMedia(); + await this.clearBySizeLimit(); + } + + // 私有辅助方法 + private generateFilename(uri: string, type: MediaType): string { + const ext = this.getExtension(uri, type); + const hash = this.hashString(uri); + return `${type}_${hash}_${Date.now()}.${ext}`; + } + + private getExtension(uri: string, type: MediaType): string { + if (type === MediaType.IMAGE) { + if (uri.includes('.gif')) return 'gif'; + if (uri.includes('.webp')) return 'webp'; + return 'jpg'; + } + if (type === MediaType.VIDEO) return 'mp4'; + if (type === MediaType.AUDIO) return 'mp3'; + return 'bin'; + } + + private getMediaPath(type: MediaType, filename: string): string { + switch (type) { + case MediaType.IMAGE: return `${this.IMAGE_DIR}${filename}`; + case MediaType.VIDEO: return `${this.VIDEO_DIR}${filename}`; + case MediaType.AUDIO: return `${this.AUDIO_DIR}${filename}`; + } + } + + private async exists(path: string): Promise { + const info = await FileSystem.getInfoAsync(path); + return info.exists; + } + + private async deleteMedia(path: string): Promise { + try { + await FileSystem.deleteAsync(path, { idempotent: true }); + this.mediaRecords.delete(path); + await this.removeMediaRecord(path); + } catch (error) { + console.warn('[MediaCacheManager] 删除失败:', path, error); + } + } + + private async saveMediaRecord(path: string, info: MediaCacheInfo): Promise { + this.mediaRecords.set(path, info); + await cacheDataSource.set(`media_${path}`, info, null); + } + + private async loadMediaRecords(): Promise { + // 从缓存数据源加载 + } + + private async updateAccessTime(path: string): Promise { + const info = this.mediaRecords.get(path); + if (info) { + info.lastAccessedAt = Date.now(); + await this.saveMediaRecord(path, info); + } + } + + private findByUri(uri: string): MediaCacheInfo | undefined { + for (const info of this.mediaRecords.values()) { + if (info.uri === uri) { + return info; + } + } + return undefined; + } + + private async getTotalCacheSize(): Promise { + let total = 0; + for (const info of this.mediaRecords.values()) { + total += info.size; + } + return total; + } + + 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 = new MediaCacheManager(); +``` + +#### 3. 创建清理策略 + +```typescript +// src/services/media/MediaCleanupPolicy.ts + +export enum CleanupTrigger { + STARTUP = 'startup', + SCHEDULED = 'scheduled', + MANUAL = 'manual', + LOW_STORAGE = 'low_storage', + CONVERSATION_DELETED = 'conversation_deleted', +} + +export interface CleanupResult { + trigger: CleanupTrigger; + timestamp: number; + deletedCount: number; + freedSize: number; + errors: string[]; +} + +class MediaCleanupPolicy { + private lastCleanup: number = 0; + private config: MediaCacheConfig; + + // 启动时清理 + async cleanupOnStartup(): Promise { + const errors: string[] = []; + let deletedCount = 0; + let freedSize = 0; + + try { + // 清理过期文件 + const expired = await mediaCacheManager.clearExpiredMedia(); + deletedCount += expired; + + // 清理超过保留期的会话媒体 + const oldConversation = await mediaCacheManager.clearOldConversationMedia(); + deletedCount += oldConversation; + + // 超过大小限制时清理 + const bySize = await mediaCacheManager.clearBySizeLimit(); + deletedCount += bySize; + + this.lastCleanup = Date.now(); + } catch (error) { + errors.push(`Startup cleanup failed: ${error}`); + } + + return { + trigger: CleanupTrigger.STARTUP, + timestamp: Date.now(), + deletedCount, + freedSize, + errors, + }; + } + + // 检查是否需要定期清理 + shouldRunScheduledCleanup(): boolean { + const interval = this.config.global.checkInterval * 60 * 60 * 1000; + return Date.now() - this.lastCleanup > interval; + } + + // 会话删除时清理 + async cleanupOnConversationDeleted(conversationId: string): Promise { + const errors: string[] = []; + let deletedCount = 0; + let freedSize = 0; + + try { + const statsBefore = await mediaCacheManager.getCacheStats(); + await mediaCacheManager.clearConversationMedia(conversationId); + const statsAfter = await mediaCacheManager.getCacheStats(); + + deletedCount = statsBefore.imageCount + statsBefore.videoCount + statsBefore.audioCount + - (statsAfter.imageCount + statsAfter.videoCount + statsAfter.audioCount); + freedSize = statsBefore.totalSize - statsAfter.totalSize; + } catch (error) { + errors.push(`Conversation cleanup failed: ${error}`); + } + + return { + trigger: CleanupTrigger.CONVERSATION_DELETED, + timestamp: Date.now(), + deletedCount, + freedSize, + errors, + }; + } +} + +export const mediaCleanupPolicy = new MediaCleanupPolicy(); +``` + +#### 4. 创建清理 Hook + +```typescript +// src/hooks/useMediaCache.ts + +export function useMediaCache() { + const [stats, setStats] = useState(null); + const [isClearing, setIsClearing] = useState(false); + + // 加载缓存统计 + const loadStats = useCallback(async () => { + const cacheStats = await mediaCacheManager.getCacheStats(); + setStats(cacheStats); + }, []); + + // 手动清理 + const clearAll = useCallback(async () => { + setIsClearing(true); + try { + await mediaCacheManager.clearExpiredMedia(); + await mediaCacheManager.clearBySizeLimit(); + await loadStats(); + } finally { + setIsClearing(false); + } + }, [loadStats]); + + // 清理指定会话的媒体 + const clearConversation = useCallback(async (conversationId: string) => { + await mediaCacheManager.clearConversationMedia(conversationId); + await loadStats(); + }, [loadStats]); + + // 组件挂载时加载统计 + useEffect(() => { + loadStats(); + }, [loadStats]); + + return { + stats, + isClearing, + loadStats, + clearAll, + clearConversation, + formatSize: (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; + }, + }; +} +``` + +### 修改/新增文件列表 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/services/media/MediaCacheConfig.ts` | 新增 | 媒体缓存配置 | +| `src/services/media/MediaCacheManager.ts` | 新增 | 媒体缓存管理器 | +| `src/services/media/MediaCleanupPolicy.ts` | 新增 | 清理策略 | +| `src/services/media/index.ts` | 新增 | 导出文件 | +| `src/hooks/useMediaCache.ts` | 新增 | 媒体缓存 Hook | +| `src/hooks/index.ts` | 修改 | 导出新 Hook | +| `src/data/datasources/CacheDataSource.ts` | 修改 | 添加媒体记录存储 | + +### 清理策略 + +```mermaid +graph TD + A[启动 App] --> B{检查启动清理标志} + B -->|是| C[清理过期媒体] + B -->|是| D[清理超期会话媒体] + B -->|是| E[检查大小限制] + E -->|超过限制| F[LRU 清理] + + G[定期检查] -->|间隔到期| C + G -->|间隔到期| D + G -->|间隔到期| E + + H[用户操作] -->|删除会话| I[清理该会话媒体] + H -->|手动清理| J[清理所有过期媒体] + + K[存储空间检查] -->|低于阈值| L[紧急清理] +``` + +--- + +## 实现优先级与依赖关系 + +### 优先级矩阵 + +| 优先级 | 功能 | 依赖 | 复杂度 | +|--------|------|------|--------| +| P0 | 分页状态管理 | 无 | 中 | +| P0 | 同步状态机 | 无 | 低 | +| P1 | 差异更新 | P0 分页状态管理 | 高 | +| P1 | 媒体缓存清理 | 无 | 中 | + +### 推荐实现顺序 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 1: 基础改进 (1-2天) │ +│ ┌───────────────────┐ ┌───────────────────┐ │ +│ │ P0 分页状态管理 │ │ P0 同步状态机 │ │ +│ │ │ │ │ │ +│ │ - 避免重复加载 │ │ - 清晰状态反馈 │ │ +│ │ - 页面缓存 │ │ - 连接状态 Hook │ │ +│ │ - 预加载机制 │ │ - UI 状态指示器 │ │ +│ └───────────────────┘ └───────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 2: 性能优化 (2-3天) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ P1 差异更新 ││ +│ │ ││ +│ │ - 依赖分页状态管理 ││ +│ │ - 消息更新类型定义 ││ +│ │ - 差异更新 Hook ││ +│ │ - FlatList 渲染优化 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Phase 3: 存储优化 (1-2天) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ P1 媒体缓存清理 ││ +│ │ ││ +│ │ - 媒体缓存管理器 ││ +│ │ - 清理策略 ││ +│ │ - 用户清理界面 ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 风险与注意事项 + +1. **差异更新风险** + - 需要确保消息 ID 的唯一性 + - 并发更新时需要正确合并 + - 建议在开发环境进行大房间压力测试 + +2. **分页状态管理风险** + - 页面缓存会占用内存,需要设置上限 + - 分页状态需要正确序列化和恢复(跨会话) + +3. **状态机风险** + - 状态转换需要严格测试 + - 重连逻辑需要处理边界情况(如网络中断) + +4. **媒体缓存风险** + - 删除文件时需要确保没有正在使用 + - 缓存元数据需要持久化存储 + - 清理操作应该在后台线程执行 \ No newline at end of file diff --git a/src/components/business/PostCard.tsx b/src/components/business/PostCard.tsx index c992e42..fb5fabd 100644 --- a/src/components/business/PostCard.tsx +++ b/src/components/business/PostCard.tsx @@ -362,6 +362,20 @@ const PostCard: React.FC = ({ const gridUsernameFontSize = isDesktop ? 14 : 12; const gridLikeFontSize = isDesktop ? 14 : 12; + const handleContainerPress = () => { + onPress(); + }; + + const handleImagePress = (e: any) => { + e.stopPropagation(); + onImagePress?.(post.images || [], 0); + }; + + const handleUserPress = (e: any) => { + e.stopPropagation(); + onUserPress(); + }; + return ( = ({ !hasImage && styles.gridContainerNoImage, isDesktop && styles.gridContainerDesktop ]} - onPress={onPress} + onPress={handleContainerPress} activeOpacity={0.9} > {/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */} {hasImage && ( onImagePress?.(post.images || [], 0)} + onPress={handleImagePress} > = ({ {/* 底部信息 */} - + = ({ return 3; }, [isWideScreen, isDesktop, isTablet]); + const handleContainerPress = () => { + onPress(); + }; + + const handleUserPress = (e: any) => { + e.stopPropagation(); + onUserPress(); + }; + + const handleLikePress = (e: any) => { + e.stopPropagation(); + onLike(); + }; + + const handleCommentPress = (e: any) => { + e.stopPropagation(); + onComment(); + }; + + const handleSharePress = (e: any) => { + e.stopPropagation(); + onShare(); + }; + + const handleBookmarkPress = (e: any) => { + e.stopPropagation(); + onBookmark(); + }; + + const handleDeletePress = (e: any) => { + e.stopPropagation(); + handleDelete(); + }; + return ( = ({ } : {}) } ]} - onPress={onPress} + onPress={handleContainerPress} activeOpacity={0.9} > {/* 用户信息 */} - + = ({ - + = ({ {isPostAuthor && onDelete && ( = ({ - + = ({ - + = ({ - + = ({ /> - + ; +} + +export interface Message { + id: string; + conversationId: string; + senderId: string; + seq: number; + segments: MessageSegment[]; + createdAt: string; + status: 'normal' | 'recalled' | 'deleted'; + category?: string; + sender?: { + id: string; + username: string; + avatar?: string; + nickname?: string; + }; +} + +export interface Conversation { + id: string; + type: 'private' | 'group'; + isPinned: boolean; + lastSeq: number; + lastMessage?: Message; + lastMessageAt: string; + unreadCount: number; + participants: Array<{ + id: string; + username: string; + avatar?: string; + nickname?: string; + }>; + group?: { + id: string; + name: string; + avatar?: string; + }; + createdAt: string; + updatedAt: string; +} + +export interface UnreadCount { + total: number; + system: number; +} + +export interface TypingStatus { + groupId: string; + userId: string; + isTyping: boolean; +} + +export interface GroupNotice { + type: 'member_join' | 'member_leave' | 'member_removed' | 'role_changed' | 'muted' | 'unmuted'; + groupId: string; + data: { + userId?: string; + operatorId?: string; + role?: string; + username?: string; + [key: string]: any; + }; + timestamp: number; + messageId?: string; + seq?: number; +} + +// 工厂函数 +export const createMessage = (data: Partial): Message => ({ + id: data.id || '', + conversationId: data.conversationId || '', + senderId: data.senderId || '', + seq: data.seq || 0, + segments: data.segments || [], + createdAt: data.createdAt || new Date().toISOString(), + status: data.status || 'normal', + category: data.category, + sender: data.sender, +}); + +export const createConversation = (data: Partial): Conversation => ({ + id: data.id || '', + type: data.type || 'private', + isPinned: data.isPinned || false, + lastSeq: data.lastSeq || 0, + lastMessage: data.lastMessage, + lastMessageAt: data.lastMessageAt || new Date().toISOString(), + unreadCount: data.unreadCount || 0, + participants: data.participants || [], + group: data.group, + createdAt: data.createdAt || new Date().toISOString(), + updatedAt: data.updatedAt || new Date().toISOString(), +}); + +// 工具函数 +export const isMessageFromCurrentUser = (message: Message, currentUserId: string): boolean => { + return message.senderId === currentUserId; +}; + +export const shouldIncrementUnread = ( + message: Message, + currentUserId: string, + activeConversationId: string | null +): boolean => { + return ( + message.senderId !== currentUserId && + message.conversationId !== activeConversationId && + !!currentUserId + ); +}; + +export const buildTextContent = (segments: MessageSegment[]): string => { + return segments + .filter((s) => s.type === 'text') + .map((s) => s.data?.text || '') + .join(''); +}; + +export const buildGroupNoticeText = (notice: GroupNotice): string => { + const username = notice.data?.username || '用户'; + switch (notice.type) { + case 'member_join': + return `"${username}" 加入了群聊`; + case 'member_leave': + return `"${username}" 退出了群聊`; + case 'member_removed': + return `"${username}" 被移出群聊`; + case 'muted': + return `"${username}" 已被管理员禁言`; + case 'unmuted': + return `"${username}" 已被管理员解除禁言`; + default: + return ''; + } +}; diff --git a/src/core/usecases/ProcessMessageUseCase.ts b/src/core/usecases/ProcessMessageUseCase.ts new file mode 100644 index 0000000..aaab9a0 --- /dev/null +++ b/src/core/usecases/ProcessMessageUseCase.ts @@ -0,0 +1,656 @@ +/** + * ProcessMessageUseCase - 处理消息用例 + * 处理消息的业务逻辑,协调 Repository 和 SSEClient + * 纯业务逻辑,无UI依赖 + */ + +import { messageRepository } from '../../data/repositories/MessageRepository'; +import { sseClient, SSEEventType } from '../../data/datasources/SSEClient'; +import { + Message, + Conversation, + GroupNotice, + createMessage, + isMessageFromCurrentUser, + shouldIncrementUnread, + buildGroupNoticeText, +} from '../entities/Message'; +import { messageService } from '../../services/messageService'; +import { api } from '../../services/api'; + +// 事件类型定义 +export type MessageUseCaseEventType = + | 'message_received' + | 'message_recalled' + | 'typing_status' + | 'group_notice' + | 'connection_changed' + | 'error'; + +export interface MessageUseCaseEvent { + type: MessageUseCaseEventType; + payload: any; + timestamp: number; +} + +export type MessageUseCaseSubscriber = (event: MessageUseCaseEvent) => void; + +// 已读状态保护记录 +interface ReadStateRecord { + timestamp: number; + version: number; + lastReadSeq: number; + clearTimer?: NodeJS.Timeout; +} + +class ProcessMessageUseCase { + private subscribers: Set = new Set(); + private unsubscribeFns: Array<() => void> = []; + private processedMessageIds: Set = new Set(); + private processedMessageIdsExpiry: Map = new Map(); + private pendingReadMap: Map = new Map(); + private readStateVersion = 0; + private pendingUserRequests: Map> = new Map(); + private currentUserId: string | null = null; + + // 常量 + private readonly READ_STATE_PROTECTION_DELAY = 5000; // 5秒 + private readonly MESSAGE_ID_EXPIRY = 5 * 60 * 1000; // 5分钟 + + /** + * 初始化用例 + */ + initialize(currentUserId: string | null): void { + this.currentUserId = currentUserId; + this.setupSSEListeners(); + } + + /** + * 销毁用例 + */ + destroy(): void { + this.unsubscribeFns.forEach((fn) => fn()); + this.unsubscribeFns = []; + this.subscribers.clear(); + this.processedMessageIds.clear(); + this.processedMessageIdsExpiry.clear(); + this.pendingReadMap.clear(); + this.pendingUserRequests.clear(); + } + + /** + * 设置SSE监听器 + */ + private setupSSEListeners(): void { + // 监听私聊消息 + const unsubChat = sseClient.on('chat', (message) => { + this.handleNewMessage(message); + }); + + // 监听群聊消息 + const unsubGroupMessage = sseClient.on('group_message', (message) => { + this.handleNewMessage(message); + }); + + // 监听私聊已读回执 + const unsubRead = sseClient.on('read', (message) => { + this.handleReadReceipt(message); + }); + + // 监听群聊已读回执 + const unsubGroupRead = sseClient.on('group_read', (message) => { + this.handleGroupReadReceipt(message); + }); + + // 监听私聊消息撤回 + const unsubRecall = sseClient.on('recall', (message) => { + this.handleRecallMessage(message); + }); + + // 监听群聊消息撤回 + const unsubGroupRecall = sseClient.on('group_recall', (message) => { + this.handleGroupRecallMessage(message); + }); + + // 监听群聊输入状态 + const unsubGroupTyping = sseClient.on('group_typing', (message) => { + this.handleGroupTyping(message); + }); + + // 监听群通知 + const unsubGroupNotice = sseClient.on('group_notice', (message) => { + this.handleGroupNotice(message); + }); + + // 监听连接状态 + const unsubConnected = sseClient.on('connected', () => { + this.notifySubscribers({ + type: 'connection_changed', + payload: { connected: true }, + timestamp: Date.now(), + }); + }); + + const unsubDisconnected = sseClient.on('disconnected', () => { + this.notifySubscribers({ + type: 'connection_changed', + payload: { connected: false }, + timestamp: Date.now(), + }); + }); + + this.unsubscribeFns = [ + unsubChat, + unsubGroupMessage, + unsubRead, + unsubGroupRead, + unsubRecall, + unsubGroupRecall, + unsubGroupTyping, + unsubGroupNotice, + unsubConnected, + unsubDisconnected, + ]; + } + + /** + * 处理新消息 + */ + private async handleNewMessage(message: any): Promise { + const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message; + + // 消息去重检查 + if (this.isMessageProcessed(id)) { + return; + } + this.markMessageAsProcessed(id); + + // 构造消息对象 + const newMessage = createMessage({ + id, + conversationId: String(conversation_id), + senderId: sender_id, + seq, + segments: segments || [], + createdAt: created_at, + }); + + // 异步获取发送者信息(如果是群聊且不是当前用户发送的) + if (message.type === 'group_message' && sender_id && sender_id !== this.currentUserId) { + this.enrichMessageWithSender(newMessage); + } + + // 异步保存到本地数据库 + const isRead = _isAck || sender_id === this.currentUserId; + messageRepository.saveMessage(newMessage, isRead).catch((error) => { + console.error('[ProcessMessageUseCase] 保存消息失败:', error); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'message_received', + payload: { + message: newMessage, + isCurrentUser: sender_id === this.currentUserId, + isAck: _isAck, + }, + timestamp: Date.now(), + }); + } + + /** + * 丰富消息的发送者信息 + */ + private async enrichMessageWithSender(message: Message): Promise { + try { + const user = await this.getSenderInfo(message.senderId); + if (user) { + message.sender = user; + // 重新通知,包含发送者信息 + this.notifySubscribers({ + type: 'message_received', + payload: { + message, + isCurrentUser: false, + isUpdated: true, + }, + timestamp: Date.now(), + }); + } + } catch (error) { + console.error('[ProcessMessageUseCase] 获取发送者信息失败:', error); + } + } + + /** + * 获取发送者信息(带缓存和去重) + */ + private async getSenderInfo(userId: string): Promise { + // 先检查本地缓存 + const cachedUser = await messageRepository.getUserCache(userId); + if (cachedUser) { + return cachedUser; + } + + // 检查是否已有正在进行的请求 + const pendingRequest = this.pendingUserRequests.get(userId); + if (pendingRequest) { + return pendingRequest; + } + + // 发起新请求 + const request = this.fetchUserInfo(userId); + this.pendingUserRequests.set(userId, request); + + try { + const user = await request; + return user; + } finally { + this.pendingUserRequests.delete(userId); + } + } + + /** + * 从服务器获取用户信息 + */ + private async fetchUserInfo(userId: string): Promise { + try { + const response = await api.get(`/users/${userId}`); + if (response.code === 0 && response.data) { + await messageRepository.saveUserCache(response.data); + return response.data; + } + return null; + } catch (error) { + console.error(`[ProcessMessageUseCase] 获取用户信息失败: ${userId}`, error); + return null; + } + } + + /** + * 检查消息是否已处理 + */ + private isMessageProcessed(messageId: string): boolean { + return this.processedMessageIds.has(messageId); + } + + /** + * 标记消息已处理 + */ + private markMessageAsProcessed(messageId: string): void { + this.processedMessageIds.add(messageId); + this.processedMessageIdsExpiry.set(messageId, Date.now()); + + // 定期清理 + if (this.processedMessageIds.size > 1000) { + this.cleanupProcessedMessageIds(); + } + } + + /** + * 清理过期的消息ID + */ + private cleanupProcessedMessageIds(): void { + const now = Date.now(); + for (const [id, timestamp] of this.processedMessageIdsExpiry.entries()) { + if (now - timestamp > this.MESSAGE_ID_EXPIRY) { + this.processedMessageIds.delete(id); + this.processedMessageIdsExpiry.delete(id); + } + } + } + + /** + * 处理私聊已读回执 + */ + private handleReadReceipt(message: any): void { + // 可以在这里处理对方已读的状态更新 + } + + /** + * 处理群聊已读回执 + */ + private handleGroupReadReceipt(message: any): void { + // 可以在这里处理群聊已读状态更新 + } + + /** + * 处理私聊消息撤回 + */ + private handleRecallMessage(message: any): Promise { + const { conversation_id, message_id } = message; + + // 更新本地数据库状态 + messageRepository + .updateMessageStatus(message_id, 'recalled', true) + .catch((error) => { + console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'message_recalled', + payload: { + conversationId: String(conversation_id), + messageId: message_id, + isGroup: false, + }, + timestamp: Date.now(), + }); + + return Promise.resolve(); + } + + /** + * 处理群聊消息撤回 + */ + private handleGroupRecallMessage(message: any): Promise { + const { conversation_id, message_id } = message; + + // 更新本地数据库状态 + messageRepository + .updateMessageStatus(message_id, 'recalled', true) + .catch((error) => { + console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'message_recalled', + payload: { + conversationId: String(conversation_id), + messageId: message_id, + isGroup: true, + }, + timestamp: Date.now(), + }); + + return Promise.resolve(); + } + + /** + * 处理群聊输入状态 + */ + private handleGroupTyping(message: any): void { + const { group_id, user_id, is_typing } = message; + + this.notifySubscribers({ + type: 'typing_status', + payload: { + groupId: String(group_id), + userId: user_id, + isTyping: is_typing, + }, + timestamp: Date.now(), + }); + } + + /** + * 处理群通知 + */ + private handleGroupNotice(message: any): void { + const { notice_type, group_id, data, timestamp, message_id, seq } = message; + const groupIdStr = String(group_id); + + const notice: GroupNotice = { + type: notice_type, + groupId: groupIdStr, + data: data || {}, + timestamp: timestamp || Date.now(), + messageId: message_id, + seq, + }; + + // 通知订阅者 + this.notifySubscribers({ + type: 'group_notice', + payload: { + notice, + text: buildGroupNoticeText(notice), + }, + timestamp: Date.now(), + }); + } + + /** + * 标记会话已读 + */ + async markAsRead(conversationId: string, seq: number): Promise { + const existingRecord = this.pendingReadMap.get(conversationId); + + // 使用seq去重 + if (existingRecord && seq <= existingRecord.lastReadSeq) { + return; + } + + // 清除可能存在的旧定时器 + if (existingRecord?.clearTimer) { + clearTimeout(existingRecord.clearTimer); + } + + // 递增全局版本号 + this.readStateVersion++; + const currentVersion = this.readStateVersion; + + // 记录已读状态 + this.pendingReadMap.set(conversationId, { + timestamp: Date.now(), + version: currentVersion, + lastReadSeq: seq, + }); + + // 更新本地数据库 + await messageRepository.markConversationAsRead(conversationId); + + // 调用API + try { + await messageService.markAsRead(conversationId, seq); + } catch (error) { + console.error('[ProcessMessageUseCase] 标记已读API失败:', error); + this.pendingReadMap.delete(conversationId); + throw error; + } + + // 设置延迟清除保护 + const clearTimer = setTimeout(() => { + const record = this.pendingReadMap.get(conversationId); + if (record && record.version === currentVersion) { + this.pendingReadMap.delete(conversationId); + } + }, this.READ_STATE_PROTECTION_DELAY); + + this.pendingReadMap.set(conversationId, { + timestamp: Date.now(), + version: currentVersion, + lastReadSeq: seq, + clearTimer, + }); + } + + /** + * 检查会话是否有进行中的已读请求 + */ + hasPendingRead(conversationId: string): boolean { + return this.pendingReadMap.has(conversationId); + } + + /** + * 获取进行中的已读记录 + */ + getPendingReadRecord(conversationId: string): ReadStateRecord | undefined { + return this.pendingReadMap.get(conversationId); + } + + /** + * 发送消息 + */ + async sendMessage( + conversationId: string, + segments: any[], + options?: { replyToId?: string } + ): Promise { + try { + const response = await messageService.sendMessage(conversationId, { + segments, + reply_to_id: options?.replyToId, + }); + + if (response) { + const message = createMessage({ + id: response.id, + conversationId, + senderId: this.currentUserId || '', + seq: response.seq, + segments, + createdAt: new Date().toISOString(), + }); + + // 保存到本地数据库 + await messageRepository.saveMessage(message, true); + + return message; + } + + return null; + } catch (error) { + console.error('[ProcessMessageUseCase] 发送消息失败:', error); + throw error; + } + } + + /** + * 获取本地消息 + */ + async getLocalMessages(conversationId: string, limit: number = 20): Promise { + return messageRepository.getMessagesByConversation(conversationId, limit); + } + + /** + * 获取历史消息 + */ + async getHistoryMessages( + conversationId: string, + beforeSeq: number, + limit: number = 20 + ): Promise { + // 先从本地获取 + const localMessages = await messageRepository.getMessagesBeforeSeq( + conversationId, + beforeSeq, + limit + ); + + if (localMessages.length >= limit) { + return localMessages; + } + + // 本地数据不足,从服务端获取 + try { + const response = await messageService.getMessages( + conversationId, + undefined, + beforeSeq, + limit + ); + + if (response?.messages && response.messages.length > 0) { + const serverMessages = response.messages.map((m: any) => + createMessage({ + id: m.id, + conversationId: m.conversation_id || conversationId, + senderId: m.sender_id, + seq: m.seq, + segments: m.segments || [], + createdAt: m.created_at, + status: m.status || 'normal', + }) + ); + + // 保存到本地 + await messageRepository.saveMessages(serverMessages, false); + + return serverMessages; + } + } catch (error) { + console.error('[ProcessMessageUseCase] 获取历史消息失败:', error); + } + + return localMessages; + } + + /** + * 从服务端同步消息 + */ + async syncMessagesFromServer( + conversationId: string, + afterSeq?: number + ): Promise { + try { + const response = await messageService.getMessages(conversationId, afterSeq); + + if (response?.messages && response.messages.length > 0) { + const messages = response.messages.map((m: any) => + createMessage({ + id: m.id, + conversationId: m.conversation_id || conversationId, + senderId: m.sender_id, + seq: m.seq, + segments: m.segments || [], + createdAt: m.created_at, + status: m.status || 'normal', + }) + ); + + // 保存到本地 + await messageRepository.saveMessages(messages, false); + + return messages; + } + } catch (error) { + console.error('[ProcessMessageUseCase] 同步消息失败:', error); + } + + return []; + } + + /** + * 订阅事件 + */ + subscribe(subscriber: MessageUseCaseSubscriber): () => void { + this.subscribers.add(subscriber); + + return () => { + this.subscribers.delete(subscriber); + }; + } + + /** + * 通知订阅者 + */ + private notifySubscribers(event: MessageUseCaseEvent): void { + this.subscribers.forEach((subscriber) => { + try { + subscriber(event); + } catch (error) { + console.error('[ProcessMessageUseCase] 订阅者执行失败:', error); + } + }); + } + + /** + * 获取当前用户ID + */ + getCurrentUserId(): string | null { + return this.currentUserId; + } + + /** + * 设置当前用户ID + */ + setCurrentUserId(userId: string | null): void { + this.currentUserId = userId; + } +} + +export const processMessageUseCase = new ProcessMessageUseCase(); +export default processMessageUseCase; diff --git a/src/data/datasources/ApiDataSource.ts b/src/data/datasources/ApiDataSource.ts new file mode 100644 index 0000000..f097e94 --- /dev/null +++ b/src/data/datasources/ApiDataSource.ts @@ -0,0 +1,103 @@ +/** + * API 数据源实现 + * 封装所有 API 调用,统一错误处理和请求拦截 + */ + +import { api, ApiResponse, ApiError } from '../../services/api'; +import { IApiDataSource, DataSourceError } from './interfaces'; + +export class ApiDataSource implements IApiDataSource { + private baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl || ''; + } + + private buildUrl(url: string): string { + if (url.startsWith('http')) { + return url; + } + return `${this.baseUrl}${url}`; + } + + private handleError(error: unknown, operation: string): never { + if (error instanceof ApiError) { + throw new DataSourceError( + error.message, + String(error.code), + 'ApiDataSource', + error + ); + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new DataSourceError( + `API ${operation} failed: ${message}`, + 'API_ERROR', + 'ApiDataSource', + error instanceof Error ? error : undefined + ); + } + + async get(url: string, params?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.get(fullUrl, params); + return response.data; + } catch (error) { + this.handleError(error, 'GET'); + } + } + + async post(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.post(fullUrl, data); + return response.data; + } catch (error) { + this.handleError(error, 'POST'); + } + } + + async put(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.put(fullUrl, data); + return response.data; + } catch (error) { + this.handleError(error, 'PUT'); + } + } + + async delete(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + // 处理带 request body 的 DELETE 请求 + if (data) { + const response = await api.request('DELETE', fullUrl, undefined, data); + return response.data; + } + const response = await api.delete(fullUrl); + return response.data; + } catch (error) { + this.handleError(error, 'DELETE'); + } + } + + async upload( + url: string, + file: { uri: string; name: string; type: string }, + additionalData?: Record + ): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.upload(fullUrl, file, additionalData); + return response.data; + } catch (error) { + this.handleError(error, 'UPLOAD'); + } + } +} + +// 导出单例实例 +export const apiDataSource = new ApiDataSource(); diff --git a/src/data/datasources/CacheDataSource.ts b/src/data/datasources/CacheDataSource.ts new file mode 100644 index 0000000..921e767 --- /dev/null +++ b/src/data/datasources/CacheDataSource.ts @@ -0,0 +1,199 @@ +/** + * 缓存数据源实现 + * 基于内存和 AsyncStorage 的混合缓存实现 + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { ICacheDataSource, DataSourceError } from './interfaces'; + +interface CacheEntry { + value: T; + expiry: number | null; // null 表示永不过期 +} + +export class CacheDataSource implements ICacheDataSource { + private memoryCache: Map> = new Map(); + private maxSize: number; + private defaultTtl: number; // 默认过期时间(毫秒) + private persistPrefix: string; + + constructor(config?: { + maxSize?: number; + defaultTtl?: number; // 默认5分钟 + persistPrefix?: string; + }) { + this.maxSize = config?.maxSize || 100; + this.defaultTtl = config?.defaultTtl || 5 * 60 * 1000; + this.persistPrefix = config?.persistPrefix || 'cache_'; + } + + /** + * 检查 key 是否过期 + */ + private isExpired(entry: CacheEntry): boolean { + if (entry.expiry === null) return false; + return Date.now() > entry.expiry; + } + + /** + * 清理过期缓存 + */ + private cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.memoryCache.entries()) { + if (entry.expiry !== null && now > entry.expiry) { + this.memoryCache.delete(key); + } + } + + // 如果仍然超过最大大小,删除最旧的条目 + while (this.memoryCache.size > this.maxSize) { + const firstKey = this.memoryCache.keys().next().value; + if (firstKey !== undefined) { + this.memoryCache.delete(firstKey); + } + } + } + + /** + * 生成持久化存储 key + */ + private getPersistKey(key: string): string { + return `${this.persistPrefix}${key}`; + } + + // ==================== ICacheDataSource 实现 ==================== + + async get(key: string): Promise { + try { + // 先检查内存缓存 + const memoryEntry = this.memoryCache.get(key); + if (memoryEntry) { + if (!this.isExpired(memoryEntry)) { + return memoryEntry.value as T; + } + // 过期了,从内存中删除 + this.memoryCache.delete(key); + } + + // 尝试从持久化存储读取 + const persistKey = this.getPersistKey(key); + const stored = await AsyncStorage.getItem(persistKey); + if (stored) { + const entry: CacheEntry = JSON.parse(stored); + if (!this.isExpired(entry)) { + // 重新加载到内存缓存 + this.memoryCache.set(key, entry); + this.cleanup(); + return entry.value; + } + // 过期了,删除 + await AsyncStorage.removeItem(persistKey); + } + + return null; + } catch (error) { + console.warn(`[CacheDataSource] Failed to get ${key}:`, error); + return null; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const expiry = ttl === undefined + ? (this.defaultTtl > 0 ? Date.now() + this.defaultTtl : null) + : (ttl > 0 ? Date.now() + ttl : null); + + const entry: CacheEntry = { value, expiry }; + + // 存入内存 + this.memoryCache.set(key, entry); + this.cleanup(); + + // 持久化存储(重要数据) + if (ttl === undefined || ttl > 0) { + const persistKey = this.getPersistKey(key); + await AsyncStorage.setItem(persistKey, JSON.stringify(entry)); + } + } catch (error) { + throw new DataSourceError( + `Failed to set cache ${key}`, + 'CACHE_SET_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async delete(key: string): Promise { + try { + // 从内存删除 + this.memoryCache.delete(key); + + // 从持久化存储删除 + const persistKey = this.getPersistKey(key); + await AsyncStorage.removeItem(persistKey); + } catch (error) { + throw new DataSourceError( + `Failed to delete cache ${key}`, + 'CACHE_DELETE_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async clear(): Promise { + try { + // 清空内存 + this.memoryCache.clear(); + + // 清空持久化存储中的所有缓存项 + const keys = await AsyncStorage.getAllKeys(); + const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix)); + if (cacheKeys.length > 0) { + await AsyncStorage.multiRemove(cacheKeys); + } + } catch (error) { + throw new DataSourceError( + 'Failed to clear cache', + 'CACHE_CLEAR_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async has(key: string): Promise { + try { + // 检查内存 + const memoryEntry = this.memoryCache.get(key); + if (memoryEntry && !this.isExpired(memoryEntry)) { + return true; + } + + // 检查持久化存储 + const persistKey = this.getPersistKey(key); + const stored = await AsyncStorage.getItem(persistKey); + if (stored) { + const entry: CacheEntry = JSON.parse(stored); + return !this.isExpired(entry); + } + + return false; + } catch (error) { + return false; + } + } + + async getMultiple(keys: string[]): Promise<(T | null)[]> { + return Promise.all(keys.map(key => this.get(key))); + } + + async setMultiple(entries: { key: string; value: T; ttl?: number }[]): Promise { + await Promise.all(entries.map(entry => this.set(entry.key, entry.value, entry.ttl))); + } +} + +// 导出单例实例 +export const cacheDataSource = new CacheDataSource(); diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts new file mode 100644 index 0000000..90ddc98 --- /dev/null +++ b/src/data/datasources/LocalDataSource.ts @@ -0,0 +1,281 @@ +/** + * 本地数据库数据源实现 + * 封装所有 SQLite 操作,提供统一的错误处理和事务支持 + */ + +import * as SQLite from 'expo-sqlite'; +import { ILocalDataSource, DataSourceError } from './interfaces'; + +// 数据库实例管理 +let dbInstance: SQLite.SQLiteDatabase | null = null; +let currentDbName: string | null = null; +let writeQueue: Promise = Promise.resolve(); + +export interface LocalDataSourceConfig { + dbName?: string; + userId?: string; +} + +export class LocalDataSource implements ILocalDataSource { + private db: SQLite.SQLiteDatabase | null = null; + private dbName: string; + private initialized = false; + + constructor(config: LocalDataSourceConfig = {}) { + // 如果提供了userId,使用用户专属数据库 + this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db'); + } + + /** + * 初始化数据库连接 + */ + async initialize(): Promise { + if (this.initialized && this.db) { + return; + } + + try { + // 使用全局实例管理 + if (dbInstance && currentDbName === this.dbName) { + this.db = dbInstance; + this.initialized = true; + return; + } + + // 关闭旧连接 + if (dbInstance) { + try { + await dbInstance.closeAsync(); + } catch (e) { + console.warn('关闭旧数据库连接失败:', e); + } + } + + // 创建新连接 + this.db = await SQLite.openDatabaseAsync(this.dbName); + dbInstance = this.db; + currentDbName = this.dbName; + this.initialized = true; + + // 初始化数据库表结构 + await this.createTables(); + } catch (error) { + this.handleError(error, 'INITIALIZE'); + } + } + + /** + * 创建数据库表结构 + */ + private async createTables(): Promise { + if (!this.db) return; + + const tables = [ + // 消息表 + `CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY NOT NULL, + conversationId TEXT NOT NULL, + senderId TEXT NOT NULL, + content TEXT, + type TEXT DEFAULT 'text', + isRead INTEGER DEFAULT 0, + createdAt TEXT NOT NULL, + seq INTEGER DEFAULT 0, + status TEXT DEFAULT 'normal', + segments TEXT + )`, + // 会话表 + `CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY NOT NULL, + participantId TEXT NOT NULL, + lastMessageId TEXT, + lastSeq INTEGER DEFAULT 0, + unreadCount INTEGER DEFAULT 0, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 会话缓存表 + `CREATE TABLE IF NOT EXISTS conversation_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 会话列表缓存表 + `CREATE TABLE IF NOT EXISTS conversation_list_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 用户缓存表 + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 当前登录用户缓存 + `CREATE TABLE IF NOT EXISTS current_user_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 群组缓存表 + `CREATE TABLE IF NOT EXISTS groups ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, + // 群成员缓存表 + `CREATE TABLE IF NOT EXISTS group_members ( + groupId TEXT NOT NULL, + userId TEXT NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL, + PRIMARY KEY (groupId, userId) + )`, + ]; + + for (const sql of tables) { + await this.db.execAsync(sql); + } + + // 创建索引 + const indexes = [ + `CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`, + `CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`, + `CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`, + `CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`, + ]; + + for (const sql of indexes) { + await this.db.execAsync(sql); + } + } + + /** + * 处理错误 + */ + private handleError(error: unknown, operation: string): never { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new DataSourceError( + `Database ${operation} failed: ${message}`, + 'DB_ERROR', + 'LocalDataSource', + error instanceof Error ? error : undefined + ); + } + + /** + * 检查数据库连接 + */ + private ensureDb(): SQLite.SQLiteDatabase { + if (!this.db) { + throw new DataSourceError( + 'Database not initialized', + 'DB_NOT_INITIALIZED', + 'LocalDataSource' + ); + } + return this.db; + } + + // ==================== ILocalDataSource 实现 ==================== + + async query(sql: string, params?: any[]): Promise { + try { + const db = this.ensureDb(); + return await db.getAllAsync(sql, params); + } catch (error) { + this.handleError(error, 'QUERY'); + } + } + + async getFirst(sql: string, params?: any[]): Promise { + try { + const db = this.ensureDb(); + return await db.getFirstAsync(sql, params); + } catch (error) { + this.handleError(error, 'GET_FIRST'); + } + } + + async execute(sql: string, params?: any[]): Promise { + try { + const db = this.ensureDb(); + await db.execAsync(sql); + } catch (error) { + this.handleError(error, 'EXECUTE'); + } + } + + async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> { + try { + const db = this.ensureDb(); + return await db.runAsync(sql, params); + } catch (error) { + this.handleError(error, 'RUN'); + } + } + + async transaction(operations: () => Promise): Promise { + try { + const db = this.ensureDb(); + await db.withTransactionAsync(async () => { + await operations(); + }); + } catch (error) { + this.handleError(error, 'TRANSACTION'); + } + } + + async batch(operations: (db: ILocalDataSource) => Promise): Promise { + try { + await this.transaction(async () => { + await operations(this); + }); + } catch (error) { + this.handleError(error, 'BATCH'); + } + } + + // ==================== 队列写入支持 ==================== + + /** + * 使用队列执行写入操作(避免并发写入冲突) + */ + async enqueueWrite(operation: () => Promise): Promise { + const wrappedOperation = async () => { + try { + return await operation(); + } catch (error) { + // 尝试重连后重试 + if (this.isRecoverableError(error)) { + console.error('数据库写入异常,尝试重连后重试:', error); + this.db = null; + dbInstance = null; + await this.initialize(); + return operation(); + } + throw error; + } + }; + + const queued = writeQueue.then(wrappedOperation, wrappedOperation); + writeQueue = queued.then(() => undefined, () => undefined); + return queued; + } + + /** + * 判断错误是否可恢复 + */ + private isRecoverableError(error: unknown): boolean { + const message = String(error); + return ( + message.includes('NativeDatabase.prepareAsync') || + message.includes('NullPointerException') || + message.includes('database is locked') + ); + } +} + +// 导出单例实例 +export const localDataSource = new LocalDataSource(); diff --git a/src/data/datasources/SSEClient.ts b/src/data/datasources/SSEClient.ts new file mode 100644 index 0000000..ae955ac --- /dev/null +++ b/src/data/datasources/SSEClient.ts @@ -0,0 +1,184 @@ +/** + * SSEClient - SSE连接管理 + * 只负责SSE连接管理,提供事件驱动架构 + */ + +import { + sseService, + WSChatMessage, + WSGroupChatMessage, + WSReadMessage, + WSGroupReadMessage, + WSRecallMessage, + WSGroupRecallMessage, + WSGroupTypingMessage, + WSGroupNoticeMessage, + WSMessageType, +} from '../../services/sseService'; + +// 事件处理器类型 +type EventHandler = (data: T) => void; + +// SSE事件类型 +export interface SSEEvents { + 'chat': WSChatMessage; + 'group_message': WSGroupChatMessage; + 'read': WSReadMessage; + 'group_read': WSGroupReadMessage; + 'recall': WSRecallMessage; + 'group_recall': WSGroupRecallMessage; + 'group_typing': WSGroupTypingMessage; + 'group_notice': WSGroupNoticeMessage; + 'connected': void; + 'disconnected': void; + 'error': Error; +} + +export type SSEEventType = keyof SSEEvents; + +class SSEClient { + private handlers: Map>> = new Map(); + private unsubscribeFns: Array<() => void> = []; + private isInitialized = false; + + /** + * 初始化SSE监听 + */ + initialize(): void { + if (this.isInitialized) return; + + // 监听私聊消息 + const unsubChat = sseService.on('chat', (message) => { + this.emit('chat', message); + }); + + // 监听群聊消息 + const unsubGroupMessage = sseService.on('group_message', (message) => { + this.emit('group_message', message); + }); + + // 监听私聊已读回执 + const unsubRead = sseService.on('read', (message) => { + this.emit('read', message); + }); + + // 监听群聊已读回执 + const unsubGroupRead = sseService.on('group_read', (message) => { + this.emit('group_read', message); + }); + + // 监听私聊消息撤回 + const unsubRecall = sseService.on('recall', (message) => { + this.emit('recall', message); + }); + + // 监听群聊消息撤回 + const unsubGroupRecall = sseService.on('group_recall', (message) => { + this.emit('group_recall', message); + }); + + // 监听群聊输入状态 + const unsubGroupTyping = sseService.on('group_typing', (message) => { + this.emit('group_typing', message); + }); + + // 监听群通知 + const unsubGroupNotice = sseService.on('group_notice', (message) => { + this.emit('group_notice', message); + }); + + // 监听连接状态 + const unsubConnect = sseService.onConnect(() => { + this.emit('connected', undefined); + }); + + const unsubDisconnect = sseService.onDisconnect(() => { + this.emit('disconnected', undefined); + }); + + this.unsubscribeFns = [ + unsubChat, + unsubGroupMessage, + unsubRead, + unsubGroupRead, + unsubRecall, + unsubGroupRecall, + unsubGroupTyping, + unsubGroupNotice, + unsubConnect, + unsubDisconnect, + ]; + + this.isInitialized = true; + } + + /** + * 销毁SSE监听 + */ + destroy(): void { + this.unsubscribeFns.forEach((fn) => fn()); + this.unsubscribeFns = []; + this.handlers.clear(); + this.isInitialized = false; + } + + /** + * 订阅事件 + */ + on( + event: T, + handler: EventHandler + ): () => void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)!.add(handler); + + return () => { + this.handlers.get(event)?.delete(handler); + }; + } + + /** + * 触发事件 + */ + private emit( + event: T, + data: SSEEvents[T] + ): void { + const handlers = this.handlers.get(event); + if (handlers) { + handlers.forEach((handler) => { + try { + handler(data); + } catch (error) { + console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error); + } + }); + } + } + + /** + * 检查连接状态 + */ + isConnected(): boolean { + return sseService.isConnected(); + } + + /** + * 启动SSE连接 + */ + async connect(): Promise { + return sseService.start(); + } + + /** + * 断开SSE连接 + */ + disconnect(): void { + sseService.stop(); + } +} + +export const sseClient = new SSEClient(); +export default sseClient; diff --git a/src/data/datasources/index.ts b/src/data/datasources/index.ts new file mode 100644 index 0000000..683b5ec --- /dev/null +++ b/src/data/datasources/index.ts @@ -0,0 +1,9 @@ +/** + * 数据源导出 + * 统一导出所有数据源实现 + */ + +export * from './interfaces'; +export * from './ApiDataSource'; +export * from './LocalDataSource'; +export * from './CacheDataSource'; diff --git a/src/data/datasources/interfaces.ts b/src/data/datasources/interfaces.ts new file mode 100644 index 0000000..c2c9b46 --- /dev/null +++ b/src/data/datasources/interfaces.ts @@ -0,0 +1,64 @@ +/** + * 数据源接口定义 + * 定义所有数据源的标准接口,便于替换和测试 + */ + +// API 数据源接口 +export interface IApiDataSource { + get(url: string, params?: any): Promise; + post(url: string, data?: any): Promise; + put(url: string, data?: any): Promise; + delete(url: string, data?: any): Promise; + upload(url: string, file: { uri: string; name: string; type: string }, additionalData?: Record): Promise; +} + +// 本地数据源接口 (SQLite) +export interface ILocalDataSource { + query(sql: string, params?: any[]): Promise; + getFirst(sql: string, params?: any[]): Promise; + execute(sql: string, params?: any[]): Promise; + run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>; + transaction(operations: () => Promise): Promise; + batch(operations: (db: ILocalDataSource) => Promise): Promise; +} + +// 缓存数据源接口 +export interface ICacheDataSource { + get(key: string): Promise; + set(key: string, value: T, ttl?: number): Promise; + delete(key: string): Promise; + clear(): Promise; + has(key: string): Promise; + getMultiple(keys: string[]): Promise<(T | null)[]>; + setMultiple(entries: { key: string; value: T; ttl?: number }[]): Promise; +} + +// 数据源错误类型 +export class DataSourceError extends Error { + constructor( + message: string, + public code: string, + public source: string, + public originalError?: Error + ) { + super(message); + this.name = 'DataSourceError'; + } +} + +// 数据源配置 +export interface DataSourceConfig { + api?: { + baseUrl: string; + timeout?: number; + retryCount?: number; + }; + local?: { + dbName: string; + version: number; + }; + cache?: { + maxSize: number; + defaultTtl: number; + }; +} diff --git a/src/data/mappers/ConversationMapper.ts b/src/data/mappers/ConversationMapper.ts new file mode 100644 index 0000000..4eaaca0 --- /dev/null +++ b/src/data/mappers/ConversationMapper.ts @@ -0,0 +1,290 @@ +/** + * 会话数据映射器 + * 负责 Conversation 模型与 API 响应、数据库记录之间的转换 + */ + +import { + ConversationModel, + ConversationType, + UserModel, + GroupModel, + MessageModel, +} from '../models'; +import type { + ConversationResponse, + ConversationDetailResponse, + UserDTO, + GroupResponse, + MessageResponse, +} from '../../types/dto'; +import { MessageMapper } from './MessageMapper'; + +// 数据库会话记录类型 +export interface ConversationDbRecord { + id: string; + participantId: string; + lastMessageId: string | null; + lastSeq: number; + unreadCount: number; + createdAt: string; + updatedAt: string; +} + +// 缓存数据类型 +export interface ConversationCacheData { + id: string; + type: string; + is_pinned?: boolean; + last_seq?: number; + last_message?: any; + last_message_at?: string; + unread_count?: number; + participants?: any[]; + my_last_read_seq?: number; + other_last_read_seq?: number; + group?: any; + created_at?: string; + updated_at?: string; +} + +export class ConversationMapper { + /** + * 将 API 列表响应转换为应用模型 + */ + static fromApiResponse(response: ConversationResponse): ConversationModel { + return { + id: String(response.id || ''), + type: (response.type as ConversationType) || 'private', + participantId: this.extractParticipantId(response), + lastMessageId: response.last_message?.id, + lastMessage: response.last_message + ? MessageMapper.fromApiResponse(response.last_message) + : undefined, + lastSeq: response.last_seq || 0, + myLastReadSeq: response.my_last_read_seq || 0, + otherLastReadSeq: response.other_last_read_seq || 0, + unreadCount: response.unread_count || 0, + isPinned: response.is_pinned || false, + participants: response.participants?.map(p => this.mapUserFromApi(p)), + group: response.group ? this.mapGroupFromApi(response.group) : undefined, + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 详情响应转换为应用模型 + */ + static fromDetailApiResponse(response: ConversationDetailResponse): ConversationModel { + return { + id: String(response.id || ''), + type: (response.type as ConversationType) || 'private', + participantId: this.extractParticipantIdFromDetail(response), + lastMessageId: response.last_message?.id, + lastMessage: response.last_message + ? MessageMapper.fromApiResponse(response.last_message) + : undefined, + lastSeq: response.last_seq || 0, + myLastReadSeq: response.my_last_read_seq || 0, + otherLastReadSeq: response.other_last_read_seq || 0, + unreadCount: response.unread_count || 0, + isPinned: response.is_pinned || false, + participants: response.participants?.map(p => this.mapUserFromApi(p)), + group: response.group ? this.mapGroupFromApi(response.group) : undefined, + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: ConversationResponse[]): ConversationModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 从缓存数据转换为应用模型 + */ + static fromCacheData(id: string, cached: ConversationCacheData): ConversationModel { + return { + id: String(cached?.id || id), + type: (cached?.type as ConversationType) || 'private', + participantId: '', + lastSeq: Number(cached?.last_seq || 0), + lastMessage: cached?.last_message + ? MessageMapper.fromApiResponse(cached.last_message) + : undefined, + lastMessageId: cached?.last_message?.id, + myLastReadSeq: Number(cached?.my_last_read_seq || 0), + otherLastReadSeq: Number(cached?.other_last_read_seq || 0), + unreadCount: Number(cached?.unread_count || 0), + isPinned: Boolean(cached?.is_pinned), + participants: Array.isArray(cached?.participants) + ? cached.participants.map(p => this.mapUserFromApi(p)) + : [], + group: cached?.group ? this.mapGroupFromApi(cached.group) : undefined, + createdAt: new Date(cached?.created_at || Date.now()), + updatedAt: new Date(cached?.updated_at || Date.now()), + }; + } + + /** + * 将数据库记录转换为应用模型 + */ + static fromDbRecord(record: ConversationDbRecord): ConversationModel { + return { + id: record.id, + type: 'private', // 数据库中不存储类型,需要额外查询 + participantId: record.participantId, + lastMessageId: record.lastMessageId || undefined, + lastSeq: record.lastSeq, + myLastReadSeq: 0, + otherLastReadSeq: 0, + unreadCount: record.unreadCount, + isPinned: false, + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.updatedAt), + }; + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: ConversationModel): ConversationDbRecord { + return { + id: model.id, + participantId: model.participantId, + lastMessageId: model.lastMessageId || null, + lastSeq: model.lastSeq, + unreadCount: model.unreadCount, + createdAt: model.createdAt.toISOString(), + updatedAt: model.updatedAt.toISOString(), + }; + } + + /** + * 将应用模型转换为缓存数据 + */ + static toCacheData(model: ConversationModel): ConversationCacheData { + return { + id: model.id, + type: model.type, + is_pinned: model.isPinned, + last_seq: model.lastSeq, + last_message: model.lastMessage ? this.messageToCache(model.lastMessage) : undefined, + last_message_at: model.updatedAt.toISOString(), + unread_count: model.unreadCount, + participants: model.participants?.map(p => this.userToCache(p)), + my_last_read_seq: model.myLastReadSeq, + other_last_read_seq: model.otherLastReadSeq, + group: model.group ? this.groupToCache(model.group) : undefined, + created_at: model.createdAt.toISOString(), + updated_at: model.updatedAt.toISOString(), + }; + } + + /** + * 提取会话参与者 ID + */ + private static extractParticipantId(response: ConversationResponse): string { + if (response.participants && response.participants.length > 0) { + return String(response.participants[0].id || ''); + } + return ''; + } + + /** + * 从详情响应提取参与者 ID + */ + private static extractParticipantIdFromDetail(response: ConversationDetailResponse): string { + if (response.participants && response.participants.length > 0) { + return String(response.participants[0].id || ''); + } + return ''; + } + + /** + * 映射用户 API 响应 + */ + private static mapUserFromApi(user: UserDTO): UserModel { + return { + id: String(user.id || ''), + username: user.username || '', + nickname: user.nickname, + avatar: user.avatar, + }; + } + + /** + * 映射群组 API 响应 + */ + private static mapGroupFromApi(group: GroupResponse): GroupModel { + return { + id: String(group.id || ''), + name: group.name || '', + avatar: group.avatar, + description: group.description, + announcement: group.announcement, + ownerId: String(group.owner_id || ''), + memberCount: group.member_count || 0, + maxMemberCount: group.max_member_count || 500, + joinType: group.join_type || 'approval', + isMuted: group.mute_all || false, + createdAt: new Date(group.created_at || Date.now()), + updatedAt: new Date(group.updated_at || Date.now()), + }; + } + + /** + * 消息模型转缓存格式 + */ + private static messageToCache(message: MessageModel): any { + return { + id: message.id, + conversation_id: message.conversationId, + sender_id: message.senderId, + content: message.content, + message_type: message.type, + is_read: message.isRead, + created_at: message.createdAt.toISOString(), + seq: message.seq, + status: message.status, + segments: message.segments, + reply_to_id: message.replyToId, + sender: message.sender ? this.userToCache(message.sender) : undefined, + }; + } + + /** + * 用户模型转缓存格式 + */ + private static userToCache(user: UserModel): any { + return { + id: user.id, + username: user.username, + nickname: user.nickname, + avatar: user.avatar, + }; + } + + /** + * 群组模型转缓存格式 + */ + private static groupToCache(group: GroupModel): any { + return { + id: group.id, + name: group.name, + avatar: group.avatar, + description: group.description, + announcement: group.announcement, + owner_id: group.ownerId, + member_count: group.memberCount, + max_member_count: group.maxMemberCount, + join_type: group.joinType, + mute_all: group.isMuted, + created_at: group.createdAt.toISOString(), + updated_at: group.updatedAt.toISOString(), + }; + } +} diff --git a/src/data/mappers/MessageMapper.ts b/src/data/mappers/MessageMapper.ts new file mode 100644 index 0000000..d6e99ea --- /dev/null +++ b/src/data/mappers/MessageMapper.ts @@ -0,0 +1,183 @@ +/** + * 消息数据映射器 + * 负责 Message 模型与 API 响应、数据库记录之间的转换 + */ + +import { + MessageModel, + MessageSegment, + UserModel, +} from '../models'; +import type { + MessageResponse, + UserDTO, +} from '../../types/dto'; + +// 数据库消息记录类型 +export interface MessageDbRecord { + id: string; + conversationId: string; + senderId: string; + content: string | null; + type: string; + isRead: number; + createdAt: string; + seq: number; + status: string; + segments: string | null; +} + +export class MessageMapper { + /** + * 将 API 响应转换为应用模型 + */ + static fromApiResponse(response: MessageResponse): MessageModel { + return { + id: String(response.id || ''), + conversationId: String(response.conversation_id || ''), + senderId: String(response.sender_id || ''), + content: response.content || '', + type: response.message_type || 'text', + isRead: response.is_read || false, + createdAt: new Date(response.created_at || Date.now()), + seq: response.seq || 0, + status: response.status || 'normal', + segments: response.segments || [], + replyToId: response.reply_to_id, + sender: response.sender ? this.mapSenderFromApi(response.sender) : undefined, + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: MessageResponse[]): MessageModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 将数据库记录转换为应用模型 + */ + static fromDbRecord(record: MessageDbRecord): MessageModel { + return { + id: record.id, + conversationId: record.conversationId, + senderId: record.senderId, + content: record.content || undefined, + type: record.type || 'text', + isRead: record.isRead === 1, + createdAt: new Date(record.createdAt), + seq: record.seq || 0, + status: record.status || 'normal', + segments: record.segments ? this.safeParseJson(record.segments) : undefined, + }; + } + + /** + * 将数据库记录数组转换为应用模型数组 + */ + static fromDbRecordList(records: MessageDbRecord[]): MessageModel[] { + return records.map(r => this.fromDbRecord(r)); + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: MessageModel): MessageDbRecord { + return { + id: model.id, + conversationId: model.conversationId, + senderId: model.senderId, + content: model.content || null, + type: model.type, + isRead: model.isRead ? 1 : 0, + createdAt: model.createdAt.toISOString(), + seq: model.seq, + status: model.status, + segments: model.segments ? JSON.stringify(model.segments) : null, + }; + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.segments) { + request.segments = model.segments; + } + if (model.content) { + request.content = model.content; + } + if (model.type) { + request.message_type = model.type; + } + if (model.replyToId) { + request.reply_to_id = model.replyToId; + } + + return request; + } + + /** + * 创建发送消息请求体 + */ + static createSendRequest( + detailType: 'private' | 'group', + segments: MessageSegment[], + replyToId?: string + ): Record { + const body: Record = { + detail_type: detailType, + segments, + }; + if (replyToId) { + body.reply_to_id = replyToId; + } + return body; + } + + /** + * 创建文本消息段 + */ + static createTextSegment(text: string): MessageSegment { + return { + type: 'text', + data: { text }, + }; + } + + /** + * 创建图片消息段 + */ + static createImageSegment(url: string): MessageSegment { + return { + type: 'image', + data: { url }, + }; + } + + /** + * 安全解析 JSON + */ + private static safeParseJson(value: string): T | undefined { + try { + return JSON.parse(value) as T; + } catch { + return undefined; + } + } + + /** + * 映射发送者信息 + */ + private static mapSenderFromApi(sender: UserDTO): UserModel { + return { + id: String(sender.id || ''), + username: sender.username || '', + nickname: sender.nickname, + avatar: sender.avatar, + }; + } +} diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts new file mode 100644 index 0000000..172a6ac --- /dev/null +++ b/src/data/mappers/PostMapper.ts @@ -0,0 +1,123 @@ +/** + * 帖子数据映射器 + * 负责 Post 模型与 API 响应之间的转换 + */ + +import { PostModel, UserModel } from '../models'; +import type { Post } from '../../types'; + +export class PostMapper { + /** + * 将 API 响应转换为应用模型 + */ + static fromApiResponse(response: Post): PostModel { + return { + id: String(response.id || ''), + authorId: String(response.author_id || ''), + author: response.author ? this.mapAuthorFromApi(response.author) : undefined, + title: response.title || '', + content: response.content || '', + images: response.images || [], + likeCount: response.like_count || 0, + commentCount: response.comment_count || 0, + shareCount: response.share_count || 0, + viewCount: response.view_count || 0, + favoriteCount: response.favorite_count || 0, + isLiked: response.is_liked || false, + isFavorited: response.is_favorited || false, + isTop: response.is_top || false, + status: response.status || 'published', + communityId: response.community_id, + tags: response.tags || [], + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: Post[]): PostModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.title !== undefined) { + request.title = model.title; + } + if (model.content !== undefined) { + request.content = model.content; + } + if (model.images !== undefined) { + request.images = model.images; + } + if (model.communityId !== undefined) { + request.community_id = model.communityId; + } + if (model.tags !== undefined) { + request.tags = model.tags; + } + + return request; + } + + /** + * 创建帖子请求 + */ + static createPostRequest( + title: string, + content: string, + images?: string[], + communityId?: string + ): Record { + const request: Record = { + title, + content, + }; + if (images && images.length > 0) { + request.images = images; + } + if (communityId) { + request.community_id = communityId; + } + return request; + } + + /** + * 更新帖子请求 + */ + static updatePostRequest( + title?: string, + content?: string, + images?: string[] + ): Record { + const request: Record = {}; + if (title !== undefined) { + request.title = title; + } + if (content !== undefined) { + request.content = content; + } + if (images !== undefined) { + request.images = images; + } + return request; + } + + /** + * 映射作者信息 + */ + private static mapAuthorFromApi(author: any): UserModel { + return { + id: String(author.id || ''), + username: author.username || '', + nickname: author.nickname, + avatar: author.avatar, + }; + } +} diff --git a/src/data/mappers/UserMapper.ts b/src/data/mappers/UserMapper.ts new file mode 100644 index 0000000..46168a7 --- /dev/null +++ b/src/data/mappers/UserMapper.ts @@ -0,0 +1,168 @@ +/** + * 用户数据映射器 + * 负责 User 模型与 API 响应、数据库记录之间的转换 + */ + +import { UserModel } from '../models'; +import type { UserDTO, User } from '../../types/dto'; + +// 数据库用户记录类型 +export interface UserDbRecord { + id: string; + data: string; + updatedAt: string; +} + +export class UserMapper { + /** + * 将 API DTO 转换为应用模型 + */ + static fromDTO(dto: UserDTO): UserModel { + return { + id: String(dto.id || ''), + username: dto.username || '', + nickname: dto.nickname, + avatar: dto.avatar, + bio: dto.bio, + website: dto.website, + location: dto.location, + email: dto.email, + phone: dto.phone, + followersCount: dto.followers_count, + followingCount: dto.following_count, + postsCount: dto.posts_count, + isFollowing: dto.is_following, + isBlocked: dto.is_blocked, + createdAt: dto.created_at ? new Date(dto.created_at) : undefined, + updatedAt: dto.updated_at ? new Date(dto.updated_at) : undefined, + }; + } + + /** + * 将 User 类型转换为应用模型 + */ + static fromUser(user: User): UserModel { + return { + id: String(user.id || ''), + username: user.username || '', + nickname: user.nickname, + avatar: user.avatar, + bio: user.bio, + website: user.website, + location: user.location, + email: user.email, + phone: user.phone, + followersCount: user.followers_count, + followingCount: user.following_count, + postsCount: user.posts_count, + isFollowing: user.is_following, + isBlocked: user.is_blocked, + createdAt: user.created_at ? new Date(user.created_at) : undefined, + updatedAt: user.updated_at ? new Date(user.updated_at) : undefined, + }; + } + + /** + * 将 DTO 数组转换为应用模型数组 + */ + static fromDTOList(dtos: UserDTO[]): UserModel[] { + return dtos.map(dto => this.fromDTO(dto)); + } + + /** + * 从数据库记录转换为应用模型 + */ + static fromDbRecord(record: UserDbRecord): UserModel | null { + try { + const data = JSON.parse(record.data) as UserDTO; + return this.fromDTO(data); + } catch { + return null; + } + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: UserModel): UserDbRecord { + const dto: UserDTO = { + id: model.id, + username: model.username, + nickname: model.nickname, + avatar: model.avatar, + bio: model.bio, + website: model.website, + location: model.location, + email: model.email, + phone: model.phone, + followers_count: model.followersCount, + following_count: model.followingCount, + posts_count: model.postsCount, + is_following: model.isFollowing, + is_blocked: model.isBlocked, + created_at: model.createdAt?.toISOString(), + updated_at: model.updatedAt?.toISOString(), + }; + + return { + id: model.id, + data: JSON.stringify(dto), + updatedAt: new Date().toISOString(), + }; + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.nickname !== undefined) { + request.nickname = model.nickname; + } + if (model.avatar !== undefined) { + request.avatar = model.avatar; + } + if (model.bio !== undefined) { + request.bio = model.bio; + } + if (model.website !== undefined) { + request.website = model.website; + } + if (model.location !== undefined) { + request.location = model.location; + } + if (model.phone !== undefined) { + request.phone = model.phone; + } + if (model.email !== undefined) { + request.email = model.email; + } + + return request; + } + + /** + * 将应用模型转换为 DTO + */ + static toDTO(model: UserModel): UserDTO { + return { + id: model.id, + username: model.username, + nickname: model.nickname, + avatar: model.avatar, + bio: model.bio, + website: model.website, + location: model.location, + email: model.email, + phone: model.phone, + followers_count: model.followersCount, + following_count: model.followingCount, + posts_count: model.postsCount, + is_following: model.isFollowing, + is_blocked: model.isBlocked, + created_at: model.createdAt?.toISOString(), + updated_at: model.updatedAt?.toISOString(), + }; + } +} diff --git a/src/data/mappers/index.ts b/src/data/mappers/index.ts new file mode 100644 index 0000000..291c970 --- /dev/null +++ b/src/data/mappers/index.ts @@ -0,0 +1,9 @@ +/** + * 映射器导出 + * 统一导出所有数据映射器 + */ + +export * from './MessageMapper'; +export * from './ConversationMapper'; +export * from './UserMapper'; +export * from './PostMapper'; diff --git a/src/data/models/index.ts b/src/data/models/index.ts new file mode 100644 index 0000000..cfbf362 --- /dev/null +++ b/src/data/models/index.ts @@ -0,0 +1,153 @@ +/** + * Repository 层模型定义 + * 定义应用内部使用的数据模型,与 API 响应和数据库结构解耦 + */ + +// ==================== 消息模型 ==================== + +export interface MessageSegment { + type: string; + data: Record; +} + +export interface MessageModel { + id: string; + conversationId: string; + senderId: string; + content?: string; + type: 'text' | 'image' | 'file' | 'system' | string; + isRead: boolean; + createdAt: Date; + seq: number; + status: 'normal' | 'recalled' | 'deleted' | string; + segments?: MessageSegment[]; + replyToId?: string; + sender?: UserModel; +} + +// ==================== 会话模型 ==================== + +export type ConversationType = 'private' | 'group'; + +export interface ConversationModel { + id: string; + type: ConversationType; + participantId: string; + lastMessageId?: string; + lastMessage?: MessageModel; + lastSeq: number; + myLastReadSeq: number; + otherLastReadSeq: number; + unreadCount: number; + isPinned: boolean; + participants?: UserModel[]; + group?: GroupModel; + createdAt: Date; + updatedAt: Date; +} + +// ==================== 用户模型 ==================== + +export interface UserModel { + id: string; + username: string; + nickname?: string; + avatar?: string; + bio?: string; + website?: string; + location?: string; + email?: string; + phone?: string; + followersCount?: number; + followingCount?: number; + postsCount?: number; + isFollowing?: boolean; + isBlocked?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +// ==================== 帖子模型 ==================== + +export interface PostModel { + id: string; + authorId: string; + author?: UserModel; + title: string; + content: string; + images?: string[]; + likeCount: number; + commentCount: number; + shareCount: number; + viewCount: number; + favoriteCount: number; + isLiked: boolean; + isFavorited: boolean; + isTop: boolean; + status: 'published' | 'draft' | 'deleted'; + communityId?: string; + tags?: string[]; + createdAt: Date; + updatedAt: Date; +} + +// ==================== 群组模型 ==================== + +export type GroupMemberRole = 'owner' | 'admin' | 'member'; +export type GroupJoinType = 'anyone' | 'approval' | 'invite'; + +export interface GroupMemberModel { + userId: string; + user?: UserModel; + role: GroupMemberRole; + nickname?: string; + joinTime: Date; + muteUntil?: Date; + isMuted?: boolean; +} + +export interface GroupModel { + id: string; + name: string; + avatar?: string; + description?: string; + announcement?: string; + ownerId: string; + owner?: UserModel; + memberCount: number; + maxMemberCount: number; + joinType: GroupJoinType; + isMuted: boolean; + createdAt: Date; + updatedAt: Date; +} + +// ==================== 分页模型 ==================== + +export interface PaginatedResult { + list: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + hasMore: boolean; +} + +// ==================== 通用操作结果 ==================== + +export interface OperationResult { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +// ==================== 同步状态模型 ==================== + +export interface SyncStatus { + entityType: string; + entityId: string; + lastSyncedAt: Date; + syncVersion: number; + pendingChanges: boolean; +} diff --git a/src/data/repositories/MessageRepository.ts b/src/data/repositories/MessageRepository.ts new file mode 100644 index 0000000..5ad28a2 --- /dev/null +++ b/src/data/repositories/MessageRepository.ts @@ -0,0 +1,220 @@ +/** + * MessageRepository - 消息仓库实现 + * 封装所有SQLite数据库操作,不依赖任何UI或状态管理 + */ + +import { + saveMessage as dbSaveMessage, + saveMessagesBatch as dbSaveMessagesBatch, + getMessagesByConversation as dbGetMessagesByConversation, + getMaxSeq as dbGetMaxSeq, + getMinSeq as dbGetMinSeq, + getMessagesBeforeSeq as dbGetMessagesBeforeSeq, + markConversationAsRead as dbMarkConversationAsRead, + updateConversationCacheUnreadCount as dbUpdateConversationCacheUnreadCount, + getUserCache as dbGetUserCache, + saveUserCache as dbSaveUserCache, + deleteConversation as dbDeleteConversation, + updateMessageStatus as dbUpdateMessageStatus, + CachedMessage, +} from '../../services/database'; +import { Message, Conversation, createMessage, createConversation } from '../../core/entities/Message'; + +// 数据库消息到领域实体的转换 +const cachedMessageToMessage = (cached: CachedMessage): Message => ({ + id: cached.id, + conversationId: cached.conversationId, + senderId: cached.senderId, + seq: cached.seq, + segments: cached.segments || [], + createdAt: cached.createdAt, + status: cached.status as Message['status'], +}); + +// 领域实体到数据库消息的转换 +const messageToCachedMessage = (message: Message, isRead: boolean): CachedMessage => ({ + id: message.id, + conversationId: message.conversationId, + senderId: message.senderId, + content: message.segments + .filter((s) => s.type === 'text') + .map((s) => s.data?.text || '') + .join(''), + type: message.segments.find((s) => s.type === 'image') ? 'image' : 'text', + isRead, + createdAt: message.createdAt, + seq: message.seq, + status: message.status, + segments: message.segments, +}); + +class MessageRepository { + // ==================== 消息操作 ==================== + + /** + * 保存单条消息 + */ + async saveMessage(message: Message, isRead: boolean): Promise { + try { + const cachedMessage = messageToCachedMessage(message, isRead); + await dbSaveMessage(cachedMessage); + } catch (error) { + console.error('[MessageRepository] 保存消息失败:', error); + throw error; + } + } + + /** + * 批量保存消息 + */ + async saveMessages(messages: Message[], isRead: boolean): Promise { + if (!messages || messages.length === 0) return; + + try { + const cachedMessages = messages.map((m) => messageToCachedMessage(m, isRead)); + await dbSaveMessagesBatch(cachedMessages); + } catch (error) { + console.error('[MessageRepository] 批量保存消息失败:', error); + throw error; + } + } + + /** + * 获取会话的消息 + */ + async getMessagesByConversation( + conversationId: string, + limit: number = 20 + ): Promise { + try { + const cachedMessages = await dbGetMessagesByConversation(conversationId, limit); + return cachedMessages.map(cachedMessageToMessage); + } catch (error) { + console.error('[MessageRepository] 获取消息失败:', error); + return []; + } + } + + /** + * 获取会话的最大消息序号 + */ + async getMaxSeq(conversationId: string): Promise { + try { + return await dbGetMaxSeq(conversationId); + } catch (error) { + console.error('[MessageRepository] 获取最大序号失败:', error); + return 0; + } + } + + /** + * 获取会话的最小消息序号 + */ + async getMinSeq(conversationId: string): Promise { + try { + return await dbGetMinSeq(conversationId); + } catch (error) { + console.error('[MessageRepository] 获取最小序号失败:', error); + return 0; + } + } + + /** + * 获取指定seq之前的历史消息 + */ + async getMessagesBeforeSeq( + conversationId: string, + beforeSeq: number, + limit: number = 20 + ): Promise { + try { + const cachedMessages = await dbGetMessagesBeforeSeq(conversationId, beforeSeq, limit); + return cachedMessages.map(cachedMessageToMessage); + } catch (error) { + console.error('[MessageRepository] 获取历史消息失败:', error); + return []; + } + } + + /** + * 标记会话的所有消息为已读 + */ + async markConversationAsRead(conversationId: string): Promise { + try { + await dbMarkConversationAsRead(conversationId); + } catch (error) { + console.error('[MessageRepository] 标记会话已读失败:', error); + throw error; + } + } + + /** + * 更新会话缓存的未读数 + */ + async updateConversationUnreadCount( + conversationId: string, + count: number + ): Promise { + try { + await dbUpdateConversationCacheUnreadCount(conversationId, count); + } catch (error) { + console.error('[MessageRepository] 更新会话未读数失败:', error); + } + } + + /** + * 更新消息状态(如撤回) + */ + async updateMessageStatus( + messageId: string, + status: string, + clearContent: boolean = false + ): Promise { + try { + await dbUpdateMessageStatus(messageId, status, clearContent); + } catch (error) { + console.error('[MessageRepository] 更新消息状态失败:', error); + throw error; + } + } + + /** + * 删除会话及其所有消息 + */ + async deleteConversation(conversationId: string): Promise { + try { + await dbDeleteConversation(conversationId); + } catch (error) { + console.error('[MessageRepository] 删除会话失败:', error); + throw error; + } + } + + // ==================== 用户缓存操作 ==================== + + /** + * 获取用户缓存 + */ + async getUserCache(userId: string): Promise { + try { + return await dbGetUserCache(userId); + } catch (error) { + console.error('[MessageRepository] 获取用户缓存失败:', error); + return null; + } + } + + /** + * 保存用户缓存 + */ + async saveUserCache(user: any): Promise { + try { + await dbSaveUserCache(user); + } catch (error) { + console.error('[MessageRepository] 保存用户缓存失败:', error); + } + } +} + +export const messageRepository = new MessageRepository(); +export default messageRepository; diff --git a/src/data/repositories/interfaces/IMessageRepository.ts b/src/data/repositories/interfaces/IMessageRepository.ts new file mode 100644 index 0000000..1964401 --- /dev/null +++ b/src/data/repositories/interfaces/IMessageRepository.ts @@ -0,0 +1,93 @@ +/** + * 消息 Repository 接口 + * 定义消息数据访问的抽象 + */ + +import type { Message, Conversation, ConversationType } from '../../types/dto'; + +export interface IMessageRepository { + // ==================== 消息操作 ==================== + + /** + * 获取会话的消息列表 + */ + getMessages( + conversationId: string, + options?: { + beforeSeq?: number; + afterSeq?: number; + limit?: number; + } + ): Promise; + + /** + * 保存消息 + */ + saveMessage(message: Message): Promise; + + /** + * 批量保存消息 + */ + saveMessages(messages: Message[]): Promise; + + /** + * 更新消息状态 + */ + updateMessageStatus( + messageId: string, + status: 'normal' | 'recalled' | 'deleted' + ): Promise; + + /** + * 标记消息为已读 + */ + markAsRead(messageIds: string[]): Promise; + + /** + * 获取未读消息数 + */ + getUnreadCount(conversationId: string): Promise; + + // ==================== 会话操作 ==================== + + /** + * 获取会话列表 + */ + getConversations(): Promise; + + /** + * 获取单个会话 + */ + getConversation(conversationId: string): Promise; + + /** + * 保存会话 + */ + saveConversation(conversation: Conversation): Promise; + + /** + * 更新会话未读数 + */ + updateUnreadCount(conversationId: string, count: number): Promise; + + /** + * 更新会话最后消息 + */ + updateLastMessage( + conversationId: string, + messageId: string, + seq: number + ): Promise; + + // ==================== 同步操作 ==================== + + /** + * 从服务器同步消息 + */ + syncMessages(conversationId: string, lastSeq: number): Promise; + + /** + * 获取服务器最新消息序列号 + */ + getServerLastSeq(conversationId: string): Promise; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 94d29f1..88416f5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,31 +2,66 @@ * Hooks 导出 */ -// 响应式相关 Hooks +// ==================== 新的响应式 Hooks (推荐) ==================== export { - useResponsive, - BREAKPOINTS, - FINE_BREAKPOINTS, - useResponsiveValue, - useResponsiveStyle, + // 核心 hooks + useBreakpoint, + useFineBreakpoint, useBreakpointGTE, useBreakpointLT, useBreakpointBetween, + useScreenSize, + useWindowDimensions, + useOrientation, usePlatform, useMediaQuery, + useResponsiveValue, + useResponsiveStyle, useColumnCount, useResponsiveSpacing, + // 兼容层 + useResponsive, + useLegacyResponsive, + // 工具函数 + getBreakpoint, + getFineBreakpoint, isBreakpointGTE, isBreakpointLT, -} from './useResponsive'; + isBreakpointBetween, + // 常量 + BREAKPOINTS, + FINE_BREAKPOINTS, +} from '../presentation/hooks/responsive'; + export type { - ResponsiveInfo, BreakpointKey, - BreakpointValue, FineBreakpointKey, + BreakpointValue, ResponsiveValue, + Orientation, + PlatformInfo, + ScreenSize, + MediaQueryOptions, + // 兼容层类型 + ResponsiveInfo, +} from '../presentation/hooks/responsive'; + +// ==================== 旧版响应式 Hooks (保持向后兼容) ==================== +// 注意:这些导出将在未来版本中移除,请使用新的 hooks +export { + BREAKPOINTS as BREAKPOINTS_OLD, + FINE_BREAKPOINTS as FINE_BREAKPOINTS_OLD, } from './useResponsive'; +export type { + ResponsiveInfo as ResponsiveInfo_OLD, + BreakpointKey as BreakpointKey_OLD, + BreakpointValue as BreakpointValue_OLD, + FineBreakpointKey as FineBreakpointKey_OLD, + ResponsiveValue as ResponsiveValue_OLD, +} from './useResponsive'; + +// ==================== 其他 Hooks ==================== export { usePrefetch, prefetchPosts, @@ -36,3 +71,24 @@ export { prefetchMessageScreen, prefetchHomeScreen, } from './usePrefetch'; + +// ==================== 分页 Hooks ==================== +export { + usePagination, + usePaginationManager, +} from './usePagination'; + +export type { + UsePaginationOptions, + UsePaginationReturn, +} from './usePagination'; + +// ==================== 连接状态 Hooks ==================== +export { useConnectionState } from './useConnectionState'; + +export type { UseConnectionStateResult } from './useConnectionState'; + +// ==================== 媒体缓存 Hooks ==================== +export { useMediaCache } from './useMediaCache'; + +export type { UseMediaCacheReturn } from './useMediaCache'; diff --git a/src/hooks/responsive/constants.ts b/src/hooks/responsive/constants.ts new file mode 100644 index 0000000..ae57078 --- /dev/null +++ b/src/hooks/responsive/constants.ts @@ -0,0 +1,82 @@ +/** + * 响应式常量定义 + */ + +// ==================== 断点定义 ==================== +export const BREAKPOINTS = { + mobile: 0, // 手机 + tablet: 768, // 平板竖屏 + desktop: 1024, // 平板横屏/桌面 + wide: 1440, // 宽屏桌面 +} as const; + +export type BreakpointKey = keyof typeof BREAKPOINTS; +export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey]; + +// ==================== 更细粒度的断点定义 ==================== +export const FINE_BREAKPOINTS = { + xs: 0, // 超小屏手机 + sm: 375, // 小屏手机 (iPhone SE, 小屏安卓) + md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓) + lg: 768, // 平板竖屏 / 大折叠屏手机展开 + xl: 1024, // 平板横屏 / 小桌面 + '2xl': 1280, // 桌面 + '3xl': 1440, // 大桌面 + '4xl': 1920, // 超大屏 +} as const; + +export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS; + +// ==================== 响应式值类型 ==================== +export type ResponsiveValue = T | Partial>; + +// ==================== 断点顺序(用于比较)==================== +export const BREAKPOINT_ORDER: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl']; +export const REVERSE_BREAKPOINT_ORDER: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + +// ==================== 获取当前断点 ==================== +export function getBreakpoint(width: number): BreakpointKey { + if (width >= BREAKPOINTS.wide) { + return 'wide'; + } + if (width >= BREAKPOINTS.desktop) { + return 'desktop'; + } + if (width >= BREAKPOINTS.tablet) { + return 'tablet'; + } + return 'mobile'; +} + +// ==================== 获取细粒度断点 ==================== +export function getFineBreakpoint(width: number): FineBreakpointKey { + if (width >= FINE_BREAKPOINTS['4xl']) return '4xl'; + if (width >= FINE_BREAKPOINTS['3xl']) return '3xl'; + if (width >= FINE_BREAKPOINTS['2xl']) return '2xl'; + if (width >= FINE_BREAKPOINTS.xl) return 'xl'; + if (width >= FINE_BREAKPOINTS.lg) return 'lg'; + if (width >= FINE_BREAKPOINTS.md) return 'md'; + if (width >= FINE_BREAKPOINTS.sm) return 'sm'; + return 'xs'; +} + +// ==================== 断点比较工具 ==================== +/** + * 检查当前断点是否大于等于目标断点 + */ +export function isBreakpointGTE( + current: FineBreakpointKey, + target: FineBreakpointKey +): boolean { + return BREAKPOINT_ORDER.indexOf(current) >= BREAKPOINT_ORDER.indexOf(target); +} + +/** + * 检查当前断点是否小于目标断点 + */ +export function isBreakpointLT( + current: FineBreakpointKey, + target: FineBreakpointKey +): boolean { + return !isBreakpointGTE(current, target); +} diff --git a/src/hooks/responsive/index.ts b/src/hooks/responsive/index.ts new file mode 100644 index 0000000..022fb89 --- /dev/null +++ b/src/hooks/responsive/index.ts @@ -0,0 +1,45 @@ +/** + * 响应式 Hooks 统一导出 + */ + +// 基础 Hooks +export { useWindowDimensions } from './useWindowDimensions'; +export { useBreakpoint, type BreakpointInfo } from './useBreakpoint'; +export { useOrientation, type Orientation, type OrientationInfo } from './useOrientation'; +export { usePlatform, type PlatformInfo } from './usePlatform'; +export { useScreenSize, type ScreenSizeInfo } from './useScreenSize'; + +// 响应式值 Hooks +export { useResponsiveValue } from './useResponsiveValue'; +export { useResponsiveStyle } from './useResponsiveStyle'; +export { useResponsiveSpacing } from './useResponsiveSpacing'; + +// 断点范围 Hooks +export { + useBreakpointGTE, + useBreakpointLT, + useBreakpointBetween, +} from './useBreakpointRange'; + +// 工具 Hooks +export { useMediaQuery } from './useMediaQuery'; +export { useColumnCount } from './useColumnCount'; + +// 常量和工具函数 +export { + BREAKPOINTS, + FINE_BREAKPOINTS, + BREAKPOINT_ORDER, + REVERSE_BREAKPOINT_ORDER, + type BreakpointKey, + type BreakpointValue, + type FineBreakpointKey, + type ResponsiveValue, + getBreakpoint, + getFineBreakpoint, + isBreakpointGTE, + isBreakpointLT, +} from './constants'; + +// 为了保持向后兼容,重新导出 useResponsive +export { useResponsive, type ResponsiveInfo } from './useResponsive'; diff --git a/src/hooks/responsive/useBreakpoint.ts b/src/hooks/responsive/useBreakpoint.ts new file mode 100644 index 0000000..f05e948 --- /dev/null +++ b/src/hooks/responsive/useBreakpoint.ts @@ -0,0 +1,62 @@ +/** + * 断点检测 Hook + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; +import { + BreakpointKey, + FineBreakpointKey, + getBreakpoint, + getFineBreakpoint, +} from './constants'; + +export interface BreakpointInfo { + // 基础断点 + breakpoint: BreakpointKey; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; + isWideScreen: boolean; + + // 细粒度断点 + fineBreakpoint: FineBreakpointKey; + isXS: boolean; + isSM: boolean; + isMD: boolean; + isLG: boolean; + isXL: boolean; + is2XL: boolean; + is3XL: boolean; + is4XL: boolean; +} + +/** + * 断点检测 Hook + * 返回当前断点信息和布尔标志 + */ +export function useBreakpoint(): BreakpointInfo { + const { width } = useWindowDimensions(); + + const breakpoint = useMemo(() => getBreakpoint(width), [width]); + const fineBreakpoint = useMemo(() => getFineBreakpoint(width), [width]); + + return useMemo(() => ({ + breakpoint, + isMobile: breakpoint === 'mobile', + isTablet: breakpoint === 'tablet', + isDesktop: breakpoint === 'desktop', + isWide: breakpoint === 'wide', + isWideScreen: breakpoint === 'tablet' || breakpoint === 'desktop' || breakpoint === 'wide', + fineBreakpoint, + isXS: fineBreakpoint === 'xs', + isSM: fineBreakpoint === 'sm', + isMD: fineBreakpoint === 'md', + isLG: fineBreakpoint === 'lg', + isXL: fineBreakpoint === 'xl', + is2XL: fineBreakpoint === '2xl', + is3XL: fineBreakpoint === '3xl', + is4XL: fineBreakpoint === '4xl', + }), [breakpoint, fineBreakpoint]); +} diff --git a/src/hooks/responsive/useBreakpointRange.ts b/src/hooks/responsive/useBreakpointRange.ts new file mode 100644 index 0000000..232f7cd --- /dev/null +++ b/src/hooks/responsive/useBreakpointRange.ts @@ -0,0 +1,46 @@ +/** + * 断点范围检查 Hooks + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { FineBreakpointKey, isBreakpointGTE, isBreakpointLT } from './constants'; + +/** + * 检查当前断点是否大于等于目标断点 + * + * @example + * const isMediumUp = useBreakpointGTE('md'); + */ +export function useBreakpointGTE(target: FineBreakpointKey): boolean { + const { fineBreakpoint } = useBreakpoint(); + return useMemo(() => isBreakpointGTE(fineBreakpoint, target), [fineBreakpoint, target]); +} + +/** + * 检查当前断点是否小于目标断点 + * + * @example + * const isMobileOnly = useBreakpointLT('lg'); + */ +export function useBreakpointLT(target: FineBreakpointKey): boolean { + const { fineBreakpoint } = useBreakpoint(); + return useMemo(() => isBreakpointLT(fineBreakpoint, target), [fineBreakpoint, target]); +} + +/** + * 检查当前是否在指定断点范围内 + * + * @example + * const isTabletRange = useBreakpointBetween('md', 'xl'); + */ +export function useBreakpointBetween( + min: FineBreakpointKey, + max: FineBreakpointKey +): boolean { + const { fineBreakpoint } = useBreakpoint(); + return useMemo( + () => isBreakpointGTE(fineBreakpoint, min) && !isBreakpointGTE(fineBreakpoint, max), + [fineBreakpoint, min, max] + ); +} diff --git a/src/hooks/responsive/useColumnCount.ts b/src/hooks/responsive/useColumnCount.ts new file mode 100644 index 0000000..bc61348 --- /dev/null +++ b/src/hooks/responsive/useColumnCount.ts @@ -0,0 +1,53 @@ +/** + * 列数计算 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { FineBreakpointKey } from './constants'; + +const DEFAULT_COLUMN_CONFIG: Record = { + xs: 1, + sm: 1, + md: 2, + lg: 3, + xl: 4, + '2xl': 4, + '3xl': 5, + '4xl': 6, +}; + +/** + * 根据容器宽度计算合适的列数 + * + * @param columnConfig - 列数配置 + * @returns 列数 + * + * @example + * const columns = useColumnCount({ + * xs: 1, + * sm: 2, + * md: 3, + * lg: 4 + * }); + */ +export function useColumnCount( + columnConfig: Partial> = {} +): number { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + const config = { ...DEFAULT_COLUMN_CONFIG, ...columnConfig }; + const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (config[bp] !== undefined) { + return config[bp]; + } + } + + return 1; + }, [fineBreakpoint, columnConfig]); +} diff --git a/src/hooks/responsive/useMediaQuery.ts b/src/hooks/responsive/useMediaQuery.ts new file mode 100644 index 0000000..7f28a13 --- /dev/null +++ b/src/hooks/responsive/useMediaQuery.ts @@ -0,0 +1,39 @@ +/** + * 媒体查询模拟 Hook + */ + +import { useMemo } from 'react'; +import { useScreenSize } from './useScreenSize'; +import { useOrientation } from './useOrientation'; + +interface MediaQueryOptions { + minWidth?: number; + maxWidth?: number; + minHeight?: number; + maxHeight?: number; + orientation?: 'portrait' | 'landscape'; +} + +/** + * 模拟 CSS 媒体查询 + * + * @param query - 查询条件 + * @returns 是否匹配 + * + * @example + * const isMinWidth768 = useMediaQuery({ minWidth: 768 }); + * const isPortrait = useMediaQuery({ orientation: 'portrait' }); + */ +export function useMediaQuery(query: MediaQueryOptions): boolean { + const { width, height } = useScreenSize(); + const { orientation: currentOrientation } = useOrientation(); + + return useMemo(() => { + if (query.minWidth !== undefined && width < query.minWidth) return false; + if (query.maxWidth !== undefined && width > query.maxWidth) return false; + if (query.minHeight !== undefined && height < query.minHeight) return false; + if (query.maxHeight !== undefined && height > query.maxHeight) return false; + if (query.orientation !== undefined && currentOrientation !== query.orientation) return false; + return true; + }, [width, height, currentOrientation, query]); +} diff --git a/src/hooks/responsive/useOrientation.ts b/src/hooks/responsive/useOrientation.ts new file mode 100644 index 0000000..07738dd --- /dev/null +++ b/src/hooks/responsive/useOrientation.ts @@ -0,0 +1,36 @@ +/** + * 屏幕方向检测 Hook + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; + +export type Orientation = 'portrait' | 'landscape'; + +export interface OrientationInfo { + orientation: Orientation; + isPortrait: boolean; + isLandscape: boolean; +} + +/** + * 获取屏幕方向 + */ +function getOrientation(width: number, height: number): Orientation { + return width > height ? 'landscape' : 'portrait'; +} + +/** + * 屏幕方向检测 Hook + */ +export function useOrientation(): OrientationInfo { + const { width, height } = useWindowDimensions(); + + const orientation = useMemo(() => getOrientation(width, height), [width, height]); + + return useMemo(() => ({ + orientation, + isPortrait: orientation === 'portrait', + isLandscape: orientation === 'landscape', + }), [orientation]); +} diff --git a/src/hooks/responsive/usePlatform.ts b/src/hooks/responsive/usePlatform.ts new file mode 100644 index 0000000..269050a --- /dev/null +++ b/src/hooks/responsive/usePlatform.ts @@ -0,0 +1,31 @@ +/** + * 平台检测 Hook + */ + +import { useMemo } from 'react'; +import { Platform } from 'react-native'; + +export interface PlatformInfo { + OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; + isWeb: boolean; + isIOS: boolean; + isAndroid: boolean; + isNative: boolean; +} + +/** + * 平台检测 Hook + * 提供便捷的平台检测方法 + * + * @example + * const { isWeb, isIOS, isAndroid, isNative } = usePlatform(); + */ +export function usePlatform(): PlatformInfo { + return useMemo(() => ({ + OS: Platform.OS, + isWeb: Platform.OS === 'web', + isIOS: Platform.OS === 'ios', + isAndroid: Platform.OS === 'android', + isNative: Platform.OS !== 'web', + }), []); +} diff --git a/src/hooks/responsive/useResponsive.ts b/src/hooks/responsive/useResponsive.ts new file mode 100644 index 0000000..c5f1078 --- /dev/null +++ b/src/hooks/responsive/useResponsive.ts @@ -0,0 +1,103 @@ +/** + * 响应式设计 Hook(重构版) + * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 + * + * 注意:此 Hook 现在作为各个专注 Hook 的组合,保持向后兼容 + * 新项目建议直接使用专注的 Hooks + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; +import { useBreakpoint } from './useBreakpoint'; +import { useOrientation } from './useOrientation'; +import { usePlatform } from './usePlatform'; + +// ==================== 返回值类型 ==================== +export interface ResponsiveInfo { + // 基础尺寸 + width: number; + height: number; + + // 基础断点 + breakpoint: import('./constants').BreakpointKey; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; + isWideScreen: boolean; + + // 细粒度断点 + fineBreakpoint: import('./constants').FineBreakpointKey; + isXS: boolean; + isSM: boolean; + isMD: boolean; + isLG: boolean; + isXL: boolean; + is2XL: boolean; + is3XL: boolean; + is4XL: boolean; + + // 方向 + orientation: 'portrait' | 'landscape'; + isPortrait: boolean; + isLandscape: boolean; + + // 平台检测 + platform: { + OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; + isWeb: boolean; + isIOS: boolean; + isAndroid: boolean; + isNative: boolean; + }; +} + +/** + * 响应式设计 Hook + * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 + * + * @returns ResponsiveInfo 响应式信息对象 + * + * @example + * const { + * width, height, + * breakpoint, isMobile, isTablet, isDesktop, isWide, + * fineBreakpoint, isXS, isSM, isMD, isLG, isXL, + * orientation, isPortrait, isLandscape, + * platform: { isWeb, isIOS, isAndroid } + * } = useResponsive(); + * + * @deprecated 建议使用专注的 Hooks:useBreakpoint, useOrientation, usePlatform, useScreenSize + */ +export function useResponsive(): ResponsiveInfo { + const { width, height } = useWindowDimensions(); + const breakpointInfo = useBreakpoint(); + const orientationInfo = useOrientation(); + const platformInfo = usePlatform(); + + return useMemo(() => ({ + width, + height, + breakpoint: breakpointInfo.breakpoint, + isMobile: breakpointInfo.isMobile, + isTablet: breakpointInfo.isTablet, + isDesktop: breakpointInfo.isDesktop, + isWide: breakpointInfo.isWide, + isWideScreen: breakpointInfo.isWideScreen, + fineBreakpoint: breakpointInfo.fineBreakpoint, + isXS: breakpointInfo.isXS, + isSM: breakpointInfo.isSM, + isMD: breakpointInfo.isMD, + isLG: breakpointInfo.isLG, + isXL: breakpointInfo.isXL, + is2XL: breakpointInfo.is2XL, + is3XL: breakpointInfo.is3XL, + is4XL: breakpointInfo.is4XL, + orientation: orientationInfo.orientation, + isPortrait: orientationInfo.isPortrait, + isLandscape: orientationInfo.isLandscape, + platform: platformInfo, + }), [width, height, breakpointInfo, orientationInfo, platformInfo]); +} + +export default useResponsive; diff --git a/src/hooks/responsive/useResponsiveSpacing.ts b/src/hooks/responsive/useResponsiveSpacing.ts new file mode 100644 index 0000000..6175d05 --- /dev/null +++ b/src/hooks/responsive/useResponsiveSpacing.ts @@ -0,0 +1,48 @@ +/** + * 响应式间距 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { FineBreakpointKey } from './constants'; + +const DEFAULT_SPACING_CONFIG: Record = { + xs: 8, + sm: 8, + md: 12, + lg: 16, + xl: 20, + '2xl': 24, + '3xl': 32, + '4xl': 40, +}; + +/** + * 响应式间距 Hook + * + * @param spacingConfig - 间距配置 + * @returns 当前断点对应的间距值 + * + * @example + * const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); + */ +export function useResponsiveSpacing( + spacingConfig: Partial> = {} +): number { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + const config = { ...DEFAULT_SPACING_CONFIG, ...spacingConfig }; + const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; + const currentIndex = breakpointOrder.indexOf(fineBreakpoint); + + for (let i = currentIndex; i < breakpointOrder.length; i++) { + const bp = breakpointOrder[i]; + if (config[bp] !== undefined) { + return config[bp]; + } + } + + return DEFAULT_SPACING_CONFIG.xs; + }, [fineBreakpoint, spacingConfig]); +} diff --git a/src/hooks/responsive/useResponsiveStyle.ts b/src/hooks/responsive/useResponsiveStyle.ts new file mode 100644 index 0000000..6f03124 --- /dev/null +++ b/src/hooks/responsive/useResponsiveStyle.ts @@ -0,0 +1,53 @@ +/** + * 响应式样式生成器 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants'; + +/** + * 根据断点生成响应式样式 + * + * @param styles - 响应式样式对象 + * @returns 当前断点对应的样式 + * + * @example + * const containerStyle = useResponsiveStyle({ + * padding: { xs: 8, md: 16, lg: 24 }, + * fontSize: { xs: 14, lg: 16 } + * }); + */ +export function useResponsiveStyle>>( + styles: T +): { [K in keyof T]: T[K] extends ResponsiveValue ? V : never } { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue ? V : never }; + + for (const key in styles) { + const value = styles[key]; + + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + (result as Record)[key] = value; + } else { + const valueMap = value as Partial>; + const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint); + + let selectedValue: unknown = undefined; + for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) { + const bp = REVERSE_BREAKPOINT_ORDER[i]; + if (bp in valueMap) { + selectedValue = valueMap[bp]; + break; + } + } + + (result as Record)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0]; + } + } + + return result; + }, [styles, fineBreakpoint]); +} diff --git a/src/hooks/responsive/useResponsiveValue.ts b/src/hooks/responsive/useResponsiveValue.ts new file mode 100644 index 0000000..76d47da --- /dev/null +++ b/src/hooks/responsive/useResponsiveValue.ts @@ -0,0 +1,42 @@ +/** + * 响应式值选择器 Hook + */ + +import { useMemo } from 'react'; +import { useBreakpoint } from './useBreakpoint'; +import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants'; + +/** + * 根据当前断点从响应式值对象中选择合适的值 + * + * @param value - 响应式值,可以是单一值或断点映射对象 + * @returns 选中的值 + * + * @example + * const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); + * // 在 xs 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 24 + */ +export function useResponsiveValue(value: ResponsiveValue): T { + const { fineBreakpoint } = useBreakpoint(); + + return useMemo(() => { + // 如果不是对象,直接返回 + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return value as T; + } + + const valueMap = value as Partial>; + + // 从当前断点开始向下查找 + const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint); + for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) { + const bp = REVERSE_BREAKPOINT_ORDER[i]; + if (bp in valueMap) { + return valueMap[bp]!; + } + } + + // 如果没找到,返回 xs 的值或第一个值 + return (valueMap.xs ?? Object.values(valueMap)[0]) as T; + }, [value, fineBreakpoint]); +} diff --git a/src/hooks/responsive/useScreenSize.ts b/src/hooks/responsive/useScreenSize.ts new file mode 100644 index 0000000..0dc983c --- /dev/null +++ b/src/hooks/responsive/useScreenSize.ts @@ -0,0 +1,34 @@ +/** + * 屏幕尺寸 Hook + */ + +import { useMemo } from 'react'; +import { useWindowDimensions } from './useWindowDimensions'; +import { useBreakpoint } from './useBreakpoint'; + +export interface ScreenSizeInfo { + width: number; + height: number; + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isWide: boolean; +} + +/** + * 屏幕尺寸 Hook + * 返回屏幕尺寸和断点信息 + */ +export function useScreenSize(): ScreenSizeInfo { + const { width, height } = useWindowDimensions(); + const { isMobile, isTablet, isDesktop, isWide } = useBreakpoint(); + + return useMemo(() => ({ + width, + height, + isMobile, + isTablet, + isDesktop, + isWide, + }), [width, height, isMobile, isTablet, isDesktop, isWide]); +} diff --git a/src/hooks/responsive/useWindowDimensions.ts b/src/hooks/responsive/useWindowDimensions.ts new file mode 100644 index 0000000..00f36c6 --- /dev/null +++ b/src/hooks/responsive/useWindowDimensions.ts @@ -0,0 +1,27 @@ +/** + * 窗口尺寸 Hook + * 提供屏幕尺寸监听 + */ + +import { useState, useEffect } from 'react'; +import { Dimensions, ScaledSize } from 'react-native'; + +/** + * 获取窗口尺寸,支持屏幕旋转响应 + * 替代 Dimensions.get('window'),提供实时尺寸更新 + */ +export function useWindowDimensions(): ScaledSize { + const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setDimensions(window); + }); + + return () => { + subscription.remove(); + }; + }, []); + + return dimensions; +} diff --git a/src/hooks/useConnectionState.ts b/src/hooks/useConnectionState.ts new file mode 100644 index 0000000..4a1240a --- /dev/null +++ b/src/hooks/useConnectionState.ts @@ -0,0 +1,147 @@ +/** + * 连接状态 Hook + * + * 提供连接状态的 React Hook 接口 + * 订阅 ConnectionStateManager 状态变化 + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + ConnectionState, + ConnectionStateInfo, + connectionStateManager, +} from '../infrastructure/sync'; + +/** + * useConnectionState Hook 返回值接口 + */ +export interface UseConnectionStateResult { + /** 当前连接状态 */ + state: ConnectionState; + /** 完整的状态信息 */ + stateInfo: ConnectionStateInfo; + /** 是否已连接 */ + isConnected: boolean; + /** 是否正在连接(包括 CONNECTING 和 RECONNECTING) */ + isConnecting: boolean; + /** 是否正在重连 */ + isReconnecting: boolean; + /** 是否已断开 */ + isDisconnected: boolean; + /** 是否处于错误状态 */ + hasError: boolean; + /** 当前错误信息 */ + error: Error | null; + /** 当前重连次数 */ + reconnectAttempts: number; + /** 最大重连次数 */ + maxReconnectAttempts: number; + /** 最后连接时间 */ + lastConnectedAt: number | null; + /** 状态描述文本 */ + description: string; + /** 手动触发重连 */ + triggerReconnect: () => void; + /** 重置重连次数 */ + resetReconnectAttempts: () => void; +} + +/** + * 连接状态 Hook + * + * 订阅 ConnectionStateManager 的状态变化, + * 提供便捷的状态属性和方法 + * + * @example + * ```tsx + * function ConnectionStatus() { + * const { state, isConnected, isConnecting, description } = useConnectionState(); + * + * return ( + * + * {description} + * {isConnecting && } + * + * ); + * } + * ``` + */ +export function useConnectionState(): UseConnectionStateResult { + // 状态 + const [stateInfo, setStateInfo] = useState(() => + connectionStateManager.getState() + ); + + // 订阅状态变化 + useEffect(() => { + const unsubscribe = connectionStateManager.subscribe((newState) => { + setStateInfo(newState); + }); + + return unsubscribe; + }, []); + + // 计算派生状态 + const isConnected = useMemo(() => + stateInfo.state === ConnectionState.CONNECTED, + [stateInfo.state] + ); + + const isConnecting = useMemo(() => + stateInfo.state === ConnectionState.CONNECTING || + stateInfo.state === ConnectionState.RECONNECTING, + [stateInfo.state] + ); + + const isReconnecting = useMemo(() => + stateInfo.state === ConnectionState.RECONNECTING, + [stateInfo.state] + ); + + const isDisconnected = useMemo(() => + stateInfo.state === ConnectionState.DISCONNECTED || + stateInfo.state === ConnectionState.IDLE, + [stateInfo.state] + ); + + const hasError = useMemo(() => + stateInfo.state === ConnectionState.ERROR, + [stateInfo.state] + ); + + // 手动触发重连 + const triggerReconnect = useCallback(() => { + // 只有在断开或错误状态才能触发重连 + if ( + stateInfo.state === ConnectionState.DISCONNECTED || + stateInfo.state === ConnectionState.ERROR || + stateInfo.state === ConnectionState.IDLE + ) { + connectionStateManager.setState(ConnectionState.RECONNECTING); + } + }, [stateInfo.state]); + + // 重置重连次数 + const resetReconnectAttempts = useCallback(() => { + connectionStateManager.resetReconnectAttempts(); + }, []); + + return { + state: stateInfo.state, + stateInfo, + isConnected, + isConnecting, + isReconnecting, + isDisconnected, + hasError, + error: stateInfo.error || null, + reconnectAttempts: stateInfo.reconnectAttempts || 0, + maxReconnectAttempts: connectionStateManager.getMaxReconnectAttempts(), + lastConnectedAt: stateInfo.lastConnectedAt || null, + description: stateInfo.description, + triggerReconnect, + resetReconnectAttempts, + }; +} + +export default useConnectionState; diff --git a/src/hooks/useDifferentialMessages.ts b/src/hooks/useDifferentialMessages.ts new file mode 100644 index 0000000..d597164 --- /dev/null +++ b/src/hooks/useDifferentialMessages.ts @@ -0,0 +1,369 @@ +/** + * 差异更新 Hook + * 接收原始消息列表,返回优化后的消息列表,自动处理批量更新 + */ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + MessageIdentifier, + MessageUpdate, + MessageUpdateType, + DiffConfig, +} from '../infrastructure/diff/types'; +import { + MessageDiffCalculator, + createMessageDiffCalculator, +} from '../infrastructure/diff/MessageDiffCalculator'; +import { + MessageUpdateBatcher, + createMessageUpdateBatcher, + BatcherOptions, +} from '../infrastructure/diff/MessageUpdateBatcher'; + +/** + * 差异更新 Hook 配置选项 + */ +export interface UseDifferentialMessagesOptions { + /** 差异计算配置 */ + diffConfig?: DiffConfig; + /** 批量处理配置 */ + batcherOptions?: BatcherOptions; + /** 是否启用差异计算,默认 true */ + enableDiff?: boolean; + /** 是否启用批量处理,默认 true */ + enableBatching?: boolean; + /** 最大消息数量限制,超出时触发重置 */ + maxMessageCount?: number; + /** 变化比例阈值(0-1),超过此值触发重置 */ + changeRatioThreshold?: number; +} + +/** + * 差异更新 Hook 返回值 + */ +export interface UseDifferentialMessagesResult { + /** 优化后的消息列表 */ + optimizedMessages: T[]; + /** 待处理更新数量 */ + pendingUpdateCount: number; + /** 是否正在处理更新 */ + isProcessing: boolean; + /** 手动刷新方法 */ + flush: () => void; + /** 重置方法 */ + reset: () => void; + /** 强制刷新(跳过批量处理) */ + forceUpdate: (messages: T[]) => void; + /** 获取差异统计信息 */ + getDiffStats: () => { + totalBatches: number; + totalUpdates: number; + averageBatchSize: number; + }; +} + +/** + * 差异更新 Hook + * @param messages 原始消息列表 + * @param options 配置选项 + * @returns 优化后的消息列表和相关方法 + */ +export function useDifferentialMessages( + messages: T[], + options: UseDifferentialMessagesOptions = {} +): UseDifferentialMessagesResult { + const { + diffConfig, + batcherOptions, + enableDiff = true, + enableBatching = true, + maxMessageCount = 10000, + changeRatioThreshold = 0.5, + } = options; + + // 状态 + const [optimizedMessages, setOptimizedMessages] = useState(messages); + const [isProcessing, setIsProcessing] = useState(false); + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + + // Refs + const calculatorRef = useRef | null>(null); + const batcherRef = useRef(null); + const previousMessagesRef = useRef([]); + + // 初始化差异计算器 + useEffect(() => { + if (enableDiff) { + calculatorRef.current = createMessageDiffCalculator(diffConfig); + } + return () => { + calculatorRef.current = null; + }; + }, [enableDiff, diffConfig]); + + // 初始化批量处理器 + useEffect(() => { + if (enableBatching) { + batcherRef.current = createMessageUpdateBatcher(batcherOptions); + + // 订阅批量更新 + const unsubscribe = batcherRef.current.subscribe((updates) => { + handleBatchUpdates(updates); + }); + + return () => { + unsubscribe(); + batcherRef.current?.destroy(); + batcherRef.current = null; + }; + } + }, [enableBatching, batcherOptions]); + + // 处理批量更新 + const handleBatchUpdates = useCallback((updates: MessageUpdate[]) => { + setIsProcessing(true); + + try { + setOptimizedMessages((currentMessages) => { + let newMessages = [...currentMessages]; + + for (const update of updates) { + switch (update.type) { + case MessageUpdateType.ADD: { + const addUpdate = update as any; + const index = addUpdate.index ?? newMessages.length; + newMessages.splice(index, 0, addUpdate.message); + break; + } + + case MessageUpdateType.BATCH_ADD: { + const batchAddUpdate = update as any; + const messages = batchAddUpdate.messages || []; + const startIndex = batchAddUpdate.startIndex ?? newMessages.length; + newMessages.splice(startIndex, 0, ...messages); + break; + } + + case MessageUpdateType.UPDATE: { + const updateMsg = update as any; + const index = newMessages.findIndex(m => m.id === updateMsg.messageId); + if (index !== -1) { + newMessages[index] = { ...newMessages[index], ...updateMsg.updates }; + } + break; + } + + case MessageUpdateType.BATCH_UPDATE: { + const batchUpdate = update as any; + const updates = batchUpdate.updates || []; + for (const { messageId, changes } of updates) { + const index = newMessages.findIndex(m => m.id === messageId); + if (index !== -1) { + newMessages[index] = { ...newMessages[index], ...changes }; + } + } + break; + } + + case MessageUpdateType.DELETE: { + const deleteUpdate = update as any; + newMessages = newMessages.filter(m => m.id !== deleteUpdate.messageId); + break; + } + + case MessageUpdateType.BATCH_DELETE: { + const batchDelete = update as any; + const idsToDelete = new Set(batchDelete.messageIds || []); + newMessages = newMessages.filter(m => !idsToDelete.has(m.id)); + break; + } + + case MessageUpdateType.MOVE: { + const moveUpdate = update as any; + const fromIndex = newMessages.findIndex(m => m.id === moveUpdate.messageId); + if (fromIndex !== -1) { + const [movedMessage] = newMessages.splice(fromIndex, 1); + newMessages.splice(moveUpdate.toIndex, 0, movedMessage); + } + break; + } + + case MessageUpdateType.RESET: { + const resetUpdate = update as any; + newMessages = resetUpdate.messages || []; + break; + } + } + } + + return newMessages; + }); + } finally { + setIsProcessing(false); + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + } + }, []); + + // 监听外部消息变化 + useEffect(() => { + // 如果消息数量变化太大,直接重置 + const previousCount = previousMessagesRef.current.length; + const currentCount = messages.length; + const changeRatio = previousCount === 0 ? 1 : Math.abs(currentCount - previousCount) / previousCount; + + if (changeRatio > changeRatioThreshold) { + setOptimizedMessages(messages); + previousMessagesRef.current = messages; + return; + } + + // 使用差异计算 + if (enableDiff && calculatorRef.current) { + const diff = calculatorRef.current.calculateDiff(previousMessagesRef.current, messages); + + if (diff.shouldReset) { + setOptimizedMessages(messages); + } else { + // 将差异转换为更新操作 + const updates: MessageUpdate[] = []; + + // 处理新增 + if (diff.added.length > 0) { + if (diff.added.length === 1) { + updates.push({ + type: MessageUpdateType.ADD, + timestamp: Date.now(), + message: diff.added[0], + } as any); + } else { + updates.push({ + type: MessageUpdateType.BATCH_ADD, + timestamp: Date.now(), + messages: diff.added, + } as any); + } + } + + // 处理更新 + if (diff.updated.length > 0) { + if (diff.updated.length === 1) { + updates.push({ + type: MessageUpdateType.UPDATE, + timestamp: Date.now(), + messageId: diff.updated[0].message.id, + updates: diff.updated[0].changes, + } as any); + } else { + updates.push({ + type: MessageUpdateType.BATCH_UPDATE, + timestamp: Date.now(), + updates: diff.updated.map(u => ({ + messageId: u.message.id, + changes: u.changes, + })), + } as any); + } + } + + // 处理删除 + if (diff.deleted.length > 0) { + if (diff.deleted.length === 1) { + updates.push({ + type: MessageUpdateType.DELETE, + timestamp: Date.now(), + messageId: diff.deleted[0].message.id, + } as any); + } else { + updates.push({ + type: MessageUpdateType.BATCH_DELETE, + timestamp: Date.now(), + messageIds: diff.deleted.map(d => d.message.id), + } as any); + } + } + + // 处理移动 + if (diff.moved.length > 0) { + for (const move of diff.moved) { + updates.push({ + type: MessageUpdateType.MOVE, + timestamp: Date.now(), + messageId: move.message.id, + toIndex: move.toIndex, + } as any); + } + } + + // 应用更新 + if (updates.length > 0) { + if (enableBatching && batcherRef.current) { + batcherRef.current.addUpdates(updates); + } else { + handleBatchUpdates(updates); + } + } + } + } else { + // 不使用差异计算,直接设置 + setOptimizedMessages(messages); + } + + // 更新消息数量限制检查 + if (messages.length > maxMessageCount) { + console.warn(`[useDifferentialMessages] Message count (${messages.length}) exceeds limit (${maxMessageCount})`); + } + + previousMessagesRef.current = messages; + }, [messages, enableDiff, enableBatching, maxMessageCount, changeRatioThreshold]); + + // 手动刷新方法 + const flush = useCallback(() => { + batcherRef.current?.flush(); + }, []); + + // 重置方法 + const reset = useCallback(() => { + calculatorRef.current?.reset(); + batcherRef.current?.clearPending(); + setOptimizedMessages([]); + previousMessagesRef.current = []; + }, []); + + // 强制更新方法 + const forceUpdate = useCallback((newMessages: T[]) => { + setOptimizedMessages(newMessages); + }, []); + + // 获取统计信息 + const getDiffStats = useCallback(() => { + const batcherStats = batcherRef.current?.getStats(); + return { + totalBatches: batcherStats?.totalBatches ?? 0, + totalUpdates: batcherStats?.totalUpdates ?? 0, + averageBatchSize: batcherStats?.averageBatchSize ?? 0, + }; + }, []); + + // 定期更新待处理数量 + useEffect(() => { + if (!enableBatching || !batcherRef.current) return; + + const interval = setInterval(() => { + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + }, 50); + + return () => clearInterval(interval); + }, [enableBatching]); + + return { + optimizedMessages, + pendingUpdateCount, + isProcessing, + flush, + reset, + forceUpdate, + getDiffStats, + }; +} + +export default useDifferentialMessages; diff --git a/src/hooks/useMediaCache.ts b/src/hooks/useMediaCache.ts new file mode 100644 index 0000000..9009bec --- /dev/null +++ b/src/hooks/useMediaCache.ts @@ -0,0 +1,241 @@ +/** + * 媒体缓存 Hook + * @module src/hooks/useMediaCache + * @description 提供媒体缓存管理的 React Hook + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { mediaCacheManager } from '../infrastructure/cache'; +import { + CacheStats, + CleanupPolicy, + CleanupResult, + MediaType, + CleanupTrigger, + DEFAULT_CLEANUP_POLICY, +} from '../infrastructure/cache/types'; + +/** + * useMediaCache Hook 返回值类型 + */ +export interface UseMediaCacheReturn { + /** 缓存统计信息 */ + stats: CacheStats | null; + /** 是否正在清理 */ + isClearing: boolean; + /** 是否已初始化 */ + isInitialized: boolean; + /** 加载缓存统计 */ + loadStats: () => void; + /** 执行清理(手动) */ + cleanup: (policy?: Partial) => Promise; + /** 清空所有缓存 */ + clearAll: () => Promise; + /** 清理指定会话的缓存 */ + clearConversation: (conversationId: string) => Promise; + /** 启动定期清理 */ + startPeriodicCleanup: () => void; + /** 停止定期清理 */ + stopPeriodicCleanup: () => void; + /** 记录缓存访问(用于 LRU) */ + recordAccess: (key: string) => Promise; + /** 格式化缓存大小 */ + formatSize: (bytes: number) => string; + /** 缓存媒体文件 */ + cacheMedia: ( + uri: string, + type: MediaType, + options?: { + conversationId?: string; + messageId?: string; + size?: number; + } + ) => Promise; + /** 获取缓存的媒体路径 */ + getCachedMedia: (uri: string, type: MediaType) => Promise; +} + +/** + * 媒体缓存 Hook + * @description 提供缓存统计、清理功能、访问记录等 + * @returns {UseMediaCacheReturn} 缓存管理接口 + * + * @example + * ```tsx + * function MediaSettings() { + * const { stats, cleanup, formatSize } = useMediaCache(); + * + * return ( + * + * 总缓存: {formatSize(stats?.totalSize || 0)} + * 图片: {stats?.imageCount || 0} + * 视频: {stats?.videoCount || 0} + *