(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 个变异自检(含一条自认的等价变异)。
171 lines
6.3 KiB
Go
171 lines
6.3 KiB
Go
package documents
|
||
|
||
// 本文件锁定**音频副本落盘分支**(design/24 §10.9-2 的 `SaveAudio`)的四条口径:
|
||
// ① 内容寻址 + 去重 + 0600 落盘(与文档共用同一套写入核心);
|
||
// ② 类型裁决走**音频白名单**(10 项),与文档的 23 项白名单**互不越界**;
|
||
// ③ 空内容 / 超上限 / 未知扩展名都如实拒绝(不落半截文件、不写元数据行);
|
||
// ④ 元数据行照写(uploader 只来自构造方,绝不从文件名或内容猜身份)。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"testing"
|
||
|
||
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
|
||
)
|
||
|
||
// audioMetaRecorder 记录交给元数据面的行。它按 MetadataWriter 的契约模拟
|
||
// `ON CONFLICT(name) DO NOTHING`(首次的 uploader/created_at 是权威),因此
|
||
// 「重复落盘只留下一行」在本用例里是**契约级**断言,而不是替身的记账细节。
|
||
type audioMetaRecorder struct {
|
||
seen map[string]bool
|
||
recs []Record
|
||
}
|
||
|
||
func (m *audioMetaRecorder) WriteDocument(_ context.Context, rec Record) error {
|
||
if m.seen == nil {
|
||
m.seen = make(map[string]bool)
|
||
}
|
||
if m.seen[rec.Name] {
|
||
return nil
|
||
}
|
||
m.seen[rec.Name] = true
|
||
m.recs = append(m.recs, rec)
|
||
return nil
|
||
}
|
||
|
||
// audioSample 是一份「像那么回事」的可播音频字节(RIFF/WAVE 头 + 载荷)。
|
||
func audioSample() []byte {
|
||
return append([]byte("RIFF\x24\x00\x00\x00WAVEfmt "), []byte("voice-copy-payload")...)
|
||
}
|
||
|
||
func TestSaveAudioStoresContentAddressedCopy(t *testing.T) {
|
||
t.Parallel()
|
||
dir := filepath.Join(t.TempDir(), uploadsSubDir, documentSubDir)
|
||
meta := &audioMetaRecorder{}
|
||
store := newTestStore(t, StoreConfig{UploadsDir: dir, Uploader: "im:qq-obv11:10001", Meta: meta})
|
||
ctx := context.Background()
|
||
data := audioSample()
|
||
|
||
ref, err := store.SaveAudio(ctx, "voice.wav", data)
|
||
if err != nil {
|
||
t.Fatalf("SaveAudio: %v", err)
|
||
}
|
||
wantName, wantSHA := expectedName(t, ".wav", data)
|
||
if ref.Name != wantName {
|
||
t.Fatalf("Name = %q, want %q(内容寻址)", ref.Name, wantName)
|
||
}
|
||
if ref.SHA256 != wantSHA {
|
||
t.Fatalf("SHA256 = %q, want %q", ref.SHA256, wantSHA)
|
||
}
|
||
if ref.MIME != "audio/wav" {
|
||
t.Fatalf("MIME = %q, want audio/wav(按扩展名裁决,与回放端点同一份表)", ref.MIME)
|
||
}
|
||
if ref.Size != int64(len(data)) || ref.Deduped {
|
||
t.Fatalf("Size/Deduped = %d/%v, want %d/false", ref.Size, ref.Deduped, len(data))
|
||
}
|
||
if ref.Path == "" {
|
||
t.Fatal("Path 为空:落盘路径没有回给调用方")
|
||
}
|
||
// 写进共享目录(与文档同一个目录),绝不是另一个地方。
|
||
if filepath.Dir(ref.Path) != filepath.Clean(dir) {
|
||
t.Fatalf("落盘目录 = %q, want %q", filepath.Dir(ref.Path), filepath.Clean(dir))
|
||
}
|
||
got, err := os.ReadFile(ref.Path)
|
||
if err != nil {
|
||
t.Fatalf("读回副本: %v", err)
|
||
}
|
||
if !bytes.Equal(got, data) {
|
||
t.Fatal("落盘字节与输入不一致")
|
||
}
|
||
if runtime.GOOS != "windows" {
|
||
info, err := os.Stat(ref.Path)
|
||
if err != nil {
|
||
t.Fatalf("Stat 副本: %v", err)
|
||
}
|
||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||
t.Fatalf("副本权限 = %o, want 600(继承临时文件)", perm)
|
||
}
|
||
}
|
||
if len(meta.recs) != 1 {
|
||
t.Fatalf("元数据行数 = %d, want 1", len(meta.recs))
|
||
}
|
||
if rec := meta.recs[0]; rec.Uploader != "im:qq-obv11:10001" || rec.MIME != "audio/wav" || rec.Size != int64(len(data)) {
|
||
t.Fatalf("元数据行 = %+v", rec)
|
||
}
|
||
|
||
// 同字节同扩展名第二次落盘 ⇒ 去重(不新建文件、不写第二行元数据)。
|
||
again, err := store.SaveAudio(ctx, "voice.wav", data)
|
||
if err != nil {
|
||
t.Fatalf("重复 SaveAudio: %v", err)
|
||
}
|
||
if !again.Deduped || again.Name != ref.Name {
|
||
t.Fatalf("重复落盘 = {name:%q deduped:%v}, want {name:%q deduped:true}", again.Name, again.Deduped, ref.Name)
|
||
}
|
||
if len(meta.recs) != 1 {
|
||
t.Fatalf("去重后元数据行数 = %d, want 1", len(meta.recs))
|
||
}
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
t.Fatalf("读目录: %v", err)
|
||
}
|
||
if len(entries) != 1 {
|
||
t.Fatalf("目录里文件数 = %d, want 1(盘上只留一份)", len(entries))
|
||
}
|
||
}
|
||
|
||
func TestSaveAudioRejectsBadInputs(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
data := audioSample()
|
||
|
||
store := newTestStore(t, StoreConfig{UploadsDir: filepath.Join(t.TempDir(), "docs")})
|
||
for _, name := range []string{"voice.txt", "voice.pdf", "voice", "voice.silk", "voice.mp4"} {
|
||
if _, err := store.SaveAudio(ctx, name, data); !errors.Is(err, ErrExtNotAllowed) {
|
||
t.Fatalf("SaveAudio(%q) err = %v, want ErrExtNotAllowed", name, err)
|
||
}
|
||
}
|
||
if _, err := store.SaveAudio(ctx, "voice.wav", nil); !errors.Is(err, ErrEmpty) {
|
||
t.Fatalf("空内容 err = %v, want ErrEmpty", err)
|
||
}
|
||
small := newTestStore(t, StoreConfig{UploadsDir: filepath.Join(t.TempDir(), "docs"), MaxBytes: 4})
|
||
if _, err := small.SaveAudio(ctx, "voice.wav", data); !errors.Is(err, ErrTooLarge) {
|
||
t.Fatalf("超上限 err = %v, want ErrTooLarge", err)
|
||
}
|
||
// 未配置目录 ⇒ 明确失败(绝不猜一个目录出来)。
|
||
noDir := NewStore(StoreConfig{Log: nil})
|
||
if _, err := noDir.SaveAudio(ctx, "voice.wav", data); !errors.Is(err, ErrNoDir) {
|
||
t.Fatalf("未配置目录 err = %v, want ErrNoDir", err)
|
||
}
|
||
}
|
||
|
||
// TestSaveAudioUsesAudioWhitelistNotDocumentWhitelist 钉住「两套类型裁决」:音频白名单
|
||
// 与文档白名单互不越界 —— 文档拒绝的音频扩展名在音频分支放行,音频分支拒绝的文档
|
||
// 扩展名在文档白名单里放行。变异:把 SaveAudio 改走 ExtAllowed ⇒ 本用例红。
|
||
func TestSaveAudioUsesAudioWhitelistNotDocumentWhitelist(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
data := audioSample()
|
||
store := newTestStore(t, StoreConfig{UploadsDir: filepath.Join(t.TempDir(), "docs")})
|
||
|
||
if ExtAllowed(".wav") {
|
||
t.Fatal("文档白名单不该包含 .wav")
|
||
}
|
||
if voiceref.MediaExtAllowed(".txt") {
|
||
t.Fatal("音频白名单不该包含 .txt")
|
||
}
|
||
if _, err := store.Save(ctx, "doc.wav", data); !errors.Is(err, ErrExtNotAllowed) {
|
||
t.Fatalf("文档分支收 .wav 的 err = %v, want ErrExtNotAllowed", err)
|
||
}
|
||
if _, err := store.SaveAudio(ctx, "doc.txt", []byte("昔涟的文本\n")); !errors.Is(err, ErrExtNotAllowed) {
|
||
t.Fatalf("音频分支收 .txt 的 err = %v, want ErrExtNotAllowed", err)
|
||
}
|
||
if _, err := store.SaveAudio(ctx, "voice.ogg", data); err != nil {
|
||
t.Fatalf("音频分支拒了白名单内的 .ogg: %v", err)
|
||
}
|
||
}
|