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:
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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user