(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 个变异自检(含一条自认的等价变异)。
304 lines
11 KiB
Go
304 lines
11 KiB
Go
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)
|
||
}
|
||
}
|