安卓 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 键)。
顺带把网关侧两处描述旧时序的注释改成新事实。
132 lines
7.2 KiB
Go
132 lines
7.2 KiB
Go
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,
|
||
},
|
||
})
|
||
}
|