主人裁定 C 的必然推论:她该不该提醒我,与后端走了哪条路由无关。P0 如实报回的缺口 = 通知只挂在 proactive_message 帧上,而 auto 提示在有已连接 IM 渠道时优先走 IM ⇒ 最常发生的路径恰恰不弹。 判据(先查现状、不猜):行字段**区分不出**主动推送与普通回复(已用真库证据排除 platform/adapterId/turnId 反推)⇒ 新增**结构化的正向声明**:帧顶 data.proactive === true,只由主动推送那条通道打(gateway.BroadcastProactivePersisted);普通路径永远传 false。 - 后端:channelbridge 抽出唯一写入口径 persistMain + 新增 proactive_notify 接缝;gateway 抽出 broadcastPersisted(msg, proactive) 两入口共用 - 前端:handlers/proactive-notice.ts 两条帧路共用同一套提醒运行时(有界去重 + 降级说明 + DOM/Notification 取值);message.ts 读到判据才走提醒 - 对照断言(必须有):普通 IM 回复/主人提问/显式 proactive:false ⇒ **0 通知、3 气泡**(不误伤) - 变异实测:去掉非主动保护 / 跨两路去重失效 / 前台也弹 / IM 不发判据帧 / 普通广播带判据 ⇒ 5 条全部打红 - 契约 v0.74(纯增量:message_persisted 新增可选 proactive:true) - 边界(如实):判据**不落库**(翻历史不弹提醒,也不得从行字段反推);落库失败时不发判据帧(无权威 id 的帧前端整帧丢弃)
147 lines
6.3 KiB
Go
147 lines
6.3 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// 本文件锁定切片 P0b:**IM 路由的主动消息也要在「非前台」补一发浏览器通知**。
|
||
//
|
||
// 背景(P0 如实报回的边界):`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM
|
||
// 路由过去只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上,于是
|
||
// **实际最可能发生的路径恰恰不弹**。主人裁定「她说话该不该提醒我,与走哪条路由无关」。
|
||
//
|
||
// 判据是**结构化**的:主动推送在落库之后追加一条**帧顶带 `proactive:true`** 的
|
||
// `message_persisted`(gateway.BroadcastProactivePersisted)。普通消息(Web 回合 / IM
|
||
// 回复 / IM 镜像)走的仍是 BroadcastPersisted,那条路**永不**放该键 —— 因此普通消息
|
||
// 不可能被误判成主动消息。下面分别钉住两端:IM 路由真的发得出这条带判据的帧,而普通
|
||
// 广播路径永远发不出它。
|
||
|
||
// dataFlag 报告帧顶是否带 proactive 判据(以及它的值)。
|
||
func dataFlag(t *testing.T, ev wsEvent) (any, bool) {
|
||
t.Helper()
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
|
||
}
|
||
v, has := data["proactive"]
|
||
return v, has
|
||
}
|
||
|
||
// frameMessageID 取帧内消息 DTO 的 id。
|
||
func frameMessageID(t *testing.T, ev wsEvent) string {
|
||
t.Helper()
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
|
||
}
|
||
msg, ok := data["message"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame message type = %T, want map[string]any", data["message"])
|
||
}
|
||
id, _ := msg["id"].(string)
|
||
return id
|
||
}
|
||
|
||
// TestChannelBridge_SendProactive_EmitsProactiveFlaggedFrame 是切片 P0b 的端到端证据:
|
||
// IM 路由的主动消息**既**照旧广播普通镜像帧(P0 起的行为,任何客户端都能渲染),**又**
|
||
// 追加一条帧顶带 `proactive:true`、并携带落库行权威 id 的帧 —— 前端据此走与
|
||
// `proactive_message` 同一套提醒逻辑(非前台补一发通知,两帧同 id ⇒ 去重后一个气泡、
|
||
// 一次提醒)。它同时守住 P0 的红线:主会话里仍**恰好一条**(绝无双份)。
|
||
func TestChannelBridge_SendProactive_EmitsProactiveFlaggedFrame(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
store := newHistoryStoreForGateway(t)
|
||
env.srv.hub.history = store
|
||
owner := newOwnerConn(env.srv.hub, 8)
|
||
env.srv.hub.register(owner)
|
||
// claims 为 nil 的裸连接是 fail-closed 的,与普通持久化帧同一条角色门。
|
||
bare := newFakeConn(env.srv.hub, 8)
|
||
env.srv.hub.register(bare)
|
||
|
||
ownerProfile := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "telegram", AccountID: "100"}}}
|
||
bridge, err := channelbridge.New(channelbridge.Options{
|
||
Log: gwTestLogger(),
|
||
Loop: stubBridgeChatter{},
|
||
Sessions: stubBridgeSessions{},
|
||
Owner: ownerProfile,
|
||
History: store,
|
||
OnPersisted: env.srv.BroadcastPersisted,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("new bridge: %v", err)
|
||
}
|
||
bridge.SetOnProactivePersisted(env.srv.BroadcastProactivePersisted)
|
||
bridge.Add(&fakeIMAdapter{platform: "telegram"})
|
||
|
||
if err := bridge.SendProactive(context.Background(), proactiveStoreMsg("记得喝水哦")); err != nil {
|
||
t.Fatalf("SendProactive err = %v", err)
|
||
}
|
||
rows := mainSessionRows(t, store)
|
||
if len(rows) != 1 {
|
||
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条(>1 = 双份)", len(rows))
|
||
}
|
||
|
||
// 第一帧:普通镜像帧(P0 起的行为),**不带**判据。
|
||
first := readFakeFrame(t, owner)
|
||
if first.Type != "message_persisted" {
|
||
t.Fatalf("frame 1 type = %q, want message_persisted", first.Type)
|
||
}
|
||
if v, has := dataFlag(t, first); has {
|
||
t.Fatalf("普通镜像帧带上了 proactive=%v,want 该键缺席(判据只属于主动那条)", v)
|
||
}
|
||
|
||
// 第二帧:主动帧 —— 帧顶带判据,且消息 id 就是落库行的权威 id。
|
||
second := readFakeFrame(t, owner)
|
||
if second.Type != "message_persisted" {
|
||
t.Fatalf("frame 2 type = %q, want message_persisted(帧类型不变,只多一个结构化键)", second.Type)
|
||
}
|
||
if v, has := dataFlag(t, second); !has || v != true {
|
||
t.Fatalf("主动帧的 proactive 判据 = %v(present=%v),want true", v, has)
|
||
}
|
||
if got := frameMessageID(t, second); got != rows[0].ID {
|
||
t.Fatalf("主动帧的消息 id = %q, want 落库行的权威 id %q(前端去重与对齐都靠它)", got, rows[0].ID)
|
||
}
|
||
if err := connHasNoFrame(owner); err != nil {
|
||
t.Fatalf("owner 连接应恰好收到两帧(普通 + 主动),多出来的:%v", err)
|
||
}
|
||
if err := connHasNoFrame(bare); err != nil {
|
||
t.Fatalf("主动帧只投递给 owner/admin,裸连接不该收到:%v", err)
|
||
}
|
||
}
|
||
|
||
// TestServer_BroadcastPersisted_NeverCarriesProactiveFlag 是「不误伤」那一条的后端对照:
|
||
// 普通广播路径(Web 回合 / IM 回复 / IM 镜像全走它)**永远**不在帧顶放 proactive 判据,
|
||
// 因此客户端不可能把普通消息误判成主动消息去弹通知。同一行改走主动那条入口时,判据才
|
||
// 出现 —— 两条入口的差别只有这一个键。
|
||
func TestServer_BroadcastPersisted_NeverCarriesProactiveFlag(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
owner := newOwnerConn(env.srv.hub, 8)
|
||
env.srv.hub.register(owner)
|
||
|
||
row := history.Message{
|
||
ID: "row-plain", SessionID: history.MainSessionID, Role: history.RoleAssistant,
|
||
Content: "普通 IM 回复", Platform: "telegram", AdapterID: "telegram:100", TurnID: "turn-abc", CreatedAt: 1_760_000_000_000,
|
||
}
|
||
env.srv.BroadcastPersisted(row)
|
||
plain := readFakeFrame(t, owner)
|
||
if plain.Type != "message_persisted" {
|
||
t.Fatalf("frame type = %q, want message_persisted", plain.Type)
|
||
}
|
||
if v, has := dataFlag(t, plain); has {
|
||
t.Fatalf("普通消息的帧顶带上了 proactive=%v,want 该键缺席(否则会被误判成主动消息)", v)
|
||
}
|
||
|
||
env.srv.BroadcastProactivePersisted(row)
|
||
flagged := readFakeFrame(t, owner)
|
||
if v, has := dataFlag(t, flagged); !has || v != true {
|
||
t.Fatalf("主动入口的 proactive 判据 = %v(present=%v),want true", v, has)
|
||
}
|
||
if got := frameMessageID(t, flagged); got != "row-plain" {
|
||
t.Fatalf("主动帧的消息 id = %q, want row-plain", got)
|
||
}
|
||
}
|