diff --git a/src/screens/message/components/ConversationListRow.tsx b/src/screens/message/components/ConversationListRow.tsx index 139f0dc..09ec234 100644 --- a/src/screens/message/components/ConversationListRow.tsx +++ b/src/screens/message/components/ConversationListRow.tsx @@ -262,9 +262,6 @@ const ConversationListRowInner: React.FC = ({ )} - {__DEV__ && ( - [DEBUG: {item.unread_count || 0}] - )} diff --git a/src/services/database.ts b/src/services/database.ts index 746930f..276c7ff 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -942,10 +942,12 @@ export const updateConversationListCacheEntry = async ( /** * 更新会话 SWR 缓存中的未读数(标记已读后立即同步清零,避免下次从缓存加载时仍显示旧红点) + * @param myLastReadSeq 若传入,同时写入 my_last_read_seq,便于冷启动用 last_seq 与已读游标校正未读 */ export const updateConversationCacheUnreadCount = async ( conversationId: string, - count: number + count: number, + myLastReadSeq?: number ): Promise => { await enqueueWrite(async () => { const database = await getDb(); @@ -959,6 +961,9 @@ export const updateConversationCacheUnreadCount = async ( const conv = safeParseJson(listRow.data); if (conv) { conv.unread_count = count; + if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) { + conv.my_last_read_seq = myLastReadSeq; + } await database.runAsync( `UPDATE conversation_list_cache SET data = ? WHERE id = ?`, [JSON.stringify(conv), String(conversationId)] @@ -975,6 +980,9 @@ export const updateConversationCacheUnreadCount = async ( const conv = safeParseJson>(cacheRow.data); if (conv) { conv.unread_count = count; + if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) { + conv.my_last_read_seq = myLastReadSeq; + } await database.runAsync( `UPDATE conversation_cache SET data = ? WHERE id = ?`, [JSON.stringify(conv), String(conversationId)] diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 270ca1f..4589887 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -31,17 +31,23 @@ import { updateConversationCacheUnreadCount, } 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 { - private async refreshConversationsFromServer( - page = 1, - pageSize = 20 - ): Promise { - const response = await api.get('/conversations', { - page, - page_size: pageSize, - }); - const list = response.data.list || []; + /** 与会话 offset 列表一致:将本页会话写入 SQLite,避免游标路径不落库导致冷启动未读与线上一致 */ + private async persistConversationListPageToLocalCache( + list: ConversationResponse[] + ): Promise { + if (!list.length) return; await saveConversationsCache(list); await saveUsersCache( list.flatMap(conv => [ @@ -54,9 +60,98 @@ class MessageService { .map(conv => conv.group) .filter((group): group is NonNullable => Boolean(group)) ); + } + + /** 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)}` @@ -433,8 +528,8 @@ class MessageService { await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, { last_read_seq: Number(seq), }); - // 立即清零本地缓存中的 unread_count,避免回到列表时仍显示旧红点 - updateConversationCacheUnreadCount(conversationId, 0).catch(error => { + // 立即清零本地缓存中的 unread_count,并写入已读游标,避免冷启动仍显示旧红点 + updateConversationCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => { console.error('更新本地会话未读数失败:', error); }); } @@ -560,37 +655,6 @@ class MessageService { // ==================== 游标分页方法 ==================== - /** - * 获取会话列表(游标分页) - * GET /api/v1/conversations/cursor - * @param params 游标分页请求参数 - */ - async getConversationsCursor( - params: CursorPaginationRequest = {} - ): Promise> { - try { - const response = await api.get>( - '/conversations/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/conversations/:id/messages/cursor diff --git a/src/stores/conversationListSources.ts b/src/stores/conversationListSources.ts index 6cece1d..d091065 100644 --- a/src/stores/conversationListSources.ts +++ b/src/stores/conversationListSources.ts @@ -25,51 +25,28 @@ export interface IConversationListPagedSource { readonly hasMore: boolean; } -/** 远端常规分页:GET /conversations?page=&page_size=(始终请求网络,避免走 getConversations 的本地短路) */ -export class NetworkOffsetConversationListPagedSource implements IConversationListPagedSource { +/** + * 远端会话列表(offset 与 cursor 共用实现) + * 均通过 messageService.fetchRemoteConversationListPage 拉取并落库,行为一致。 + */ +export class NetworkRemoteConversationListPagedSource implements IConversationListPagedSource { private nextPage = 1; + private nextCursor: string | null = null; private hasMoreAfterLastLoad = false; private loadedOnce = false; private readonly pageSize: number; + private readonly mode: RemoteConversationListSourceKind; - constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) { + constructor( + mode: RemoteConversationListSourceKind, + pageSize: number = CONVERSATION_LIST_PAGE_SIZE + ) { + this.mode = mode; this.pageSize = pageSize; } restart(): void { this.nextPage = 1; - this.hasMoreAfterLastLoad = false; - this.loadedOnce = false; - } - - get hasMore(): boolean { - return this.loadedOnce && this.hasMoreAfterLastLoad; - } - - async loadNext(): Promise { - const response = await messageService.getConversations(this.nextPage, this.pageSize, true); - const items = response.list || []; - const totalPages = Math.max(0, response.total_pages ?? 0); - const currentPage = response.page ?? this.nextPage; - this.hasMoreAfterLastLoad = totalPages > 0 && currentPage < totalPages; - this.nextPage = currentPage + 1; - this.loadedOnce = true; - return { items, hasMore: this.hasMoreAfterLastLoad }; - } -} - -/** 远端游标接口 */ -export class NetworkCursorConversationListPagedSource implements IConversationListPagedSource { - private nextCursor: string | null = null; - private hasMoreAfterLastLoad = false; - private loadedOnce = false; - private readonly pageSize: number; - - constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) { - this.pageSize = pageSize; - } - - restart(): void { this.nextCursor = null; this.hasMoreAfterLastLoad = false; this.loadedOnce = false; @@ -80,16 +57,22 @@ export class NetworkCursorConversationListPagedSource implements IConversationLi } async loadNext(): Promise { - const response = await messageService.getConversationsCursor( - this.nextCursor == null - ? { page_size: this.pageSize } - : { cursor: this.nextCursor, page_size: this.pageSize } - ); - const items = response.list || []; - this.nextCursor = response.next_cursor ?? null; - this.hasMoreAfterLastLoad = Boolean(response.has_more && response.next_cursor != null); + const result = await messageService.fetchRemoteConversationListPage({ + mode: this.mode, + pageSize: this.pageSize, + ...(this.mode === 'offset' + ? { page: this.nextPage } + : { cursor: this.nextCursor }), + }); + + this.hasMoreAfterLastLoad = result.hasMore; + if (this.mode === 'offset') { + this.nextPage = result.nextPage ?? this.nextPage + 1; + } else { + this.nextCursor = result.nextCursor ?? null; + } this.loadedOnce = true; - return { items, hasMore: this.hasMoreAfterLastLoad }; + return { items: result.items, hasMore: result.hasMore }; } } @@ -120,14 +103,12 @@ export class SqliteConversationListPagedSource implements IConversationListPaged } } -/** 未注入 remote 时,按类型创建默认远端源 */ +/** 远端会话列表分页策略 */ export type RemoteConversationListSourceKind = 'cursor' | 'offset'; export function createRemoteConversationListSource( kind: RemoteConversationListSourceKind, pageSize: number = CONVERSATION_LIST_PAGE_SIZE ): IConversationListPagedSource { - return kind === 'offset' - ? new NetworkOffsetConversationListPagedSource(pageSize) - : new NetworkCursorConversationListPagedSource(pageSize); + return new NetworkRemoteConversationListPagedSource(kind, pageSize); } diff --git a/src/stores/index.ts b/src/stores/index.ts index 9707373..2d82f9b 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -25,8 +25,7 @@ export { type ConversationListPage, type IConversationListPagedSource, type RemoteConversationListSourceKind, - NetworkCursorConversationListPagedSource, - NetworkOffsetConversationListPagedSource, + NetworkRemoteConversationListPagedSource, SqliteConversationListPagedSource, createRemoteConversationListSource, } from './conversationListSources'; diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index 70e88c5..acc872d 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -353,6 +353,18 @@ class MessageManager { this.state.conversationList = list; } + /** + * 当已读游标已追上 last_seq 时,强制未读为 0(修复本地 SQLite 中 unread_count 与已读状态不一致) + */ + private normalizeConversationUnreadFromReadCursor(conv: ConversationResponse): ConversationResponse { + const lastSeq = Number(conv.last_seq || 0); + const myRead = Number(conv.my_last_read_seq ?? 0); + if (lastSeq > 0 && myRead >= lastSeq && (conv.unread_count || 0) > 0) { + return { ...conv, unread_count: 0 }; + } + return conv; + } + /** * 将游标接口返回的单条会话写入 Map(统一 string id、尊重 pendingReadMap 已读保护) */ @@ -368,6 +380,7 @@ class MessageManager { } else { next = { ...conv, id }; } + next = this.normalizeConversationUnreadFromReadCursor(next); this.state.conversations.set(id, next); } @@ -776,14 +789,6 @@ class MessageManager { const normalizedConversationId = this.normalizeConversationId(conversation_id); const currentUserId = this.getCurrentUserId(); - // 【调试日志】追踪消息来源和重复问题 - - // 0. 如果是ACK消息,直接跳过不增加未读数(ACK是发送确认,不是新消息) - if (_isAck) { - // 但仍然需要更新消息列表(因为ACK包含完整消息内容) - // 继续处理但不增加未读数 - } - // 1. 消息去重检查 - 防止ACK消息和正常消息重复处理 if (this.isMessageProcessed(id)) { return; @@ -813,7 +818,6 @@ class MessageManager { timestamp: Date.now(), }); } - } else { } }).catch(error => { console.error('[MessageManager][ERROR] 获取发送者信息失败:', { userId: sender_id, error }); @@ -858,16 +862,13 @@ class MessageManager { const isCurrentUserMessage = sender_id === currentUserId; const isActiveConversation = normalizedConversationId === this.state.currentConversationId; - // 【调试日志】追踪未读数增加逻辑 - - // 修复:确保当前用户发送的消息不会增加未读数 + // 确保当前用户发送的消息不会增加未读数 // 同时确保currentUserId有效(避免undefined === undefined的情况) // ACK消息(发送确认)也不应该增加未读数 const shouldIncrementUnread = !isCurrentUserMessage && !isActiveConversation && !!currentUserId && !_isAck; if (shouldIncrementUnread) { this.incrementUnreadCount(normalizedConversationId); - } else { } // 6. 如果是当前活动会话,自动标记已读 @@ -1315,9 +1316,11 @@ class MessageManager { const isPending = !!readRecord; // 使用智能合并:如果服务器返回 unread_count > 0 但本地有更晚的已读记录,保留本地状态 const shouldPreserveLocalRead = isPending && (detail.unread_count || 0) > 0; - const safeDetail = shouldPreserveLocalRead - ? { ...(detail as ConversationResponse), id: normalizedId, unread_count: 0 } - : { ...(detail as ConversationResponse), id: normalizedId }; + const safeDetail = this.normalizeConversationUnreadFromReadCursor( + shouldPreserveLocalRead + ? { ...(detail as ConversationResponse), id: normalizedId, unread_count: 0 } + : { ...(detail as ConversationResponse), id: normalizedId } + ); this.state.conversations.set(normalizedId, safeDetail); this.updateConversationList(); @@ -1681,6 +1684,25 @@ class MessageManager { this.state.totalUnreadCount = unreadData?.total_unread_count ?? 0; this.state.systemUnreadCount = systemUnreadData?.unread_count ?? 0; + // 服务端汇总未读为 0 时,清掉内存/本地缓存中残留的逐会话红点(常见于仅暖机了 SQLite 旧数据) + if (this.state.totalUnreadCount === 0 && this.state.conversations.size > 0) { + let anyCleared = false; + for (const [cid, conv] of this.state.conversations) { + if ((conv.unread_count || 0) > 0) { + this.state.conversations.set(cid, { ...conv, unread_count: 0 }); + anyCleared = true; + } + } + if (anyCleared) { + this.updateConversationList(); + this.persistConversationListCache(); + this.notifySubscribers({ + type: 'conversations_updated', + payload: { conversations: this.state.conversationList }, + timestamp: Date.now(), + }); + } + } this.notifySubscribers({ type: 'unread_count_updated', @@ -1824,10 +1846,11 @@ class MessageManager { lastReadSeq: seq, }); - // 2. 乐观更新本地状态(立即反映到UI) + // 2. 乐观更新本地状态(立即反映到UI;同步已读游标避免与 last_seq 不一致) const updatedConv: ConversationResponse = { ...conversation, unread_count: 0, + my_last_read_seq: Math.max(Number(conversation.my_last_read_seq || 0), seq), }; this.state.conversations.set(normalizedId, updatedConv); this.state.totalUnreadCount = Math.max(0, this.state.totalUnreadCount - prevUnreadCount);