/** * 消息服务 * 处理私信/聊天消息功能 * 使用新的 API 接口: /api/v1/conversations (RESTful action 风格) */ import { api } from '../core/api'; import { wsService } from '../core/wsService'; import { ConversationListResponse, ConversationResponse, ConversationDetailResponse, MessageListResponse, MessageResponse, SendMessageRequest, UnreadCountResponse, ConversationUnreadCountResponse, SystemMessageListResponse, SystemMessageResponse, SystemUnreadCountResponse, MessageSegment, CursorPaginationRequest, CursorPaginationResponse, } from '@/types/dto'; import { conversationRepository, userCacheRepository } from '@/database'; /** 远端会话列表分页(offset / cursor)统一结果:拉取成功后均已执行本地缓存同步 */ export interface RemoteConversationListPageResult { items: ConversationResponse[]; hasMore: boolean; /** cursor 模式:下一页游标;offset 模式固定为 null */ nextCursor: string | null; /** offset 模式:下一次 loadNext 应请求的页码;cursor 模式为 undefined */ nextPage?: number; } // 消息服务类 class MessageService { /** 与会话 offset 列表一致:将本页会话写入 SQLite,避免游标路径不落库导致冷启动未读与线上一致 */ private async persistConversationListPageToLocalCache( list: ConversationResponse[] ): Promise { if (!list.length) return; const users = list.flatMap(conv => [ ...(conv.participants || []), ...(conv.last_message?.sender ? [conv.last_message.sender] : []), ]); const groups = list .map(conv => conv.group) .filter((group): group is NonNullable => Boolean(group)); await conversationRepository.saveWithRelated(list, users, groups); } /** offset 会话列表单页:请求 + 落库 */ private async requestOffsetConversationsPage( page: number, pageSize: number ): Promise { const response = await api.get('/conversations', { page, page_size: pageSize, }); const list = response.data.list || []; await this.persistConversationListPageToLocalCache(list); return response.data; } /** cursor 会话列表单页:请求 + 落库 */ private async requestCursorConversationsPage( params: CursorPaginationRequest ): Promise> { try { const response = await api.get>( '/conversations/cursor', params ); const raw = response.data; const list = Array.isArray(raw?.list) ? raw.list : []; await this.persistConversationListPageToLocalCache(list); return { list, next_cursor: raw?.next_cursor ?? null, prev_cursor: raw?.prev_cursor ?? null, has_more: raw?.has_more ?? false, }; } catch (error) { console.error('获取会话列表失败:', error); return { list: [], next_cursor: null, prev_cursor: null, has_more: false, }; } } /** * 远端会话列表单页(offset / cursor 统一入口) * 两种模式均先写 SQLite 再返回,行为与 NetworkRemoteConversationListPagedSource 配套。 */ async fetchRemoteConversationListPage(args: { mode: 'offset' | 'cursor'; pageSize: number; /** offset:本次页码,默认 1 */ page?: number; /** cursor:本次游标;首屏不传或 null */ cursor?: string | null; direction?: CursorPaginationRequest['direction']; }): Promise { if (args.mode === 'offset') { const page = args.page ?? 1; const data = await this.requestOffsetConversationsPage(page, args.pageSize); const totalPages = Math.max(0, data.total_pages ?? 0); const currentPage = data.page ?? page; const hasMore = totalPages > 0 && currentPage < totalPages; return { items: data.list || [], hasMore, nextCursor: null, nextPage: currentPage + 1, }; } const raw = await this.requestCursorConversationsPage({ page_size: args.pageSize, ...(args.cursor != null && args.cursor !== '' ? { cursor: args.cursor } : {}), ...(args.direction ? { direction: args.direction } : {}), }); const items = raw.list || []; const nextCursor = raw.next_cursor ?? null; const hasMore = Boolean(raw.has_more && nextCursor != null); return { items, hasMore, nextCursor }; } private async refreshConversationDetailFromServer(id: string): Promise { const response = await api.get( `/conversations/${encodeURIComponent(id)}` ); await conversationRepository.saveCache(response.data); if (response.data.participants?.length) { await userCacheRepository.saveBatch(response.data.participants); } if (response.data.last_message?.sender) { await userCacheRepository.save(response.data.last_message.sender); } return response.data; } private normalizeCachedDetail( id: string, cached: any ): ConversationDetailResponse { return { id: String(cached?.id || id), type: cached?.type || 'private', is_pinned: Boolean(cached?.is_pinned), last_seq: Number(cached?.last_seq || 0), last_message: cached?.last_message, last_message_at: cached?.last_message_at || cached?.updated_at || new Date().toISOString(), unread_count: Number(cached?.unread_count || 0), participants: Array.isArray(cached?.participants) ? cached.participants : [], my_last_read_seq: Number(cached?.my_last_read_seq || 0), other_last_read_seq: Number(cached?.other_last_read_seq || 0), created_at: cached?.created_at || new Date().toISOString(), updated_at: cached?.updated_at || new Date().toISOString(), }; } /** * 创建私聊会话 * POST /api/v1/conversations * @param userId 目标用户ID (UUID格式) */ async createConversation(userId: string): Promise { const response = await api.post('/conversations', { user_id: userId, }); await conversationRepository.saveCache(response.data); if (response.data.participants?.length) { await userCacheRepository.saveBatch(response.data.participants); } return response.data; } /** * 获取会话详情 * GET /api/v1/conversations/:id */ async getConversationById(id: string): Promise { const cached = await conversationRepository.getCache(id); if (cached) { this.refreshConversationDetailFromServer(id).catch(error => { console.error('后台刷新会话详情失败:', error); }); return this.normalizeCachedDetail(id, cached); } try { return await this.refreshConversationDetailFromServer(id); } catch (error) { console.error('获取会话详情失败:', error); throw error; } } /** * 仅自己删除会话 * DELETE /api/v1/conversations/:id/self */ async deleteConversationForSelf(conversationId: string): Promise { await api.delete(`/conversations/${encodeURIComponent(conversationId)}/self`); } /** * 设置会话置顶 * PUT /api/v1/conversations/:id/pinned */ async setConversationPinned(conversationId: string, isPinned: boolean): Promise { await api.put(`/conversations/${encodeURIComponent(conversationId)}/pinned`, { is_pinned: isPinned, }); } async setConversationNotificationMuted(conversationId: string, notificationMuted: boolean): Promise { await api.put(`/conversations/${encodeURIComponent(conversationId)}/notification_muted`, { notification_muted: notificationMuted, }); } // ==================== 消息相关 ==================== /** * 获取消息列表(支持增量同步和历史消息加载) * GET /api/v1/conversations/:id/messages * @param conversationId 会话ID * @param afterSeq 获取此序号之后的消息(增量同步,获取新消息) * @param beforeSeq 获取此序号之前的历史消息(下拉加载更多,获取旧消息) * @param limit 返回消息数量限制 */ async getMessages( conversationId: string, afterSeq?: number, beforeSeq?: number, limit?: number ): Promise { const params: Record = {}; if (afterSeq !== undefined) { params.after_seq = afterSeq; } if (beforeSeq !== undefined) { params.before_seq = beforeSeq; } if (limit !== undefined) { params.limit = limit; } try { const response = await api.get( `/conversations/${encodeURIComponent(conversationId)}/messages`, params ); // 处理增量同步响应(after_seq 或 before_seq) if ((afterSeq !== undefined || beforeSeq !== undefined) && response.data.messages !== undefined) { return { messages: response.data.messages, total: response.data.messages.length, page: 1, page_size: limit || 100, }; } // 处理分页响应(后端返回 list 字段) const data = response.data; return { messages: data.list || [], total: data.total || 0, page: data.page || 1, page_size: data.page_size || 20, }; } catch (error: any) { // 检查是否是权限错误或会话不存在错误 - 这是正常情况,不需要打印错误日志 const errorMessage = error?.message || String(error); if ( errorMessage.includes('not found') || errorMessage.includes('no permission') || error?.code === 'NOT_FOUND' || error?.code === 'FORBIDDEN' ) { // 会话不存在或无权限访问,返回空消息列表 // 这可能是因为会话已被删除、用户被踢出群聊等情况 console.warn('[MessageService] 会话不存在或无权限访问:', conversationId); } else { // 其他错误才打印错误日志 console.error('[MessageService] 获取消息列表失败:', error); } return { messages: [], total: 0, page: 1, page_size: 20, }; } } /** * 按消息 ID 获取被引用消息(用于引用预览回填/跳转) * 后端鉴权:必须是该会话参与者,否则返回错误。 * @param messageId 被引用消息的 ID */ async getReplyMessage(messageId: string): Promise { try { const response = await api.get( `/messages/${encodeURIComponent(messageId)}` ); return response.data ?? null; } catch (error: any) { // 消息不存在或无权限(已被删除/不可见)——引用回填的常见降级场景 const errorMessage = error?.message || String(error); if ( errorMessage.includes('not found') || errorMessage.includes('no permission') || error?.code === 'NOT_FOUND' || error?.code === 'FORBIDDEN' ) { return null; } console.error('[MessageService] 获取被引用消息失败:', error); return null; } } /** * 发送消息(新格式) * 优先使用WebSocket发送,失败时降级到HTTP * POST /api/v1/conversations/:id/messages * Body: { detail_type, segments } */ async sendMessage( conversationId: string, data: SendMessageRequest, detailType: 'private' | 'group' = 'private' ): Promise { // 直接使用传入的 segments if (!data.segments || data.segments.length === 0) { throw new Error('消息内容不能为空'); } // 优先使用WebSocket发送 try { const result = await wsService.sendMessage( conversationId, detailType, data.segments, data.reply_to_id ); return result; } catch (error) { console.log('WebSocket发送失败,降级到HTTP:', error); // 降级到HTTP发送 return this.sendMessageByAction( detailType, conversationId, data.segments, data.reply_to_id ); } } /** * 通过 HTTP POST 发送消息(新格式) * 请求格式: POST /api/v1/conversations/:id/messages * Body: { "detail_type": "private", "segments": [...] } * @param detailType 消息类型: 'private' 私聊, 'group' 群聊 * @param conversationId 会话ID * @param message 消息段数组 [{"type": "text", "data": {"text": "xxx"}}] * @param replyToId 回复的消息ID(可选) * @returns 消息响应 */ async sendMessageByAction( detailType: 'private' | 'group', conversationId: string, message: MessageSegment[], replyToId?: string ): Promise { try { const body: Record = { detail_type: detailType, segments: message, }; if (replyToId) { body.reply_to_id = replyToId; } const response = await api.post( `/conversations/${encodeURIComponent(conversationId)}/messages`, body ); return response.data; } catch (error) { console.error('发送消息失败:', error); throw error; } } /** * 撤回/删除消息 * 优先使用WebSocket,失败时降级到HTTP * POST /api/v1/messages/delete_msg * Body: { "message_id": "xxx" } */ async recallMessage(messageId: string): Promise { try { await wsService.recallMessage(messageId); } catch (error) { console.log('WebSocket撤回失败,降级到HTTP:', error); await api.post('/messages/delete_msg', { message_id: messageId, }); } } /** * 删除消息(同撤回) * POST /api/v1/messages/delete_msg */ async deleteMessage(messageId: string): Promise { await this.recallMessage(messageId); } // ==================== 已读相关 ==================== /** * 标记已读 * 优先使用WebSocket,失败时降级到HTTP * POST /api/v1/conversations/:id/read * @param conversationId 会话ID * @param seq 已读到的消息序号 */ async markAsRead(conversationId: string, seq: number): Promise { // 使用WebSocket发送已读标记(不等待响应) wsService.markRead(conversationId, seq).catch(() => {}); // 同时通过HTTP确保可靠性 await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, { last_read_seq: Number(seq), }); // 立即清零本地缓存中的 unread_count,并写入已读游标,避免冷启动仍显示旧红点 conversationRepository.updateCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => { console.error('更新本地会话未读数失败:', error); }); } /** * 批量标记所有会话已读 * POST /api/v1/conversations/read-all */ async markAllAsRead(conversations: Array<{ id: string; lastSeq: number }>): Promise { await api.post('/conversations/read-all', { conversations: conversations.map(c => ({ conversation_id: c.id, last_read_seq: Number(c.lastSeq), })), }); } /** * 上报输入状态 * 优先使用WebSocket,失败时降级到HTTP * POST /api/v1/conversations/:id/typing */ async sendTyping(conversationId: string): Promise { // 使用WebSocket发送输入状态 wsService.sendTyping(conversationId, true); // 同时通过HTTP确保可靠性 await api.post(`/conversations/${encodeURIComponent(conversationId)}/typing`); } /** * 获取未读总数 * GET /api/v1/conversations/unread/count */ async getUnreadCount(): Promise { try { const response = await api.get( '/conversations/unread/count' ); return response.data; } catch (error) { console.error('获取未读总数失败:', error); return { total_unread_count: 0 }; } } /** * 获取同步元数据(轻量级 seq + 时间,用于增量同步判断) * GET /api/v1/conversations/sync-data */ async getSyncData(): Promise> { const response = await api.get<{ conversations: Array<{ id: string; max_seq: number; last_message_at: string }> }>('/conversations/sync-data'); return response.data?.conversations || []; } /** * 增量同步(基于版本号) * GET /api/v1/conversations/sync?version=N&limit=100 */ async getSyncByVersion(version: number, limit: number = 100): Promise<{ current_version: number; changes: Array<{ conversation_id: string; change_type: string; max_seq?: number; last_message_at?: number; version: number; }>; full_sync: boolean; has_more: boolean; }> { const response = await api.get<{ current_version: number; changes: Array<{ conversation_id: string; change_type: string; max_seq?: number; last_message_at?: number; version: number; }>; full_sync: boolean; has_more: boolean; }>('/conversations/sync', { version, limit }); return response.data; } /** * 获取系统消息未读数 * GET /api/v1/messages/system/unread-count */ async getSystemUnreadCount(): Promise { try { const response = await api.get( '/messages/system/unread-count' ); return response.data; } catch (error) { console.error('获取系统消息未读数失败:', error); return { unread_count: 0 }; } } /** * 标记单条系统消息已读 * PUT /api/v1/messages/system/:messageId/read * @param messageId 消息ID */ async markSystemMessageRead(messageId: string): Promise { await api.put(`/messages/system/${encodeURIComponent(messageId)}/read`); } /** * 标记所有系统消息已读 * PUT /api/v1/messages/system/read-all */ async markAllSystemMessagesRead(): Promise { await api.put('/messages/system/read-all'); } /** * 获取系统消息列表(游标分页) * GET /api/v1/messages/system/cursor * @param params 游标分页请求参数 */ async getSystemMessagesCursor( params: CursorPaginationRequest = {} ): Promise> { try { // 始终传递 cursor 参数(空字符串表示首次请求),确保使用游标分页 const requestParams: Record = { cursor: params.cursor || '', page_size: params.page_size, }; const response = await api.get('/messages/system', requestParams); const data = response.data; // 游标或带 page/total_pages 的分页(统一为 CursorPaginationResponse:list) if (data && typeof data === 'object') { const list = Array.isArray(data.list) ? data.list : null; if (list) { const isPageShape = typeof data.page === 'number' && typeof data.total_pages === 'number' && data.next_cursor === undefined && data.prev_cursor === undefined; if (isPageShape) { const currentPage = data.page || 1; const totalPages = data.total_pages || 1; return { list, next_cursor: currentPage < totalPages ? String(currentPage + 1) : null, prev_cursor: currentPage > 1 ? String(currentPage - 1) : null, has_more: currentPage < totalPages, }; } return { list, next_cursor: data.next_cursor ?? null, prev_cursor: data.prev_cursor ?? null, has_more: data.has_more ?? false, }; } } return { list: [], next_cursor: null, prev_cursor: null, has_more: false, }; } catch (error) { console.error('获取系统消息列表失败:', error); return { list: [], next_cursor: null, prev_cursor: null, has_more: false, }; } } } // 导出消息服务实例 export const messageService = new MessageService(); // 导出类型以方便使用 export type { ConversationListResponse, ConversationResponse, ConversationDetailResponse, MessageListResponse, MessageResponse, SendMessageRequest, UnreadCountResponse, ConversationUnreadCountResponse, SystemMessageListResponse, SystemUnreadCountResponse, };