package gateway // 本文件锁定**消息 DTO 的语音面**(契约 v0.67 §4.4;design/24 §10.4)在网关侧的 // 投影口径: // ① 有语音事实 ⇒ `voice` 键存在,字段逐一对上,`url` 由服务端拼出; // ② 非语音消息 ⇒ 键**整体缺席**(不是 `{}` 也不是 `null`); // ③ 无副本(微信那条路)⇒ `source`/`status` 在,`name`/`mime`/`size`/`url` **全缺席** // (四者同生共死); // ④ 列缺失 / 列被改坏 ⇒ **字段缺席但正文照常 200**(可选的读取面绝不把整页历史拖成 500); // ⑤ 它挂在**多个读取面**上(会话消息页、渠道日志页、编辑消息的响应)。 import ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice" ) // voiceEnv 打包语音面测试所需的依赖:真实(内存)历史存储 + httptest 网关。 // 与本包既有的 newHistoryEnv 的差别只有一点:它把 `*sql.DB` 也交出来,使「列被外部 // 改坏」这类场景能直接落 SQL(那是唯一能造出坏列的途径)。 type voiceEnv struct { ts *httptest.Server jwt *auth.JWTService st *history.Store conn *sql.DB } func newVoiceEnv(t *testing.T) *voiceEnv { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, ":memory:", testLogger()) if err != nil { t.Fatalf("打开内存库: %v", err) } t.Cleanup(func() { _ = conn.Close() }) st, err := history.NewStore(ctx, conn, testLogger()) if err != nil { t.Fatalf("构造历史存储: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st}) return &voiceEnv{ts: ts, jwt: jwt, st: st, conn: conn} } // seedVoiceRow 追加一条消息并(可选)把一条语音事实写进它的 voice 列。 func (e *voiceEnv) seedVoiceRow(t *testing.T, m history.Message, raw string) history.Message { t.Helper() got, err := e.st.AppendFull(context.Background(), m) if err != nil { t.Fatalf("落库消息: %v", err) } if raw != "" { if err := e.st.UpdateMessageVoice(context.Background(), got.ID, raw); err != nil { t.Fatalf("写 voice 列: %v", err) } } return got } // messagePage 是一页消息的**原始 JSON 形态**(`{data:[…]}` 信封里的数组;用于断言 // 键是否真的缺席 —— 解码进结构体是看不出「键缺席」与「零值」的差别的)。 func messagePage(t *testing.T, body []byte) []map[string]json.RawMessage { t.Helper() var page []map[string]json.RawMessage if err := json.Unmarshal(decodeBody(t, body)["data"], &page); err != nil { t.Fatalf("解析消息页: %v (body=%s)", err, body) } return page } // decodedVoice 从原始 JSON 里取出 `voice` 对象;键缺席时返回 (nil, false)。 func decodedVoice(t *testing.T, row map[string]json.RawMessage) (*historyVoice, bool) { t.Helper() raw, ok := row["voice"] if !ok { return nil, false } var v historyVoice if err := json.Unmarshal(raw, &v); err != nil { t.Fatalf("解析 voice 对象: %v (raw=%s)", err, raw) } return &v, true } func TestHistoryMessageVoiceFaceOnSessionPage(t *testing.T) { t.Parallel() env := newVoiceEnv(t) tok := signAny(t, env.jwt, "acct", auth.RoleOwner) name := "0123456789abcdef0123456789abcdef.ogg" raw, err := voiceref.Encode(voiceref.Ref{ Source: voiceref.SourceASR, Status: voiceref.StatusTranscribed, Name: name, MIME: "audio/ogg", Size: 4242, DurationMs: 3200, }) if err != nil { t.Fatalf("编码语音事实: %v", err) } voiceMsg := env.seedVoiceRow(t, history.Message{ SessionID: history.MainSessionID, Role: history.RoleUser, Content: "转写文本", }, raw) plain := env.seedVoiceRow(t, history.Message{ SessionID: history.MainSessionID, Role: history.RoleUser, Content: "普通文本", }, "") resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } page := messagePage(t, body) if len(page) != 2 { t.Fatalf("消息条数 = %d, want 2", len(page)) } byID := make(map[string]map[string]json.RawMessage, len(page)) for _, row := range page { var id string if err := json.Unmarshal(row["id"], &id); err != nil { t.Fatalf("解析 id: %v", err) } byID[id] = row } got, ok := decodedVoice(t, byID[voiceMsg.ID]) if !ok { t.Fatalf("语音消息缺 voice 键(body=%s)", body) } if got.Source != "asr" || got.Status != "transcribed" { t.Fatalf("source/status = %q/%q, want asr/transcribed", got.Source, got.Status) } if got.Name != name || got.MIME != "audio/ogg" || got.Size != 4242 || got.DurationMs != 3200 { t.Fatalf("副本字段 = %+v", got) } if want := voiceref.MediaPathPrefix + name; got.URL != want { t.Fatalf("url = %q, want %q(服务端唯一派生字段)", got.URL, want) } // 非语音消息:键必须**真的缺席**(不是 `{}`、不是 `null`)。 if _, ok := decodedVoice(t, byID[plain.ID]); ok { t.Fatalf("普通消息带了 voice 键(body=%s)", body) } } func TestHistoryMessageVoiceWithoutCopyOmitsCopyFields(t *testing.T) { t.Parallel() env := newVoiceEnv(t) tok := signAny(t, env.jwt, "acct", auth.RoleOwner) // 微信那条路:平台自带转写、按既有裁决不取字节 ⇒ 没有可回放副本。 raw, err := voiceref.Encode(voiceref.Ref{ Source: voiceref.SourcePlatform, Status: voiceref.StatusTranscribed, }) if err != nil { t.Fatalf("编码语音事实: %v", err) } msg := env.seedVoiceRow(t, history.Message{ SessionID: history.MainSessionID, Role: history.RoleUser, Content: "平台转写文本", }, raw) resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } page := messagePage(t, body) if len(page) != 1 { t.Fatalf("消息条数 = %d, want 1", len(page)) } var id string _ = json.Unmarshal(page[0]["id"], &id) if id != msg.ID { t.Fatalf("消息 id = %q, want %q", id, msg.ID) } v, ok := decodedVoice(t, page[0]) if !ok { t.Fatal("缺 voice 键:没有副本不等于不是语音消息") } if v.Source != "platform" || v.Status != "transcribed" { t.Fatalf("source/status = %q/%q, want platform/transcribed", v.Source, v.Status) } var obj map[string]json.RawMessage if err := json.Unmarshal(page[0]["voice"], &obj); err != nil { t.Fatalf("解析 voice 对象: %v", err) } for _, key := range []string{"name", "mime", "size", "url"} { if _, has := obj[key]; has { t.Fatalf("无副本时不该有 %q 键(四者同生共死)", key) } } } func TestHistoryMessageVoiceAbsentWhenColumnOrValueBroken(t *testing.T) { t.Parallel() ctx := context.Background() // ① 列还不存在(迁移 0020 之前建出的表且从未写过语音):字段缺席、正文照常 200。 fresh := newVoiceEnv(t) tok := signAny(t, fresh.jwt, "acct", auth.RoleOwner) msg := fresh.seedVoiceRow(t, history.Message{ SessionID: history.MainSessionID, Role: history.RoleUser, Content: "普通文本", }, "") resp, body := doReq(t, http.MethodGet, fresh.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("列缺失时 status = %d, want 200(可选面不该拖垮正文)(body=%s)", resp.StatusCode, body) } page := messagePage(t, body) var id, content string _ = json.Unmarshal(page[0]["id"], &id) _ = json.Unmarshal(page[0]["content"], &content) if id != msg.ID || content != "普通文本" { t.Fatalf("正文行不可用:id=%q content=%q, want %q/普通文本", id, content, msg.ID) } if _, ok := decodedVoice(t, page[0]); ok { t.Fatal("列缺失时不该凭空产出 voice 键") } // ② 列被外部改坏:同样只让该字段缺席,并记一条带来源的 WARN(可观测,不静默)。 broken := newVoiceEnv(t) btok := signAny(t, broken.jwt, "acct", auth.RoleOwner) bmsg := broken.seedVoiceRow(t, history.Message{ SessionID: history.MainSessionID, Role: history.RoleUser, Content: "坏列消息", }, "") valid, err := voiceref.Encode(voiceref.Ref{Source: voiceref.SourceASR, Status: voiceref.StatusEmpty}) if err != nil { t.Fatalf("编码: %v", err) } // 先用一次合法写入把列前滚出来,再把它改成坏内容。 if err := broken.st.UpdateMessageVoice(ctx, bmsg.ID, valid); err != nil { t.Fatalf("前滚 voice 列: %v", err) } if _, err := broken.conn.ExecContext(ctx, `UPDATE chat_messages SET voice = ? WHERE id = ?`, "{not json", bmsg.ID); err != nil { t.Fatalf("写坏列: %v", err) } resp, body = doReq(t, http.MethodGet, broken.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, btok) if resp.StatusCode != http.StatusOK { t.Fatalf("坏列时 status = %d, want 200 (body=%s)", resp.StatusCode, body) } brokenPage := messagePage(t, body) var brokenContent string _ = json.Unmarshal(brokenPage[0]["content"], &brokenContent) if brokenContent != "坏列消息" { t.Fatalf("正文被坏列影响:%q", brokenContent) } if _, ok := decodedVoice(t, brokenPage[0]); ok { t.Fatal("坏列不该被静默解释成一个语音事实") } } func TestHistoryMessageVoiceOnChannelLogAndEditResponse(t *testing.T) { t.Parallel() env := newVoiceEnv(t) tok := signAny(t, env.jwt, "acct", auth.RoleOwner) name := "ffffffffffffffffffffffffffffffff.mp3" raw, err := voiceref.Encode(voiceref.Ref{ Source: voiceref.SourceASR, Status: voiceref.StatusTranscribed, Name: name, MIME: "audio/mpeg", Size: 99, }) if err != nil { t.Fatalf("编码语音事实: %v", err) } adapter := "qq-obv11:10001" msg := env.seedVoiceRow(t, history.Message{ SessionID: history.MainSessionID, Role: history.RoleUser, Content: "IM 语音", Platform: "qq-obv11", AdapterID: adapter, }, raw) // 渠道日志页(管理面读取面之一)同样带 voice。 resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/channels/messages?adapter="+adapter, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("渠道日志 status = %d, want 200 (body=%s)", resp.StatusCode, body) } page := messagePage(t, body) if len(page) != 1 { t.Fatalf("渠道日志条数 = %d, want 1", len(page)) } v, ok := decodedVoice(t, page[0]) if !ok || v.Name != name { t.Fatalf("渠道日志里的 voice = %+v (ok=%v)", v, ok) } // 编辑消息的响应同样带 voice(同一次读取面口径)。 resp, body = doReq(t, http.MethodPatch, env.ts.URL+"/api/v1/messages/"+msg.ID, map[string]any{"content": "改过的正文"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("PATCH status = %d, want 200 (body=%s)", resp.StatusCode, body) } var patched map[string]json.RawMessage if err := json.Unmarshal(decodeBody(t, body)["data"], &patched); err != nil { t.Fatalf("解析 PATCH data: %v", err) } pv, ok := decodedVoice(t, patched) if !ok || pv.Name != name { t.Fatalf("PATCH 响应里的 voice = %+v (ok=%v)", pv, ok) } }