Files
Cyrene_v2/internal/services/channelbridge/inbound_voice_copy.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

210 lines
12 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
}
// persistVoiceFirstNotify 返回一个 notify:**先**把语音事实写进刚落库的那一行,**再**交给
// 广播(M44,主人 2026-10-26 指令)。顺序就是内容 —— 实时帧上的 `voice` 是**读列**得来的,
// 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST
// 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。
//
// 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法,
// 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回,
// 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。
func (b *Bridge) persistVoiceFirstNotify(ref voiceref.Ref) func(history.Message) {
return func(m history.Message) {
b.persistMessageVoice(m, ref)
b.notifyPersisted(m)
}
}
// 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
}