修实根:`message_persisted` / `channel_persisted` 与握手 `history` 回放
此前只在 `toHistoryMessage` 之后直接扇出,于是消息 DTO 的语音面在这三条
面上恒缺席,客户端只能等下一次 REST 拉取才知道那是一条语音。
- `history_voice.go`:把语音补齐抽成存储无关的唯一收口点 `attachVoiceTo`
(Server 与 hub 共用),并加 `voiceColumnsOf` 把任意历史视图窄化成读面,
未实现该视图时为真 nil 接口(字段缺席,不 panic、不 500)。
- `broadcast.go`:新增 `persistedWire` —— thinking 抹除 + 可选 voice,
`broadcastPersisted` 改用它;两类帧因此天然同口径。
- `ws_history.go`:握手回放同样补一次批量读列(最多 50 个 id 一条 SQL)。
成本实测:一帧 = 1 次 `IN (?)` 批量读列(1 个 id);回放一页 = 1 次
(去重后的全部 id);存储未提供该视图时 0 次。`history.Message` 没有
voice 列字段(`messageColumns` 也不含),故无法「零查询直接填」——补它
要改 internal/runtime/history 与 INSERT 写路径,超出本切片范围。
纯增量:voice 是可选键,非语音消息整键缺席(不是 null、不是 {});
帧壳、帧类型、REST DTO 形状一律未动。
测试(广播_voice_test.go):帧序列化输出上断言 ①②③④,并逐字比对
「帧/回放里的 voice」与「REST 读面同一行」;摘掉任一挂载即打红。
56 lines
2.7 KiB
Go
56 lines
2.7 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
)
|
||
|
||
// wsReplayLimit 是回放给新接入客户端的最近消息条数。刻意取小值,使重连代价
|
||
// 低廉;完整活跃会话历史由前端走 REST 拉取(design/16 §4.1),回放只是让
|
||
// 一条全新连接立刻拿到上下文。
|
||
const wsReplayLimit = 50
|
||
|
||
// replayHistory 把 sessionID 最近的若干条消息作为 ws "history" 事件发给
|
||
// 单条刚注册的连接。
|
||
//
|
||
// 回放携带主会话的消息正文,因此仅限 owner/admin(design/11 §7.2 A,主人于
|
||
// 2026-10-03 决策)。ownerAdminClaims 与主动推送用的是同一判据,所以
|
||
// 「谁可以在未被要求的情况下拿到主人的对话」只在一处回答,而不散落到各调用点。
|
||
// viewer 连接完成握手时完全不做回放:它的只读窗口是 REST 监控面
|
||
// (design/11 §7.1「语义澄清」),不是推送。
|
||
//
|
||
// 门放在这里而不是 ws_hub_conn.go 里的 register/回放调用点,是刻意的:
|
||
// 这是唯一发出回放的函数,此后任何调用者都无法绕开该检查。日后主人想让某个
|
||
// 角色也收到回放,必须先把它登记到 design/11 §7.2,与上行帧的做法完全一致
|
||
// (见 ws_route.go)。
|
||
//
|
||
// 回放是尽力而为:store 为 nil 或读取出错都静默跳过。
|
||
// 回放不含 thinking(design/16 §6),因此该帧即使对 owner/admin
|
||
// 也不会泄漏思维链。每条消息**可能**带可选 `voice`(契约 v0.67 §4.4),
|
||
// 且与 REST 读面同一份投影 —— 它的缺席语义同样是「这条消息不是语音」
|
||
// (非语音消息整键缺席),因此回放对老客户端仍是同一份形状。
|
||
func (h *hub) replayHistory(c *wsConn, sessionID string) {
|
||
if !ownerAdminClaims(c.claims) {
|
||
return
|
||
}
|
||
if h.history == nil {
|
||
return
|
||
}
|
||
msgs, err := h.history.Recent(context.Background(), sessionID, wsReplayLimit)
|
||
if err != nil {
|
||
h.log.Warn("history replay", "err", err, "session", sessionID)
|
||
return
|
||
}
|
||
out := make([]historyMessage, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
wire := toHistoryMessage(m)
|
||
wire.Thinking = ""
|
||
out = append(out, wire)
|
||
}
|
||
// 语音面(契约 v0.67 §4.4):回放与 REST 读面共用**同一个**收口点与同一份投影,
|
||
// 因此刚连上的客户端就能按 id 认出语音气泡,不必等下一次 REST 拉取。一次批量读列
|
||
// (最多 wsReplayLimit 条 id 一条 SQL),不是逐条查;存储没提供该视图 / 读失败时
|
||
// 只让该字段缺席,回放本身照常发出(尽力而为,与上面的 Recent 同一纪律)。
|
||
attachVoiceTo(context.Background(), h.log, voiceColumnsOf(h.history), out)
|
||
c.enqueueJSON(wsEvent{Type: "history", SessionID: sessionID, Data: map[string]any{"messages": out}})
|
||
}
|