feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

(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 个变异自检(含一条自认的等价变异)。
This commit is contained in:
AskaEth committed 2026-10-06 14:12:27 +08:00
1 parent 4a937bd269
commit 1420e8aaa6
15 files changed
+2202 -5

No files matched your search

@@ -0,0 +1,303 @@
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)
}
}