feat(chat): 静默告知改为按事故复位,队列排空即清零

W43 的静默规则只在「补发真的成功一次」时把 queuedResendFailures 清零。若一段坏网络的
队列以回滚或被收编收场(一次都没成功发出去),这一格就永远挂着 ⇒ 之后连接恢复、再断
一次、用户又发消息时,那句解释再也说不出口。那不是减少噪音,是把解释永久关掉(主人
2026-10-06 裁决:哪个体验好用哪个)。

改成按事故复位:一个「事故」= 队列从空到非空、再回到空,结束就复位,不管它是怎么结束
的——发出去了、回滚了(内容退回输入栏)、还是被收编了(气泡换成权威消息)。清零点因此
有两个:补发成功一次(原样保留),以及队列排空(既有钩子 forgetQueuedNoticeIfDrained,
回滚那条路也走这里,它是队列清空的唯一两个入口之一)。

这样不会在长期断网时刷屏:阈值仍是 3;「一批从空到非空只说一次」仍由 queuedNoticeTold
管着;纯网络故障下消息会堆积在队列里 ⇒ 队列不清空 ⇒ 不算新事故、计数不复位 ⇒ 不会每条
消息都弹一次横幅。而事故一旦结束,下一次断线又能重新解释。

判据提为纯函数 queuedResendFailuresAfterDelivery(VM 与测试共用,跟
queuedSendNoticeAllowed 同一个写法),单测钉住:队列排空即复位(哪怕这一批一条都没成功
发出去)、队列还有货则计数保留、补发成功照旧清零、一个事故里失败 0/1/2 次仍允许告知而
到阈值静默、下一个事故重新告知。静默不动队列:那条既有用例(判定那一刻队列仍在、下一步
仍是发这条)一字未改。

只动告知判据与它的记账。队列与出队逻辑、sendRouteFor / 三档路由 / W38 分享落地 / W45
直接试 REST / W46 的 CyreneRoute 日志全部未碰。

验证:gradlew.bat clean assembleDebug 与 gradlew.bat testDebugUnitTest,
failure / error 均为 0。
This commit is contained in:
AskaEth committed 2026-10-05 20:25:00 +08:00
1 parent 798c6ee907
commit ce50bd2c24
3 files changed
+150 -30

No files matched your search

