From af11a94614f8234b3ccd1f0e928a773e0e1fbfb9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 20:27:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E6=89=8B=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E5=90=8C=E4=B8=80=E6=9D=A1=E5=86=85=E5=AE=B9=E6=97=B6?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E5=8E=9F=E5=B9=82=E7=AD=89=E9=94=AE=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E8=BF=87=E7=9A=84=E5=86=85=E5=AE=B9=E7=85=A7=E6=97=A7?= =?UTF-8?q?=E9=93=B8=E6=96=B0=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W13 顺手查出的「文档与实现不符」:PendingMessage 的 KDoc 一直写着「重试复用同一个 localId」,而 emitAndDeliver 每次发送都新铸 localId —— 若某次失败其实是「服务端已经收下、 客户端判成失败」(队列满 / 超时 / 回执丢失),用户手动重试就会铸新键、被服务端当成第二条 消息落库,用户看到重复。这正是幂等键(契约 v0.56 §3.13 / 协同 G4)要防的事。 - 新增 domain/chat/MessageFingerprint.kt:正文 + 图片本地 URI + 附件本地 URI 的内容指纹。 用纯值相等而不是哈希(摘要碰撞会让两条不同消息共用一个键,后一条被服务端判重丢掉), 正文一个字都不归一(只改一个空白字符也算改过 —— 归一正是会把两条真实消息并成一条的操作)。 附件只看本地引用:displayName / sizeBytes 是元数据,同一份文件两次查询之间可能一次报得出 一次报不出。 - 新增 domain/chat/RetryKeyMemory.kt:单槽重试记忆,三个纯函数(remember / keyFor / forget) 加一个判定结果类型。remember 只在回滚时调用;keyFor 是唯一的消费点,指纹相等即复用、 不等则铸新键(两支架都消费掉记录);forget 按 localId 清,避免两条同时在飞时互相踩。 - ChatViewModel:所有回滚路径(发送失败、队列满被拒、附件与图片上传失败、图片处理失败、 附件超限……)本来就汇到一个 rollback,现在在那里按指纹记下那条的键;队列满判定的 Rollback 档同样记、Adopt 档清。emitAndDeliver 改用 keyFor 取键。完成回执与 duplicate / 收编按 localId 清(正常那一路在发送时已消费,这几步是兜底)。落库消息到达时刻意不清:一条晚到的 message_persisted 恰恰是「服务端其实收下了」的证据,留着键才能让重试真正幂等。 - duplicate 路径复用既有 alignDuplicate / applyQueueFull 的 Adopt 档,没有另造对齐逻辑。 - 测试 706 → 729(+23,0 失败):指纹 13 条(正文空白 / 换图 / 增删与换序附件 / 元数据不算 内容 / 快照语义),重试记忆 10 条(复用只在指纹相等时发生、用掉即消费、编辑后不再复用、 forget 只清得动那一条、以及 duplicate 收口的端到端那条)。 --- .../cyrene/domain/chat/MessageFingerprint.kt | 40 ++++ .../yeij/cyrene/domain/chat/RetryKeyMemory.kt | 69 ++++++ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 104 ++++++++- .../domain/chat/MessageFingerprintTest.kt | 151 +++++++++++++ .../cyrene/domain/chat/RetryKeyMemoryTest.kt | 198 ++++++++++++++++++ 5 files changed, 551 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprint.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprintTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprint.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprint.kt new file mode 100644 index 0000000..650b9cc --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprint.kt @@ -0,0 +1,40 @@ +package top.yeij.cyrene.domain.chat + +/** + * 一条待发消息的**内容指纹**:正文 + 图片的本地引用 + 附件的本地引用(纯值,可比较、可单测)。 + * + * ## 它回答的问题 + * 「用户这次按发送,是不是在**重试**刚才那条没发出去的消息?」——只有答「是」,本端才敢把 + * 那一次用过的**幂等键**(`clientMsgId`)接着用;答「不是」就必须铸新键,否则服务端会把用户 + * **改过的那条**当成重复到达(`chat_duplicate` / `{duplicate:true}`)静默丢掉——他的编辑一个字 + * 都发不出去。 + * + * ## 为什么是「值相等」而不是哈希 / 摘要 + * 指纹相等 = 本端认定「同一条消息」 = 复用幂等键。摘要一旦碰撞,两条**不同**的消息就会共用 + * 一个键,后一条被服务端判成重复而丢掉;而摘要在这里买不到任何东西(比较只发生在进程内, + * 没有要跨网络传的指纹)。所以直接拿可比较的值本身当指纹,碰撞面为零。 + * + * ## 为什么正文不做任何归一 + * 一个空白字符的差别也算「改过字」:trim / 折叠空白都可能把用户真实打算发的两条消息并成一条, + * 而那正是本类型要防的事。发送路径传进来的已经是发送那一刻的正文(`ChatViewModel.send` 先 + * `trim`),所以「重试时输入栏里原样躺着的还是同一段字」天然相等。 + * + * ## 附件为什么只取 uri + * `DraftDocument` 的 `displayName` / `sizeBytes` 是**元数据**:同一份本地文件在两次 + * `ContentResolver` 查询之间完全可能一次报得出、一次报不出(`UNKNOWN_SIZE`),那不影响 + * 「这是同一份文件」。决定内容身份的是**本地引用本身**,所以指纹只取它。 + * 顺序照旧有意义(附件的先后就是用户在芯片区看到的先后),因此是列表而不是集合。 + */ +data class MessageFingerprint( + val text: String, + /** 图片的**本地可读** URI;null = 这条不带图(与待发消息的字段一一对应)。 */ + val imageUri: String? = null, + /** 附件的本地 URI,顺序即用户在芯片区看到的顺序。 */ + val documentUris: List = emptyList(), +) { + companion object { + /** 从发送那一刻的三件套算指纹(正文 / 图片 / 待发附件)。 */ + fun of(text: String, imageUri: String?, documents: List): MessageFingerprint = + MessageFingerprint(text = text, imageUri = imageUri, documentUris = documents.map { it.uri }) + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt new file mode 100644 index 0000000..94db291 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemory.kt @@ -0,0 +1,69 @@ +package top.yeij.cyrene.domain.chat + +/** 一次「没发出去」留下的记录:**那一条**的内容指纹 + 当时铸的幂等键(`localId`)。 */ +data class RetryKeySlot(val fingerprint: MessageFingerprint, val localId: String) + +/** + * [RetryKeyMemory.keyFor] 的判定结果:这一次发送该用哪个幂等键,以及记忆变成什么样。 + * + * 两半都是判定的一部分,所以一起回:记忆**无论走哪一支都要被消费掉**(见 [RetryKeyMemory.keyFor]), + * 把它放进返回值,调用方就没有「用完了忘了清」的机会。 + */ +data class RetryKeyDecision(val localId: String, val memory: RetryKeyMemory) + +/** + * 「上一次没发出去的那条」的记忆——**单槽**、纯值、可单测。 + * + * ## 它存在的理由 + * 幂等键要想真的挡住重复,就必须在**同一条消息**的两次发送之间保持不变。而「某次失败」并不 + * 等于「服务端没收下」:队列满(REST `409` / 定向 `chat_busy`)是**真的没进去**,但超时、 + * 回执丢失、连接被踢完全可能是「服务端已经落库了,只是客户端没看见」。用户手动重试时若铸一个 + * 新键,服务端就会把它当成**第二条**消息落库 —— 用户看到重复。这里记下失败那次的指纹与键, + * 让「一模一样的内容再发一次」落回同一个键,由服务端用 `chat_duplicate` / + * `{duplicate:true,messageId}` 替本端裁决。 + * + * ## 三条纪律 + * 1. **只在回滚时写**([remember]:发送失败 / 队列满被拒 / 附件与图片上传失败 / 图片处理失败…): + * 被受理的那条没有任何要记的东西。 + * 2. **在下一次发送时被消费**([keyFor]):指纹相等 ⇒ 复用那个键;不等 ⇒ 铸新键。**不等这一支 + * 同样消费**——用户改过字就是**另一条**消息,那条旧记录再留着,只会让「下一条恰好和上一条 + * 失败的一模一样」这种巧合偷偷复用别人的键。 + * 3. **按 `localId` 清**([forget]),不是无条件清空:两条同时在飞时,一条被受理(它的键与槽里 + * 记的那条对不上)不该把另一条刚记下的重试键抹掉。 + * + * ## 为什么单槽够 + * 记忆只对「用户下一次按发送」有意义,而那一次只可能是**最近一次失败**的那条——回滚已经把正文 / + * 图片 / 附件还回输入栏了,用户改的就是它。同时在飞的多条各有自己的 `localId`,与重试语义无关。 + * + * ## 与「绝不自动重试」不矛盾 + * 这里一个字节都不自动重发:复用键只发生在**用户自己又按了一次发送**、且内容一字不差的时候。 + * 它做的唯一一件事是把那次重发变成幂等的。 + */ +data class RetryKeyMemory(val slot: RetryKeySlot? = null) { + + /** 记下「这一条没发出去」(回滚时调用)。同一时刻只需要记住一条,后写的覆盖先写的。 */ + fun remember(fingerprint: MessageFingerprint, localId: String): RetryKeyMemory = + RetryKeyMemory(RetryKeySlot(fingerprint, localId)) + + /** + * 忘掉 [localId] 那条——受理成功 / 撞上 `duplicate` / 气泡被收编时调用。 + * + * 键对不上就**原样返回同一个实例**(不清):槽里记的可能是另一条刚回滚的消息,顺手清掉它 + * 会让那条的重试退回「铸新键」。 + */ + fun forget(localId: String): RetryKeyMemory = + if (slot?.localId == localId) RetryKeyMemory() else this + + /** + * 这一次发送该用哪个幂等键:内容与记下的那条一致 ⇒ **复用**它的键;否则铸一个新的 + * ([mint] 由调用方给,测试才能喂确定性的键,也才能断言「该不该铸」)。 + * + * 无论走哪一支,返回的 [RetryKeyDecision.memory] 都是空的——消费掉记录是本函数的定义的一部分 + * (见类注释第 2 条),而不是调用方的义务。 + */ + fun keyFor(fingerprint: MessageFingerprint, mint: () -> String): RetryKeyDecision = + when (val remembered = slot?.takeIf { it.fingerprint == fingerprint }) { + null -> RetryKeyDecision(mint(), RetryKeyMemory()) + else -> RetryKeyDecision(remembered.localId, RetryKeyMemory()) + } +} 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 c8a28fe..72074f4 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 @@ -37,6 +37,8 @@ import top.yeij.cyrene.domain.chat.DocumentPlan import top.yeij.cyrene.domain.chat.DocumentRead import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.chat.ImageDelivery +import top.yeij.cyrene.domain.chat.MessageFingerprint +import top.yeij.cyrene.domain.chat.RetryKeyMemory import top.yeij.cyrene.domain.chat.SendChannel import top.yeij.cyrene.domain.chat.readBounded import top.yeij.cyrene.domain.chat.toDocumentFailure @@ -55,9 +57,13 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1" * * [localId] 同时是**上行幂等键** `clientMsgId`(契约 v0.56 §3.13):同一会话内服务端只承认 * 第一个非空值,重复到达回 `chat_duplicate`(WS)或 `{duplicate:true,messageId}`(REST)而 - * **不再落库、不再起回合**。所以手动重试可以**复用**同一个 id,弱网重发不会变成第二条消息。 - * 它必须足够唯一:服务端的判重窗口是**整个会话历史**(键没有过期清理),进程重启后复用 - * 旧值会被当成重复、消息永远发不出去——因此用随机值而不是自增序号。 + * **不再落库、不再起回合**。所以**同一条内容**的手动重试可以**复用**同一个 id:回滚时按 + * [MessageFingerprint] 把它记进 [ChatViewModel] 的单槽记忆,下一次发送指纹相等就接着用—— + * 「服务端其实已经收下、客户端判成失败」那种失败(队列满 / 超时 / 回执丢失)因此不会在 + * 重试时落成第二条消息。指纹**不相等**(用户改过字 / 换了图 / 动了附件)则铸新键: + * 那是**另一条**消息,绝不能顶替。它必须足够唯一:服务端的判重窗口是**整个会话历史** + * (键没有过期清理),进程重启后复用旧值会被当成重复、消息永远发不出去——因此用随机值 + * 而不是自增序号。 */ data class PendingMessage( val localId: String, @@ -409,6 +415,12 @@ data class ChatUiState( * WS 上行**没有** REST 那样的同步回执:一条帧发出去之后,只有队列满(`pending_full` 广播 + * 定向 `error{chat_busy}`)与幂等重复(`chat_duplicate`)这两类信号能说明「这条其实没进去 / * 早就进去了」。两类的处置都在 [onQueueFull] / [alignDuplicate],判据是纯函数,可单测。 + * + * 幂等由 [MessageFingerprint] + [retryKey] 这一对承担:**回滚**时按内容指纹记下那条用过的 + * `clientMsgId`,用户**自己**再按一次发送、且内容一字不差时复用它([emitAndDeliver] 是唯一的 + * 消费者)——服务端据此判重(`chat_duplicate` / `{duplicate:true}`)而不是落成第二条消息; + * 内容改过(哪怕只多一个空格)就铸新键,因为那是另一条消息。**始终没有自动重试**: + * 键的复用只发生在用户手动重发的那一刻。 */ class ChatViewModel( private val container: AppContainer, @@ -423,6 +435,22 @@ class ChatViewModel( private var oldestLoadedAt: Long? = null private var streamParser: StreamingContentParser? = null + /** + * 「上一次没发出去的那条」的记忆:**回滚**时按内容指纹记下它用的幂等键,**下一次发送** + * 指纹相等就复用那个键(见 [RetryKeyMemory])。 + * + * 这就是「手动重试同一个键 = 幂等」的落地处:**没有任何自动重试**,键的复用只在用户自己 + * 又按了一次发送、且内容一字不差时发生([emitAndDeliver] 是唯一的消费者)。所有回滚路径都 + * 汇到 [rollback],所以只有那一个写入口。 + * + * 受理那一半由 [onAck] / [alignDuplicate] / [onQueueFull] 按 `localId` 清(正常那一路其实在 + * 发送时就已经被消费掉了,那几步是兜底)。但**落库消息到达时([onPersisted] / [reconcilePending]) + * 刻意不清**:一条晚到的 `message_persisted` 恰恰是「服务端其实收下了、客户端当时判成失败」 + * 的证据,这时候把键留着,用户接下来的重试才会被判重而不是落成第二条消息 —— 那正是本机制 + * 存在的理由。 + */ + private var retryKey = RetryKeyMemory() + init { start() } @@ -578,8 +606,16 @@ class ChatViewModel( ) { if (text.isEmpty() && imageUri == null && documents.isEmpty()) return - // localId 即幂等键:重试复用同一个值,服务端不会把它当成第二条消息。 - val localId = "local-${UUID.randomUUID()}" + // `localId` 即幂等键。它与「上一次没发出去的那条」内容一致时**复用当时的键**: + // 那次失败可能是「服务端已经收下、客户端判成失败」(队列满 / 超时 / 回执丢失), + // 复用键会让服务端判重并回 `chat_duplicate` / `{duplicate:true}`,而不是落成第二条消息。 + // 内容不同(改过字 / 换图 / 动了附件)则铸新键——用户的编辑是一条**新**消息。 + // 记忆无论走哪一支都在这里被消费掉,绝不会被下一次发送再捡起来。 + val decision = retryKey.keyFor(MessageFingerprint.of(text, imageUri, documents)) { + "local-${UUID.randomUUID()}" + } + retryKey = decision.memory + val localId = decision.localId _state.update { it.copy( pending = it.pending + PendingMessage(localId, text, imageUri, documents), @@ -785,15 +821,25 @@ class ChatViewModel( .onFailure { e -> rollback(localId, e.userMessage()) } } - /** REST 受理回执的六个分支(契约 v0.56 的响应面;形状见 [ChatAck])。 */ + /** + * REST 受理回执的六个分支(契约 v0.56 的响应面;形状见 [ChatAck])。 + * + * 对重试记忆([retryKey])来说这一层只有两种走向:**受理类**(`200` 起回合 / `202` 已排队 / + * `duplicate` 早已受理)一律清掉那条的记忆;**拒绝类**(`409 chat_busy` / 接不住 / 别的明确 + * 拒绝)走 [rollback],由那里把「没发出去」记下来。清是按 `localId` 清的——同时有两条在飞时, + * 一条受理不该把另一条刚回滚的记忆抹掉(见 [RetryKeyMemory.forget])。 + */ private fun onAck(localId: String, ack: ChatAck) { when (ack) { // 200 {stream:true}:回合已起,气泡等 message_persisted 归一。 - ChatAck.Accepted -> Unit + ChatAck.Accepted -> retryKey = retryKey.forget(localId) // 202 {queued:true}:已**落库并续接排队**。轻量态交给既有忙碌标签(busyLabel 认 // queueDepth);队列清空时 WS 的 `pending_drain` / `session_idle` 会把它归零。 - is ChatAck.Queued -> _state.update { it.copy(queueDepth = ack.queueDepth) } + is ChatAck.Queued -> { + retryKey = retryKey.forget(localId) + _state.update { it.copy(queueDepth = ack.queueDepth) } + } // 幂等重复:服务端没有重复落库、也没有起第二个回合——对齐既有消息,**不是错误**。 is ChatAck.Duplicate -> alignDuplicate(localId, ack.messageId) @@ -823,8 +869,13 @@ class ChatViewModel( * 本端不必再发、也不是失败**。 * * 第一段(摘气泡)与 [applyQueueFull] 的 Adopt 档等价,只是键来自帧而不是本地判定。 + * + * 它也是「重试真的撞上了原来那条」的收口:复用幂等键重发 ⇒ 服务端回 `duplicate` ⇒ 这里把 + * 待发气泡摘掉、由 [ensureMessageKnown] 补上落库那条 ⇒ 屏幕上**只多一版原来那条消息**, + * 不会再冒出第二个气泡(v0.56 之前靠「同文本」猜,这里不再另造一套对齐逻辑)。 */ private fun alignDuplicate(clientMsgId: String, messageId: String?) { + retryKey = retryKey.forget(clientMsgId) _state.update { state -> state.copy(pending = state.pending.filterNot { it.localId == clientMsgId }) } @@ -876,10 +927,31 @@ class ChatViewModel( /** * 回滚乐观气泡:把文本 / 图片 / 附件还给输入框并给出原因。 * - * 迁移本身是纯函数 [rollbackPending](可单测);这里只把它接进状态流。 + * 迁移本身是纯函数 [rollbackPending](可单测);这里只做两件事:**先**把这条的幂等键记进 + * [retryKey](气泡马上就没了,指纹只能在这之前取),再把它接进状态流。 + * + * 所有「没发出去」都汇到这一处(WS 发送失败 / REST 各种失败 / `409 chat_busy` / 附件与图片 + * 上传失败 / 图片处理失败 / 附件超限…),所以重试记忆也只需要这一个写入口。 */ - private fun rollback(localId: String, reason: String) = + private fun rollback(localId: String, reason: String) { + rememberRetryKey(_state.value.pending, localId) _state.update { it.rollbackPending(localId, reason) } + } + + /** + * 把「这一条没发出去」记进 [retryKey](指纹 + 当时用的幂等键)。 + * + * 气泡**不在**待发列表里就什么都不记:那种情况下没有任何东西被回滚,记一条只会在下一次 + * 发送时凭空复用别人的键。传 [pending] 而不是自己读 `_state`,是因为调用点必须能在这条 + * 气泡被摘掉**之前**取到它(见 [onQueueFull])。 + */ + private fun rememberRetryKey(pending: List, localId: String) { + val failed = pending.firstOrNull { it.localId == localId } ?: return + retryKey = retryKey.remember( + MessageFingerprint.of(failed.text, failed.imageUri, failed.documents), + localId, + ) + } private fun onEvent(event: WsEvent) { // 广播是发给每条已认证连接的(服务端没有按会话订阅),所以每个 VM 都会收到**所有**会话的帧。 @@ -1033,11 +1105,21 @@ class ChatViewModel( * (无歧义判定)。发送者本来就会同时收到广播帧与定向帧两条,第二次到达时待发列表已经变了, * [queueFullOutcome] 自然落到 [QueueFullOutcome.Ignore],不会回滚两次、也不会提示两遍。 * + * 重试记忆也在这里跟着两档事实走:**回滚**档(真的没发出去)记下那个幂等键,好让用户重试时 + * 复用;**收编**档(那条其实早就落库了)清掉它。记必须在 `applyQueueFull` **之前**做—— + * 那一步就把气泡摘掉了,之后再也取不到它的指纹。 + * * @param frameSessionId 帧给的会话归属;空白 = 无法判定,按既有约定**放行** */ private fun onQueueFull(frameSessionId: String?, clientMsgId: String?, messageId: String?) { if (!frameSessionId.isNullOrBlank() && frameSessionId != sessionId) return - val outcome = queueFullOutcome(_state.value.pending, clientMsgId, messageId) + val pending = _state.value.pending + val outcome = queueFullOutcome(pending, clientMsgId, messageId) + when (outcome) { + is QueueFullOutcome.Rollback -> rememberRetryKey(pending, outcome.localId) + is QueueFullOutcome.Adopt -> retryKey = retryKey.forget(outcome.localId) + is QueueFullOutcome.Ambiguous, QueueFullOutcome.Ignore -> Unit + } _state.update { it.applyQueueFull(outcome) } // 收编时本地可能还不认识那条消息(弱网 / 重连丢了 message_persisted)——补拉一页, // 别只让气泡凭空消失。这是与幂等重复完全同一件事,所以共用 [ensureMessageKnown]。 diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprintTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprintTest.kt new file mode 100644 index 0000000..d80f450 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/MessageFingerprintTest.kt @@ -0,0 +1,151 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * 内容指纹:什么算「同一条消息」,什么算「用户改过」。 + * + * 这组断言直接决定**幂等键复不复用**,而两个方向的误判都很糟: + * - 把「同一条」判成「改了」⇒ 重试铸新键,服务端把它当第二条落库(用户看到重复); + * - 把「改了」判成「同一条」⇒ 复用旧键,服务端判重,**用户这次改的内容一个字都发不出去**。 + * + * 所以下面把正文(含空白)、图片、附件三根轴上的每一处差别逐条钉住。 + */ +class MessageFingerprintTest { + + private fun doc(uri: String, name: String = "笔记.md", size: Long = 1024L) = + DraftDocument(uri = uri, displayName = name, sizeBytes = size) + + // —— 相等:一字不差就是同一条 —— + + @Test + fun `identical text image and attachments make the same fingerprint`() { + val documents = listOf(doc("content://doc/a"), doc("content://doc/b")) + + assertEquals( + MessageFingerprint.of("你好", "content://img/1", documents), + MessageFingerprint.of("你好", "content://img/1", documents), + ) + } + + @Test + fun `a pure image message is fingerprinted by its image alone`() { + assertEquals( + MessageFingerprint.of("", "content://img/1", emptyList()), + MessageFingerprint.of("", "content://img/1", emptyList()), + ) + } + + // —— 正文 —— + + @Test + fun `a single whitespace character inside the text is a different message`() { + // 这是真实可达的差别:发送路径只 trim 首尾,中间的空白一个字都不会动。 + assertNotEquals( + MessageFingerprint.of("你好 世界", null, emptyList()), + MessageFingerprint.of("你好 世界", null, emptyList()), + ) + } + + @Test + fun `the fingerprint itself normalises nothing`() { + // 发送路径(`send`)会先 trim,所以首尾空白在线上到不了这里;指纹仍然一个字都不归一—— + // 「归一」正是那种会把用户真实打算发的两条消息并成一条的操作,而这个类要防的就是它。 + assertNotEquals( + MessageFingerprint.of("你好", null, emptyList()), + MessageFingerprint.of("你好 ", null, emptyList()), + ) + } + + @Test + fun `an edit anywhere in the text is a different message`() { + assertNotEquals( + MessageFingerprint.of("今晚吃什么", null, emptyList()), + MessageFingerprint.of("今晚吃什么?", null, emptyList()), + ) + } + + // —— 图片 —— + + @Test + fun `swapping the image is a different message`() { + assertNotEquals( + MessageFingerprint.of("看这张", "content://img/1", emptyList()), + MessageFingerprint.of("看这张", "content://img/2", emptyList()), + ) + } + + @Test + fun `adding or dropping the image is a different message`() { + assertNotEquals( + MessageFingerprint.of("看这张", "content://img/1", emptyList()), + MessageFingerprint.of("看这张", null, emptyList()), + ) + } + + // —— 附件 —— + + @Test + fun `adding one attachment is a different message`() { + assertNotEquals( + MessageFingerprint.of("看这几份", null, listOf(doc("content://doc/a"))), + MessageFingerprint.of("看这几份", null, listOf(doc("content://doc/a"), doc("content://doc/b"))), + ) + } + + @Test + fun `dropping one attachment is a different message`() { + assertNotEquals( + MessageFingerprint.of("看这几份", null, listOf(doc("content://doc/a"), doc("content://doc/b"))), + MessageFingerprint.of("看这几份", null, listOf(doc("content://doc/a"))), + ) + } + + @Test + fun `swapping two attachments is a different message`() { + // 顺序有意义(就是用户在芯片区看到的先后),所以用列表而不是集合。 + assertNotEquals( + MessageFingerprint.of("看这几份", null, listOf(doc("content://doc/a"), doc("content://doc/b"))), + MessageFingerprint.of("看这几份", null, listOf(doc("content://doc/b"), doc("content://doc/a"))), + ) + } + + @Test + fun `swapping one attachment for another is a different message`() { + assertNotEquals( + MessageFingerprint.of("看这份", null, listOf(doc("content://doc/a"))), + MessageFingerprint.of("看这份", null, listOf(doc("content://doc/z"))), + ) + } + + // —— 元数据不算内容 —— + + @Test + fun `attachment metadata differences on the same reference keep the fingerprint equal`() { + // 有意的取舍:显示名与体积是**元数据**,同一份文件在两次 `ContentResolver` 查询之间完全 + // 可能一次报得出、一次报不出(`UNKNOWN_SIZE`)。那不影响「这是同一份文件」,决定内容身份的 + // 是本地引用本身。 + assertEquals( + MessageFingerprint.of("看这份", null, listOf(doc("content://doc/a", name = "笔记.md", size = 1024L))), + MessageFingerprint.of( + "看这份", + null, + listOf(doc("content://doc/a", name = "", size = DraftDocument.UNKNOWN_SIZE)), + ), + ) + } + + @Test + fun `the fingerprint snapshots the attachments instead of aliasing the input list`() { + // 纯函数的语义:指纹算完就与入参脱钩。回滚时算过一次的指纹,不该因为输入栏那几条芯片 + // 后来被改动而跟着变——那会让「同一条消息」的重试判断凭空失效。 + val documents = mutableListOf(doc("content://doc/a")) + val fingerprint = MessageFingerprint.of("看这份", null, documents) + + documents += doc("content://doc/b") + + assertEquals(MessageFingerprint.of("看这份", null, listOf(doc("content://doc/a"))), fingerprint) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt new file mode 100644 index 0000000..663f402 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/RetryKeyMemoryTest.kt @@ -0,0 +1,198 @@ +package top.yeij.cyrene.domain.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.ui.chat.ChatUiState +import top.yeij.cyrene.ui.chat.PendingMessage +import top.yeij.cyrene.ui.chat.QueueFullOutcome +import top.yeij.cyrene.ui.chat.applyQueueFull + +/** + * 重试记忆:幂等键什么时候**复用**、什么时候**重铸**。 + * + * 这是本模块要修的那条「文档与实现不符」的判据所在:`PendingMessage` 的 KDoc 一直写着「重试复用 + * 同一个 id」,而实现每次发送都铸新键 —— 于是「服务端其实已经收下、客户端判成失败」(队列满 / + * 超时 / 回执丢失)之后,用户的手动重试会落成**第二条**消息。 + * + * 这里把三条纪律逐条钉死,它们**缺一不可**: + * 1. 指纹相等 ⇒ 复用;不相等 ⇒ 重铸(否则用户的编辑会被服务端判重丢掉); + * 2. 记下的键**用掉就没了**(否则他后来又打了一遍同样的话,会被无声吞掉); + * 3. 只有这一条能清的清得掉、别的条清不动(否则两条同时在飞时会互相踩)。 + */ +class RetryKeyMemoryTest { + + /** 一个会数数、且与记忆里的键长得不一样的 `mint`:用它同时断言「铸没铸」和「铸成了什么」。 */ + private class KeyMinter { + var count = 0 + private set + + fun next(): String = "new-${++count}" + } + + private val text = MessageFingerprint.of("你好", null, emptyList()) + + private val edited = MessageFingerprint.of("你好!", null, emptyList()) + + private fun doc(uri: String) = DraftDocument(uri = uri, displayName = "a.txt", sizeBytes = 1L) + + // —— 铸新键:没有记录时 —— + + @Test + fun `an empty memory mints a brand new key`() { + val minter = KeyMinter() + + val decision = RetryKeyMemory().keyFor(text, minter::next) + + assertEquals("new-1", decision.localId) + assertEquals(1, minter.count) + assertNull(decision.memory.slot) + } + + // —— 复用:指纹一字不差 —— + + @Test + fun `the same content sent again reuses the key that failed`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + val minter = KeyMinter() + + val decision = remembered.keyFor(text, minter::next) + + // 键保住了,服务端才可能用 `chat_duplicate` / `{duplicate:true}` 认出这是同一条。 + assertEquals("local-K1", decision.localId) + assertEquals(0, minter.count) + } + + @Test + fun `using the remembered key consumes it`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + + val afterUse = remembered.keyFor(text) { "local-K1" }.memory + assertNull(afterUse.slot) + + // 用户后来**又打了一遍**同样的话(上一条早就发出去了):那是真的第二条消息, + // 必须铸新键 —— 记录要是留在这儿,这句话会被服务端当重复吞掉,屏幕上什么都不出现。 + val minter = KeyMinter() + assertEquals("new-1", afterUse.keyFor(text, minter::next).localId) + assertEquals(1, minter.count) + } + + // —— 重铸:用户改过 —— + + @Test + fun `editing the text mints a new key and drops the record`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + val minter = KeyMinter() + + val decision = remembered.keyFor(edited, minter::next) + + // 改过字就是**另一条**消息:复用旧键会让服务端判重,用户这次的编辑一个字都发不出去。 + assertEquals("new-1", decision.localId) + assertEquals(1, minter.count) + assertNull(decision.memory.slot) + } + + @Test + fun `an edit followed by re-sending the original text mints yet another key`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + + // 「改了再发」这一步把记录消费掉了 ⇒ 之后发回原来那句话也拿不回旧键。 + val afterEdit = remembered.keyFor(edited) { "new-1" }.memory + + val minter = KeyMinter() + assertEquals("new-1", afterEdit.keyFor(text, minter::next).localId) + assertEquals(1, minter.count) + } + + @Test + fun `only a fingerprint that matches exactly reuses the key`() { + val original = MessageFingerprint.of("你好", "content://img/1", listOf(doc("content://doc/a"))) + val remembered = RetryKeyMemory().remember(original, "local-K1") + + // 三根轴各差一点点,都必须走「铸新键」这一支:正文多一个空格 / 换了图 / 少一个附件 / + // 多一个附件。任何一条判成「同一条」,用户这次的改动就发不出去。 + val variants = listOf( + MessageFingerprint.of("你好 ", "content://img/1", listOf(doc("content://doc/a"))), + MessageFingerprint.of("你好", "content://img/2", listOf(doc("content://doc/a"))), + MessageFingerprint.of("你好", "content://img/1", emptyList()), + MessageFingerprint.of( + "你好", + "content://img/1", + listOf(doc("content://doc/a"), doc("content://doc/b")), + ), + ) + variants.forEachIndexed { index, variant -> + val minter = KeyMinter() + assertEquals("new-1", remembered.keyFor(variant, minter::next).localId) + assertEquals(1, minter.count) + } + + // 对照组:原封不动的那条仍然复用(上面每一条的差别都真的来自内容)。 + assertEquals("local-K1", remembered.keyFor(original) { "new-x" }.localId) + } + + // —— 清:只在无歧义时清得动 —— + + @Test + fun `forget clears exactly the message that settled`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + + assertNull(remembered.forget("local-K1").slot) + } + + @Test + fun `forget leaves another message's key alone`() { + val remembered = RetryKeyMemory().remember(text, "local-K1") + + // 两条同时在飞时,一条被受理(这里键是 local-other)不该把另一条刚回滚的记忆抹掉: + // 抹掉它,那条的重试就退回「铸新键」,正是本模块要消灭的重复。 + assertSame(remembered, remembered.forget("local-other")) + assertEquals(RetryKeySlot(text, "local-K1"), remembered.forget("local-other").slot) + } + + @Test + fun `remembering a later failure replaces the earlier one`() { + // 单槽:记忆只对「用户下一次按发送」有意义,而那一次只可能是最近一次失败的那条。 + val memory = RetryKeyMemory() + .remember(text, "local-K1") + .remember(edited, "local-K2") + + assertEquals(RetryKeySlot(edited, "local-K2"), memory.slot) + // 更早那条已经不在输入栏里了,它的键也就跟着作废。 + assertEquals("new-1", memory.keyFor(text) { "new-1" }.localId) + } + + // —— 与气泡对齐的衔接:重试真的撞上原来那条 —— + + @Test + fun `a retried message the server already has collapses into the single persisted bubble`() { + // 全链路:失败时记住键 ⇒ 用户按发送复用它 ⇒ 服务端按同一个键判重 ⇒ 客户端只摘待发气泡, + // 由**原来那条**落库消息顶上(不再冒出第二个气泡,也不再另造一套对齐逻辑)。 + val remembered = RetryKeyMemory().remember(text, "local-K1") + val retryKey = remembered.keyFor(text) { "new-1" }.localId + assertEquals("local-K1", retryKey) + + val persisted = HistoryMessageDto( + id = "m-42", + sessionId = "web", + role = "user", + content = "你好", + clientMsgId = retryKey, + createdAt = 1L, + ) + val state = ChatUiState( + messages = listOf(persisted), + pending = listOf(PendingMessage(localId = retryKey, text = "你好")), + ) + + // REST `{duplicate:true}` / WS `chat_duplicate` / 队列满收编在客户端是同一件事(见 + // `alignDuplicate` 与 `QueueFullOutcome.Adopt`),所以这里用后者这条纯函数钉住状态迁移。 + val after = state.applyQueueFull(QueueFullOutcome.Adopt(retryKey, persisted.id)) + + assertTrue(after.pending.isEmpty()) + assertEquals(listOf("m-42"), after.messages.map { it.id }) + } +}