Files
Cyrene_v2/internal/services/channelbridge/inbound_voice_copy.go
T
AskaEth ede00d9928 feat(voice): 语音消息落库 + 转码接入(切片 0020)
- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
2026-10-06 09:04:06 +08:00

195 lines
11 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 channelbridge
// 本文件承载**入站语音副本的落盘与引用回填**(design/24 §10.2 第 4/5 步;design/25
// §7.1 第 ④ 步;切片 CODE-VOICE)。
//
// 位置纪律(两条都不能挪):
// - **闸门之后**:本函数只在消息被放行后调用 ⇒ 被黑白名单 / 最小打扰拦下的语音
// 绝不在盘上留下任何文件(与 IM 附件的既有纪律逐字相同);
// - **落库之前**:副本必须在消息行写进 `chat_messages` 之前拿到引用,这样引用与
// 摘要(来自内容寻址)落的是同一份事实。转写则相反,它已经在**最前端**用**原始
// 字节**做完了(design/10 那句「转码产物对 ASR 无用且有害」)。
//
// 三条口径:
// 1. **只落可播副本**(design/25 §9-4 的保守默认):源格式可播 ⇒ 原样落盘;不可播 ⇒
// 先转码(`voice.Prepare`)再落;转码不可用 ⇒ **不落副本、绝不给假 url**,只留一条
// 带分类的 WARN 与已定的 source/status(正文与 🎤 标记都不受影响)。
// 2. **拿不到字节就不落**:微信按既有裁决连字节都不取(design/24 §10.14-1)⇒ 这条
// 语音只有 `source:"platform"` 的 🎤 标记,没有可回放副本 —— 这是**刻意**的边界。
// 3. **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志,
// 且副本字段要么全在、要么全不在(`voice.Ref` 的「同生共死」)。
//
// 两个**可选窄视图**(与 inbound_documents_persist.go 的 documentStatusWriter 同一手法:
// 用类型断言发现,未实现就走「如实报告没做成」,绝不因此让回合失败):
// - `VoiceAudioStore`(channelbridge.go,**导出**,因此也能显式注入)= 共享存储的**音频
// 分支**(design/24 §10.9-2 的 `documents.SaveAudio`,与 `Store.Save` 同形:内容寻址 +
// 去重 + 元数据行);
// - `voiceColumnWriter` = 历史存储 `voice` 列的写入视图(design/24 §10.9-4,与既有的
// `UpdateMessageDocument` 同形)。
//
// ⚠️ 这两个视图由**别片**落地(见交付报告「跨片接缝」):在那之前,本文件的两条路都会
// 走「未接线」的诚实分支(WARN + 不写库),而**不是**假装落成了。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// voiceColumnWriter 是历史存储 `voice` 列的写入视图:把这条语音的来源事实与副本引用
// 写进刚落库的那一行(JSON 单对象,形状见 voiceref.Ref)。
type voiceColumnWriter interface {
UpdateMessageVoice(ctx context.Context, id, voice string) error
}
// ingestInboundVoiceCopy 在闸门之后把这条语音的原件(必要时先转码成可播副本)落进共享
// 存储,并把副本事实回填进结论(design/24 §10.2 第 4/5 步)。它**不改正文、不改标记**:
// 返回的 Ref 的 source/status 来自归一结论(正文是那次转写的事实),只有副本三字段
// (name/mime/size)由这里填。
//
// 任何一步失败都**只影响副本**(不落副本、不打假 url),绝不反过来影响消息本身:
// 「这条语音转写出来了但播不了」是一份完整、可理解的事实,而「把整条消息丢掉」不是。
func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.UnifiedMessage, turn voiceTurn) voiceref.Ref {
if !turn.Found {
return voiceref.Ref{}
}
log := b.voiceLogger()
if len(turn.Media.Data) == 0 {
// 没有字节就没有副本:这不是故障,是渠道侧的既定事实(微信按 design/06 §1.1
// 的裁决不取字节)。留 DEBUG(可预期),`voice` 契约里 name/mime/size/url 全缺席。
log.Debug("inbound voice copy skipped: no audio bytes",
"platform", msg.Platform, "channel", msg.ChannelID,
"format", turn.Format, "mime", turn.Media.MIMEType, "size", turn.Media.Size)
return turn.Ref
}
prepared, err := voiceref.Prepare(ctx, b.voiceTranscoder, voiceref.Prep{
Data: turn.Media.Data,
Format: turn.Format,
DurationMs: turn.DurationMs,
})
if err != nil {
// 诚实路径(design/25 §5.4):**不可播且转码不可用** ⇒ 不落副本。
// 分类进日志、原因方向进下一版的面板/契约(design/24 §10.12-9:本期不下发细分)。
log.Warn("inbound voice copy not stored: not playable",
"platform", msg.Platform, "channel", msg.ChannelID,
"format", turn.Format, "size", len(turn.Media.Data),
"category", voiceref.Category(err), "error", err)
return turn.Ref
}
store := b.inboundVoiceStore(msg)
if store == nil {
log.Warn("inbound voice copy not stored: audio store not wired",
"platform", msg.Platform, "channel", msg.ChannelID,
"format", prepared.Format, "transcoded", prepared.Transcoded)
return turn.Ref
}
ref, err := store.SaveAudio(ctx, voiceStorageName(prepared.Format), prepared.Data)
if err != nil {
log.Warn("inbound voice copy not stored: save failed",
"platform", msg.Platform, "channel", msg.ChannelID,
"format", prepared.Format, "size", len(prepared.Data), "error", err)
return turn.Ref
}
if ref.Name == "" || ref.MIME == "" || ref.Size <= 0 {
// 「同生共死」的前提:拿不到完整的副本事实就**不给副本**(给一个没有 MIME 的
// name 会让客户端拼出一个它读不懂的 voice 对象)。这是不该发生的状态(存储侧
// 成功即应给全三项),因此按 ERROR 报,绝不静默。
log.Error("inbound voice copy stored but ref is incomplete",
"platform", msg.Platform, "channel", msg.ChannelID,
"name", ref.Name, "mime", ref.MIME, "size", ref.Size)
return turn.Ref
}
turn.Ref.Name, turn.Ref.MIME, turn.Ref.Size = ref.Name, ref.MIME, ref.Size
turn.Ref.DurationMs = prepared.DurationMs
log.Info("inbound voice copy stored",
"platform", msg.Platform, "channel", msg.ChannelID,
"source_format", turn.Format, "format", prepared.Format,
"transcoded", prepared.Transcoded, "name", ref.Name, "mime", ref.MIME,
"size", ref.Size, "duration_ms", prepared.DurationMs, "deduped", ref.Deduped)
return turn.Ref
}
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列
// (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
//
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
//
// 写入使用全新的后台 context(与 persistTurnDocumentStatus 同一理由):被取消回合的
// ctx 绝不该让这次补写半途而废。
//
// ⚠️ 与设计的差异(**必须知道**):design/24 §10.5 的首选口径是「voice 列的名字并入
// **同一次**引用计数、与消息行**同一事务**」(即随 INSERT 写入 `history.Message.Voice`)。
// 本实现走的是**两段式**(落库后补写同一列),与 document 列的既有手法逐字同族
// (`UpdateMessageDocument` 也是两段式)。代价:引用计数的增减必须由**那个**写路径
// (在它自己的事务里)完成 —— 这正是本文件只依赖一个窄视图、而不自己碰 ref_count 的原因。
// 接线方二选一即可(两处都在报告里给了确切落点):① 让历史存储实现 `UpdateMessageVoice`
// (与 UpdateMessageDocument 同形);② 或按设计首选口径给 `history.Message` 加 `Voice`
// 字段并随 INSERT 写入,那时本函数应改为不再调用(避免同一事实两处写)。
func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if msg.ID == "" || !ref.IsVoice() {
return
}
raw, err := voiceref.Encode(ref)
if err != nil {
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
b.voiceLogger().Error("inbound voice ref not persisted: encode failed",
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
return
}
if raw == "" {
return
}
writer, ok := b.history.(voiceColumnWriter)
if !ok {
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
// 如实报告「没写成」,绝不假装写了。
b.voiceLogger().Warn("inbound voice ref not persisted",
"reason", "history store has no voice column path", "message", msg.ID,
"source", ref.Source, "status", ref.Status)
return
}
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
b.voiceLogger().Warn("inbound voice ref update",
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
}
}
// inboundVoiceStore 给出音频落盘面:优先用组合根**显式注入**的那个(`Options.VoiceAudioStore`),
// 否则退回「按消息构造共享文档存储,并在它实现了音频分支时直接用它」这条发现路径。
//
// 两条路都取不到时返回 nil —— 调用方据此走「没落成」的诚实路径,而不是假装落成了。
// 发现路径复用附件那一套构造(inboundDocumentStore):目录 / 上限 / 元数据写入面来自
// 组合根注入的配置,上传者身份按消息给出 —— 因此语音副本在 `documents` 元数据里同样
// 记着「是谁发来的」(与附件同一口径,身份只来自渠道元数据)。
func (b *Bridge) inboundVoiceStore(msg adapter.UnifiedMessage) VoiceAudioStore {
if b.voiceAudioStore != nil {
return b.voiceAudioStore
}
store := b.inboundDocumentStore(msg)
if store == nil {
return nil
}
audio, ok := any(store).(VoiceAudioStore)
if !ok {
return nil
}
return audio
}
// voiceStorageName 给出交给共享存储的**提议名**。存储侧只取它的扩展名决定「按哪种格式
// 裁决与下发」,主名一律由内容寻址算出(sha256[:32])—— 因此原始文件名永不构成存储身份
// (与附件同一口径:名字来自服务端,不来自渠道)。用我们自己拼的名字(而不是渠道给的
// 文件名)还有一个理由:转码产物的格式**与源文件名无关**(`amr → wav`),拿原名会把
// `.amr` 扩展名带进一份 WAV 字节里,成对裁决必然失败。
func voiceStorageName(format string) string {
if format == "" {
return "voice"
}
return "voice." + format
}