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

+83
View File
@@ -0,0 +1,83 @@
package documents
// 本文件归属**音频副本**的落盘分支(design/24 §10.9-2 的 `SaveAudio`;切片 CODE-VOICE
// 留下的跨片接缝,本片补齐)。语音副本**复用**文档那一整套存储(同一
// `<DataDir>/uploads/documents` 目录、同一份内容寻址、同一张 `documents` 元数据表、
// 同一套引用计数与 GC),只有**类型裁决**不同:
//
// - 文档:字节嗅探是唯一裁决者,且扩展名必须与嗅探**成对**(`.pdf` 必须嗅探成
// `application/pdf`,文本类必须 `text/plain`);
// - 音频:格式在**上游**已经由格式名确定(`voice.Prepare` 决定「原样落」还是
// 「转码后落」),字节嗅探对它**无意义**(`http.DetectContentType` 对绝大多数
// 音频容器返回 `application/octet-stream`,用它必误拒)⇒ 这里只认**扩展名**,
// 且限定在 10 项音频白名单内(`voice.MediaExtAllowed`,与回放端点的白名单同一份)。
//
// 因此两者的关系是「同一存储核心 + 两套类型裁决」,而不是「两套存储」:目录、去重、
// 路径双检、临时文件 + rename 的原子性、0600 文件权限全部走 `commit`,一行都没有分叉。
//
// 为什么不进文档的抽取 / 注入链路(design/24 §10.9-14):音频的「正文」是那次转写,
// 已经在消息的 `content`/`voice` 列里;把音频当文档抽取只会得到一堆噪声字节的摘要。
// 因此音频只占存储与引用计数,不占注入预算、不进 `documents` DTO 数组。
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// SaveAudio 把一份语音副本落进共享存储,返回服务端签发的引用(形状与 Save 完全一致:
// `Name`/`MIME`/`Size`/`SHA256`/`Deduped`)。判定顺序刻意与 Save 同族,只是把
// 「扩展名白名单 + 嗅探成对裁决」换成「**音频扩展名白名单**」:
//
// 1. **扩展名白名单**(deny-by-default):`voice.MediaExtAllowed` 之外的扩展名一律
// 拒绝(`ErrExtNotAllowed`)—— 调用方给的是 `voice.<format>`,格式名来自
// `voice.Prepare`,两者同一份归一;
// 2. **空内容**拒绝(`ErrEmpty`):空副本播放不了,也没有落盘意义;
// 3. **字节上限**拒绝(`ErrTooLarge`,上限见 Limit):与文档共用同一个生效上限
// (`CHAT_DOCUMENT_MAX_BYTES`),因为两者落在**同一个目录、同一张表**,给它一个
// 独立上限只会让「这份文件为什么被拒」取决于它是从哪条路来的;
// 4. 内容寻址落盘(`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重;文件权限继承
// 临时文件的 0600、目录 0700)+ 路径双检,随后(可选)写一行元数据。
//
// 元数据的 uploader 与文档同源(由构造 Store 的一方给出:网关是请求 claims 的 sub,
// IM 是渠道来源标签)—— 语音副本在 `documents` 表里同样记着「是谁发来的」,身份
// 绝不来自文件名或内容。
//
// 返回错误时的契约与 Save 一致:元数据写入失败的错误同时满足
// `errors.Is(err, ErrMetadata)`,且**已落盘的引用照旧返回**。
func (s *Store) SaveAudio(ctx context.Context, origName string, data []byte) (Ref, error) {
ext := ExtOf(origName)
if !voiceref.MediaExtAllowed(ext) {
return Ref{}, fmt.Errorf("documents: save audio: %w", ErrExtNotAllowed)
}
if len(data) == 0 {
return Ref{}, fmt.Errorf("documents: save audio: %w", ErrEmpty)
}
if limit := s.Limit(); len(data) > limit {
return Ref{}, fmt.Errorf("documents: save audio: 上限 %d 字节,本次 %d 字节: %w", limit, len(data), ErrTooLarge)
}
mime := voiceref.MediaMIME(ext)
if mime == "" {
// 白名单已判过一次;这里是纵深防御 —— 没有下发类型就绝不落盘(落一份读不出
// `Content-Type` 的副本,客户端只能靠猜)。
return Ref{}, fmt.Errorf("documents: save audio: 扩展名 %q 没有下发类型: %w", ext, ErrExtNotAllowed)
}
sum := sha256.Sum256(data)
ref := Ref{
Name: hashName(sum[:], ext),
MIME: mime,
Size: int64(len(data)),
SHA256: hex.EncodeToString(sum[:]),
}
committed, err := s.commit(ref, data)
if err != nil {
return Ref{}, err
}
if err := s.record(ctx, committed); err != nil {
return committed, err
}
return committed, nil
}
@@ -0,0 +1,170 @@
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)
}
}