@@ -664,8 +664,11 @@ data class ChatUiState(
*
* **不刷屏**:一轮「队列从空到空」只说一次,连续几条离线消息不会每按一次发送就弹一遍
* (记账在 [ChatViewModel.queueForConnection],不是按时间窗判的)。W43 起在那之上再加一道
* **静默闸**(主人 2026-10-06 定案):同一批**反复补发失败**到 [QUEUED_RESEND_SILENCE_THRESHOLD]
* 次之后,这一块牌子不再挂新的告知,静静地等——判据是纯函数 [queuedSendNoticeAllowed]。
* **静默闸**(主人 2026-10-06 定案):**同一个事故内**反复补发失败到
* [QUEUED_RESEND_SILENCE_THRESHOLD] 次之后,这一块牌子不再挂新的告知,静静地等——判据是纯函数
* [queuedSendNoticeAllowed]。静默按**事故**复位(队列排空即结束),所以它只压掉同一个事故里的
* 重复播报,下一个事故照旧会解释一次;而纯网络故障下消息会堆积在队列里、队列不清空 ⇒ 不算新
* 事故 ⇒ 不会每条都弹一次(见 [ChatViewModel.forgetQueuedNoticeIfDrained])。
*/
val sendQueueNotice: String? = null,
val connection: WsState = WsState.Idle,
@@ -875,7 +878,9 @@ class ChatViewModel(
* 重新知道这件事)。
*
* 它与队列本身同生共死:这个位只由 [queueForConnection] 置真、由 [forgetQueuedNoticeIfDrained]
* 置假,两个调用点都在这一处,没有第三个地方碰它。
* 置假,两个调用点都在这一处,没有第三个地方碰它。判据与它同一个口径的还有
* [queuedResendFailures](同一个事故内反复失败就静默),那一格**同样**在队列排空时复位
* (主人 2026-10-06 裁决:按事故复位)。
*/
private var queuedNoticeTold = false
@@ -893,11 +898,23 @@ class ChatViewModel(
* 切走会话就随 VM 一起没了。**不做全局单例**:「这条会话的这批消息连着几次没发出去」与别的
* 会话毫无关系,共用一个计数器只会让一个会话的反复失败把另一个会话的告知也掐掉。
*
* ## 计数与清零
* ## 计数与清零(主人 2026-10-06 裁决:**按事故复位**)
* - `+1`:补发**没能上路、内容仍留在队列里**([drainQueuedSends])——只有这种会反复发生。
* 被服务端裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不计数。
* - 归零:**补发真的成功一次**([dropQueuedSend] 的 `sent = true`)。「静默之后连接恢复并补发
* 成功 ⇒ 下一次离线又能重新告知」,靠的就是这一句。
* - 归零:判据是纯函数 [queuedResendFailuresAfterDelivery],两个清零点——**补发真的成功一次**
* ([dropQueuedSend] 的 `sent = true`),以及**队列排空**([forgetQueuedNoticeIfDrained],
* 回滚 / 被收编收场的那种排空同样算)。
*
* W43 原本只有前者,于是「队列以回滚或被收编结束」时这一格永远不会归零,静默就一直挂着;主人
* 2026-10-06 裁决改成按事故复位:**一个「事故」= 队列从空到非空、再回到空**,结束就复位,不管
* 它是怎么结束的。
*
* ## 这样不会在长期断网时刷屏(验收体验)
* - **一次事故内**:连发几条,只有**一次**横幅(重试本身不产生横幅,[queuedNoticeTold] 管着
* 「一批只说一次」);
* - **事故结束后**:队列排空 ⇒ 计数复位 ⇒ 下一次事故**重新解释**,不会永久闭嘴;
* - **纯网络故障下消息会堆积在队列里**(队列不清空 ⇒ 不算新事故,计数也不复位)⇒ 长期断网
* **不会**每条消息都弹一次横幅。
*
* 它只回答「还说不说」,**从不**影响队列内容本身(判据见 [queuedSendNoticeAllowed])。
*/
@@ -1608,8 +1625,8 @@ class ChatViewModel(
when (outcome) {
// 成功路径**一个字都没变**:帧交给 socket 之后这里什么都不做(补发那条顺手出队)。
// 补发出去的那条顺手把「反复失败」的记账归零([queuedResendFailures]):静默之后连接
// 恢复并补发成功 ⇒ 下一次离线又能重新告知,不会永久闭嘴。
// 补发出去的那条顺手把「反复失败」的记账归零([queuedResendFailures]):补发成功本就是
// 一个清零点(另一个是队列排空,见 [forgetQueuedNoticeIfDrained])。
WsSendOutcome.Dispatched -> if (fromQueue) dropQueuedSend(localId, sent = true)
// 连接就绪却仍然发不出去:只剩「socket 对象已空 / 已被 cancel」或「toJson() / send()
@@ -1803,8 +1820,9 @@ class ChatViewModel(
* 不报错误横幅**:`error` 的语义是「出错了」,而这条消息一次都没上过路。
* 2. **该说就说一句**([queuedSendNoticeFor] 定哪句、走 [showQueuedSendNotice] 这条柔和通道):
* 说与不说的**唯一判据是纯函数 [queuedSendNoticeAllowed]**——**一轮「队列从空到空」只说一次**
* ([queuedNoticeTold]),且同一批**反复补发失败到 [QUEUED_RESEND_SILENCE_THRESHOLD] 次之后
* 连这一句也停下**([queuedResendFailures],W43 主人 2026-10-06 定案:多次重试失败就静默)。
* ([queuedNoticeTold]),且**同一个事故内**反复补发失败到 [QUEUED_RESEND_SILENCE_THRESHOLD]
* 次之后连这一句也停下([queuedResendFailures],W43 主人 2026-10-06 定案:多次重试失败就
* 静默;事故结束=队列排空就复位,见 [forgetQueuedNoticeIfDrained])。
* 静默只是不说:内容照旧留在队列里,连接一好照旧补发。
* 3. **竞态兜底**:投递那一刻读到的「未就绪」与现在可能已经不是同一帧(连接恰在前面几百微秒里
* 就绪,而那时这条还没进队列、[onConnectionChanged] 那次补发正好扑空)⇒ 入队之后再看一眼
@@ -1822,8 +1840,8 @@ class ChatViewModel(
*/
private fun queueForConnection(localId: String, parallel: Boolean) {
_state.update { it.withQueuedSend(localId, parallel) }
// 静默那一档**不置 [queuedNoticeTold]**:这一轮确实一个字都没说,往后有了一次成功的补发
// ([queuedResendFailures] 归零)之后,用户本来就该重新知道这件事。
// 静默那一档**不置 [queuedNoticeTold]**:这一轮确实一个字都没说,往后这一批了结
// (队列排空 ⇒ [queuedResendFailures] 复位)之后,用户本来就该重新知道这件事。
if (queuedSendNoticeAllowed(queuedNoticeTold, queuedResendFailures)) {
queuedNoticeTold = true
queuedSendNoticeFor(socket?.state?.value ?: WsState.Idle)?.let(::showQueuedSendNotice)
@@ -1902,8 +1920,9 @@ class ChatViewModel(
// 一直挂着,刷第二遍只是噪声。
if (_state.value.isAwaitingConnection(step.localId)) {
// 这是「反复重试」里的一次(补发又没上路、内容仍留在队列里)⇒ 记一次。
// 到了 [QUEUED_RESEND_SILENCE_THRESHOLD] 之后本批不再多说一句
// (判据 [queuedSendNoticeAllowed]),但**队列一个字节都没动**:连接一好
// 到了 [QUEUED_RESEND_SILENCE_THRESHOLD] 之后**本事故内**不再多说一句
// (判据 [queuedSendNoticeAllowed];事故结束=队列排空即复位,见
// [forgetQueuedNoticeIfDrained]),但**队列一个字节都没动**:连接一好
// 照旧按序补发,静默从来不是丢弃。
queuedResendFailures++
return
@@ -1916,23 +1935,39 @@ class ChatViewModel(
/**
* 出队(**唯一入口**):补发成功了、这条以别的方式了结了、或它补发不了时调用。
*
* 队列空了就把「这一轮还没连上」的告知记账归零([forgetQueuedNoticeIfDrained]):下一轮
* 断线时用户确实需要重新知道一次这件事。
* 队列空了就把两格告知记账一起归零([forgetQueuedNoticeIfDrained]):下一轮断线时用户确实
* 需要重新知道一次这件事(这一轮说过了没有、这一批失败过几次,都随事故结束作废)。
*
* @param sent true = 这一次是**真的补发出去了**(服务端收下 / 帧交给 socket)。它顺手把
* 「同一批反复失败」的记账归零([queuedResendFailures]):静默之后连接恢复并补发成功 ⇒
* 下一次离线又**能**重新告知。回滚 / 气泡被别路收编那两种出队**不归零**——它们不是
* 「补发成功」,这条(批)并没有因为一次成功的补发而了结。
* 下一次离线又**能**重新告知;成功那一刻队列可能还没空(后面还排着几条),所以这一个清零点
* 不能只靠「排空」判,见 [queuedResendFailuresAfterDelivery]。
*/
private fun dropQueuedSend(localId: String, sent: Boolean = false) {
if (sent) queuedResendFailures = 0
_state.update { it.withoutQueuedSend(localId) }
forgetQueuedNoticeIfDrained()
forgetQueuedNoticeIfDrained(sent)
}
/** 队列空了 ⇒ 这一轮的告知记账归零(见 [queuedNoticeTold] 的「一轮 = 队列从空到空」)。 */
private fun forgetQueuedNoticeIfDrained() {
if (_state.value.queuedForConnection.isEmpty()) queuedNoticeTold = false
/**
* 队列空了 ⇒ 这一轮(这一个**事故**)结束,两格记账一起复位:这一轮的告知记账
* ([queuedNoticeTold],见它那里「一轮 = 队列从空到空」)与「同一批反复失败」的记账
* ([queuedResendFailures],判据 [queuedResendFailuresAfterDelivery])。
*
* **按事故复位**(主人 2026-10-06 裁决):只要队列排空,事故就结束了,不管它是怎么结束的
* ——发出去了、回滚了(内容退回输入栏)、还是被收编了(气泡换成权威消息)。W43 的旧口径只在
* 补发成功时清零,于是以回滚 / 被收编收场的坏网络时段会把静默**永久挂着**:之后连接恢复、
* 再断一次,用户永远得不到解释。那不是少点噪音,是把解释关掉。
*
* 复位只碰这两个计数,**队列内容一个字节都不动**(调用方本来就已经把它摘掉了)。
*
* @param sent 这一次出队是不是由一次**成功的补发**引起的(成功那一刻队列可能还没空
* ——比如后面还排着几条);它单独就能让计数归零,见 [queuedResendFailuresAfterDelivery]。
*/
private fun forgetQueuedNoticeIfDrained(sent: Boolean = false) {
val drained = _state.value.queuedForConnection.isEmpty()
queuedResendFailures =
queuedResendFailuresAfterDelivery(queuedResendFailures, sent = sent, drained = drained)
if (drained) queuedNoticeTold = false
}
/**
@@ -2332,6 +2367,8 @@ class ChatViewModel(
// 顺带出队:回滚的意思是「这条了结、内容退回输入栏」,补发队列里若还留着它,
// 连接恢复时就会对着一条**已经退回给用户**的消息再发一次(用户可能已经改过、又发了一遍)。
// 队列通常本来就是空的,[ChatUiState.withoutQueuedSend] 那时原样返回同一个实例。
// 这一步也走 [forgetQueuedNoticeIfDrained]:如果队列因此排空,这一个「事故」就结束了
// (尽管它一条都没成功发出去过)⇒ 计数复位,下一次断线又能重新解释。
_state.update { it.rollbackPending(localId, reason, queueFullNotice).withoutQueuedSend(localId) }
forgetQueuedNoticeIfDrained()
}
@@ -239,8 +239,28 @@ const val QUEUED_RESEND_SILENCE_THRESHOLD = 3
* 那种失败计进来(见 `ChatViewModel.drainQueuedSends`):它是唯一会**反复发生**的一种;已被服务端
* 裁决 / 已回滚的那两类是一次性了结(内容退回输入栏并给了明确原因),不在这条线上。
*
* 计数归零由调用方在做:**补发真的成功一次**就清零(`ChatViewModel.dropQueuedSend` 的
* `sent = true`)⇒「静默之后连接恢复、补发成功」之后,下一次离线**又能重新告知**,不会永久闭嘴。
* 计数归零由调用方在做,判据是 [queuedResendFailuresAfterDelivery](**按事故复位**,主人
* 2026-10-06 裁决):一次成功的补发清零,队列**排空**(这一批了结)同样清零。
*/
fun queuedSendNoticeAllowed(toldThisRound: Boolean, failedResends: Int): Boolean =
!toldThisRound && failedResends < QUEUED_RESEND_SILENCE_THRESHOLD
/**
* 一次出队 / 回滚之后,「这一批已经失败过几次」该记成多少(**纯函数**,VM 与测试共用)。
*
* ## 为什么按事故复位,而不是只在成功时清零(主人 2026-10-06 裁决)
* 「事故」= 一次「队列从空到非空、再回到空」,与 [queuedSendNoticeAllowed] 的 `toldThisRound`
* 同一个口径。**事故只要结束就复位,不要求它是靠一次成功的补发结束的**:W43 的旧口径只在补发
* 成功时清零,于是当一段坏网络的队列**以回滚 / 被收编收场**(一次都没发出去)时,静默计数会一直
* 挂在那里 ⇒ 之后连接恢复、再断一次、用户又发消息时,那句话永远说不出口。那不是「少点噪音」,
* 是把解释**永久关掉**。
*
* 三种结束方式都是了结,都该复位:
* - [sent] = 这一次补发真的上路了(`WsSendOutcome.Dispatched`)⇒ `0`;
* - [drained] = 队列已经空了 ⇒ `0`(回滚把内容退回输入栏、被收编把气泡换成权威消息,都是了结);
* - 其余(队列还有货、这一次又没成功)⇒ 原值:**同一个事故里**的静默照旧生效,不刷屏的规矩不变。
*
* 它只算「还说不说」,**从不**碰队列内容:静默从来不是丢弃。
*/
fun queuedResendFailuresAfterDelivery(failures: Int, sent: Boolean, drained: Boolean): Int =
if (sent || drained) 0 else failures
@@ -6,19 +6,23 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 「同一条(或同一批)反复补发失败就**静默**」的判据([queuedSendNoticeAllowed])——
* 主人 2026-10-06 定案。
* 「同一条(或同一批)反复补发失败就**静默**」的判据([queuedSendNoticeAllowed]),以及静默计数
* 在什么时候复位([queuedResendFailuresAfterDelivery])——主人 2026-10-06 定案。
*
* ## 背景
* W40/W41 之后,未连接时普通发送会进补发队列([ChatUiState.queuedForConnection]),并给**一句**
* 柔和的告知([SEND_QUEUED_NOTICE]:还没连上、已排队、连上后自动发出);连接恢复后自动补发。
* 主人新加的规矩是**不要一直刷屏**:同一条(或同一批)反复重试都失败时,**停止再弹告知**,静静地等。
* 2026-10-06 主人又裁决了一件跟它配套的事:静默按**事故**复位——一个「事故」= 队列从空到非空、
* 再回到空;结束就复位,**不要求**它是以一次成功的补发结束的。
*
* ## 本测试钉住四件事(就是那条规则的四个边)
* ## 本测试钉住五件事(那条规则的五个边)
* 1. 第一次失败 ⇒ 告知;
* 2. 到阈值之前(1、2 次)⇒ 仍然允许告知;
* 3. 到阈值([QUEUED_RESEND_SILENCE_THRESHOLD] = 3)之后 ⇒ **静默**;
* 4. **静默之后连接恢复并补发成功 ⇒ 计数清零**,下一次离线又能重新告知(不能永久闭嘴)。
* 4. **补发成功 ⇒ 计数清零**(W43 的旧清零点,一字不改);
* 5. **队列排空 ⇒ 计数清零**,哪怕这一次(乃至这一整批)**一次都没成功发出过**
* —— 静默按事故复位,不会永久闭嘴。
*
* 另外钉住两条性质:阈值不许取 1 或 2(那等于根本没告知);静默**只是不说**——队列内容与补发的
* 下一步一个字节都不受影响(静默 ≠ 丢弃)。
@@ -58,12 +62,71 @@ class QueuedSendSilenceTest {
fun `a successful resend clears the count so the next offline tells again`() {
// 补发成功时 `ChatViewModel.dropQueuedSend(sent = true)` 把计数归零 ⇒ 又回到「第一次失败」。
assertFalse(queuedSendNoticeAllowed(toldThisRound = false, failedResends = QUEUED_RESEND_SILENCE_THRESHOLD))
val afterSuccess = 0
val afterSuccess = queuedResendFailuresAfterDelivery(
QUEUED_RESEND_SILENCE_THRESHOLD,
sent = true,
drained = false,
)
assertEquals("补发成功这一个清零点照旧:一批还没发完也清零", 0, afterSuccess)
assertTrue(queuedSendNoticeAllowed(toldThisRound = false, failedResends = afterSuccess))
// 再连着失败又要静默一次:静默不是一次性的,也不是永久闭嘴。
assertFalse(queuedSendNoticeAllowed(toldThisRound = false, failedResends = QUEUED_RESEND_SILENCE_THRESHOLD))
}
// —— 一之二、事故结束(队列排空)就复位(W47 主人 2026-10-06 裁决) ——
@Test
fun `a drained batch resets the count even when nothing ever got sent`() {
// W43 的旧口径只在「补发成功」时清零。主人改成按事故复位:一个「事故」= 队列从空到非空、
// 再回到空,**它怎么结束都算**——包括一段坏网络以回滚 / 被收编收场、一次都没发出去。
// 旧口径下这种事故会把静默永远挂着:之后连接恢复、再断一次,用户永远得不到解释。
val failures = QUEUED_RESEND_SILENCE_THRESHOLD
assertFalse(queuedSendNoticeAllowed(toldThisRound = false, failedResends = failures))
val afterDrain = queuedResendFailuresAfterDelivery(failures, sent = false, drained = true)
assertEquals("队列排空就复位,不管这一批有没有成功发出过一条", 0, afterDrain)
assertTrue(
"下一个事故要能重新解释,不能永久闭嘴",
queuedSendNoticeAllowed(toldThisRound = false, failedResends = afterDrain),
)
}
@Test
fun `the count is kept while the queue is still non empty`() {
// 事故还没结束(队列里还有货、这一次又没成功)⇒ 计数原样留着,**同一个事故内**照旧静默。
// 这正是长期断网不刷屏的原因:消息堆在队列里 ⇒ 队列不清空 ⇒ 不算新事故 ⇒ 不重新告知。
val failures = QUEUED_RESEND_SILENCE_THRESHOLD
val kept = queuedResendFailuresAfterDelivery(failures, sent = false, drained = false)
assertEquals(failures, kept)
assertFalse(queuedSendNoticeAllowed(toldThisRound = false, failedResends = kept))
}
@Test
fun `one incident tells once at most and the next one starts over`() {
// 走一遍完整的事故:进队列那一刻(失败 0 次)说一次,之后每次补发又没上路都 ++,
// 到阈值就静默;队列排空(事故结束)后,下一个事故的第一次失败又能说出来。
var failures = 0
assertTrue("事故里的第一句必须说", queuedSendNoticeAllowed(toldThisRound = false, failedResends = failures))
while (failures < QUEUED_RESEND_SILENCE_THRESHOLD) {
assertTrue(
"第 ${failures + 1} 次失败之前仍然允许告知",
queuedSendNoticeAllowed(toldThisRound = false, failedResends = failures),
)
failures++
}
assertFalse("到阈值就停", queuedSendNoticeAllowed(toldThisRound = false, failedResends = failures))
failures = queuedResendFailuresAfterDelivery(failures, sent = false, drained = true)
assertEquals(0, failures)
assertTrue(
"下一个事故重新解释",
queuedSendNoticeAllowed(toldThisRound = false, failedResends = failures),
)
}
// —— 二、阈值本身与既有的「一轮只说一次」 ——
@Test