安卓 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 键)。 顺带把网关侧两处描述旧时序的注释改成新事实。
修实根:`message_persisted` / `channel_persisted` 与握手 `history` 回放 此前只在 `toHistoryMessage` 之后直接扇出,于是消息 DTO 的语音面在这三条 面上恒缺席,客户端只能等下一次 REST 拉取才知道那是一条语音。 - `history_voice.go`:把语音补齐抽成存储无关的唯一收口点 `attachVoiceTo` (Server 与 hub 共用),并加 `voiceColumnsOf` 把任意历史视图窄化成读面, 未实现该视图时为真 nil 接口(字段缺席,不 panic、不 500)。 - `broadcast.go`:新增 `persistedWire` —— thinking 抹除 + 可选 voice, `broadcastPersisted` 改用它;两类帧因此天然同口径。 - `ws_history.go`:握手回放同样补一次批量读列(最多 50 个 id 一条 SQL)。 成本实测:一帧 = 1 次 `IN (?)` 批量读列(1 个 id);回放一页 = 1 次 (去重后的全部 id);存储未提供该视图时 0 次。`history.Message` 没有 voice 列字段(`messageColumns` 也不含),故无法「零查询直接填」——补它 要改 internal/runtime/history 与 INSERT 写路径,超出本切片范围。 纯增量:voice 是可选键,非语音消息整键缺席(不是 null、不是 {}); 帧壳、帧类型、REST DTO 形状一律未动。 测试(广播_voice_test.go):帧序列化输出上断言 ①②③④,并逐字比对 「帧/回放里的 voice」与「REST 读面同一行」;摘掉任一挂载即打红。
(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 个变异自检(含一条自认的等价变异)。