Files
Cyrene_v2/internal/services/gateway/proactive_im_notify_test.go
T
AskaEth f4adf0b526
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(notify): IM 路由的主动消息也在非前台补浏览器通知(契约 v0.74)
主人裁定 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 的帧前端整帧丢弃)
2026-10-05 19:47:54 +08:00

147 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}