From 1d49d266f0fe62d3de1ed878cf685cb20aec8c39 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 13 Oct 2026 20:50:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(channel):=20message=5Fsent=20=E5=9B=9E?= =?UTF-8?q?=E5=A3=B0=E5=8D=87=20T2=20=E8=90=BD=E8=A1=8C=EF=BC=8C=E5=87=BA?= =?UTF-8?q?=E7=AB=99=E6=B6=88=E6=81=AF=20id=20=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E4=B8=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 塔转 M12 登记的缺口(塔裁定「归你」,文件都在本片 scope 内):她自己发出消息 的回声(message_sent)被当成「帧级链路遥测」一并豁免 self 升档 ⇒ 停在 T1, 只进计数、不落行,`message_id` 被丢,「她最近发了哪些消息」在库里查不到。 - events.go: 把 MessageSent 从 isFrameTelemetry 的豁免里摘出(它不是帧级遥测: 一帧 = 她真发出的一条消息,回声带的是那条出站消息的 id)。 心跳 / 生命周期的豁免**保持不变**(其登记的迁移债注释原样保留)。 - events_mapping_test.go: 两条 MessageSent 断言同步更新 (:52 自身回声命中自己 ⇒ 由 T1 改 T2;:33 无 self 信号仍走基础档 T1,标签改精确)。 - events_test.go: 新增端到端用例(真 SQLite):self 命中 ⇒ 落 1 行、tier=t2、 MessageID=out-msg-42 留在库里、不进上下文、不驱动 loop。 --- internal/services/channelbridge/events.go | 17 +++++--- .../channelbridge/events_mapping_test.go | 6 +-- .../services/channelbridge/events_test.go | 42 +++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/internal/services/channelbridge/events.go b/internal/services/channelbridge/events.go index b5abbff..56046f3 100644 --- a/internal/services/channelbridge/events.go +++ b/internal/services/channelbridge/events.go @@ -259,10 +259,16 @@ func kindTier(kind adapter.NoticeKind) events.Tier { } } -// isFrameTelemetry 报告该类别是否属于**帧级链路遥测**:心跳(~15s 一帧)、生命周期、 -// 她自己发出消息的回声。这三类的 self 命中是**恒定**的(帧帧都命中 self_id),所以 -// 按 §2.2 升档等于「每帧写一行 channel_events」—— 而 §4.3 明令「心跳 / 在线状态逐帧 -// 写库是纯浪费」(计数表存在的理由),§3.1 给这三类的档位也正是 T1。 +// isFrameTelemetry 报告该类别是否属于**帧级链路遥测**:心跳(~15s 一帧)与生命周期。 +// 这两类的 self 命中是**恒定**的(帧帧都命中 self_id),所以按 §2.2 升档等于「每帧写一行 +// channel_events」—— 而 §4.3 明令「心跳 / 在线状态逐帧写库是纯浪费」(计数表存在的理由), +// §3.1 给这两类的档位也正是 T1。 +// +// 🔴 **`message_sent`(她自己发出消息的回声)不在此列**(切 M13;原 M12 登记的缺口): +// 它**不是**帧级遥测 —— 一帧对应她真发出的一条消息,回声带的是那条出站消息的 id +// (`RefMessage`)。此前把它一并豁免,会让 self 命中**不升档** ⇒ 整条回声只进计数, +// `message_id` 被丢,「她自己最近发了哪些消息」在库里查不到(M12 的 A 层出站 id 因此 +// 断在这里)。摘出后它按 §2.2 正常 T1 → T2 逐条落行。 // // ⚠️ **由此登记的债**(design/26 §2.6「她自己账号的在线 / 离线」= T2 高价值): // 那是**状态迁移**(在线→离线 / 离线→在线),不是每一帧。判定它需要迁移信号 @@ -272,8 +278,7 @@ func kindTier(kind adapter.NoticeKind) events.Tier { // 迁移事件(T2)留待后续切片;已在切片报告「未做 / 存疑」中列明。 func isFrameTelemetry(cat adapter.NoticeCategory) bool { switch cat { - case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle, - adapter.NoticeCategoryMessageSent: + case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle: return true default: return false diff --git a/internal/services/channelbridge/events_mapping_test.go b/internal/services/channelbridge/events_mapping_test.go index d99dc52..cb909ff 100644 --- a/internal/services/channelbridge/events_mapping_test.go +++ b/internal/services/channelbridge/events_mapping_test.go @@ -30,7 +30,7 @@ func TestEventTier_两轴映射_正常(t *testing.T) { }{ {"心跳是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat}, events.TierT1}, {"生命周期是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle}, events.TierT1}, - {"自身回声是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1}, + {"自身回声(无 self 信号)是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1}, {"他人在线状态是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryClientStatus}, events.TierT1}, {"未知类型是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryUnknown}, events.TierT1}, {"解析失败是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryDecodeError}, events.TierT1}, @@ -48,8 +48,8 @@ func TestEventTier_两轴映射_正常(t *testing.T) { adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat, SelfRelated: true}, events.TierT1}, {"生命周期命中自己 ⇒ 不升档(帧级遥测)", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle, SelfRelated: true}, events.TierT1}, - {"自身回声命中自己 ⇒ 不升档(帧级遥测)", - adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT1}, + {"自身回声命中自己 ⇒ 升档 T2(出站 id 回声须落行)", + adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT2}, {"类别缺失按 Kind 兜底:join ⇒ T2", adapter.SystemNotice{Kind: adapter.NoticeJoin}, events.TierT2}, {"类别缺失按 Kind 兜底:request ⇒ T2", diff --git a/internal/services/channelbridge/events_test.go b/internal/services/channelbridge/events_test.go index 137510b..46eff5a 100644 --- a/internal/services/channelbridge/events_test.go +++ b/internal/services/channelbridge/events_test.go @@ -205,6 +205,48 @@ func TestRecordEvent_unknown不进上下文但落行_正常(t *testing.T) { } } +// TestRecordEvent_自身回声升T2落行带出站id_正常 钉住切 M13 修掉的缺口(原 M12 登记): +// 她自己发出消息的回声(message_sent)**不是帧级遥测** —— self 命中必须把它从 T1 升到 T2 +// 逐条落行,`message_id`(出站消息 id)随之留在库里;此前它被帧级豁免挡回 T1,id 被丢。 +func TestRecordEvent_自身回声升T2落行带出站id_正常(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestEventStore(t) + + ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{ + Kind: adapter.NoticeOther, + Category: adapter.NoticeCategoryMessageSent, + PlatformType: "message_sent", + Ignorable: true, + SelfRelated: true, + RefMessage: "out-msg-42", + }) + b, chat, hist, _ := eventBridgeFixture(t, ad, store) + + b.processFrom(ctx, groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("出站回声驱动了 loop:session=%q", chat.sessionID) + } + if recs := hist.records(); len(recs) != 0 { + t.Fatalf("出站回声被登记进会话历史:%+v", recs) + } + rows, err := store.List(ctx, events.Filter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(rows) != 1 { + t.Fatalf("出站回声落行数 = %d, want 1(self 命中 ⇒ T2 逐条落行)", len(rows)) + } + ev := rows[0] + if ev.Tier != events.TierT2 || ev.Category != events.Category(adapter.NoticeCategoryMessageSent) { + t.Fatalf("落行档位/类别 = (%s,%s), want (t2,message_sent)", ev.Tier, ev.Category) + } + if ev.MessageID != "out-msg-42" { + t.Fatalf("出站消息 id = %q, want out-msg-42(id 必须留在库里)", ev.MessageID) + } +} + // TestRecordEvent_未接线时入站照常_异常:`Options.EventStore == nil` 时降级为 // 「内存计数 + 首次一条 DEBUG」,**绝不**中断入站 —— 高价值事件的升级真会话行为必须 // 逐字不变(fail-visible 而非 fail-crash,§4.3)。