fix(gateway): Web 活跃客户端主动推送走 turnreply 规整(先规整再落库,推帧与落库逐字相同)

M19 复审抓到的真缺口:internal/services/gateway/proactive.go 的
SendProactive -> persistProactiveToMain 这条 Web 活跃客户端主动推送路由
完全绕过 turnreply.NormalizeParagraphs —— 同一条主动消息走 IM 是规整过的、
走 Web 实时推还是原样(半包 <p> 在客户端按「存在完整 <p> 就不再按空行回退」
的契约下整条塌成一坨)。

- 接线:SendProactive 在落库之前先 msg.Content = turnreply.NormalizeParagraphs(msg.Content),
  落库与 proactive_message 帧取同一个字符串 ⇒ 两端逐字相同;不在 gateway 另写实现。
- 新增 proactive_normalize_test.go:修前红(web 原样 / im 已规整),修后断言
  ① 落库是规整后文本 ② 推帧 == 落库行 ③ 两条路由同源同文本 ④ <br>/<action> 语义不动。
- 既有两条断言按新口径更新(纯散文现在也被包成一个 <p>,与其它三处一致)。
This commit is contained in:
AskaEth committed 2026-10-24 19:40:30 +08:00
1 parent 4ed5925296
commit 1d3965aa80
5 files changed
+173 -7

No files matched your search

@@ -0,0 +1,146 @@
package gateway
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/turnreply"
)
// 本文件锁定 M19 复审抓到的真缺口(切片 M25):**Web 活跃客户端**这条主动推送
// 路由过去把 msg.Content 原样落库、原样推帧,**完全绕过**
// `turnreply.NormalizeParagraphs` —— 于是同一条主动消息走 IM 是规整过的
// (channelbridge/proactive.go 出口),走 Web 实时推还是原样:半包 `<p>`
// (只有末段被包)在客户端「只要存在完整 <p> 块就不再按空行回退切分」的契约下
// 整条塌成一坨、而不是多个气泡。
//
// 三条断言缺一不可:
// - 落库行吃的是**规整后**的文本(顺序:先规整、再落库 —— 否则库里还是脏的);
// - 推帧文本与落库行**逐字相同**(只断言「各自含换行」会漏掉两侧分叉);
// - 规整确实发生了(原文 ≠ 期望值),否则断言会退化成同义反复。
// proactiveHalfWrapped 是缺陷形状的输入:第一段裸文本用空行分隔,末段已包 `<p>`。
// 它同时带**字面换行**(`\n`)与半包段落 —— 正是主人 2026-10-24 报的那两类。
const proactiveHalfWrapped = "今晚早点睡。\n\n<p>明天还有早会。</p>"
// proactiveHalfWrappedFixed 是同一段文本经共享规整器后的规范形态:每段一个 <p>,
// 段间空行原样保留(客户端因此拆出两个气泡)。
const proactiveHalfWrappedFixed = "<p>今晚早点睡。</p>\n\n<p>明天还有早会。</p>"
// TestHub_SendProactive_NormalizesParagraphsAndFrameMatchesRow 是 M25 的主断言。
func TestHub_SendProactive_NormalizesParagraphsAndFrameMatchesRow(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
owner := newOwnerConn(hb, 8)
hb.register(owner)
if _, err := hb.SendProactive(context.Background(), proactiveStoreMsg(proactiveHalfWrapped)); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条", len(rows))
}
row := rows[0]
if row.Content == proactiveHalfWrapped {
t.Fatalf("落库文本仍与原文逐字相同 ⇒ 规整没生效(本路由绕过了 NormalizeParagraphs)")
}
if row.Content != proactiveHalfWrappedFixed {
t.Fatalf("落库文本 =\n%q\nwant\n%q\n(Web 活跃路由必须与其它三处同源:先规整再落库)",
row.Content, proactiveHalfWrappedFixed)
}
frame := readFakeFrame(t, owner)
if frame.Type != wsProactiveMessage {
t.Fatalf("frame type = %q, want %q", frame.Type, wsProactiveMessage)
}
data, ok := frame.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", frame.Data)
}
content, _ := data["content"].(string)
if content != row.Content {
t.Fatalf("推帧文本与落库行必须逐字相同:\nframe=%q\nrow =%q", content, row.Content)
}
}
// TestHub_SendProactive_KeepsExistingRenderSemantics 钉住「不许改语义」:规整只做
// 段落包裹,`<br>` 仍是气泡内分隔(由客户端按既有契约渲染)、`<action>` 仍随所在
// 段落进**同一个** `<p>`(Web 灰斜体、IM 出口剥除,两条都不许被拆散)。
func TestHub_SendProactive_KeepsExistingRenderSemantics(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
owner := newOwnerConn(hb, 8)
hb.register(owner)
raw := "先点点头。<br>再说一句。\n\n<action>把杯子放下</action>"
if _, err := hb.SendProactive(context.Background(), proactiveStoreMsg(raw)); err != nil {
t.Fatalf("SendProactive err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 1 {
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条", len(rows))
}
row := rows[0]
want := turnreply.NormalizeParagraphs(raw)
if row.Content != want {
t.Fatalf("落库文本 =\n%q\nwant\n%q", row.Content, want)
}
if !strings.Contains(row.Content, "<br>") {
t.Fatalf("文本里的 <br> 被改掉了:%q(它仍是气泡内分隔,不许被吞)", row.Content)
}
if !strings.Contains(row.Content, "<p><action>把杯子放下</action></p>") {
t.Fatalf("action 段必须随所在段落进同一个 <p>:%q", row.Content)
}
frame := readFakeFrame(t, owner)
data, ok := frame.Data.(map[string]any)
if !ok {
t.Fatalf("frame data type = %T, want map[string]any", frame.Data)
}
if content, _ := data["content"].(string); content != row.Content {
t.Fatalf("推帧文本与落库行必须逐字相同:\nframe=%q\nrow =%q", content, row.Content)
}
}
// TestProactiveRoutes_NormalizeSameText 是「同源」的可执行证据:同一条主动消息分别
// 走 Web 活跃路由与 IM 桥路由,两侧落库文本必须逐字相同 —— 两条路由各写一份规整
// 实现就迟早漂移,本仓已因此栽过多次。
func TestProactiveRoutes_NormalizeSameText(t *testing.T) {
t.Parallel()
store := newHistoryStoreForGateway(t)
hb := newTestHub(nil, nil)
hb.history = store
hb.register(newOwnerConn(hb, 8))
bridge, _ := newProactiveTestBridge(t, store, nil)
msg := proactiveStoreMsg(proactiveHalfWrapped)
if _, err := hb.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("active route err = %v", err)
}
if rows := mainSessionRows(t, store); len(rows) != 1 {
t.Fatalf("活跃路由之后主会话有 %d 条,want 1", len(rows))
}
if err := bridge.SendProactive(context.Background(), msg); err != nil {
t.Fatalf("im route err = %v", err)
}
rows := mainSessionRows(t, store)
if len(rows) != 2 {
t.Fatalf("两条路由各跑一次后主会话有 %d 条,want 2", len(rows))
}
// rows 最新优先:第一条是 IM 路由刚写的,第二条是活跃路由写的。
if rows[0].Content != rows[1].Content {
t.Fatalf("两条路由的落库文本必须逐字相同(同一份规整):\nim =%q\nweb =%q",
rows[0].Content, rows[1].Content)
}
if rows[0].Content != proactiveHalfWrappedFixed {
t.Fatalf("两条路由的文本 =\n%q\nwant\n%q", rows[0].Content, proactiveHalfWrappedFixed)
}
}