Files
Cyrene_v2/internal/services/gateway/history_voice_test.go
T
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(a) 记忆入库前审查的两条 admin 端点(主人判「有必要就做」):
  GET /api/v1/admin/memories/reviews?limit= (留痕读侧,不含记忆正文)
  POST /api/v1/admin/memories/{id}/revert (回滚软合并,落审计 memory.review.revert)
  两条 writeAuth(裁定 DECISIONS #10:留痕属私有面,不给 viewer)
(b) 语音用户可见面:DTO voice(含服务端派生 url)+ GET /api/v1/media/{name}
  (200/206/304/416/HEAD、Accept-Ranges、非法名/穿越统一 404 media_not_found)
  + documents.Store.SaveAudio(内容寻址、$DATA_DIR 0600)+ history voice 列读写与引用计数差动

缺口(如实):message_persisted/握手回放仍不带 voice(文件面外,需给 history.Message 加字段);
AMR 需一行 VoiceTranscoder 注入(DECISIONS #11 已排)。4 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

304 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}