-- 0020_chat_message_voice: 给聊天消息挂上「语音事实」(design/24 §10.4;切片 CODE-VOICE)。 -- -- 一条语音消息在历史里要能被辨认成语音(客户端据此显示 🎤 与播放控件),并且要能 -- 说清它的**正文从哪来**。这两件事都是「整条消息」的属性,不是某个内容片段的属性, -- 因此落在消息行的一列上(与 0015 的 document 同族): -- -- {"source":"asr","status":"transcribed","name":"a1b2…ogg","mime":"audio/ogg", -- "size":12345,"durationMs":3200} -- -- JSON **单对象**(不是数组:一条消息只有一条被归一/落副本的语音,design/24 §10.1 -- 的 N7 明确「第 2..N 条语音既不转写也不落盘」),形状即 internal/runtime/voice 的 -- Ref —— 它就是历史 DTO 里 `voice` 对象**减去 url** 的那份数据(url 由服务端按 name -- 拼出,绝不落库)。 -- -- 字段语义(逐条见 design/24 §10.4): -- source 转写文本从哪来:asr(服务端语音识别)/ platform(渠道平台自带转写)。 -- status 转写结果三态闭集:transcribed / empty / failed。客户端按它分支, -- **绝不按正文文案分支**。 -- name 音频副本的服务端存储名(内容寻址、32 位小写十六进制 + 音频白名单扩展名)。 -- mime 保存时成对裁决所得的下发 MIME(audio/*)。 -- size 副本字节数。name/mime/size 三者**同生共死**:有副本时全在,无副本时全空 -- (绝不出现「有 url 指着一个不存在的 name」这种形状)。 -- durationMs 只有渠道**如实给出**或转码产物**可确证**时才 >0,绝不估算。 -- -- 列口径与同族可选列(document / turn_id / client_msg_id)逐字一致: -- `NOT NULL DEFAULT ''`,**绝不用 NULL** —— 于是「老行」与「这条消息不是语音」是同一个 -- 值,读侧只有一种空情形要处理(空串 ⇒ DTO 里 `voice` 键**缺席**)。 -- -- 这一列**不需要索引**:它只被随行读出,没有任何按它过滤/排序/聚合的查询(与 0018 的 -- turn_id 同一口径)。引用计数的增减发生在消息写路径上按**新旧列内容**逐条解码比较 -- (design/24 §10.5 的加粗警告),不走 SQL 过滤;因此建索引只会给每次写加一笔无收益的 -- 维护开销。 -- -- ⚠️ 引用计数(design/24 §10.5,本列落地时**必须同时做**,否则音频副本会静默坏掉): -- `history.refs` 的 retain/release/sync 今天只认 `document` 列;voice 列里的 name 必须 -- 并入**同一次**增减(与消息行同一事务、按新旧名字集合的差增减、保留重复),否则 -- 音频副本的 ref_count 恒为 0 ⇒ GC 在保留期后删掉仍被消息引用的音频 ⇒ 历史里的语音 -- 变成永远播不出来的死链,而且**没有任何错误**。 -- -- 回填(本迁移不做,也不该做):老行一律空串 = 「不是语音消息」,行为与加入本列之前 -- 逐字相同。存量语音消息的正文是转写文本,但没有来源事实可考 —— 凭空补一个 -- `{"source":"asr",...}` 就是编造(与「绝不假装」的口径冲突)。 -- -- 0003 的 chat_messages 表在此已存在,故 ALTER ADD COLUMN 是非破坏性的正确一步; -- 全新库由 internal/runtime/history.NewStore 幂等地前滚(含该列)。表名沿用复数口径 -- (援引 AGENTS §10 已登记的例外,与既有迁移一致)。 ALTER TABLE chat_messages ADD COLUMN voice TEXT NOT NULL DEFAULT '';