Files
Cyrene_v2/internal/services/gateway/history_voice.go
T
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
  GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
  POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
  两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
  (200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
  + documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动

缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

135 lines
6.5 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_persisted` /
// `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)——
// 前者还额外需要「随 INSERT 写入」的写路径(设计 §10.5 的首选口径),见交付报告。
import (
"context"
"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 切片)。它只查页内**非空**
// 的消息 id,且**一次批量**(页内 id 集合交给存储侧一条 SQL 查完)。
//
// 失败处理与工具回看面同一纪律:查不到就只让这一面缺席并记 WARN —— 消息本身已经
// 读到了,让一个可选列的故障把整页历史变成 500 是反的。
func (s *Server) attachVoice(ctx context.Context, msgs []historyMessage) {
ids := pageMessageIDs(msgs)
if len(ids) == 0 {
return
}
reader, ok := s.history.(voiceColumnReader)
if !ok {
// 存储未提供这一面(未接线 / 测试替身):整体缺席,不是错误。
return
}
cols, err := reader.VoiceColumns(ctx, ids)
if err != nil {
s.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 与错误,
// 绝不带列内容或正文。
s.log.Warn("history voice column malformed", "err", err, "message", msgs[i].ID)
continue
}
msgs[i].Voice = historyVoiceOf(ref)
}
}
// 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
}