Files
Cyrene_v2/internal/services/gateway/history_voice.go
T
AskaEth 28afa11aeb feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)
- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
2026-10-27 22:08:38 +08:00

175 lines
9.3 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`。客户端绝不从正文里认 `[语音]` 字样。
// ⚠️ 出站合成降级(source=tts、status=failed + fallbackReason)同样**有键**,但
// 没有可播副本(name/mime/size/url 四键一起缺席)⇒ 客户端按 `status` 分支:只挂一个
// 「本来想用语音」的小标、**不**画播放器,也不打「来自语音」的 🎤(正文是文本,
// 不是转写文本)。契约文档对 🎤 判据的措辞由塔统一收口。
// - **副本四键同生共死**:`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),出站语音(outbound_voice.go)走的是
// 同一条顺序纪律。本文件因此无需再区别对待实时帧与读面。
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(渠道平台自带转写)|
// tts(我们自己合成的出站语音)。
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"`
// FallbackReason 只在**出站合成降级**时出现(source=tts、status=failed):说明这条
// 消息为什么没能变成语音(能力未接线 / 预算超时 / 合成为空)。正文此时照发文本,
// 客户端据 status=failed 只挂一个小标,**绝不**把它当成可播气泡。
FallbackReason string `json:"fallbackReason,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)}
// 降级原因只在失败态下发(`Ref.Validate` 已保证它不会挂在其它状态上):客户端据此
// 显示「这条本来想用语音」的小标,正文仍是原文本。
if ref.Status == voiceref.StatusFailed {
out.FallbackReason = ref.FallbackReason
}
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
}