- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息
⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
195 lines
11 KiB
Go
195 lines
11 KiB
Go
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
|
||
}
|