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

+23 -3
View File
@@ -1,6 +1,7 @@
package gateway
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
@@ -36,10 +37,10 @@ func (s *Server) BroadcastPersisted(msg history.Message) {
// 帧的 data 另带**可选**的 `unreadCount`(契约 v0.76):该会话此刻的绝对未读数,
// 与 GET /sessions 的 `unreadCount` 同一口径。它只在 >0 且真能算出来时出现(见
// unreadCountOf),缺席即老行为。Web 路径与 IM 路径共用本函数 ⇒ 两处口径天然一致,
// 不存在「Web 帧带、IM 帧不带」的分叉。
// 不存在「Web 帧带、IM 帧不带」的分叉。帧内的 `message` 对象另带**可选**的 `voice`
// (契约 v0.67 §4.4;见 persistedWire):非语音消息整键缺席,因此老客户端逐字不变。
func (s *Server) broadcastPersisted(msg history.Message, proactive bool) {
wire := toHistoryMessage(msg)
wire.Thinking = ""
wire := s.persistedWire(msg)
typ := "channel_persisted"
if msg.SessionID == history.MainSessionID || strings.HasPrefix(msg.SessionID, history.MainSessionID+":") {
typ = "message_persisted"
@@ -58,6 +59,25 @@ func (s *Server) broadcastPersisted(msg history.Message, proactive bool) {
s.hub.broadcast(wsEvent{Type: typ, SessionID: msg.SessionID, Data: data})
}
// persistedWire 把一条待广播的消息投影成帧内的 `message` 对象:thinking 抹除(与 WS
// 历史回放一致,design/16 §6)+ 可选的语音面(契约 v0.67 §4.4)。
//
// 语音面与 REST 读面共用**同一个**收口点(attachVoiceTo → historyVoiceOf),因此
// 「同一行消息在帧里与 GET /sessions/{id}/messages 里」是逐字相同的对象,客户端不必
// 再拉一次 REST 才认出语音气泡。
//
// 代价(实测,见交付报告):每帧**一次**批量读列(`IN (?)` 一条 SQL,页内 id 去重),
// 与本路径既有的 unreadCountOf 查询同量级;存储未提供该视图时**零**查询、字段缺席。
// 之所以要查库而不是「从 history.Message 直接填」:那个结构体没有 voice 列字段
// (`messageColumns` 也不含它),补它要改 `internal/runtime/history` 与 INSERT 写路径
// ——那是本切片的范围之外(见交付报告的跨片接缝)。
func (s *Server) persistedWire(msg history.Message) historyMessage {
one := []historyMessage{toHistoryMessage(msg)}
one[0].Thinking = ""
attachVoiceTo(context.Background(), s.log, voiceColumnsOf(s.history), one)
return one[0]
}
// wsCapacityAwaken 是能力状态迁移的 WS 事件类型(design/14 §5)。
// 其 data 字段是前端的 AwakenPayload {fromState,toState,tool}。
const wsCapacityAwaken = "capacity_awaken"