fix(chat): WS history 帧按会话过滤,修次级会话串进主会话消息
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 的帧放行、过滤不修改入参。
This commit is contained in:
1 parent
c09a21136c
commit
2a49c25e1b
3 files changed
+56
-1
No files matched your search
@@ -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<HistoryMessageDto>,
|
||||
sessionId: String,
|
||||
): List<HistoryMessageDto> = messages.filter { it.sessionId.isBlank() || it.sessionId == sessionId }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user