安卓 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 键)。
顺带把网关侧两处描述旧时序的注释改成新事实。
159 lines
8.0 KiB
Go
159 lines
8.0 KiB
Go
package gateway
|
||
|
||
// 本文件是消息 DTO 的**语音面**(契约 v0.67 §4.4 的 `voice` 对象;design/24 §10.4)。
|
||
//
|
||
// 它回答客户端那个问题:「这条消息来自一条语音吗?转写成不成?有没有可回放的副本?」
|
||
// 三个答案都在 `chat_messages.voice` 列里(JSON 单对象,迁移 0020),本文件只做
|
||
// **读侧投影**:解码 → 映射为 DTO → 拼出唯一的派生字段 `url`。它不写列、不改正文,
|
||
// 也不判身份(来源是渠道事实,与谁在说话无关 —— 身份永远由 actor 元数据决定)。
|
||
//
|
||
// 四条硬口径:
|
||
//
|
||
// - **🎤 判据 = `voice` 键存在**(契约明写):非语音消息整键缺席(`omitempty` 的
|
||
// 指针 nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
|
||
// - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现
|
||
// (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现
|
||
// 「`url` 指着一个不存在的 `name`」。
|
||
// - **`url` 只由服务端拼**(`voice.MediaPath`):客户端绝不自己拼路径。
|
||
// - **坏列可见地缺席**:列被外部改坏(非法 JSON / 闭集破坏)时**不静默当「不是语音」**
|
||
// —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice`
|
||
// 没有「读不出」这一态,因此缺席是唯一诚实的表达(与文档列回一条 `unreadable`
|
||
// 不同:文档有那个态,语音没有)。
|
||
//
|
||
// 覆盖范围(**如实**):本文件的补齐点挂在**所有**会用 DTO 的出口上 —— 会话消息页、
|
||
// 渠道消息日志两个端点、编辑消息的响应与其 `message_edited` 帧、`message_persisted` /
|
||
// `channel_persisted` 实时帧(`broadcast.go` 的 persistedWire)与握手 `history` 回放
|
||
// (`ws_history.go`)。三条收口点共用本文件这一份投影,因此「同一条消息在 REST 读面与
|
||
// 实时帧 / 回放里的 `voice` 对象」逐字相同,不会两处各算一份。
|
||
//
|
||
// ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的,
|
||
// 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的
|
||
// **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge
|
||
// 的 persistBatchUsers / persistVoiceFirstNotify),本文件因此无需再区别对待实时帧与读面。
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"strings"
|
||
|
||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||
)
|
||
|
||
// historyVoice 是消息 DTO 里的 `voice` 对象(契约 v0.67 §4.4 逐字形状):
|
||
// `{source,status,name?,mime?,size?,durationMs?,url?}`。
|
||
//
|
||
// `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余
|
||
// 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。
|
||
type historyVoice struct {
|
||
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)。
|
||
Source string `json:"source"`
|
||
// Status ∈ transcribed(正文即转写文本)| empty(转写成功但没识别出文字)|
|
||
// failed(没拿到转写文本,正文是一行中文诚实提示)。闭集三态。
|
||
Status string `json:"status"`
|
||
// Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。
|
||
Name string `json:"name,omitempty"`
|
||
// MIME 是保存时成对裁决所得的下发类型(`audio/ogg` 等)。
|
||
MIME string `json:"mime,omitempty"`
|
||
// Size 是副本字节数。
|
||
Size int64 `json:"size,omitempty"`
|
||
// DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。
|
||
DurationMs int64 `json:"durationMs,omitempty"`
|
||
// URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。
|
||
URL string `json:"url,omitempty"`
|
||
}
|
||
|
||
// voiceColumnReader 是历史存储的**可选读侧视图**:按消息 id 批量取 voice 列原文
|
||
// (一次 `IN (...)`,禁 N+1)。生产实现是 `*history.Store`(`VoiceColumns`)。
|
||
//
|
||
// 它刻意是**可选**接口(与 PersonaMoodStore / voiceProviderReporter 同一手法):
|
||
// 未实现它的存储(测试替身、只做基本读写的实现)不会因为缺少这一面而失败 ——
|
||
// 这条路径上「没有这一面」的诚实结果是**字段缺席**,而不是 500。
|
||
type voiceColumnReader interface {
|
||
VoiceColumns(ctx context.Context, ids []string) (map[string]string, error)
|
||
}
|
||
|
||
// attachVoice 给一页消息补上各自的语音面(就地写进 DTO 切片)。
|
||
func (s *Server) attachVoice(ctx context.Context, msgs []historyMessage) {
|
||
attachVoiceTo(ctx, s.log, voiceColumnsOf(s.history), msgs)
|
||
}
|
||
|
||
// attachVoiceTo 是语音面的**唯一**收口点:REST 读面、实时帧与握手回放都调它,因此三处
|
||
// 对同一条消息给出**同一个**投影(`historyVoiceOf`),不存在「REST 带、帧不带」的分叉。
|
||
//
|
||
// 它只查页内**非空**的消息 id,且**一次批量**(页内 id 集合交给存储侧一条 `IN (...)`
|
||
// 查完)—— 成本与「一页 / 一帧」同量级,不是逐条查。
|
||
//
|
||
// reader 为 nil(存储未提供这一面:未接线 / 测试替身)或 log 为 nil 时都只让这一面整体
|
||
// 缺席:这条路径上「问不到」不是错误,帧与响应照常发出。
|
||
//
|
||
// 失败处理与工具回看面同一纪律:查不到就只让这一面缺席并记 WARN —— 消息本身已经
|
||
// 读到了,让一个可选列的故障把整页历史变成 500 是反的。
|
||
func attachVoiceTo(ctx context.Context, log *slog.Logger, reader voiceColumnReader, msgs []historyMessage) {
|
||
ids := pageMessageIDs(msgs)
|
||
if len(ids) == 0 || reader == nil || log == nil {
|
||
return
|
||
}
|
||
cols, err := reader.VoiceColumns(ctx, ids)
|
||
if err != nil {
|
||
log.Warn("history voice unavailable", "err", err, "messages", len(ids))
|
||
return
|
||
}
|
||
for i := range msgs {
|
||
raw, found := cols[msgs[i].ID]
|
||
if !found {
|
||
continue // 该行取不到(已删除):缺席
|
||
}
|
||
ref, err := voiceref.Decode(raw)
|
||
if err != nil {
|
||
// 坏列**可见地**缺席:绝不静默当成「不是语音」。日志只带消息 id 与错误,
|
||
// 绝不带列内容或正文。
|
||
log.Warn("history voice column malformed", "err", err, "message", msgs[i].ID)
|
||
continue
|
||
}
|
||
msgs[i].Voice = historyVoiceOf(ref)
|
||
}
|
||
}
|
||
|
||
// voiceColumnsOf 把任意历史存储视图窄化成语音列读面;没实现时返回**真 nil 接口**
|
||
// (`_, ok` 形式让未实现的存储得到 nil,而不是一个非 nil 的 nil 指针 —— 后者会在
|
||
// 调用点炸成 panic)。Server 与 hub 持有的 history 字段同源,因此两处同一结果。
|
||
func voiceColumnsOf(store any) voiceColumnReader {
|
||
reader, ok := store.(voiceColumnReader)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return reader
|
||
}
|
||
|
||
// pageMessageIDs 收集页内非空且**互不相同**的消息 id(保持首次出现顺序)。去重让
|
||
// 同一页的「提问 + 回答」或编辑场景里的两份 DTO 共用同一次查询。
|
||
func pageMessageIDs(msgs []historyMessage) []string {
|
||
seen := make(map[string]bool, len(msgs))
|
||
out := make([]string, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
id := strings.TrimSpace(m.ID)
|
||
if id == "" || seen[id] {
|
||
continue
|
||
}
|
||
seen[id] = true
|
||
out = append(out, id)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// historyVoiceOf 把一条已解码的语音事实投影成 DTO;不是语音消息(闭集外/零值)时
|
||
// 返回 nil,使 `voice` 键整体缺席(契约:非语音消息与引入语音之前逐字相同)。
|
||
func historyVoiceOf(ref voiceref.Ref) *historyVoice {
|
||
if !ref.IsVoice() {
|
||
return nil
|
||
}
|
||
out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)}
|
||
if ref.HasCopy() {
|
||
// 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。
|
||
out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size
|
||
out.URL = voiceref.MediaPath(ref.Name)
|
||
}
|
||
out.DurationMs = ref.DurationMs
|
||
return out
|
||
}
|