Files
Cyrene_v2/internal/services/gateway/history_voice.go
T
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

159 lines
8.0 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
// 本文件是消息 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
}