/** * 消息服务 * 处理私信/聊天消息功能 * 使用新的 API 接口: /api/v1/conversations (RESTful action 风格) */ import { api } from './api'; import { ConversationListResponse, ConversationResponse, ConversationDetailResponse, MessageListResponse, MessageResponse, SendMessageRequest, UnreadCountResponse, ConversationUnreadCountResponse, SystemMessageListResponse, SystemMessageResponse, SystemUnreadCountResponse, MessageSegment, CursorPaginationRequest, CursorPaginationResponse, } from '../types/dto'; import { getConversationCache, getConversationListCache, saveConversationCache, saveConversationsWithRelatedCache, updateConversationCacheUnreadCount, saveUsersCache, } 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 saveConversationsWithRelatedCache(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, }; } } private async refreshConversationsFromServer( page = 1, pageSize = 20 ): Promise { return this.requestOffsetConversationsPage(page, pageSize); } /** * 远端会话列表单页(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 saveConversationCache(response.data); if (response.data.participants?.length) { await saveUsersCache(response.data.participants); } if (response.data.last_message?.sender) { await saveUsersCache([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(), }; } // ==================== 会话相关 ==================== /** * 获取会话列表 * GET /api/v1/conversations/list */ async getConversations( page = 1, pageSize = 20, forceRefresh = false ): Promise { // 如果强制刷新,直接从服务器获取,不使用缓存 if (!forceRefresh) { const cachedList = await getConversationListCache(); if (cachedList.length > 0) { // 后台刷新数据,但不阻塞当前返回 this.refreshConversationsFromServer(page, pageSize).catch(error => { console.error('后台刷新会话列表失败:', error); }); return { list: cachedList, total: cachedList.length, page: 1, page_size: cachedList.length, total_pages: 1, }; } } // 强制刷新或没有缓存时,直接从服务器获取 try { return await this.refreshConversationsFromServer(page, pageSize); } catch (error) { console.error('获取会话列表失败:', error); return { list: [], total: 0, page: 1, page_size: pageSize, total_pages: 0, }; } } /** * 创建私聊会话 * POST /api/v1/conversations * @param userId 目标用户ID (UUID格式) */ async createConversation(userId: string): Promise { const response = await api.post('/conversations', { user_id: userId, }); await saveConversationCache(response.data); if (response.data.participants?.length) { await saveUsersCache(response.data.participants); } return response.data; } /** * 获取会话详情 * GET /api/v1/conversations/:id */ async getConversationById(id: string): Promise { const cached = await getConversationCache(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, }); } // ==================== 消息相关 ==================== /** * 获取消息列表(支持增量同步和历史消息加载) * 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) { console.error('获取消息列表失败:', error); return { messages: [], total: 0, page: 1, page_size: 20, }; } } /** * 发送消息(新格式) * 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('消息内容不能为空'); } 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; } } /** * 发送文本消息(便捷方法) */ async sendTextMessage( conversationId: string, content: string, detailType: 'private' | 'group' = 'private' ): Promise { return this.sendTextMessageByAction(detailType, conversationId, content); } /** * 通过 HTTP POST 发送文本消息 * @param detailType 消息类型: 'private' 私聊, 'group' 群聊 * @param conversationId 会话ID * @param text 文本内容 * @returns 消息响应 */ async sendTextMessageByAction( detailType: 'private' | 'group', conversationId: string, text: string ): Promise { const message: MessageSegment[] = [ { type: 'text', data: { text: text, }, }, ]; return this.sendMessageByAction(detailType, conversationId, message); } /** * 发送图片消息(便捷方法) */ async sendImageMessage( conversationId: string, mediaUrl: string, content?: string, detailType: 'private' | 'group' = 'private' ): Promise { const message: MessageSegment[] = [ { type: 'image', data: { url: mediaUrl, }, }, ]; // 如果有文本说明,添加一个文本段 if (content) { message.unshift({ type: 'text', data: { text: content }, }); } return this.sendMessageByAction(detailType, conversationId, message); } /** * 发送 segments 格式消息(新版统一格式) * @param conversationId 会话ID * @param segments 消息链数组 * @param detailType 消息类型 */ async sendSegmentsMessage( conversationId: string, segments: MessageSegment[], detailType: 'private' | 'group' = 'private' ): Promise { return this.sendMessageByAction(detailType, conversationId, segments); } /** * 发送带回复的 segments 消息 * @param conversationId 会话ID * @param segments 消息链数组 * @param replyToId 被回复消息的ID * @param detailType 消息类型 */ async sendReplySegmentsMessage( conversationId: string, segments: MessageSegment[], replyToId: string, detailType: 'private' | 'group' = 'private' ): Promise { // 使用 reply_to_id 参数发送回复消息 return this.sendMessageByAction(detailType, conversationId, segments, replyToId); } /** * 撤回/删除消息 * POST /api/v1/messages/delete_msg * Body: { "message_id": "xxx" } */ async recallMessage(messageId: string): Promise { await api.post('/messages/delete_msg', { message_id: messageId, }); } /** * 删除消息(同撤回) * POST /api/v1/messages/delete_msg */ async deleteMessage(messageId: string): Promise { await this.recallMessage(messageId); } // ==================== 已读相关 ==================== /** * 标记已读 * POST /api/v1/conversations/:id/read * @param conversationId 会话ID * @param seq 已读到的消息序号 */ async markAsRead(conversationId: string, seq: number): Promise { await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, { last_read_seq: Number(seq), }); // 立即清零本地缓存中的 unread_count,并写入已读游标,避免冷启动仍显示旧红点 updateConversationCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => { console.error('更新本地会话未读数失败:', error); }); } /** * 上报输入状态 * POST /api/v1/conversations/:id/typing */ async sendTyping(conversationId: string): Promise { 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 }; } } /** * 获取单个会话未读数 * GET /api/v1/conversations/unread/count?conversation_id=xxx */ async getConversationUnreadCount( conversationId: string ): Promise { try { const response = await api.get( '/conversations/unread/count', { conversation_id: conversationId } ); return response.data; } catch (error) { console.error('获取会话未读数失败:', error); return { conversation_id: conversationId, unread_count: 0, }; } } // ==================== 系统消息相关 ==================== /** * 获取系统消息列表 * GET /api/v1/messages/system * @param limit 返回数量限制 * @param beforeId 获取此ID之前的消息(用于分页) */ async getSystemMessages( pageSize: number = 20, page: number = 1 ): Promise { try { const response = await api.get<{ list: any[]; total: number; page: number; page_size: number; total_pages: number; }>('/messages/system', { page, page_size: pageSize, }); const data = response.data; return { messages: data.list || [], total: data.total || 0, has_more: data.page < data.total_pages, }; } catch (error) { console.error('获取系统消息列表失败:', error); return { messages: [], total: 0, has_more: false, }; } } /** * 获取系统消息未读数 * 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/conversations/:id/messages/cursor * @param conversationId 会话ID * @param params 游标分页请求参数 */ async getMessagesCursor( conversationId: string, params: CursorPaginationRequest = {} ): Promise> { try { const response = await api.get( `/conversations/${encodeURIComponent(conversationId)}/messages/cursor`, params ); const raw = response.data; return { list: Array.isArray(raw?.list) ? raw.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, }; } } /** * 获取系统消息列表(游标分页) * 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, };