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 }
|
incoming.forEach { byId[it.id] = it }
|
||||||
return byId.values.sortedWith(MessageOrder)
|
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.CyreneException
|
||||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||||
import top.yeij.cyrene.data.repository.MessageRepository
|
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.repository.mergeMessages
|
||||||
import top.yeij.cyrene.data.ws.ChatSocket
|
import top.yeij.cyrene.data.ws.ChatSocket
|
||||||
import top.yeij.cyrene.data.ws.WsEvent
|
import top.yeij.cyrene.data.ws.WsEvent
|
||||||
@@ -173,7 +174,9 @@ class ChatViewModel(
|
|||||||
private fun onEvent(event: WsEvent) {
|
private fun onEvent(event: WsEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is WsEvent.History -> {
|
is WsEvent.History -> {
|
||||||
val incoming = event.messages
|
// 这一帧是**主会话**的回放,却会被投给任意 ChatViewModel——必须先收敛到本会话,
|
||||||
|
// 否则进次级会话会看到主会话的消息混进来。
|
||||||
|
val incoming = historyForSession(event.messages, sessionId)
|
||||||
if (incoming.isEmpty()) {
|
if (incoming.isEmpty()) {
|
||||||
_state.update { it.copy(loading = false) }
|
_state.update { it.copy(loading = false) }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -85,4 +85,38 @@ class MessageMergeTest {
|
|||||||
assertEquals(listOf("m2", "m1"), idsOf(existing))
|
assertEquals(listOf("m2", "m1"), idsOf(existing))
|
||||||
assertEquals(listOf("m3"), idsOf(incoming))
|
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