feat(gateway): M44 实时帧与握手回放挂上 voice(纯增量)

修实根:`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 读面同一行」;摘掉任一挂载即打红。
This commit is contained in:
AskaEth committed 2026-10-26 18:46:06 +08:00
1 parent af61b8ccfe
commit 5717735da9
4 files changed
+507 -19

No files matched your search

+8 -1
View File
@@ -25,7 +25,9 @@ const wsReplayLimit = 50
//
// 回放是尽力而为: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
@@ -44,5 +46,10 @@ func (h *hub) replayHistory(c *wsConn, sessionID string) {
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}})
}