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

This commit is contained in:
AskaEth committed 2026-10-04 20:28:40 +08:00
commit 8b907036db
5 files changed
+551 -11

No files matched your search

@@ -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<String> = emptyList(),
) {
companion object {
/** 从发送那一刻的三件套算指纹(正文 / 图片 / 待发附件)。 */
fun of(text: String, imageUri: String?, documents: List<DraftDocument>): MessageFingerprint =
MessageFingerprint(text = text, imageUri = imageUri, documentUris = documents.map { it.uri })
}
}
@@ -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())
}
}
@@ -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<PendingMessage>, 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]。
@@ -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 })
}
}