Files
Cyrene_v2/internal/services/gateway/ws_history.go
T
AskaEth 5717735da9 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 读面同一行」;摘掉任一挂载即打红。
2026-10-26 18:46:06 +08:00

56 lines
2.7 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"
)
// 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}})
}