fix(chat): 手动重试同一条内容时复用原幂等键,改过的内容照旧铸新键

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 收口的端到端那条)。
This commit is contained in:
AskaEth committed 2026-10-04 20:27:00 +08:00
1 parent 76d49b73d1
commit af11a94614
5 files changed
+551 -11

No files matched your search

@@ -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)
}
}
@@ -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 })
}
}