Files
AskaEth b955a27479 fix(channelbridge): M44 入站语音先写 voice 列再广播 ⇒ 首帧就带 voice
安卓 2026-10-26 回执关心的场景:新来一条语音消息时,**第一条**帧里有没有
`voice`。此前没有 —— `persistBatchUsers` 先 `persistToMain`/`persistIsolated`
(内部同步 notify 把帧发出去),之后才 `persistMessageVoice` 写列,帧发出那
一刻列还是空串。网关侧的帧投影(上一 commit)读的就是这一列,因此首帧必然
缺 voice,客户端要等重连或下一次 REST 拉取。

修法(A 路的唯一可行形态,理由见下):
- `persistBatchUsers` 的通知改成 `persistVoiceFirstNotify(ref)`:
  **INSERT → 写 voice 列 → notify**。语句数不变,只把 notify 从 INSERT 内部
  挪到最后;非语音消息(ref 零值)时该包装与 `notifyPersisted` 逐字等价。
- `persist.go` 抽出 `mainRow` / `isolatedRow`(字段清单只写一次)与
  `persistIsolatedWith`(可注入通知的变体),`persistToMain`/
  `persistIsolated` 的对外行为逐字不变。

为什么不走 B(给 `history.Message` 加 voice 随 INSERT 写):`persistMessageVoice`
是按 id 的 UPDATE,行还不存在时必然 `ErrMessageNotFound` ⇒ 写列**不可能**真正
挪到 INSERT 之前;可行的最小形态就是上面那次换序,无需动 domain 类型、列清单
与 7 条读语句。B 若要做,属于 design/24 §10.5 的独立切片(那时还应删掉这里的
两段式,避免同一事实两处写)。

整链验收(`inbound_voice_gateway_e2e_test.go`,真桥接器 + 真存储 + 真网关 WS):
- 主人私聊语音:第一帧就是 `message_persisted`,带 `voice`,且与
  `GET /sessions/{id}/messages` 同一行**逐字相等**;随后的非语音消息帧整键缺席。
- 群聊语音(被点名 ⇒ 走完整回合):第一帧 `channel_persisted` 带 `voice`,
  且与渠道日志读面同一行逐字相等。
- 变异实测(把顺序还原成「先广播、后写列」):两条用例都 FAIL,输出即报障症状
  (帧里 content 是转写文本,却没有 voice 键)。

顺带把网关侧两处描述旧时序的注释改成新事实。
2026-10-26 18:54:13 +08:00

