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 键)。

顺带把网关侧两处描述旧时序的注释改成新事实。
This commit is contained in:
AskaEth committed 2026-10-26 18:54:13 +08:00
1 parent 5717735da9
commit b955a27479
6 files changed
+416 -19

No files matched your search

@@ -111,6 +111,21 @@ func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.Unified
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 在用户消息落库之后调用,因此
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。