From 2a49c25e1b1b2491e5afbd5891989e2ccb1c05de Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 14:05:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20WS=20history=20=E5=B8=A7=E6=8C=89?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=BF=87=E6=BB=A4=EF=BC=8C=E4=BF=AE=E6=AC=A1?= =?UTF-8?q?=E7=BA=A7=E4=BC=9A=E8=AF=9D=E4=B8=B2=E8=BF=9B=E4=B8=BB=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS 首帧 history 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意 ChatViewModel;onEvent 此前把它**原样**并进当前消息列表,导致进次级会话会看到 主会话的消息混在一起——是数据串台,不是显示问题。 - data/repository/PagedMessages.kt:新增纯函数 historyForSession(messages, sessionId), 按**消息自身的 sessionId** 过滤;sessionId 为空的帧视为「无法判定」放行, 不因缺一个字段就整段丢掉历史(保持既有行为) - ui/chat/ChatViewModel.kt:history 分支先收敛到本会话再合并 为什么按 sessionId 而不是「只有主会话才收 history」:前者更耐将来—— 服务端哪天开始给次级会话回放,这里无需改动。 测试:178 全绿(175 → +3),新增三条:主会话回放不得泄漏进次级会话、 缺 sessionId 的帧放行、过滤不修改入参。 --- .../cyrene/data/repository/PagedMessages.kt | 18 ++++++++++ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 5 ++- .../data/repository/MessageMergeTest.kt | 34 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt index 013b2ae..40ea402 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/PagedMessages.kt @@ -38,3 +38,21 @@ fun mergeMessages( incoming.forEach { byId[it.id] = it } return byId.values.sortedWith(MessageOrder) } + +/** + * 把一帧 `history` 收敛到**本会话**。 + * + * WS 首帧 `history` 是**主会话**最近 50 条的回放,但连接是全局的、帧会被投给任意 + * `ChatViewModel`——不过滤就会把主会话的消息混进次级会话。 + * + * 判定以**消息自身的 `sessionId`** 为准,而不是「只有主会话才收 history」:前者更耐将来, + * 服务端哪天开始给次级会话回放,这里无需改动。 + * + * `sessionId` 为空的帧视为「无法判定」**放行**——不因缺一个字段就整段丢掉历史(保持既有行为)。 + * + * 纯函数,不修改入参。 + */ +fun historyForSession( + messages: List, + sessionId: String, +): List = messages.filter { it.sessionId.isBlank() || it.sessionId == sessionId } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 6d5da62..6ff0419 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -11,6 +11,7 @@ import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.repository.MessageRepository +import top.yeij.cyrene.data.repository.historyForSession import top.yeij.cyrene.data.repository.mergeMessages import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.WsEvent @@ -173,7 +174,9 @@ class ChatViewModel( private fun onEvent(event: WsEvent) { when (event) { is WsEvent.History -> { - val incoming = event.messages + // 这一帧是**主会话**的回放,却会被投给任意 ChatViewModel——必须先收敛到本会话, + // 否则进次级会话会看到主会话的消息混进来。 + val incoming = historyForSession(event.messages, sessionId) if (incoming.isEmpty()) { _state.update { it.copy(loading = false) } } else { diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt index c8ca355..17d7f39 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt @@ -85,4 +85,38 @@ class MessageMergeTest { assertEquals(listOf("m2", "m1"), idsOf(existing)) assertEquals(listOf("m3"), idsOf(incoming)) } + + private fun msgIn(sessionId: String, id: String) = HistoryMessageDto( + id = id, + sessionId = sessionId, + role = "user", + content = id, + createdAt = 0L, + ) + + @Test + fun `the main session's history replay cannot leak into a sub session`() { + // 真实缺陷:WS 首帧 history 是主会话的回放,却会被并进每一个 ChatViewModel。 + val replay = listOf(msgIn("web", "m1"), msgIn("web:abc", "m2")) + + assertEquals(listOf("m2"), idsOf(historyForSession(replay, "web:abc"))) + assertEquals(listOf("m1"), idsOf(historyForSession(replay, "web"))) + } + + @Test + fun `a frame without a sessionId is kept rather than dropping history`() { + // 缺字段时无法判定归属,放行以保持既有行为——不因缺一个字段整段丢掉历史。 + val replay = listOf(msgIn("", "m1")) + + assertEquals(listOf("m1"), idsOf(historyForSession(replay, "web:abc"))) + } + + @Test + fun `history filtering does not mutate the input`() { + val replay = listOf(msgIn("web", "m1"), msgIn("web:abc", "m2")) + + historyForSession(replay, "web") + + assertEquals(listOf("m1", "m2"), idsOf(replay)) + } }