feat(voice): 语音消息落库 + 转码接入(切片 0020)

- 迁移 0020_chat_message_voice:按 design/24 §10 与契约 §7.2 已登记的 voice 形状建列/索引
- 语音回合进编排并落库;voice 引用与统计如实回填(转码/转写没成功就如实标未成功,不假装)
- 复用既有 internal/capability/transcode:非 wav(微信 AMR 等)先转码再送 ASR;失败可分类且不丢消息

⚠️ 部分完成(如实登记):生产端到端未成立(本片只到落库与转码接入),且**未经验真机**。
This commit is contained in:
AskaEth committed 2026-10-06 09:04:06 +08:00
1 parent 120d926cf8
commit ede00d9928
17 files changed
+2125 -31

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
package db
import (
"context"
"testing"
)
// voiceMigrationID 是「语音事实」列的迁移 id(design/24 §10.4;切片 CODE-VOICE)。
const voiceMigrationID = "0020_chat_message_voice"
// TestMigrateVoiceColumnReversible 验证 0020(chat_messages.voice,语音来源事实 + 音频
// 副本引用)可上下翻滚:up 建列、重复 up 是空操作、down 删列、再 up 恢复;并钉住列口径
// —— 默认空串(**不是 NULL**)与「老行 = 不是语音消息」逐字等价。它用自己的内存库,
// 绝不碰 data/cyrene.db。
//
// 0020 的编号**小于**既有的 0021/0022(那两条先落地),因此本测试刻意按
// `upMigrationIDs` 的下标算回滚步数(照 0018 的写法),不假设「最后一条就是自己」。
func TestMigrateVoiceColumnReversible(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
t.Fatal("chat_messages.voice should exist after MigrateUp")
}
// 重复 up:已记录的迁移被跳过,列仍在且不报错(幂等)。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
t.Fatal("chat_messages.voice should still exist after idempotent re-run")
}
// 恰好回滚到 0020(含它之后新加的迁移),因此日后再加迁移也不会让本测试失效。
ids := upMigrationIDs(t)
idx := -1
for i, id := range ids {
if id == voiceMigrationID {
idx = i
}
}
if idx < 0 {
t.Fatalf("%s not found in %v", voiceMigrationID, ids)
}
if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err)
}
if columnExists(t, ctx, conn, "chat_messages", "voice") {
t.Fatal("chat_messages.voice should be dropped by the 0020 down")
}
// 重新 up 恢复端到端可用。
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
if !columnExists(t, ctx, conn, "chat_messages", "voice") {
t.Fatal("chat_messages.voice should exist again after re-MigrateUp")
}
}
// TestMigrateVoiceColumnDefaultIsEmptyString 钉住列口径:**用空串不用 NULL**(与
// document / turn_id / client_msg_id 同族)。插入一条不带 voice 的消息(显式不写该列)
// 必须读回空串 —— 「老行」与「这条消息不是语音」因此是同一个值,读侧只有一种空情形。
//
// 若有人把默认值改成 NULL,SQL 层 Scan 会失败或读出 NULL 语义,客户端看到的 `voice`
// 键行为随之漂移(design/24 §10.4:非语音消息**键缺席**,不是 `{}` 也不是 `null`)。
func TestMigrateVoiceColumnDefaultIsEmptyString(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
if _, err := conn.ExecContext(ctx,
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
"m-voice-default", "web", "user", "hi", 1000); err != nil {
t.Fatalf("insert without voice: %v", err)
}
// 用 *string 读回:默认值若是 NULL,Scan 会得到 nil(这里就会红)。
var voice *string
if err := conn.QueryRowContext(ctx,
`SELECT voice FROM chat_messages WHERE id = ?`, "m-voice-default").Scan(&voice); err != nil {
t.Fatalf("select voice: %v", err)
}
if voice == nil {
t.Fatal("voice is NULL, want the empty-string default(绝不使用 NULL)")
}
if *voice != "" {
t.Fatalf("voice = %q, want \"\"", *voice)
}
}
@@ -0,0 +1,12 @@
-- 0020_chat_message_voice down: 回滚「语音事实」列。
--
-- voice 只被 chat_messages 自己引用(无索引、无视图、无约束牵涉),因此直接删列即可。
-- 回滚后的状态是「服务端不再记得这条消息来自一条语音」:列里的来源事实与音频副本引用
-- 随列一起丢弃。
--
-- ⚠️ 音频副本本体(<DataDir>/uploads/documents 里的文件与 `documents` 元数据行)**不在
-- 本迁移的范围内**:删掉这一列等于一次性放掉这些副本的最后一份引用,它们的 ref_count
-- 不会再被消息写路径增减,此后只能由既有 GC(保留期 + ref_count = 0)收走。这是**有意**
-- 的取舍:迁移绝不代替 GC 去删用户数据(同 0015 的 down 口径)。
ALTER TABLE chat_messages DROP COLUMN voice;
@@ -0,0 +1,48 @@
-- 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 '';
+98
View File
@@ -0,0 +1,98 @@
package voice
import (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
)
// 本文件承载「**能不能播**」与「该转成什么」这两条**格式级**判据(design/24 §10.6、
// design/25 §3.1/§3.2)。
//
// 🔴 判据纪律:可播与否必须是**格式级结论**(格式名 → 已知支持表),**绝不**靠「扩展名
// 看起来像音频」,也绝不靠「试着播一下」。设计里的同一条话(design/24 §10.14-3)说的是
// 同一件事:宣称能播就要有依据,播不了就照实提示。
// playableFormats 是「浏览器与常见原生播放器**能直接播**」的格式集合,取值口径与
// mediafetch.AudioFormatOf 归一出的格式名同一份(小写、无点)。
//
// 依据 = design/25 §3.2 的 MDN《Audio codecs》支持表(规范/文档级结论):
// wav(PCM 基线)、mp3、ogg/oga/opus、m4a/aac(MP4 家族)、flac、webm 均可播。
//
// `amr` / `amr-wb` **刻意不在**集合内:Chrome 的 AMR 支持为 **No**(只有 ChromeOS 支持
// AMR-NB/WB)⇒ QQ OneBot v11 的语音在 Web 上点不开,必须转码(design/24 §10.14-2 的
// 核实结论,唯一必需的那条路 = `amr → wav`)。
//
// ⚠️ **诚实边界(别当已实测)**:上表是规范/文档级结论,**不是**本项目在目标客户端上
// 的逐浏览器实测(design/25 §3.2 已登记同一条「未确证」)。实测不符则以实测为准,
// 照实提示、不假装。对本函数而言,判错的后果是**有界的**:判「可播」⇒ 原样落副本,
// 客户端播不了会照实提示;判「不可播」⇒ 走转码,没有转码路就直接不落副本。
var playableFormats = map[string]bool{
"wav": true,
"mp3": true,
"ogg": true,
"oga": true,
"opus": true,
"m4a": true,
"aac": true,
"flac": true,
"webm": true,
}
// transcodeTargets 是「不可播格式 → 该转成的目标格式」的唯一登记点(design/25 §3.1:
// 本期只发布 `amr → wav` 一条路;新增一条路 = 这里加一行 + transcode 包加一个注册项)。
//
// amr-wb 也登记目标(同族、目标同样是 wav),但**本期没有实现**:让 `transcode` 层如实
// 回答「注册表里没有这条路」(design/25 §3.3 把它后置),比在这里假称「我们不会做」
// 更能说清问题。silk 刻意**不登记**:design/24 §10.14-1 的既有裁决是微信语音不取字节,
// 且纯 Go SILK 解码器生态不成熟(design/25 §3.3)。
var transcodeTargets = map[string]transcode.Format{
"amr": transcode.FormatWAV,
"amr-wb": transcode.FormatWAV,
}
// Playable 报告某个格式名(小写、无点)的字节能否**直接被播放器消费**。格式不认识
// ⇒ false(deny-by-default:不认识的类型绝不当成可播)。
func Playable(format string) bool {
return playableFormats[normalize(format)]
}
// TranscodeTarget 给出「该格式要落可播副本时应当转成什么」。ok=false 表示**没有登记的
// 目标**(格式可播、或这条路我们不做)——调用方据此如实提示,绝不自己发明一条路。
func TranscodeTarget(format string) (transcode.Format, bool) {
target, ok := transcodeTargets[normalize(format)]
return target, ok
}
// NeedsTranscode 报告这个格式要落副本是否**必须先转码**(= 不可播且登记了目标)。
// 它刻意不等价于 `!Playable`:不可播但没登记目标的格式(如 silk)属于「做不了」,
// 走 ErrNotPlayable,而不是走一次注定失败的转码。
func NeedsTranscode(format string) bool {
if Playable(format) {
return false
}
_, ok := TranscodeTarget(format)
return ok
}
// PlayableFormats 返回当前认定为可播的格式名(**发现**用;顺序无保证,调用方不得依赖
// 顺序)。它存在的理由是让「这份表是活的」有据可查(面板/日志能列出判据面)。
func PlayableFormats() []string {
out := make([]string, 0, len(playableFormats))
for f := range playableFormats {
out = append(out, f)
}
return out
}
// normalize 把格式名归一成小写、去空白、去开头的点(`.amr` → `amr`)。归一只有这一处,
// 与 `transcode.FormatOf` 同口径(design/25 §6.2:三处格式名必须同源)。
func normalize(format string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), "."))
}
// errNotPlayable 给 ErrNotPlayable 附上格式,便于日志与测试按 errors.Is + 文案双判。
func errNotPlayable(format string) error {
return fmt.Errorf("voice: prepare: format %q: %w", format, ErrNotPlayable)
}
+133
View File
@@ -0,0 +1,133 @@
package voice
import (
"sort"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
)
// TestPlayableIsFormatLevelVerdict 把「能不能播」的判据钉成**格式级**结论表(design/25
// §3.2 的 MDN 支持表)。AMR 是唯一必须转码的常见入站格式(Chrome/Firefox 的 AMR 支持为
// No,只有 ChromeOS 支持)——若有人把 amr 也标成可播,「能点开听」这条验收会静默失效
// (Web 上点开播不了,而服务端以为自己给了一份能播的副本)。
func TestPlayableIsFormatLevelVerdict(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want bool
why string
}{
{"wav", true, "<audio> 的基线格式"},
{"mp3", true, "KOOK 语音的实际格式"},
{"ogg", true, "Telegram 语音(Ogg/Opus)"},
{"oga", true, "Ogg 的同义扩展名"},
{"opus", true, "裸 Opus(按 Ogg 家族放行)"},
{"m4a", true, "MP4/AAC 家族"},
{"aac", true, "AAC 家族"},
{"flac", true, "无损容器"},
{"webm", true, "WebM/Opus"},
{"amr", false, "Chrome 的 AMR 支持为 No(唯一必需的转码路 amr→wav 的来源)"},
{"amr-wb", false, "同族不可播,且本期没有转码实现(后置 P2)"},
{"silk", false, "不在音频白名单内(微信按既有裁决不取字节)"},
{"", false, "空格式:deny-by-default"},
{"xyz", false, "不认识的格式:deny-by-default"},
}
for _, tc := range cases {
t.Run(tc.format+"/"+tc.why, func(t *testing.T) {
t.Parallel()
if got := Playable(tc.format); got != tc.want {
t.Fatalf("Playable(%q) = %v, want %v(%s)", tc.format, got, tc.want, tc.why)
}
})
}
}
// TestPlayableNormalizesFormatName 钉住归一:大小写、首尾空白、开头的点都要吃得下
// (渠道给的扩展名/MIME 归一后可能带点或大写),否则「可播」会在同一格式上给出两个答案。
func TestPlayableNormalizesFormatName(t *testing.T) {
t.Parallel()
for _, in := range []string{"WAV", " wav ", ".wav", ".WAV"} {
if !Playable(in) {
t.Fatalf("Playable(%q) = false, want true(归一后是 wav)", in)
}
}
for _, in := range []string{"AMR", " amr ", ".AMR"} {
if Playable(in) {
t.Fatalf("Playable(%q) = true, want false(归一后是 amr,不可播)", in)
}
}
}
// TestTranscodeTargetIsRegisteredOnlyForAMR 钉住「哪些不可播格式有登记的转码目标」:
// 本期只发布 `amr → wav`(design/25 §3.1)。可播格式**不是**转码目标(白转一圈没收益),
// silk 也**没有**目标(design/25 §3.3:不引入纯 Go SILK 解码器)。
func TestTranscodeTargetIsRegisteredOnlyForAMR(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want transcode.Format
ok bool
}{
{"amr", transcode.FormatWAV, true},
{"amr-wb", transcode.FormatWAV, true},
{"wav", "", false},
{"mp3", "", false},
{"silk", "", false},
{"", "", false},
}
for _, tc := range cases {
t.Run(tc.format, func(t *testing.T) {
t.Parallel()
got, ok := TranscodeTarget(tc.format)
if ok != tc.ok || got != tc.want {
t.Fatalf("TranscodeTarget(%q) = (%q, %v), want (%q, %v)",
tc.format, got, ok, tc.want, tc.ok)
}
})
}
}
// TestNeedsTranscodeDistinguishesCannotDoFromMustTranscode 钉住一条容易混的边界:
// NeedsTranscode 不是 `!Playable`。silk 不可播但**没登记目标** ⇒ false(做不了,
// 走 ErrNotPlayable),而不是「要转码」(那会去问一条注定不存在的路)。
func TestNeedsTranscodeDistinguishesCannotDoFromMustTranscode(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want bool
}{
{"amr", true},
{"amr-wb", true},
{"wav", false},
{"mp3", false},
{"silk", false},
{"", false},
}
for _, tc := range cases {
t.Run(tc.format, func(t *testing.T) {
t.Parallel()
if got := NeedsTranscode(tc.format); got != tc.want {
t.Fatalf("NeedsTranscode(%q) = %v, want %v", tc.format, got, tc.want)
}
})
}
}
// TestPlayableFormatsMatchesDesignTable 把「可播集合」的**字面成员**钉死(discovery 面
// 的作者容易顺手加一项而没有依据)。design/25 §3.2 的支持表 + design/24 §10.6 的音频
// 白名单(10 项扩展名)⇒ 可播名集合恰好是这 9 个(ogg/oga/opus 三名同容器)。
func TestPlayableFormatsMatchesDesignTable(t *testing.T) {
t.Parallel()
want := []string{"aac", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "webm"}
got := PlayableFormats()
sort.Strings(got)
if len(got) != len(want) {
t.Fatalf("PlayableFormats() = %v(%d 项), want %v(%d 项)", got, len(got), want, len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("PlayableFormats() = %v, want %v", got, want)
}
}
}
+146
View File
@@ -0,0 +1,146 @@
package voice
import (
"context"
"errors"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
)
// 本文件是「**可播归一化**」的实现(design/25 §7.1 第 ④ 步):把一条入站语音的原始
// 字节变成一份**可以落盘、可以播放**的副本。
//
// 位置与时机(design/24 §10.2 第 4 步 + design/25 §7.1):
// - **闸门之后**:被黑白名单 / 最小打扰拦下的语音不产生任何字节与 CPU 开销;
// - **转写之后**:ASR 用的是**原始字节**(转码产物对它无用且有害:AMR→WAV 放大 10 倍);
// - **落盘之前**:这是唯一决定「副本是什么格式」的位置,与内容寻址同一处 ⇒
// 同字节去重 ⇒ 同一段语音只转一次;
// - 只在**入站**发生,不在读端点(播放)发生 ⇒ 没有「读端点被轮询打爆」这个面。
// Prep 是一次可播归一化的输入。
type Prep struct {
// Data 是入站拿到的原始音频字节(mediafetch 内联所得)。
Data []byte
// Format 是源格式名(小写无点;与 mediafetch.AudioFormatOf 同一口径)。平台给了
// 文件名就按扩展名,只给 MIME 就按 MIME 归一 —— 「拿不到」时调用方不该走到这里。
Format string
// DurationMs 是渠道**如实给出**的时长;0 = 不知道(绝不估)。负数按「不知道」处理
// (渠道给了个无意义的值,我们既不采信也不编造)。
DurationMs int64
}
// Copy 是可落盘、可播放的一份副本。
type Copy struct {
// Data 是要落盘的字节(可播格式)。
Data []byte
// Format 是落盘格式名(= 存储名的扩展名口径)。
Format string
// DurationMs 是时长的**最好来源**:转码产物能确证时取它,否则沿用渠道如实给出的值;
// 两者都没有就是 0(落库/DTO 里该键缺席,绝不估算)。
DurationMs int64
// Transcoded 报告这一份是不是**转码产物**(false = 源格式本就可播、原样落盘)。
// 它进日志与(将来的)DTO,使「为什么这条副本的格式和渠道给的不一样」可追溯。
Transcoded bool
}
// Prepare 把一条入站语音归一成可播副本(design/24 §10.14-3 的新口径:**不可播即转码;
// 转码不可用才诚实提示**)。三态:
//
// ① 源格式可播 ⇒ 原样返回(Transcoded=false,零 CPU、零转码依赖);
// ② 不可播且转码可用 ⇒ 返回转码产物(Transcoded=true,目标来自 TranscodeTarget);
// ③ 不可播且转码不可用 ⇒ 返回错误(未接线 / 没这条路 / 越限 / 解码失败 / 忙 / 超时),
// **绝不**返回一个播不了却当作可播的结果 —— 调用方据此不落副本、不打假 url。
//
// 错误分类一律可 `errors.Is` 判定:本包的 ErrNoBytes / ErrNotPlayable / ErrNoTranscoder,
// 或原样包裹的 `transcode` 哨兵。文案纪律照 design/25 §2.4:上游错误原文只进日志,
// 绝不进用户可见正文(这里只包哨兵,不回显解码器文本)。
func Prepare(ctx context.Context, tr transcode.Transcoder, in Prep) (Copy, error) {
if len(in.Data) == 0 {
return Copy{}, fmt.Errorf("voice: prepare: %w", ErrNoBytes)
}
format := normalize(in.Format)
duration := in.DurationMs
if duration < 0 {
duration = 0
}
// ① 本就可播:不碰转码(design/25 §7.1 的「源格式可播 ⇒ 不转码、直接落原件」)。
if Playable(format) {
return Copy{Data: in.Data, Format: format, DurationMs: duration}, nil
}
// ② / ③ 不可播:先问「这条路有没有」,再决定走转码还是诚实拒绝(设计 §4.4 的能力门,
// 与 LiteLLM 的 supports_* 同范式:绝不「试一下就假成功」)。
target, ok := TranscodeTarget(format)
if !ok {
return Copy{}, errNotPlayable(format)
}
if tr == nil {
return Copy{}, fmt.Errorf("voice: prepare: route %s→%s: %w",
format, transcode.FormatName(target), ErrNoTranscoder)
}
source := transcode.FormatOf(format)
if !tr.Supports(source, target) {
return Copy{}, fmt.Errorf("voice: prepare: route %s→%s: %w",
format, transcode.FormatName(target), transcode.ErrUnsupportedFormat)
}
res, err := tr.Transcode(ctx, transcode.Request{Data: in.Data, Source: source, Target: target})
if err != nil {
// 原样包裹:调用方按 transcode 的哨兵分类(越限 / 解码失败 / 忙 / 超时…),
// 本函数不吞、不改写原因。
return Copy{}, fmt.Errorf("voice: prepare: transcode %s→%s: %w",
format, transcode.FormatName(target), err)
}
if len(res.Data) == 0 {
// 转码器自称成功却没给字节:这是**目标侧构造失败**,绝不能当成一份空副本落盘
// (空文件播放不了,而且 documents 侧会按「空文件」拒掉 —— 两头都说不清)。
return Copy{}, fmt.Errorf("voice: prepare: transcode %s→%s produced no bytes: %w",
format, transcode.FormatName(target), transcode.ErrEncodeFailed)
}
outFormat := transcode.FormatName(res.Format)
if outFormat == "" {
outFormat = transcode.FormatName(target)
}
if res.DurationMs > 0 {
// 转码产物可从帧数**确证**时长 ⇒ 它比渠道给的更可信(渠道常常给不出)。
duration = res.DurationMs
}
return Copy{Data: res.Data, Format: outFormat, DurationMs: duration, Transcoded: true}, nil
}
// Category 把 Prepare 的错误归成一个**稳定的日志类别**(小写、无空格、不含任何上游
// 错误原文),供调用方按类记 WARN、按类排查,并在将来需要时细分客户端展示。
//
// design/24 §10.12-9 说「三态之外的原因分类本期不下发 DTO,只进日志」——本函数就是
// 「只进日志」的那一份分类,因此它是**机器可读**的(脚本/面板可按它聚合)。
func Category(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, ErrNoBytes):
return "no_bytes"
case errors.Is(err, ErrNotPlayable):
return "not_playable"
case errors.Is(err, ErrNoTranscoder):
return "transcode_not_wired"
case errors.Is(err, transcode.ErrUnsupportedFormat):
return "unsupported_format"
case errors.Is(err, transcode.ErrInputTooLarge):
return "input_too_large"
case errors.Is(err, transcode.ErrOutputTooLarge):
return "output_too_large"
case errors.Is(err, transcode.ErrDecodeFailed):
return "decode_failed"
case errors.Is(err, transcode.ErrEncodeFailed):
return "encode_failed"
case errors.Is(err, transcode.ErrTimeout):
return "timeout"
case errors.Is(err, transcode.ErrBusy):
return "busy"
case errors.Is(err, transcode.ErrNoBackend):
return "no_backend"
default:
return "failed"
}
}
+296
View File
@@ -0,0 +1,296 @@
package voice
import (
"bytes"
"context"
"errors"
"os"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
)
// fakeTranscoder 是 Prepare 的能力门与转码调用的替身:它记录是否被调用、被问了哪条路,
// 让「可播格式**绝不**碰转码」这类纪律成为可断言的事实(而不是靠读代码相信)。
type fakeTranscoder struct {
// routes 是「源>目标」的可用集合;nil 表示按 DefaultCodecs 的口径只认 amr>wav。
routes map[string]bool
result transcode.Result
err error
calls int
lastReq transcode.Request
}
func routeKey(src, dst transcode.Format) string {
return transcode.FormatName(src) + ">" + transcode.FormatName(dst)
}
func newFakeTranscoder() *fakeTranscoder {
return &fakeTranscoder{routes: map[string]bool{routeKey(transcode.FormatAMR, transcode.FormatWAV): true}}
}
func (f *fakeTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) {
f.calls++
f.lastReq = req
if f.err != nil {
return transcode.Result{}, f.err
}
res := f.result
res.Source = req.Source
if res.Format == "" {
res.Format = req.Target
}
return res, nil
}
func (f *fakeTranscoder) Supports(src, dst transcode.Format) bool {
return f.routes[routeKey(src, dst)]
}
func (f *fakeTranscoder) Formats() []transcode.Format {
return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV}
}
// TestPrepareKeepsPlayableBytesAsIs 覆盖三态之①:源格式可播 ⇒ **原样落盘**,一个字节都
// 不改、也不碰转码(design/25 §7.1:不可播才转码;可播却转一圈等于白烧 CPU 还把音质
// 往下拽)。
func TestPrepareKeepsPlayableBytesAsIs(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
in := []byte("RIFF....WAVEfmt ")
got, err := Prepare(context.Background(), tr, Prep{Data: in, Format: "WAV", DurationMs: 1500})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if !bytes.Equal(got.Data, in) {
t.Fatalf("字节被改动了:%q", got.Data)
}
if got.Format != "wav" || got.Transcoded {
t.Fatalf("产物 = %+v, want 原样 wav / Transcoded=false", got)
}
if got.DurationMs != 1500 {
t.Fatalf("时长 = %d, want 1500(渠道如实给出的值)", got.DurationMs)
}
if tr.calls != 0 {
t.Fatalf("转码被调用 %d 次, want 0(可播格式绝不转码)", tr.calls)
}
}
// TestPrepareTranscodesNonPlayableFormat 覆盖三态之②:不可播且转码可用 ⇒ 落**可播副本**,
// 并且时长优先取转码产物**可确证**的值(渠道常给不出;转码按帧数算得出来)。
func TestPrepareTranscodesNonPlayableFormat(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV, DurationMs: 500}
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n..."), Format: "amr"})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if string(got.Data) != "RIFF-out" || got.Format != "wav" || !got.Transcoded {
t.Fatalf("产物 = %+v, want 转码产物 wav/Transcoded=true", got)
}
if got.DurationMs != 500 {
t.Fatalf("时长 = %d, want 500(转码产物可确证)", got.DurationMs)
}
if tr.calls != 1 {
t.Fatalf("转码调用次数 = %d, want 1", tr.calls)
}
if tr.lastReq.Source != transcode.FormatAMR || tr.lastReq.Target != transcode.FormatWAV {
t.Fatalf("请求 = %+v, want amr→wav", tr.lastReq)
}
}
// TestPreparePrefersTranscodedDuration 覆盖时长来源的优先级:转码产物给不出时长时,
// **沿用渠道如实给出的值**(绝不因为转码成功就把已知的时长抹成 0)。
func TestPreparePrefersTranscodedDuration(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
tr.result = transcode.Result{Data: []byte("RIFF-out"), Format: transcode.FormatWAV}
got, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr", DurationMs: 3200})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if got.DurationMs != 3200 {
t.Fatalf("时长 = %d, want 3200(转码没给出 ⇒ 沿用渠道值)", got.DurationMs)
}
}
// TestPrepareNegativeDurationIsTreatedAsUnknown 钉住「渠道给了个无意义的值」的处理:
// 负数按**不知道**处理(0),既不采信也不编造 —— 落库前 Ref.Validate 会拒绝负数。
func TestPrepareNegativeDurationIsTreatedAsUnknown(t *testing.T) {
t.Parallel()
got, err := Prepare(context.Background(), nil, Prep{Data: []byte("RIFF"), Format: "wav", DurationMs: -5})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
if got.DurationMs != 0 {
t.Fatalf("时长 = %d, want 0(负值 = 不知道)", got.DurationMs)
}
}
// prepareFailureCases 是「不可播且转码不可用」的全部失败面(design/25 §2.4 / §5.4):
// 每一条都要求「有错、可判类、无字节」。表放在用例之外,好让用例本体只表达纪律。
var prepareFailureCases = []struct {
name string
tr transcode.Transcoder
in Prep
wantErr error
category string
}{
{
name: "没有字节", tr: newFakeTranscoder(),
in: Prep{Data: nil, Format: "wav"},
wantErr: ErrNoBytes,
category: "no_bytes",
},
{
name: "不可播且没有登记目标(silk)", tr: newFakeTranscoder(),
in: Prep{Data: []byte("x"), Format: "silk"},
wantErr: ErrNotPlayable,
category: "not_playable",
},
{
name: "不可播且格式不认识", tr: newFakeTranscoder(),
in: Prep{Data: []byte("x"), Format: ""},
wantErr: ErrNotPlayable,
category: "not_playable",
},
{
name: "转码能力未接线(amr 但组合根没注入)", tr: nil,
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: ErrNoTranscoder,
category: "transcode_not_wired",
},
{
name: "注册表里没有这条路(amr-wb 本期未实现)",
tr: newFakeTranscoder(),
in: Prep{Data: []byte("#!AMR-WB\n"), Format: "amr-wb"},
wantErr: transcode.ErrUnsupportedFormat,
category: "unsupported_format",
},
{
name: "输入越限", tr: failingTranscoder(transcode.ErrInputTooLarge),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrInputTooLarge,
category: "input_too_large",
},
{
name: "产物越限", tr: failingTranscoder(transcode.ErrOutputTooLarge),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrOutputTooLarge,
category: "output_too_large",
},
{
name: "解码失败(字节损坏)", tr: failingTranscoder(transcode.ErrDecodeFailed),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrDecodeFailed,
category: "decode_failed",
},
{
name: "并发槽满(快速失败,不排队)", tr: failingTranscoder(transcode.ErrBusy),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrBusy,
category: "busy",
},
{
name: "超时", tr: failingTranscoder(transcode.ErrTimeout),
in: Prep{Data: []byte("#!AMR\n"), Format: "amr"},
wantErr: transcode.ErrTimeout,
category: "timeout",
},
}
// TestPrepareFailsHonestly 覆盖三态之③与全部失败分类(design/25 §2.4 / §5.4):
// **不可播且转码不可用**时绝不返回一份播不了却当作可播的结果;错误必须可 errors.Is
// 判定、可归类(Category 是「只进日志」的那份机器可读分类,design/24 §10.12-9)。
func TestPrepareFailsHonestly(t *testing.T) {
t.Parallel()
for _, tc := range prepareFailureCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := Prepare(context.Background(), tc.tr, tc.in)
if err == nil {
t.Fatalf("Prepare(%+v) = %+v, want 错误(绝不返回播不了却当作可播的结果)", tc.in, got)
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("错误 = %v, want %v", err, tc.wantErr)
}
if cat := Category(err); cat != tc.category {
t.Fatalf("Category(%v) = %q, want %q", err, cat, tc.category)
}
if len(got.Data) != 0 {
t.Fatalf("失败时仍返回了字节:%q", got.Data)
}
})
}
}
// failingTranscoder 返回一个自称有 amr→wav 这条路、但一转就报指定哨兵的替身。
func failingTranscoder(err error) *fakeTranscoder {
f := newFakeTranscoder()
f.err = err
return f
}
// TestPrepareRejectsEmptyTranscodeOutput 覆盖「转码器自称成功却没给字节」:那是目标侧
// 构造失败,绝不能当成一份空副本落盘(空文件播放不了,documents 侧也会按空文件拒掉)。
func TestPrepareRejectsEmptyTranscodeOutput(t *testing.T) {
t.Parallel()
tr := newFakeTranscoder()
tr.result = transcode.Result{Format: transcode.FormatWAV}
_, err := Prepare(context.Background(), tr, Prep{Data: []byte("#!AMR\n"), Format: "amr"})
if !errors.Is(err, transcode.ErrEncodeFailed) {
t.Fatalf("错误 = %v, want transcode.ErrEncodeFailed", err)
}
if cat := Category(err); cat != "encode_failed" {
t.Fatalf("Category = %q, want encode_failed", cat)
}
}
// TestCategoryOfNilIsEmpty 钉住 Category(nil) 的空串:调用方按它决定「要不要记一条
// 失败日志」,因此成功必须是空串(否则每次成功转码都会刷一条 WARN)。
func TestCategoryOfNilIsEmpty(t *testing.T) {
t.Parallel()
if cat := Category(nil); cat != "" {
t.Fatalf("Category(nil) = %q, want 空串", cat)
}
}
// TestPrepareWithRealTranscodeEngine 是**真走**用例(design/25 §8-T 的「复用既有能力」
// 验收):用真实 `transcode.Engine` + `DefaultCodecs()` 把一份真 AMR-NB 夹具走通
// `amr → wav`,断言产物是 RIFF/WAVE 且时长**可确证**。
//
// 夹具取自 transcode 包自己的 testdata(同一份,绝不再造一份):它是 opencore-amrnb
// 编出的 0.5 秒合成信号,因此本用例不需要装 ffmpeg。若该目录被搬走,本用例会**红**而不是
// 静默跳过(「跳过」会让这条唯一的真走证据消失而没人发现)。
func TestPrepareWithRealTranscodeEngine(t *testing.T) {
t.Parallel()
const fixture = "../../capability/transcode/testdata/mode12200.amr"
data, err := os.ReadFile(fixture)
if err != nil {
t.Fatalf("读夹具 %s: %v(真走用例依赖 transcode 包自己的 testdata)", fixture, err)
}
tr := transcode.NewEngine(transcode.DefaultLimits(),
transcode.WithCodecs(transcode.DefaultCodecs()...))
got, err := Prepare(context.Background(), tr, Prep{Data: data, Format: "amr"})
if err != nil {
t.Fatalf("Prepare(真 amr 夹具): %v", err)
}
if !got.Transcoded || got.Format != "wav" {
t.Fatalf("产物 = %+v, want wav 转码产物", got)
}
if len(got.Data) < 44 || string(got.Data[:4]) != "RIFF" || string(got.Data[8:12]) != "WAVE" {
t.Fatalf("产物不是 RIFF/WAVE:前 12 字节 = %q", head(got.Data, 12))
}
if got.DurationMs <= 0 {
t.Fatalf("时长 = %d, want >0(AMR 帧数可确证)", got.DurationMs)
}
}
// head 返回 data 的前 n 字节(越界时返回全部),只用于失败信息。
func head(data []byte, n int) []byte {
if len(data) < n {
return data
}
return data[:n]
}
+174
View File
@@ -0,0 +1,174 @@
// Package voice 承载**语音副本**(回放侧)的领域事实与可播归一化(design/24 §10、
// design/25 §7.1)。它回答三个问题:
//
// - 这条消息**来自一条语音吗**、它的正文是哪来的(`Ref.Source`);
// - 那次转写**成功了没有**(`Ref.Status`,三态闭集);
// - 盘上**有没有一份能播的副本**(`Ref.Name`;没有就整键缺席,绝不给假 url)。
//
// ⚠️ 与 `internal/runtime/multimodal/voice` 的分工(**别混**):那个包是**转写/合成**
// 的提供方链(STT/TTS,design/06);本包是「消息里那条语音的**来源事实 + 可播副本**」
// (design/24 §10)。两者同名不同事,进口时务必看清路径。
//
// 依赖刻意极轻(stdlib + `internal/capability/transcode`):`history` 与
// `channelbridge` 都要引用它,而 `documents` 已经 import `history` —— 本包一旦 import
// `documents` 就会让「history → voice」成环。因此这里**不引用** `documents.Ref`,
// 副本事实用本包自有的 `Copy` 承载,由调用方负责映射(channelbridge 一处)。
package voice
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
// Source 是转写文本的**来源**(design/24 §10.4 的闭集)。它只描述「这段文字是怎么来的」,
// **绝不**描述说话人是谁(身份仍由 channel 层 actor 元数据 + 白名单决定)。
type Source string
const (
// SourceASR 表示服务端语音识别所得(含云端 DashScope 那条链)。
SourceASR Source = "asr"
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
SourcePlatform Source = "platform"
)
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
// 文案分支**:正文可能是转写文本,也可能是一行中文诚实提示,两者只能靠 status 区分。
type Status string
const (
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。
StatusTranscribed Status = "transcribed"
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
// 的空结果,**不是**故障。
StatusEmpty Status = "empty"
// StatusFailed 表示没拿到转写文本(正文是那行中文诚实提示)。
StatusFailed Status = "failed"
)
// 本包自有的哨兵(转码失败的分类一律复用 `transcode` 的哨兵,见 prepare.go)。
var (
// ErrBadRef 报告 voice 列 / DTO 的内容不是合法的语音事实(乱码、非法 JSON、
// 来源或状态不在闭集内、副本字段「同生共死」被破坏)。
ErrBadRef = errors.New("voice: malformed voice ref")
// ErrNoBytes 报告要归一化的那条语音**没有字节**(渠道只给了平台内部引用,或
// 代取失败)——没有字节就没有可落盘的副本,调用方据此走诚实路径。
ErrNoBytes = errors.New("voice: no audio bytes")
// ErrNotPlayable 报告源格式**不可播**且**没有已知的转码目标**(或格式不认识):
// 这条路我们做不了,调用方据此不落副本、不打假 url。
ErrNotPlayable = errors.New("voice: source format is not playable")
// ErrNoTranscoder 报告转码能力**未接线**(组合根没有把 transcode 交给本模块)。
// 它与「格式不支持」刻意分开:一个是运维缺口,一个是能力边界,日志与排查路径不同。
ErrNoTranscoder = errors.New("voice: transcode capability not wired")
)
// Ref 是一条语音消息的**落库与 DTO 事实**(design/24 §10.4):它就是
// `chat_messages.voice` 列里的 JSON **单对象**,同时是历史 DTO 里 `voice` 对象
// **减去 `url`** 的那份数据 —— `url` 是唯一的派生字段,由服务端按 `Name` 一处拼出
// (路径是服务端契约,绝不由客户端拼)。
//
// 字段顺序刻意与 design/24 §10.4 的 JSON 示例一致(编码产物逐字可比对)。
type Ref struct {
// Source 是转写文本从哪来(闭集,见 Source 常量)。
Source Source `json:"source"`
// Status 是转写结果三态(闭集,见 Status 常量)。
Status Status `json:"status"`
// Name 是音频副本的服务端存储名(内容寻址:32 位小写十六进制 + 音频白名单扩展名)。
// 空串 = **没有副本**(例:微信按既有裁决不取字节)。它与 MIME/Size 三者同生共死。
Name string `json:"name,omitempty"`
// MIME 是**保存时成对裁决所得**的下发 MIME(`audio/ogg` 等),不是客户端声明。
MIME string `json:"mime,omitempty"`
// Size 是副本的字节数。
Size int64 `json:"size,omitempty"`
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
DurationMs int64 `json:"durationMs,omitempty"`
}
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
// 它是「非语音消息」与「语音消息」的分界:false ⇒ `voice` 键缺席,客户端不显示 🎤。
func (r Ref) IsVoice() bool {
return validSource(r.Source) && validStatus(r.Status)
}
// HasCopy 报告盘上是否有一份可回放的副本(= Name 非空)。name/mime/size 三者同生共死,
// 因此 Name 是这三者的代表。
func (r Ref) HasCopy() bool { return r.Name != "" }
// Validate 检查这条事实是否自洽:两个闭集成员合法、副本三个字段**同生共死**、时长非负。
// 破坏规则的写**报错**而不是就地裁剪 —— 悄悄补一个空的 mime 或砍掉 size,会让库里
// 记着的东西与客户端读到的东西对不上(正是本列要消灭的静默)。
func (r Ref) Validate() error {
if !validSource(r.Source) {
return fmt.Errorf("%w: source %q", ErrBadRef, r.Source)
}
if !validStatus(r.Status) {
return fmt.Errorf("%w: status %q", ErrBadRef, r.Status)
}
if r.DurationMs < 0 {
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
}
switch {
case r.Name == "" && (r.MIME != "" || r.Size != 0):
return fmt.Errorf("%w: mime/size without name", ErrBadRef)
case r.Name != "" && r.MIME == "":
return fmt.Errorf("%w: name without mime", ErrBadRef)
case r.Name != "" && r.Size <= 0:
return fmt.Errorf("%w: name without size", ErrBadRef)
}
return nil
}
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
func (r Ref) IsZero() bool {
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
r.Size == 0 && r.DurationMs == 0
}
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
// `voice` 键缺席),与 document 列对「没有附件」的口径一致。
//
// 非零但自洽性被破坏时报错:写进一列**读不回来**的 JSON,等于让客户端永远看不到这条
// 语音(静默坏掉),所以这里 fail loud。
func Encode(r Ref) (string, error) {
if r.IsZero() {
return "", nil
}
if err := r.Validate(); err != nil {
return "", fmt.Errorf("voice: encode ref: %w", err)
}
b, err := json.Marshal(r)
if err != nil {
return "", fmt.Errorf("voice: encode ref: %w", err)
}
return string(b), nil
}
// Decode 解码落库的语音事实。空串(这条消息不是语音)返回零值;坏内容返回包装了
// ErrBadRef 的错误,由调用方**可见地**报告 —— 解析失败绝不静默当成「不是语音」。
//
// 它只接受**单对象**形状:本列自 0020 起从诞生就是单对象(design/24 §10.4),没有
// 需要兼容的旧形状(与 document 列的「早期单对象 → 数组」演进史不同)。
func Decode(raw string) (Ref, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return Ref{}, nil
}
var r Ref
if err := json.Unmarshal([]byte(trimmed), &r); err != nil {
return Ref{}, fmt.Errorf("voice: decode ref: %w", ErrBadRef)
}
if err := r.Validate(); err != nil {
return Ref{}, fmt.Errorf("voice: decode ref: %w", err)
}
return r, nil
}
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform }
func validStatus(s Status) bool {
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
}
+211
View File
@@ -0,0 +1,211 @@
package voice
import (
"errors"
"strings"
"testing"
)
// TestEncodeDecodeRoundTrip 覆盖落库形状的核心往返:编码 → 解码逐字段还原。
// 三种真实形态各一条:有副本的 asr 真转写、无副本的 platform 转写(微信按既有裁决
// 不取字节)、无副本的失败(正文是那行中文诚实提示)。
func TestEncodeDecodeRoundTrip(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ref Ref
}{
{"asr 真转写 + 可播副本", Ref{
Source: SourceASR, Status: StatusTranscribed,
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
Size: 12345, DurationMs: 3200,
}},
{"platform 自带转写 + 无副本", Ref{
Source: SourcePlatform, Status: StatusTranscribed,
}},
{"转写失败 + 无副本(但原件仍可能落盘)", Ref{
Source: SourceASR, Status: StatusFailed, Name: "ffeeddccbbaa99887766554433221100.wav",
MIME: "audio/wav", Size: 160044,
}},
{"转写为空 + 有副本", Ref{
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
MIME: "audio/mpeg", Size: 4321,
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := Encode(tc.ref)
if err != nil {
t.Fatalf("Encode: %v", err)
}
got, err := Decode(raw)
if err != nil {
t.Fatalf("Decode(%q): %v", raw, err)
}
if got != tc.ref {
t.Fatalf("往返 = %+v, want %+v", got, tc.ref)
}
})
}
}
// TestEncodeJSONShapeMatchesDesign 把**字面 JSON** 钉在设计上(design/24 §10.4 的示例):
// 字段顺序、键名、以及 durationMs 缺席(0 ⇒ omitempty)都必须逐字如此。
//
// 为什么需要字面量钉桩:这份 JSON 会落进 chat_messages.voice 列,是客户端与历史 DTO
// 共同的形状。字段顺序/键名漂移不会让任何单测变红(往返测试两侧同时漂移仍然相等),
// 只有把**值**钉死才能挡住(design/24 §9.7 的同一条测试质量教训)。
func TestEncodeJSONShapeMatchesDesign(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{
Source: SourceASR, Status: StatusTranscribed,
Name: "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg", MIME: "audio/ogg",
Size: 12345, DurationMs: 3200,
})
if err != nil {
t.Fatalf("Encode: %v", err)
}
want := `{"source":"asr","status":"transcribed","name":"a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg",` +
`"mime":"audio/ogg","size":12345,"durationMs":3200}`
if raw != want {
t.Fatalf("落库 JSON = %s\n want %s", raw, want)
}
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死),durationMs 缺席(= 0)。
raw, err = Encode(Ref{Source: SourcePlatform, Status: StatusTranscribed})
if err != nil {
t.Fatalf("Encode(no copy): %v", err)
}
if raw != `{"source":"platform","status":"transcribed"}` {
t.Fatalf("无副本 JSON = %s", raw)
}
}
// TestEncodeZeroRefIsEmptyColumn 钉住「不是语音消息」的落库形状:零值编码成**空串**,
// 于是列里空串 ⇒ DTO 里 voice 键缺席(不是 {} 也不是 null)。
func TestEncodeZeroRefIsEmptyColumn(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{})
if err != nil {
t.Fatalf("Encode(零值): %v", err)
}
if raw != "" {
t.Fatalf("零值编码 = %q, want 空串", raw)
}
got, err := Decode("")
if err != nil {
t.Fatalf("Decode(\"\"): %v", err)
}
if !got.IsZero() {
t.Fatalf("Decode(\"\") = %+v, want 零值", got)
}
}
// TestEncodeRejectsBrokenRef 覆盖「同生共死」与闭集两条纪律的**写侧**执行:坏形状必须在
// 写库前报错,绝不悄悄补字段(那会让库里记着的东西与客户端读到的对不上)。
func TestEncodeRejectsBrokenRef(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ref Ref
}{
{"来源不在闭集", Ref{Source: "cloud", Status: StatusTranscribed}},
{"状态不在闭集", Ref{Source: SourceASR, Status: "ok"}},
{"来源为空", Ref{Status: StatusTranscribed}},
{"状态为空", Ref{Source: SourceASR}},
{"有 mime 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, MIME: "audio/ogg"}},
{"有 size 无 name", Ref{Source: SourceASR, Status: StatusTranscribed, Size: 12}},
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := Encode(tc.ref)
if err == nil {
t.Fatalf("Encode(%+v) = %q, want 错误(坏形状绝不入库)", tc.ref, raw)
}
if !errors.Is(err, ErrBadRef) {
t.Fatalf("错误 = %v, want ErrBadRef", err)
}
})
}
}
// TestDecodeRejectsGarbage 覆盖读侧:坏列内容**报错**而不是当成「不是语音」。
// 静默当成空 = 客户端永远看不到这条语音,而库里明明有引用(正是要消灭的静默)。
func TestDecodeRejectsGarbage(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
}{
{"截断的 JSON", `{"source":"asr"`},
{"数组形状(本列从诞生就是单对象)", `[{"source":"asr","status":"transcribed"}]`},
{"字面量 null", `null`},
{"空对象(两个闭集字段都缺)", `{}`},
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
{"闭集外的状态", `{"source":"asr","status":"done"}`},
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := Decode(tc.raw)
if err == nil {
t.Fatalf("Decode(%s) = %+v, want 错误", tc.raw, got)
}
if !errors.Is(err, ErrBadRef) {
t.Fatalf("错误 = %v, want ErrBadRef", err)
}
})
}
}
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
func TestRefIsVoiceAndHasCopy(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ref Ref
isVoice bool
hasCopy bool
}{
{"零值(不是语音消息)", Ref{}, false, false},
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := tc.ref.IsVoice(); got != tc.isVoice {
t.Fatalf("IsVoice = %v, want %v", got, tc.isVoice)
}
if got := tc.ref.HasCopy(); got != tc.hasCopy {
t.Fatalf("HasCopy = %v, want %v", got, tc.hasCopy)
}
})
}
}
// TestDecodeTrimsWhitespace 钉住宽容度只到「首尾空白」为止:列里带换行的内容(人手工
// 改过、或将来某处写入时多了换行)仍可读回,坏内容仍然报错。
func TestDecodeTrimsWhitespace(t *testing.T) {
t.Parallel()
raw := "\n {\"source\":\"asr\",\"status\":\"transcribed\"}\t \n"
got, err := Decode(raw)
if err != nil {
t.Fatalf("Decode(%q): %v", raw, err)
}
if got.Source != SourceASR || got.Status != StatusTranscribed {
t.Fatalf("Decode = %+v", got)
}
if strings.TrimSpace(raw) == "" {
t.Fatal("用例前提坏了:raw 不该是空串")
}
}
@@ -20,6 +20,7 @@ import (
"sync" "sync"
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
@@ -157,6 +158,14 @@ type Options struct {
// VoiceSTTTimeout 是一次 IM 入站语音转写的超时上限(≤0 时取默认 20s)。入站流水线 // VoiceSTTTimeout 是一次 IM 入站语音转写的超时上限(≤0 时取默认 20s)。入站流水线
// 会为这次调用举着一个并发槽,因此上游卡住**不能**拖死整条流水线。 // 会为这次调用举着一个并发槽,因此上游卡住**不能**拖死整条流水线。
VoiceSTTTimeout time.Duration VoiceSTTTimeout time.Duration
// VoiceTranscoder 是**入站语音副本的可播归一化**所用的转码能力(design/25 §7.1 第 ④ 步;
// 组合根注入 `a.transcoder`,与工具面用的是同一个实例)。为 nil 时:
// 可播格式的语音照旧落副本(不需要转码),**不可播格式**(QQ 的 AMR)走
// 「不落副本 + 带分类的 WARN」的诚实路径 —— 绝不落一份播不了的字节并假装能播。
VoiceTranscoder transcode.Transcoder
// VoiceAudioStore 是入站语音副本的**落盘面**(design/24 §10.9-2)。nil 时按消息构造
// 共享文档存储并在它实现了音频分支时直接用它(见 VoiceAudioStore 的说明)。
VoiceAudioStore VoiceAudioStore
// DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1): // DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1):
// 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜 // 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜
// 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为 // 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为
@@ -174,6 +183,19 @@ type Options struct {
EventStore events.Store EventStore events.Store
} }
// VoiceAudioStore 是入站语音**可播副本**的落盘面(design/24 §10.9-2 的「共享存储音频
// 分支」)。它与 `documents.Store.Save` 同形:内容寻址 + 去重 + 元数据行,但走另一份
// 裁决(音频白名单,见 design/24 §10.6)。
//
// 生产装配**可以留 nil**:那时桥接器按消息构造共享文档存储,并在它实现了本接口时直接
// 用它(`documents.Store.SaveAudio`)—— 也就是说,只要存储侧补上那个方法,无需改组合根。
// 注入面优先于上面那条发现路径,供测试与「用别的落盘实现」的部署方使用。
//
// 两者都没有 ⇒ 走「副本没落成」的诚实路径(带来源 WARN + 不打假 url),绝不假装落成了。
type VoiceAudioStore interface {
SaveAudio(ctx context.Context, origName string, data []byte) (documents.Ref, error)
}
// Bridge 掌管渠道适配器生命周期与入站消息流水线。它 // Bridge 掌管渠道适配器生命周期与入站消息流水线。它
// 可安全并发使用。 // 可安全并发使用。
type Bridge struct { type Bridge struct {
@@ -248,10 +270,16 @@ type Bridge struct {
// voiceSTT / voiceSTTTimeout / voiceCircuit 是入站语音转写面(切片 IM-ASR; // voiceSTT / voiceSTTTimeout / voiceCircuit 是入站语音转写面(切片 IM-ASR;
// design/06 §1.1):注入点、独立短超时,以及按原因分类的**临时熔断**。 // design/06 §1.1):注入点、独立短超时,以及按原因分类的**临时熔断**。
// voiceLog 是带 `services/channelbridge/voice` 来源的子功能日志器。 // voiceLog 是带 `services/channelbridge/voice` 来源的子功能日志器。
// voiceTranscoder 是入站语音**副本**的可播归一化所用的转码能力(切片 CODE-VOICE;
// design/25 §7.1 第 ④ 步)—— 与转写面**彼此独立**:转写只认原始字节,转码只服务回放。
voiceSTT VoiceSTT voiceSTT VoiceSTT
voiceSTTTimeout time.Duration voiceSTTTimeout time.Duration
voiceCircuit voiceBreaker voiceCircuit voiceBreaker
voiceLog *slog.Logger voiceLog *slog.Logger
voiceTranscoder transcode.Transcoder
// voiceAudioStore 是**显式注入**的音频落盘面(切片 CODE-VOICE;design/24 §10.9-2)。
// nil = 退回「共享文档存储的音频分支」发现路径(见 inboundVoiceStore)。
voiceAudioStore VoiceAudioStore
// proactiveCursor 是主动推送「无信号」兜底时的轮转游标 // proactiveCursor 是主动推送「无信号」兜底时的轮转游标
// (channelbridge/proactive.go),使默认渠道不会总是 // (channelbridge/proactive.go),使默认渠道不会总是
// 白名单中的第一个主人渠道。由 mu 保护。 // 白名单中的第一个主人渠道。由 mu 保护。
@@ -333,6 +361,8 @@ func New(opts Options) (*Bridge, error) {
voiceSTT: opts.VoiceSTT, voiceSTT: opts.VoiceSTT,
voiceSTTTimeout: voiceSTTTimeout(opts.VoiceSTTTimeout), voiceSTTTimeout: voiceSTTTimeout(opts.VoiceSTTTimeout),
voiceLog: logger.WithSource(log, "services/channelbridge/voice"), voiceLog: logger.WithSource(log, "services/channelbridge/voice"),
voiceTranscoder: opts.VoiceTranscoder,
voiceAudioStore: opts.VoiceAudioStore,
cancel: func() {}, cancel: func() {},
sem: make(chan struct{}, maxConcurrent), sem: make(chan struct{}, maxConcurrent),
}, nil }, nil
+25 -11
View File
@@ -137,7 +137,10 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
// 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content // 它必须在「丢空判据」之前发生,否则 telegram 的纯语音(无 caption ⇒ Content
// 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与 // 为空)会在下面被整条丢掉。归一写回 msg.Content 之后,最小打扰判定、落库与
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。 // 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
b.normalizeInboundVoice(ctx, &msg) //
// 它同时给出这条语音的**来源与三态结论**(切片 CODE-VOICE;design/24 §10.2 第 2 步),
// 由本函数的调用方一路带到闸门之后的落副本步与落库步 —— 结论与正文同源,绝不重算。
inboundVoice := b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」—— // design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」——
// 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的 // 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的
@@ -224,7 +227,7 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
} }
} }
b.runInboundTurn(ctx, msg, a, text, actor, sess) b.runInboundTurn(ctx, msg, a, text, actor, sess, inboundVoice)
} }
// blacklistDrop 对非主人消息应用黑白名单,返回 // blacklistDrop 对非主人消息应用黑白名单,返回
@@ -270,19 +273,28 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析 // runInboundTurn 为被放行的消息驱动 agent 轮次:解析
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件** // 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
// (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路 // (design/24 §3;切片 M1)、把图片媒体内联成 data: URI 部件挂上既有视觉链路
// (切片 IM-IMG;design/24 §4.2 末)、构建模型可见的消息列表(再前置**该会话自己**最近的 // (切片 IM-IMG;design/24 §4.2 末)、把那条被归一过的语音的**可播副本**落盘并
// 历史转录:主人私聊取主会话、群聊/陌生人取该隔离会话,见 transcript.go),在轮次之前持久化入站 // 产出 `voice` 引用(design/24 §10.2 第 4 步;切片 CODE-VOICE)、构建模型可见的
// 用户消息**连同它们的 `document` 引用列与 `image` 列**(因此即使渠道回复失败它也已落地, // 消息列表(再前置**该会话自己**最近的历史转录:主人私聊取主会话、群聊/陌生人取
// 客户端也有附件芯片与图片可看),并在轮次之后把抽取结论补回同一列,运行 // 该隔离会话,见 transcript.go),在轮次之前持久化入站
// 循环并把结果交给 deliverReply。 // 用户消息**连同它们的 `document` 引用列、`image` 列与 `voice` 列**(因此即使渠道
// 回复失败它也已落地,客户端也有附件芯片、图片与 🎤 可看),并在轮次之后把抽取结论
// 补回同一 document 列,运行循环并把结果交给 deliverReply。
//
// voiceTurn(切片 CODE-VOICE)是那条语音的归一结论 —— **只在放行之后**才可能落副本:
// 被黑白名单或最小打扰拦下的语音,盘上不留任何文件(与附件同一纪律)。
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
text string, actor adapter.Actor, sess session.Session) { text string, actor adapter.Actor, sess session.Session, voiceIn voiceTurn) {
target := inboundTargetFor(sess, msg.Platform) target := inboundTargetFor(sess, msg.Platform)
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或 // 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期 // 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。 // resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
attachments := b.ingestInboundDocuments(ctx, msg) attachments := b.ingestInboundDocuments(ctx, msg)
// 语音副本落盘与附件摄取**并列**、同处闸门之后:不可播的格式先转码(复用
// internal/capability/transcode 的既有能力),转码不可用就**不落副本**并如实记分类。
// 它拿到的字节是**原始字节**(转写早已在流水线最前端用同一份原始字节做完)。
voiceRef := b.ingestInboundVoiceCopy(ctx, msg, voiceIn)
// 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件 // 图片内联与文档摄取**并列**、互不影响:文档走存储与抽取,图片走 data: URI 部件
// (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。 // (同一条用户消息上两条都能挂)。它没有 ctx —— 这里没有任何 I/O(字节由适配器取回)。
images := b.ingestInboundImages(msg) images := b.ingestInboundImages(msg)
@@ -312,10 +324,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
attachInboundContent(loopMsgs, attachments, images) attachInboundContent(loopMsgs, attachments, images)
loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs) loopMsgs = b.seedTurnTranscript(ctx, target, loopMsgs)
// 附件引用与图片必须同批落进用户消息各自的列(design/24 §2.4 / design/16 §4.4):
// 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片 // 只把部件挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片
// 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**)。这里写的是 // 只认 `document` 列,图片只认 `image` 列(**图片绝不进 `document` 列**),🎤 只认
// 引用与身份,文档结论要等回合跑完(下一段)。 // `voice` 列。这里写的是引用与身份,文档结论要等回合跑完(下一段)。
document := b.inboundDocumentColumn(msg, attachments) document := b.inboundDocumentColumn(msg, attachments)
image := inboundImageColumn(images) image := inboundImageColumn(images)
@@ -330,6 +341,9 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
} else { } else {
userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID) userMsg = b.persistIsolated(ctx, history.RoleUser, text, document, image, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a), turnID)
} }
// 语音事实与用户消息**同源落库**(design/24 §10.2 第 5 步):写进刚落的那一行,
// 客户端因此能按结构化字段显示 🎤 与播放控件(绝不从正文里认媒体类型)。
b.persistMessageVoice(userMsg, voiceRef)
res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs) res, err := b.run.Run(loop.WithTurnID(loop.WithSessionEnv(ctx, runEnv), turnID), target.sessionID, loopMsgs)
if err != nil { if err != nil {
@@ -18,7 +18,12 @@ package channelbridge
// `channelbridge/inbound_images.go` 内联成 data: URI 部件、落消息 DTO 的 `image` // `channelbridge/inbound_images.go` 内联成 data: URI 部件、落消息 DTO 的 `image`
// 字段,既不进共享文档存储也不进 `document` 列;**语音也不走这里** —— 入站语音由 // 字段,既不进共享文档存储也不进 `document` 列;**语音也不走这里** —— 入站语音由
// `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 `Content` // `channelbridge/inbound_voice.go` 在流水线最前端归一成文本写回 `Content`
// (切片 IM-ASR;design/06 §1.1),既不落文档存储也不进文档预算; // (切片 IM-ASR;design/06 §1.1),其**音频副本**另走
// `inbound_voice_copy.go`(闸门之后、内容寻址、`voice` 列;切片 CODE-VOICE):
// 语音**不进文档的摄取/注入链路**(不抽取、不进 `documents` DTO 数组、不占注入
// 预算、不出现在附件清单行里),但它**复用同一份存储与 GC**(同一个
// `<DataDir>/uploads/documents` 目录与 `documents` 元数据表,design/24 §10.5)——
// 也就是说「不走这里」指的是**摄取语义**,不是「不落盘」;
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上; // - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后), // - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
// 被拒的消息因此绝不会在盘上留下文件; // 被拒的消息因此绝不会在盘上留下文件;
@@ -12,9 +12,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
// 语音**副本**的领域事实(来源 / 三态 / 副本引用):与上一行的 STT 链同名不同事,
// 因此这里必须显式起别名(design/24 §10)。
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
) )
// 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1): // 本文件承载**入站语音 → 文本归一**(切片 IM-ASR;design/06 §1.1、design/08 §4.1),
// 并在归一时记下这条语音的**来源与转写三态**(切片 CODE-VOICE;design/24 §10.2 第 2 步):
// 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写 // 在入站流水线的**最前端**(丢空判据之前)把一条语音消息变成普通聊天文本,**只**改写
// `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份 // `msg.Content`。这样「丢空判据、最小打扰判定、落库、模型可见消息」看到的都是同一份
// 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。 // 文本(否则纯语音会在丢空判据处被整条丢掉,或落库成空行)。
@@ -29,6 +33,9 @@ import (
// `voice.STTOutcome.Transcribed()`。 // `voice.STTOutcome.Transcribed()`。
// 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写 // 3. **绝不静默、绝不留空消息**:任何一条走完本函数的语音消息,`Content` 要么是转写
// 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。 // 文本、要么是一行**中文诚实提示**(讲清没转写成功 + 原因方向)。熔断时也照落提示。
//
// 归一**不落盘、不转码**(design/24 §10.2:转写要的是原始字节;落副本必须等闸门之后):
// 本函数只产出 `voiceTurn`(来源 + 三态 + 被归一的那条媒体),字节一直留在内存里。
// VoiceSTT 是入站语音转写的注入点(组合根注入 `*voice.Chain`)。 // VoiceSTT 是入站语音转写的注入点(组合根注入 `*voice.Chain`)。
// //
@@ -50,7 +57,7 @@ const (
voiceNoticeCircuit = "[语音消息:这条语音没有转写(云端语音识别暂时不可用,已临时熔断退避 %s)。你可以把内容用文字发一遍。]" voiceNoticeCircuit = "[语音消息:这条语音没有转写(云端语音识别暂时不可用,已临时熔断退避 %s)。你可以把内容用文字发一遍。]"
voiceNoticeEmpty = "[语音消息:识别完成,但这段音频里没有识别出文字(可能是静音、太短或环境噪声)。]" voiceNoticeEmpty = "[语音消息:识别完成,但这段音频里没有识别出文字(可能是静音、太短或环境噪声)。]"
voiceNoticeNoAudio = "[语音消息:服务端没有拿到音频字节(渠道只给了平台内部引用,或代取失败),这条语音没有转写。]" voiceNoticeNoAudio = "[语音消息:服务端没有拿到音频字节(渠道只给了平台内部引用,或代取失败),这条语音没有转写。]"
voiceNoticeFormat = "[语音消息:这条语音的音频格式不支持转写(需要先转码,当前未做)。]" voiceNoticeFormat = "[语音消息:这条语音的音频格式不在服务端可转写的白名单内(服务端只把白名单内的音频送语音识别),这条语音没有转写。]"
) )
// 原因短语(用户可见,绝不包含上游错误原文/密钥)。 // 原因短语(用户可见,绝不包含上游错误原文/密钥)。
@@ -65,47 +72,94 @@ const (
voiceReasonFailed = "云端语音识别失败" voiceReasonFailed = "云端语音识别失败"
) )
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`。没有语音 // voiceTurn 是一条入站语音消息的**归一结论**(design/24 §10.2 第 2 步的产物)。它把
// 媒体时是空操作;任何走完本函数的语音消息都会留下文本或一行诚实提示。 // 「这条消息来自一条语音 + 正文是怎么来的」这件事从归一步带到**闸门之后**的落副本步
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) { // (inbound_voice_copy.go),中间不读任何全局状态 —— 因此并发入站的多条语音互不串味。
//
// 它同时是「模型侧标记」的来源事实:`Ref.Source` / `Ref.Status` 就是 design/24 §10.4
// 的两个闭集字段(渲染成独立 system 行的那一步在编排侧,本包不渲染提示词)。
type voiceTurn struct {
// Found 报告这条消息携带语音媒体(不论有没有字节)。false ⇒ 后面全部空操作。
Found bool
// Media 是被归一的那**一条**语音(`inboundVoiceTarget` 的产出:优先带字节的第一条)。
// 设计刻意只处理一条(design/24 §10.1 N7:同一条消息里的第 2..N 条语音既不转写也
// 不落盘),因此这里只有一条。
Media adapter.Media
// Format 是这条媒体的音频格式名(可转写门归一所得;空 = 认不出)。
Format string
// DurationMs 是渠道**如实给出**的时长;0 = 不知道(绝不估)。
DurationMs int64
// Ref 是来源与转写三态的结论;副本字段(name/mime/size)由闸门之后的落盘步回填。
Ref voiceref.Ref
}
// normalizeInboundVoice 把一条入站语音消息归一成文本并写回 `msg.Content`,同时返回这条
// 语音的来源与三态结论(`voiceTurn`)。没有语音媒体时是空操作(零值结论);任何走完本
// 函数的语音消息都会留下文本或一行诚实提示,且**正文与结论永远同源**(不会出现「正文是
// 转写文本、结论说没转写」这种自相矛盾)。
func (b *Bridge) normalizeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage) voiceTurn {
target, found := inboundVoiceTarget(*msg) target, found := inboundVoiceTarget(*msg)
if !found { if !found {
return return voiceTurn{}
} }
turn := voiceTurn{Found: true, Media: target, DurationMs: channelVoiceDurationMs(target)}
if format, ok := mediafetch.AudioFormatOf(target); ok {
turn.Format = format
}
if mediafetch.ChatText(msg.Content) != "" { if mediafetch.ChatText(msg.Content) != "" {
// 平台已经给了文字(微信自带转写)或这条消息本来就带正文:不调 ASR。 // 平台已经给了文字(微信自带转写)或这条消息本来就带正文:不调 ASR。
// 结论照 design/24 §10.3 的已登记口径「status=transcribed(真转写或平台自带)」:
// 正文确是一段由渠道送达的文字,来源记 platform。
turn.Ref = voiceref.Ref{Source: voiceref.SourcePlatform, Status: voiceref.StatusTranscribed}
b.voiceLogger().Debug("inbound voice: chat text already present, asr skipped", b.voiceLogger().Debug("inbound voice: chat text already present, asr skipped",
"platform", msg.Platform, "channel", msg.ChannelID, "content_type", msg.ContentType) "platform", msg.Platform, "channel", msg.ChannelID, "content_type", msg.ContentType)
return if len(target.Data) > 0 {
// ⚠️ 设计未覆盖的情形(留痕,别当已定案):正文与**内联字节**同时存在 ——
// 平台自带转写的真实形态是「有文字但没字节」(微信按既有裁决不取字节),
// 因此这里更可能是「附了配文的语音」(例如 Telegram 的 audio + caption)。
// 服务端**无法**从结构上区分二者(需要适配器声明),故按已登记口径记 source,
// 但把这条不确定性留在日志里:设计上它是一个待裁决项,绝不静默。
b.voiceLogger().Warn("inbound voice has both chat text and inlined bytes; source recorded as platform",
"platform", msg.Platform, "channel", msg.ChannelID,
"name", target.Name, "mime", target.MIMEType, "size", target.Size)
}
return turn
} }
audio, ok := voiceAudioOf(target) audio, ok := voiceAudioOf(target)
if !ok { if !ok {
msg.Content = voiceNoticeFormat msg.Content = voiceNoticeFormat
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
b.voiceLogger().Warn("inbound voice not transcribed: audio format not transcribable", b.voiceLogger().Warn("inbound voice not transcribed: audio format not transcribable",
"platform", msg.Platform, "channel", msg.ChannelID, "platform", msg.Platform, "channel", msg.ChannelID,
"name", target.Name, "mime", target.MIMEType, "size", target.Size) "name", target.Name, "mime", target.MIMEType, "size", target.Size)
return return turn
} }
if len(audio.Data) == 0 { if len(audio.Data) == 0 {
msg.Content = voiceNoticeNoAudio msg.Content = voiceNoticeNoAudio
turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusFailed}
b.voiceLogger().Warn("inbound voice not transcribed: no audio bytes", b.voiceLogger().Warn("inbound voice not transcribed: no audio bytes",
"platform", msg.Platform, "channel", msg.ChannelID, "platform", msg.Platform, "channel", msg.ChannelID,
"format", audio.Format, "mime", target.MIMEType, "size", target.Size) "format", audio.Format, "mime", target.MIMEType, "size", target.Size)
return return turn
} }
b.transcribeInboundVoice(ctx, msg, audio) turn.Ref = voiceref.Ref{Source: voiceref.SourceASR, Status: b.transcribeInboundVoice(ctx, msg, audio)}
return turn
} }
// transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本, // transcribeInboundVoice 调一次云端 STT 并把结果写回 `Content`(成功=转写文本,
// 其余一律=诚实提示)。熔断打开的短路路径也在这里:**不再发起上游调用**。 // 其余一律=诚实提示),返回这次转写的**三态结论**(design/24 §10.4 的闭集):真转写
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) { // ⇒ transcribed、服务正常的空结果 ⇒ empty、没拿到文本 ⇒ failed。熔断打开的短路路径
// 也在这里:**不再发起上游调用**,且结论同样是 failed(正文是那行熔断提示)。
func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.UnifiedMessage, audio voice.Audio) voiceref.Status {
now := b.now() now := b.now()
if !b.voiceCircuit.allowed(now) { if !b.voiceCircuit.allowed(now) {
left := b.voiceCircuit.retryAfter(now) left := b.voiceCircuit.retryAfter(now)
msg.Content = fmt.Sprintf(voiceNoticeCircuit, left.Truncate(time.Second)) msg.Content = fmt.Sprintf(voiceNoticeCircuit, left.Truncate(time.Second))
b.voiceLogger().Warn("inbound voice not transcribed: circuit open", b.voiceLogger().Warn("inbound voice not transcribed: circuit open",
"platform", msg.Platform, "channel", msg.ChannelID, "retry_after", left.String()) "platform", msg.Platform, "channel", msg.ChannelID, "retry_after", left.String())
return return voiceref.StatusFailed
} }
out, ctxErr := b.callVoiceSTT(ctx, audio) out, ctxErr := b.callVoiceSTT(ctx, audio)
if out.Transcribed() { if out.Transcribed() {
@@ -116,12 +170,12 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
b.voiceLogger().Info("inbound voice transcribed", b.voiceLogger().Info("inbound voice transcribed",
"platform", msg.Platform, "channel", msg.ChannelID, "platform", msg.Platform, "channel", msg.ChannelID,
"provider", out.Provider, "chars", utf8.RuneCountInString(text)) "provider", out.Provider, "chars", utf8.RuneCountInString(text))
return return voiceref.StatusTranscribed
} }
msg.Content = voiceNoticeEmpty msg.Content = voiceNoticeEmpty
b.voiceLogger().Info("inbound voice transcribed to empty text", b.voiceLogger().Info("inbound voice transcribed to empty text",
"platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider) "platform", msg.Platform, "channel", msg.ChannelID, "provider", out.Provider)
return return voiceref.StatusEmpty
} }
// 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面), // 没转写出来:按类别决定是否熔断(只有「再试也是白试」的类别才关掉整面),
// 无论如何都落一行诚实提示 —— 消息**绝不**留空。 // 无论如何都落一行诚实提示 —— 消息**绝不**留空。
@@ -133,8 +187,17 @@ func (b *Bridge) transcribeInboundVoice(ctx context.Context, msg *adapter.Unifie
b.voiceLogger().Warn("inbound voice not transcribed", b.voiceLogger().Warn("inbound voice not transcribed",
"platform", msg.Platform, "channel", msg.ChannelID, "platform", msg.Platform, "channel", msg.ChannelID,
"provider", out.Provider, "category", category, "circuit", !b.voiceCircuit.allowed(now)) "provider", out.Provider, "category", category, "circuit", !b.voiceCircuit.allowed(now))
return voiceref.StatusFailed
} }
// channelVoiceDurationMs 返回渠道**如实给出**的语音时长(毫秒),拿不到就是 0。
//
// ⚠️ 接线状态(诚实登记):适配器侧的字段(design/24 §10.9-6 的 `adapter.Media.DurationMs`)
// **尚未落地**,因此今天恒定返回 0 —— 也就是「时长未知」,与「渠道没给」的落库/DTO 形状
// 逐字相同(键缺席,绝不估)。它留在这里是为了让那一步只有**一处**要改(design/24 §10.4
// 的「渠道如实给出时才 >0」)。
func channelVoiceDurationMs(_ adapter.Media) int64 { return 0 }
// callVoiceSTT 调一次注入的 STT,并为这次调用套上**独立短超时**(父 ctx 取消同样立即 // callVoiceSTT 调一次注入的 STT,并为这次调用套上**独立短超时**(父 ctx 取消同样立即
// 生效)。未接线时直接返回「未配置」结果:不调上游、不等超时,走同一条诚实提示路径。 // 生效)。未接线时直接返回「未配置」结果:不调上游、不等超时,走同一条诚实提示路径。
// //
@@ -0,0 +1,194 @@
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
}
@@ -0,0 +1,168 @@
package channelbridge
// 本文件是本片(CODE-VOICE)**诚实路径**的用例:副本落不成、标记缺一块、消息被闸门拦下
// 这几种情形都必须**照实**表现 —— 报错分类进日志、库里不出现假副本、消息本身一个字不丢。
// 夹具与替身(voiceCopyEnv / fakeAudioStore / fakeVoiceTranscoder)在同目录的
// inbound_voice_copy_test.go 里,两半共用一套。
import (
"context"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// TestInboundVoiceCopyRejectedWhenStoreRefIncomplete 钉住「同生共死」在**跨模块边界**上的
// 执行:存储侧若给了一份残缺引用(有 name 没有 mime),我们**不给副本**(那是客户端读不懂
// 的形状),并按 ERROR 报(这是不该发生的状态,绝不静默)。
func TestInboundVoiceCopyRejectedWhenStoreRefIncomplete(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{incomplete: true}
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
ref := env.voiceRefOf(t)
if ref.HasCopy() {
t.Fatalf("voice 事实 = %+v, want 无副本(残缺引用绝不进库)", ref)
}
if log := env.logs.String(); !strings.Contains(log, "ref is incomplete") {
t.Fatalf("缺少残缺引用的 ERROR:%q", log)
}
}
// TestInboundVoiceMarkFollowsTranscriptionStatus 钉住三态与正文的**同源性**:服务正常的
// 空结果 ⇒ `empty`;没拿到文本 ⇒ `failed`。两种情况下副本照样落(能不能回放与能不能转写
// 是两件事)。
func TestInboundVoiceMarkFollowsTranscriptionStatus(t *testing.T) {
t.Parallel()
cases := []struct {
name string
out mmvoice.STTOutcome
status voiceref.Status
}{
{
name: "识别成功但没人说话",
out: mmvoice.STTOutcome{Text: " ", Provider: "cloud"},
status: voiceref.StatusEmpty,
},
{
name: "上游失败",
out: mmvoice.STTOutcome{Text: "", Provider: "cloud", Cause: mmvoice.ErrAuthFailed},
status: voiceref.StatusFailed,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
env := newVoiceCopyEnv(t, &fakeVoiceSTT{reply: tc.out}, newFakeVoiceTranscoder(), store)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
ref := env.voiceRefOf(t)
if ref.Status != tc.status {
t.Fatalf("status = %q, want %q", ref.Status, tc.status)
}
if ref.Source != voiceref.SourceASR {
t.Fatalf("source = %q, want asr", ref.Source)
}
if !ref.HasCopy() {
t.Fatalf("voice 事实 = %+v, want 有副本(回放与转写无关)", ref)
}
})
}
}
// TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn 钉住「未实现该视图」的诚实:
// 历史存储还没有 voice 列写路径时,**如实报 WARN**(不假装写了),消息与正文照旧落库。
func TestInboundVoiceMarkNotPersistedWhenHistoryLacksTheColumn(t *testing.T) {
t.Parallel()
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
// 只实现 HistoryStore + document 列视图(documentHistory 会给每条消息分配 id),
// 但**没有** UpdateMessageVoice。
hist := &documentHistory{captureHistory: &captureHistory{}}
store := &fakeAudioStore{}
b, err := New(Options{
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: testOwnerProfile("telegram", "100"),
History: hist,
VoiceSTT: transcodingSTT("语音内容"),
VoiceTranscoder: newFakeVoiceTranscoder(),
VoiceAudioStore: store,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(&fakeAdapter{name: "telegram"})
b.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
if got := userContentIn(t, hist.captureHistory); got != "语音内容" {
t.Fatalf("落库正文 = %q, want 转写文本", got)
}
if l := logs.String(); !strings.Contains(l, "history store has no voice column path") {
t.Fatalf("缺少「无 voice 列写路径」的 WARN:%q", l)
}
}
// TestInboundVoiceCopyNotStoredForRejectedMessage 钉住位置纪律:副本落盘必须在**闸门之后**。
// 被黑白名单拦下的群消息**绝不在盘上留下任何文件**(与 IM 附件同一纪律),也因此不产生
// 任何转码 CPU 开销。
func TestInboundVoiceCopyNotStoredForRejectedMessage(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
tr := newFakeVoiceTranscoder()
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
b, err := New(Options{
Log: log, Loop: loop.New(loop.Options{Agent: ag, MaxRounds: 2, Log: log}),
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1"}},
Owner: testOwnerProfile("telegram", "100"),
History: hist,
VoiceSTT: transcodingSTT("群里的语音"),
VoiceTranscoder: tr,
VoiceAudioStore: store,
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 该群被显式拉黑 ⇒ 消息在闸门处被丢弃。
b.Add(&fakeAdapter{name: "telegram", cfg: map[string]any{"denyGroups": []string{"-20"}}})
msg := voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw"))
msg.ChannelType = adapter.ChannelGroup
msg.ChannelID = "-20"
msg.GroupID = "-20"
msg.Sender = adapter.Actor{Role: adapter.RoleMember, IsGroup: true, GroupID: "-20"}
msg.SenderID = "9"
b.process(context.Background(), msg)
if store.callCount() != 0 {
t.Fatalf("音频存储调用次数 = %d, want 0(被拦下的消息绝不落文件)", store.callCount())
}
if tr.callCount() != 0 {
t.Fatalf("转码调用次数 = %d, want 0(闸门之后才谈转码)", tr.callCount())
}
if len(hist.records()) != 0 {
t.Fatalf("被拦下的消息不该落库:%+v", hist.records())
}
}
@@ -0,0 +1,396 @@
package channelbridge
// 本文件锁定**入站语音副本的落盘与 `voice` 列回填**(design/24 §10.2 第 4/5 步;
// design/25 §7.1 第 ④ 步;切片 CODE-VOICE)。钉住四件事:
//
// 1. 可播格式(ogg/wav/mp3…)**原样**落副本,一个字节不改、**不碰转码**;
// 2. 不可播格式(QQ 的 AMR)在落盘**之前**转码(复用 internal/capability/transcode),
// 落的是可播副本,存储名带的是**目标扩展名**(不是渠道原文件名);
// 3. 转码不可用(未接线 / 没这条路 / 越限 / 解码失败…)⇒ **不落副本、不打假 url**,
// 只留一条带**分类**的 WARN —— 而**消息本身一个字都不丢**(正文仍是转写文本)。
// 4. `voice` 列的 JSON 与 design/24 §10.4 的落库形状逐字一致:非语音消息空串、
// 无副本时 name/mime/size 三个键**一起缺席**(同生共死)。
//
// 为什么这些断言必须存在:副本这一路最容易「静默坏掉」——落了一份播不了的字节、
// 或者给了一个指向不存在文件的 url,客户端只会看到一个转不完的圈,而日志里什么都没有。
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
mmvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// voiceHistory 是记录型历史存储(在 documentHistory 之上)再加**本片新增的 `voice` 列
// 写入视图**(design/24 §10.9-4)。真存储上线后由 `history.Store` 实现同一签名。
type voiceHistory struct {
*documentHistory
voiceMu sync.Mutex
voices map[string]string
}
func (h *voiceHistory) UpdateMessageVoice(_ context.Context, id, voice string) error {
h.voiceMu.Lock()
defer h.voiceMu.Unlock()
if h.voices == nil {
h.voices = make(map[string]string)
}
h.voices[id] = voice
return nil
}
// voiceColumnOf 返回 id 所指消息当前的 `voice` 列(被回写过就是回写值)。
func (h *voiceHistory) voiceColumnOf(id string) string {
h.voiceMu.Lock()
updated, ok := h.voices[id]
h.voiceMu.Unlock()
if ok {
return updated
}
return ""
}
// lastUser 返回最近落库的那条用户消息(一个回合只有一条用户消息)。
func (h *voiceHistory) lastUser(t *testing.T) history.Message {
t.Helper()
var out history.Message
found := false
for _, m := range h.records() {
if m.Role == history.RoleUser {
out, found = m, true
}
}
if !found {
t.Fatal("本回合没有落库任何用户消息")
}
return out
}
// fakeAudioStore 是音频落盘面的替身:它记录每次保存的**提议名与字节**(好让「转码产物
// 才是落盘字节」这类纪律可断言),并按真存储的口径给出内容寻址名与下发 MIME。
type fakeAudioStore struct {
mu sync.Mutex
calls []audioSaveCall
err error
// incomplete 为 true 时返回一份**残缺**的引用(有 name 没 mime),用来钉住
// 「同生共死被破坏时不给副本」。
incomplete bool
}
type audioSaveCall struct {
name string
data []byte
}
func (s *fakeAudioStore) SaveAudio(_ context.Context, origName string, data []byte) (documents.Ref, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, audioSaveCall{name: origName, data: append([]byte(nil), data...)})
if s.err != nil {
return documents.Ref{}, s.err
}
if s.incomplete {
return documents.Ref{Name: "aabbccddeeff00112233445566778899.ogg"}, nil
}
ext := documents.ExtOf(origName)
return documents.Ref{
Name: contentAddressedName + ext,
MIME: "audio/" + strings.TrimPrefix(ext, "."),
Size: int64(len(data)),
}, nil
}
func (s *fakeAudioStore) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.calls)
}
func (s *fakeAudioStore) lastCall(t *testing.T) audioSaveCall {
t.Helper()
s.mu.Lock()
defer s.mu.Unlock()
if len(s.calls) == 0 {
t.Fatal("音频存储从未被调用")
}
return s.calls[len(s.calls)-1]
}
// contentAddressedName 是一份合法的存储主名(32 位小写十六进制),与真存储的口径一致。
const contentAddressedName = "0123456789abcdef0123456789abcdef"
// fakeVoiceTranscoder 是转码替身:按 routes 回答能力门,按 out/err 回答转换结果。
type fakeVoiceTranscoder struct {
routes map[string]bool
out transcode.Result
err error
mu sync.Mutex
calls int
}
func newFakeVoiceTranscoder() *fakeVoiceTranscoder {
return &fakeVoiceTranscoder{routes: map[string]bool{"amr>wav": true}}
}
func (f *fakeVoiceTranscoder) Transcode(_ context.Context, req transcode.Request) (transcode.Result, error) {
f.mu.Lock()
f.calls++
f.mu.Unlock()
if f.err != nil {
return transcode.Result{}, f.err
}
res := f.out
if res.Format == "" {
res.Format = req.Target
}
return res, nil
}
func (f *fakeVoiceTranscoder) Supports(src, dst transcode.Format) bool {
return f.routes[transcode.FormatName(src)+">"+transcode.FormatName(dst)]
}
func (f *fakeVoiceTranscoder) Formats() []transcode.Format {
return []transcode.Format{transcode.FormatAMR, transcode.FormatWAV}
}
func (f *fakeVoiceTranscoder) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
// voiceCopyEnv 是本片用例的装配:真流水线(`process`)+ 假 STT + 可注入的转码器与音频
// 存储 + 记录型历史(含 voice 列视图)。
type voiceCopyEnv struct {
bridge *Bridge
hist *voiceHistory
store *fakeAudioStore
tr *fakeVoiceTranscoder
logs *safeBuffer
}
func newVoiceCopyEnv(t *testing.T, stt VoiceSTT, tr *fakeVoiceTranscoder, store *fakeAudioStore) *voiceCopyEnv {
t.Helper()
logs := &safeBuffer{}
log := slog.New(slog.NewTextHandler(logs, nil))
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(&stubModelAdapter{}, nil))
if err != nil {
t.Fatalf("构造 agent: %v", err)
}
runner := loop.New(loop.Options{Agent: ag, MaxRounds: 4, Log: log})
hist := &voiceHistory{documentHistory: &documentHistory{captureHistory: &captureHistory{}}}
opts := Options{
Log: log, Loop: runner,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: testOwnerProfile("telegram", "100"),
History: hist, VoiceSTT: stt,
}
// 传 nil 与传具体值必须区别对待:`VoiceTranscoder` 是接口,「没接线」正是本片要
// 覆盖的一条路径(转码不可用 ⇒ 不落副本),装了 nil 指针会让那条判据失效。
if tr != nil {
opts.VoiceTranscoder = tr
}
if store != nil {
opts.VoiceAudioStore = store
}
b, err := New(opts)
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(&fakeAdapter{name: "telegram"})
return &voiceCopyEnv{bridge: b, hist: hist, store: store, tr: tr, logs: logs}
}
// voiceRefOf 返回本回合用户消息的 `voice` 事实(列空 ⇒ 零值,即「不是语音消息」)。
func (e *voiceCopyEnv) voiceRefOf(t *testing.T) voiceref.Ref {
t.Helper()
msg := e.hist.lastUser(t)
raw := e.hist.voiceColumnOf(msg.ID)
ref, err := voiceref.Decode(raw)
if err != nil {
t.Fatalf("解码 voice 列 %q: %v", raw, err)
}
return ref
}
// contentOf 返回本回合落库的用户消息正文。
func (e *voiceCopyEnv) contentOf(t *testing.T) string {
t.Helper()
return e.hist.lastUser(t).Content
}
// userContentIn 返回记录型历史里最后一条用户消息的正文(没有则判失败)。
func userContentIn(t *testing.T, hist *captureHistory) string {
t.Helper()
content := ""
found := false
for _, m := range hist.records() {
if m.Role == history.RoleUser {
content, found = m.Content, true
}
}
if !found {
t.Fatal("没有落库任何用户消息")
}
return content
}
// transcodingSTT 是一个「转写成功」的假 STT,供不需要关心转写细节的用例使用。
func transcodingSTT(text string) *fakeVoiceSTT {
return &fakeVoiceSTT{reply: mmvoice.STTOutcome{Text: text, Provider: "cloud"}}
}
// TestInboundVoiceCopyStoredAsIsForPlayableFormat 覆盖设计口径①(design/25 §7.1):
// 源格式可播 ⇒ **原样落副本**,一个字节不改,且**一次转码都不发生**(可播还转一圈等于
// 白烧 CPU、把音质往下拽)。同时钉住落库 JSON 的逐字形状。
func TestInboundVoiceCopyStoredAsIsForPlayableFormat(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
tr := newFakeVoiceTranscoder()
env := newVoiceCopyEnv(t, transcodingSTT("你好"), tr, store)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
if store.callCount() != 1 {
t.Fatalf("音频存储调用次数 = %d, want 1", store.callCount())
}
call := store.lastCall(t)
if call.name != "voice.ogg" {
t.Fatalf("存储提议名 = %q, want voice.ogg(扩展名决定按哪种格式裁决)", call.name)
}
if string(call.data) != "OGGDATA" {
t.Fatalf("落盘字节 = %q, want 原样(可播格式不转码)", call.data)
}
if tr.callCount() != 0 {
t.Fatalf("转码调用次数 = %d, want 0(可播格式绝不转码)", tr.callCount())
}
ref := env.voiceRefOf(t)
if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed {
t.Fatalf("voice 事实 = %+v, want asr/transcribed", ref)
}
if ref.Name != contentAddressedName+".ogg" || ref.MIME != "audio/ogg" || ref.Size != 7 {
t.Fatalf("副本事实 = %+v, want 服务端存储名 + audio/ogg + 7 字节", ref)
}
if log := env.logs.String(); !strings.Contains(log, "inbound voice copy stored") ||
!strings.Contains(log, "source=services/channelbridge/voice") {
t.Fatalf("落副本日志缺少子功能来源:%q", log)
}
}
// TestInboundVoiceCopyTranscodesAMRBeforeStoring 覆盖设计口径②(design/24 §10.14-2 的
// 核实结论:QQ 的 AMR 浏览器播不了 ⇒ 必须转码):落的是**转码产物**,存储名带的是**目标**
// 扩展名(`.wav`),且时长取转码产物可确证的值。
func TestInboundVoiceCopyTranscodesAMRBeforeStoring(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
tr := newFakeVoiceTranscoder()
tr.out = transcode.Result{Data: []byte("RIFFwavdata"), Format: transcode.FormatWAV, DurationMs: 3200}
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), tr, store)
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw")))
if tr.callCount() != 1 {
t.Fatalf("转码调用次数 = %d, want 1(AMR 不可播)", tr.callCount())
}
call := store.lastCall(t)
if call.name != "voice.wav" {
t.Fatalf("存储提议名 = %q, want voice.wav(转码产物的扩展名,不是渠道原文件名)", call.name)
}
if string(call.data) != "RIFFwavdata" {
t.Fatalf("落盘字节 = %q, want 转码产物", call.data)
}
ref := env.voiceRefOf(t)
if ref.Name != contentAddressedName+".wav" || ref.DurationMs != 3200 {
t.Fatalf("副本事实 = %+v, want .wav + durationMs=3200(转码产物可确证)", ref)
}
if log := env.logs.String(); !strings.Contains(log, "transcoded=true") {
t.Fatalf("日志没有标出转码事实:%q", log)
}
}
// TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable 覆盖设计口径③(design/25 §5.4):
// **不可播且转码不可用** ⇒ 不落副本、不打假 url,只留一条带**分类**的 WARN;而**消息本身
// 一个字都不丢**(正文仍是转写文本)—— 这是本片「转码失败要可分类且不丢消息」的验收。
func TestInboundVoiceCopyNotStoredWhenTranscodeUnavailable(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
// 组合根没把转码能力交进来(VoiceTranscoder 为 nil)——今天生产装配就是这个状态。
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), nil, store)
env.bridge.process(context.Background(), voiceMessage("v.amr", "audio/amr", []byte("#!AMR\nraw")))
if store.callCount() != 0 {
t.Fatalf("音频存储调用次数 = %d, want 0(不可播就不落,绝不给播不了的 url)", store.callCount())
}
ref := env.voiceRefOf(t)
if !ref.IsVoice() || ref.HasCopy() {
t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref)
}
if ref.Source != voiceref.SourceASR || ref.Status != voiceref.StatusTranscribed {
t.Fatalf("voice 事实 = %+v, want asr/transcribed(正文确实是转写文本)", ref)
}
if got := env.contentOf(t); got != "语音内容" {
t.Fatalf("落库正文 = %q, want 转写文本(消息绝不因为转码不可用而丢)", got)
}
log := env.logs.String()
if !strings.Contains(log, "category=transcode_not_wired") || !strings.Contains(log, "copy not stored") {
t.Fatalf("缺少带分类的诚实日志:%q", log)
}
}
// TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted 覆盖微信那条既有裁决
// (design/24 §10.14-1):平台自带转写 ⇒ 连字节都不取 ⇒ **没有可回放副本**,但 🎤 标记
// (`source:"platform"`)必须照落 —— 客户端据此显示「这是一条语音」,只是不显示播放控件。
func TestInboundVoiceCopyNotStoredWithoutBytesButMarkStillPersisted(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{}
env := newVoiceCopyEnv(t, &fakeVoiceSTT{}, newFakeVoiceTranscoder(), store)
msg := voiceMessage("v.silk", "audio/silk", nil)
msg.Content = "平台自带转写" // 微信 iLink 的 voice_item.text
env.bridge.process(context.Background(), msg)
if store.callCount() != 0 {
t.Fatalf("音频存储调用次数 = %d, want 0(没有字节就没有副本)", store.callCount())
}
ref := env.voiceRefOf(t)
if ref.Source != voiceref.SourcePlatform || ref.Status != voiceref.StatusTranscribed {
t.Fatalf("voice 事实 = %+v, want platform/transcribed", ref)
}
if ref.HasCopy() {
t.Fatalf("voice 事实 = %+v, want 无副本(name/mime/size 全缺席)", ref)
}
}
// TestInboundVoiceCopyNotStoredWhenSaveFails 钉住「存储侧失败」的诚实处理:正文与标记
// 都在,只是没有副本 —— 绝不因为落副本失败就把消息当成失败。
func TestInboundVoiceCopyNotStoredWhenSaveFails(t *testing.T) {
t.Parallel()
store := &fakeAudioStore{err: errors.New("磁盘满了")}
env := newVoiceCopyEnv(t, transcodingSTT("语音内容"), newFakeVoiceTranscoder(), store)
env.bridge.process(context.Background(), voiceMessage("v.ogg", "audio/ogg", []byte("OGGDATA")))
ref := env.voiceRefOf(t)
if !ref.IsVoice() || ref.HasCopy() {
t.Fatalf("voice 事实 = %+v, want 「是语音但无副本」", ref)
}
if got := env.contentOf(t); got != "语音内容" {
t.Fatalf("落库正文 = %q, want 转写文本", got)
}
if log := env.logs.String(); !strings.Contains(log, "inbound voice copy not stored") {
t.Fatalf("缺少落副本失败的 WARN:%q", log)
}
}
@@ -386,7 +386,13 @@ func TestInboundVoiceTimeoutIsHonest(t *testing.T) {
} }
// TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节 // TestInboundVoiceWithoutBytesOrFormatIsHonest 覆盖两条「拿不到音频」的路径:没有字节
// (渠道只给平台内部引用 / 代取失败)与格式不支持 —— 都落诚实提示,且**不调上游**。 // (渠道只给平台内部引用 / 代取失败)与格式不在**可转写白名单**内 —— 都落诚实提示,
// 且**不调上游**。
//
// ⚠️ 文案留痕(2026-10-06,切片 CODE-VOICE):本用例原先钉的是「格式不支持(需要先转码,
// 当前未做)」——那句在转码能力(design/25)落地后**就是一句假话**(design/25 §5.4 明令
// 同步改)。因此这里改钉新口径:格式**不在可转写白名单内**。这不是「改测试迁就代码」,
// 而是被断言保护的行为**已按设计变更**(与 QQ `[语音]` 占位标记那条同一性质)。
func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) { func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("没有字节", func(t *testing.T) { t.Run("没有字节", func(t *testing.T) {
@@ -404,7 +410,7 @@ func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got) t.Fatalf("落库正文 = %q, want 字节缺失的诚实提示", got)
} }
}) })
t.Run("格式不支持", func(t *testing.T) { t.Run("格式不在可转写白名单内", func(t *testing.T) {
t.Parallel() t.Parallel()
stt := &fakeVoiceSTT{} stt := &fakeVoiceSTT{}
env := newVoiceEnv(t, stt, 0) env := newVoiceEnv(t, stt, 0)
@@ -415,8 +421,12 @@ func TestInboundVoiceWithoutBytesOrFormatIsHonest(t *testing.T) {
if stt.callCount() != 0 { if stt.callCount() != 0 {
t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount()) t.Fatalf("STT 调用次数 = %d, want 0(格式不在白名单,不调上游)", stt.callCount())
} }
if got := lastUserContent(env); !strings.Contains(got, "格式不支持") { got := lastUserContent(env)
t.Fatalf("落库正文 = %q, want 格式不支持的诚实提示", got) if !strings.Contains(got, "不在服务端可转写的白名单内") {
t.Fatalf("落库正文 = %q, want 白名单外的诚实提示", got)
}
if strings.Contains(got, "当前未做") {
t.Fatalf("落库正文 = %q:转码能力已落地(design/25),这句「当前未做」是假话", got)
} }
}) })
} }