132 lines
7.2 KiB
Go
Raw Permalink 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"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件归属 gateway 的下行实时推送:与 channel bridge 共用的
// 持久化消息帧,以及前端渲染的角色级状态帧(心情徽标、能力唤醒)。
// BroadcastPersisted 把一条刚刚持久化的聊天消息作为 WS 事件广播出去,
// 使同一账号的每个浏览器无需手动刷新即可追加该消息
// (design/16 §4.2)。它由 orchestrator(Web 轮次)与 channel bridge
// (IM 消息)共用,因此两条路径走同一种帧结构。事件类型按会话
// 区分:Web 主会话及其 Web 子会话广播 `message_persisted` 用于
// Web 聊天实时同步,而其他任何会话 id 都是隔离的渠道适配器会话
// (sess-xxx),广播 `channel_persisted` 供 IM 适配器日志页使用
// —— Web 聊天没有后者的处理器,因此在那里自然被忽略、永不污染
// 会话。Thinking 会被抹除,以与 WS 历史回放保持一致(design/16 §6)。
// 当 hub 接好广播时它是安全空操作。两种帧类型都携带消息内容,
// 因此 design/11 §7.1 帧门只把它们投递给 owner/admin 连接。
//
// 它**绝不**带 `proactive` 键:普通消息的帧的 `data` 里永远没有那个结构化判据,客户端因此
// 不可能把普通消息误判成主动消息去弹通知(切片 P0b 的「不误伤」那一条)。主动推送
// 那条路走 BroadcastProactivePersisted(proactive.go)。
func (s *Server) BroadcastPersisted(msg history.Message) {
s.broadcastPersisted(msg, false)
}
// broadcastPersisted 是上面两个入口共用的帧构造与扇出:proactive 决定帧的 `data` 里是否带上
// 「这是她主动说的话」的结构化判据 `proactive:true`(切片 P0b,见 proactive.go 的
// BroadcastProactivePersisted)。两条入口走**同一个**函数,因此帧类型推导、thinking
// 抹除与角色门(design/11 §7.1)逐字一致 —— 差别只有那一个键。
//
// 帧的 data 另带**可选**的 `unreadCount`(契约 v0.76):该会话此刻的绝对未读数,
// 与 GET /sessions 的 `unreadCount` 同一口径。它只在 >0 且真能算出来时出现(见
// unreadCountOf),缺席即老行为。Web 路径与 IM 路径共用本函数 ⇒ 两处口径天然一致,
// 不存在「Web 帧带、IM 帧不带」的分叉。帧内的 `message` 对象另带**可选**的 `voice`
// (契约 v0.67 §4.4;见 persistedWire):非语音消息整键缺席,因此老客户端逐字不变。
func (s *Server) broadcastPersisted(msg history.Message, proactive bool) {
wire := s.persistedWire(msg)
typ := "channel_persisted"
if msg.SessionID == history.MainSessionID || strings.HasPrefix(msg.SessionID, history.MainSessionID+":") {
typ = "message_persisted"
}
data := map[string]any{"message": wire}
if proactive {
data["proactive"] = true
}
// 未读数只在**确实算出来、且非零**时进帧(契约 v0.76)。缺席 = 老行为:老客户端
// 忽略未知键,新客户端落下这帧时角标已经是对的,不必再拉一次 GET /sessions。
// 不用 0 顶位:0 只能从「会话有游标」推出来,而算不出来(未接线/查询失败/
// 会话没有游标)时那个 0 就是编的(见 unreadCountOf)。
if n, ok := s.unreadCountOf(msg.SessionID); ok && n > 0 {
data["unreadCount"] = n
}
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 才认出语音气泡。
//
// 帧上的 `voice` 反映的是**本帧发出那一刻**的列值 ⇒ `voice` 列的写入必须先于 notify。
// 入站语音那条路已由 M44 倒好顺序(channelbridge 的 persistVoiceFirstNotify),
// 否则这条语音消息的第一帧会读不到 voice。
//
// 代价(实测,见交付报告):每帧**一次**批量读列(`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"
// BroadcastCapacityAwaken 把一次能力状态迁移作为 `capacity_awaken` 帧
// 扇出到 Web UI(design/14 §5)。sessionID 是发生迁移的会话,因此
// 客户端能定位到正确的会话,而不会把唤醒事件当成全局事件。data 与
// 前端 AwakenPayload {fromState, toState, tool} 对应;tool 可以为空
// (没有点名单个工具,客户端此时保留原有的工具徽标)。它不属于
// design/11 §7.1 的「状态机信号」(其 `tool` 指明的是被什么唤醒,而
// 渲染它的面板属于 owner 界面),因此帧门只把它投递给 owner/admin
// 连接。
func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) {
s.hub.broadcast(wsEvent{
Type: wsCapacityAwaken,
SessionID: sessionID,
Data: map[string]any{
"fromState": fromState,
"toState": toState,
"tool": tool,
},
})
}
// wsMoodEvent 是携带角色轮次结束后心情的 WS 事件类型(design/07 §3
// 下行 `mood`;design/01 §2 情绪状态机)。前端既有的 `mood` 处理器
// (ws.ts)读取 data.mood 并显示心情徽标。
const wsMoodEvent = "mood"
// BroadcastMood 把一次情绪变化作为 `mood` 帧扇出到 owner/admin 的 Web
// 客户端(design/07 §3)。角色心情/好感度属于私有状态,因此 design/11
// §7.1 帧门让它远离 viewer 连接。它仅在心情确实发生变化时被调用
// —— 组合根中的机制就是那道门 —— 因此徽标不会因为每轮都出现未变化
// 的值而闪烁。data 与前端 `mood` 处理器读取的内容对应({mood});
// affection/personaId 作为诚实的额外细节一同携带(affection 是该角色
// 当前好感度,personaId 是心情发生变化的角色),不渲染它们的客户端
// 会忽略之。心情是角色级而非会话级的值,因此不附加 sessionId。
func (s *Server) BroadcastMood(personaID, mood string, affection int) {
s.hub.broadcast(wsEvent{
Type: wsMoodEvent,
Data: map[string]any{
"mood": mood,
"personaId": personaID,
"affection": affection,
},
})
}