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)
}
}
+203
View File
@@ -0,0 +1,203 @@
package history
// 本文件归属聊天消息的**语音事实**列(design/24 §10.4 / §10.9-4;迁移 0020):
// 一条消息来自一条语音吗、那次转写成不成、盘上有没有一份可回放的副本。
//
// 列的内容是**单个 JSON 对象**(形状见 `internal/runtime/voice.Ref`;空串 = 这条消息
// 不是语音消息)。它与 document 列的演进史不同:自诞生起就是单对象,因此没有需要
// 兼容的旧形状。
//
// 写入是**两段式**(与 document 列同一手法):入站语音的消息行先落库,副本落盘拿到
// 内容寻址名之后再由渠道桥回填同一列 —— 因为「可播放副本」只有在转码/落盘之后才
// 存在,而那时消息行早已写好。回填入口 = 本文件的 UpdateMessageVoice(渠道桥按
// 窄接口 `voiceColumnWriter` 发现它,见 channelbridge/inbound_voice_copy.go)。
//
// 音频副本与文档共用 `documents` 表与同一套引用计数(design/24 §10.5):副本一旦被
// 引用就要 +1,否则 GC 会把一份仍被播放的语音回收掉。因此本文件的每一次列变更都按
// **新旧名字集合的差**在同一条事务里增减计数(与 UpdateMessageDocument 逐字同族)。
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// voiceColumn 是语音事实的列名(迁移 0020 加的列)。
const voiceColumn = "voice"
// UpdateMessageVoice 只替换 id 所指消息的 voice 列,其余任何列保持已存值。它是
// 「入站语音落库后回填来源事实与副本引用」的写入端(design/24 §10.2 第 5 步)。
//
// 四条纪律:
// - **未知 id 返回 ErrMessageNotFound**(与 UpdateMessage / UpdateMessageDocument
// 同一口径),调用方能区分「那一行不见了」与「写失败」,并据此如实记 WARN;
// - **坏形状拒绝写**:非空值必须解码成自洽的语音事实(来源/状态在闭集内、副本三
// 字段同生共死)。写一份读不回来的 JSON 等于让客户端永远看不到这条语音,所以这里
// fail loud,绝不静默落一份半截数据。空串是**合法**值(这条消息不是语音);
// - **引用计数与消息列同一个事务**:按新旧名字集合的差增减 `documents.ref_count`
// (切片 S7 的既有通道),任何一边失败就整体回滚 —— 绝不留下「消息认了这份音频、
// 计数没加」这种让 GC 误删仍被引用文件的窗口;
// - **列不存在时前滚**:迁移 0020 之前建出的表(含本包 DDL 造的测试库)在这里幂等
// 补列,与 ensureDocumentColumn / ensureTurnIDColumn 同一手法。
func (s *Store) UpdateMessageVoice(ctx context.Context, id, raw string) error {
if _, err := voiceref.Decode(raw); err != nil {
return fmt.Errorf("history: update message voice %s: %w", id, err)
}
if err := ensureVoiceColumn(ctx, s.db); err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("history: update message voice %s: %w", id, err)
}
defer func() { _ = tx.Rollback() }()
var previous string
err = tx.QueryRowContext(ctx, `SELECT `+voiceColumn+` FROM chat_messages WHERE id = ?`, id).Scan(&previous)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("history: update message voice %s: %w", id, ErrMessageNotFound)
}
return fmt.Errorf("history: read message voice %s: %w", id, err)
}
res, err := tx.ExecContext(ctx, `UPDATE chat_messages SET `+voiceColumn+` = ? WHERE id = ?`, raw, id)
if err != nil {
return fmt.Errorf("history: update message voice %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("history: update message voice %s rows: %w", id, err)
}
if n == 0 {
return fmt.Errorf("history: update message voice %s: %w", id, ErrMessageNotFound)
}
if err := s.syncVoiceRefs(ctx, tx, previous, raw); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("history: update message voice %s: %w", id, err)
}
return nil
}
// VoiceColumns 按 id 批量取回各行的 voice 列原文(**一次** `IN (...)`,禁 N+1)。
// 返回「消息 id → 列内容」;查询不到的 id(已删除、id 拼错)**不出现在映射里**,
// 与「取到空串」(这条消息不是语音)因此可以区分。
//
// 它只读、不迁移、不建连接池(用本 Store 的活连接)。列不存在(迁移 0020 之前建出的
// 表且从未写过语音)时返回错误,由调用方决定怎么如实报告 —— 这里**不**静默回空映射,
// 「没有这一面」与「这些消息都不是语音」是两件事。
func (s *Store) VoiceColumns(ctx context.Context, ids []string) (map[string]string, error) {
unique := uniqueMessageIDs(ids)
if len(unique) == 0 {
return map[string]string{}, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(unique)), ", ")
args := make([]any, 0, len(unique))
for _, id := range unique {
args = append(args, id)
}
query := `SELECT id, ` + voiceColumn + ` FROM chat_messages WHERE id IN (` + placeholders + `)`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: read voice columns for %d messages: %w", len(unique), err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]string, len(unique))
for rows.Next() {
var id, raw string
if err := rows.Scan(&id, &raw); err != nil {
return nil, fmt.Errorf("history: scan voice column: %w", err)
}
out[id] = raw
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate voice columns: %w", err)
}
return out, nil
}
// uniqueMessageIDs 去重并丢掉空 id(保持首次出现顺序)。
func uniqueMessageIDs(ids []string) []string {
unique := make([]string, 0, len(ids))
seen := make(map[string]bool, len(ids))
for _, id := range ids {
if id == "" || seen[id] {
continue
}
seen[id] = true
unique = append(unique, id)
}
return unique
}
// syncVoiceRefs 把一行消息的 voice 列从 oldRaw 换成 newRaw 时按**差**增减引用计数。
// 两次内容相同时(含都是空)直接返回。
//
// 与文档侧同一取舍:列被外部改坏(解不出名字)时**不让写入失败**(消息行才是用户的
// 事实),只记 WARN 并跳过本次增减 —— 保守方向是「计数偏低 ⇒ 只会晚回收,绝不早删」。
func (s *Store) syncVoiceRefs(ctx context.Context, ex SQLExecer, oldRaw, newRaw string) error {
if s.refs == nil || oldRaw == newRaw {
return nil
}
oldNames, err := voiceNames(oldRaw)
if err != nil {
s.log.Warn("voice refs sync skipped: malformed old column", "err", err)
return nil
}
newNames, err := voiceNames(newRaw)
if err != nil {
s.log.Warn("voice refs sync skipped: malformed new column", "err", err)
return nil
}
add, remove := diffDocumentNames(oldNames, newNames)
if len(add) > 0 {
if err := s.refs.Retain(ctx, ex, add); err != nil {
return fmt.Errorf("history: retain voice refs: %w", err)
}
}
if len(remove) > 0 {
if err := s.refs.Release(ctx, ex, remove); err != nil {
return fmt.Errorf("history: release voice refs: %w", err)
}
}
return nil
}
// voiceNames 解出 voice 列涉及的音频副本名(有副本时恰好一个,没有副本/不是语音时为空)。
// 与本包 documentNames 同形,因此复用同一套差集计算。
func voiceNames(raw string) ([]string, error) {
ref, err := voiceref.Decode(raw)
if err != nil {
return nil, err
}
if !ref.HasCopy() {
return nil, nil
}
return []string{ref.Name}, nil
}
// ensureVoiceColumn 在 chat_messages 早于本列(0020 迁移)建表时把它前滚:缺列则
// 补加,已有则忽略重复列错误。它是幂等的(与 ensureDocumentColumn / ensureTurnIDColumn
// 同一手法),因此本包的写入路径可以在全新 schema 或既有 schema 上直接调用。
//
// 只在**写入路径**调用:读路径不迁移(只读纪律),而一条消息要带语音事实必然先经过
// 写入路径,因此列在读到它之前一定已经补上。
func ensureVoiceColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE chat_messages
ADD COLUMN voice TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// 列已存在时(由 0020 迁移补过,或上一次写入补过)SQLite 报
// "duplicate column name: voice"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("history: roll forward voice column: %w", err)
}
@@ -0,0 +1,213 @@
package history
// 本文件锁定 chat_messages.voice 列的**读写视图**(design/24 §10.4 / §10.9-4):
// ① 写入后能读回同样的 JSON 单对象,且列不存在时先幂等前滚(迁移 0020 之前建出的表);
// ② 坏形状**拒绝写入**(绝不落下读不回来的列);
// ③ 未知消息 id → ErrMessageNotFound(不假装写成功);
// ④ 音频副本的名字并入**同一套**引用计数(按新旧名字集合的差增减,同一事务);
// ⑤ 批量读是**按 id 集合一次查**,取不到的 id 不出现在映射里(与「取到空串」可区分)。
import (
"context"
"errors"
"testing"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// seedVoiceMessage 经真实存储追加一条消息并返回已存行(含生成的 id)。
func seedVoiceMessage(t *testing.T, st *Store, role, content string) Message {
t.Helper()
got, err := st.AppendFull(context.Background(), Message{SessionID: MainSessionID, Role: role, Content: content})
if err != nil {
t.Fatalf("append %q: %v", content, err)
}
return got
}
// voiceRawWithCopy 编码一条「有副本」的语音事实(ASR 转写成功 + 副本三字段齐备)。
func voiceRawWithCopy(t *testing.T, name string) string {
t.Helper()
raw, err := voiceref.Encode(voiceref.Ref{
Source: voiceref.SourceASR,
Status: voiceref.StatusTranscribed,
Name: name,
MIME: "audio/ogg",
Size: 1234,
DurationMs: 3200,
})
if err != nil {
t.Fatalf("编码语音事实: %v", err)
}
return raw
}
// voiceRawWithoutCopy 编码一条「没有副本」的语音事实(微信按既有裁决不取字节)。
func voiceRawWithoutCopy(t *testing.T) string {
t.Helper()
raw, err := voiceref.Encode(voiceref.Ref{
Source: voiceref.SourcePlatform,
Status: voiceref.StatusTranscribed,
})
if err != nil {
t.Fatalf("编码语音事实: %v", err)
}
return raw
}
func TestUpdateMessageVoiceRoundTrip(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
msg := seedVoiceMessage(t, st, RoleUser, "(语音)")
name := "0123456789abcdef0123456789abcdef.ogg"
raw := voiceRawWithCopy(t, name)
// 全新库由本包 DDL 建表(**不含** voice 列)⇒ 这一次写入顺带把列幂等前滚。
if err := st.UpdateMessageVoice(ctx, msg.ID, raw); err != nil {
t.Fatalf("UpdateMessageVoice: %v", err)
}
cols, err := st.VoiceColumns(ctx, []string{msg.ID, "does-not-exist"})
if err != nil {
t.Fatalf("VoiceColumns: %v", err)
}
if got := cols[msg.ID]; got != raw {
t.Fatalf("读回 = %q, want %q", got, raw)
}
if _, ok := cols["does-not-exist"]; ok {
t.Fatal("取不到的 id 不该出现在映射里(与「取到空串」必须可区分)")
}
if len(refs.retained) != 1 || refs.retained[0] != name {
t.Fatalf("引用计数 retain = %v, want [%s]", refs.retained, name)
}
if len(refs.released) != 0 {
t.Fatalf("首次写入不该 release:%v", refs.released)
}
// 清空(= 撤销语音事实)是合法值,且副本的名字被释放一次。
if err := st.UpdateMessageVoice(ctx, msg.ID, ""); err != nil {
t.Fatalf("清空 voice: %v", err)
}
if len(refs.released) != 1 || refs.released[0] != name {
t.Fatalf("清空后 release = %v, want [%s]", refs.released, name)
}
cols, err = st.VoiceColumns(ctx, []string{msg.ID})
if err != nil {
t.Fatalf("VoiceColumns(清空后): %v", err)
}
if got, ok := cols[msg.ID]; !ok || got != "" {
t.Fatalf("清空后列 = (%q, %v), want (\"\", true)", got, ok)
}
}
func TestUpdateMessageVoiceRejectsMalformedAndUnknown(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
msg := seedVoiceMessage(t, st, RoleUser, "(语音)")
good := voiceRawWithCopy(t, "0123456789abcdef0123456789abcdef.wav")
if err := st.UpdateMessageVoice(ctx, msg.ID, good); err != nil {
t.Fatalf("先写一条合法值: %v", err)
}
// 坏形状全部拒绝,且**列保持原值**(绝不落下读不回来的内容)。
bad := []string{
"{not json",
`{"source":"asr"}`, // 状态缺席(闭集外)
`{"source":"telepathy","status":"transcribed"}`, // 来源不在闭集
`{"source":"asr","status":"transcribed","name":"x.ogg"}`, // 有 name 没有 mime/size(同生共死被破)
`{"source":"asr","status":"transcribed","mime":"audio/x"}`, // 有 mime 没有 name
`{"source":"asr","status":"transcribed","durationMs":-1}`, // 负时长
}
for _, raw := range bad {
err := st.UpdateMessageVoice(ctx, msg.ID, raw)
if !errors.Is(err, voiceref.ErrBadRef) {
t.Fatalf("UpdateMessageVoice(%q) err = %v, want wrap of ErrBadRef", raw, err)
}
}
cols, err := st.VoiceColumns(ctx, []string{msg.ID})
if err != nil {
t.Fatalf("VoiceColumns: %v", err)
}
if cols[msg.ID] != good {
t.Fatalf("坏写入改动了列:%q, want %q", cols[msg.ID], good)
}
// 未知 id ⇒ ErrMessageNotFound(绝不假装写成功)。
if err := st.UpdateMessageVoice(ctx, "no-such-message", good); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("未知 id err = %v, want ErrMessageNotFound", err)
}
}
func TestUpdateMessageVoiceSyncsRefsByDifference(t *testing.T) {
t.Parallel()
refs := &recordingRefs{}
st := newRefStore(t, refs)
ctx := context.Background()
msg := seedVoiceMessage(t, st, RoleUser, "(语音)")
first := voiceRawWithCopy(t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ogg")
second := voiceRawWithCopy(t, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3")
// ① 没有 → 有副本:retain 一次。
if err := st.UpdateMessageVoice(ctx, msg.ID, first); err != nil {
t.Fatalf("第一次写入: %v", err)
}
// ② 内容逐字不变:一次都不增减(重复计数会让音频永远回收不掉)。
if err := st.UpdateMessageVoice(ctx, msg.ID, first); err != nil {
t.Fatalf("同值重写: %v", err)
}
if len(refs.retained) != 1 || len(refs.released) != 0 {
t.Fatalf("同值重写后 retain/release = %v/%v, want 1/0", refs.retained, refs.released)
}
// ③ 换成另一份副本:旧的释放、新的保留(各一次)。
if err := st.UpdateMessageVoice(ctx, msg.ID, second); err != nil {
t.Fatalf("换副本: %v", err)
}
if len(refs.retained) != 2 || refs.retained[1] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3" {
t.Fatalf("换副本后 retain = %v", refs.retained)
}
if len(refs.released) != 1 || refs.released[0] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ogg" {
t.Fatalf("换副本后 release = %v", refs.released)
}
// ④ 有副本 → 无副本(微信那条路):release 一次,不新增 retain。
if err := st.UpdateMessageVoice(ctx, msg.ID, voiceRawWithoutCopy(t)); err != nil {
t.Fatalf("写成无副本: %v", err)
}
if len(refs.retained) != 2 {
t.Fatalf("无副本写法不该 retain:%v", refs.retained)
}
if len(refs.released) != 2 || refs.released[1] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.mp3" {
t.Fatalf("无副本写法 release = %v", refs.released)
}
}
func TestVoiceColumnsBatchesAndSkipsBlanks(t *testing.T) {
t.Parallel()
st := newRefStore(t, &recordingRefs{})
ctx := context.Background()
withVoice := seedVoiceMessage(t, st, RoleUser, "语音")
without := seedVoiceMessage(t, st, RoleAssistant, "文本")
raw := voiceRawWithCopy(t, "cccccccccccccccccccccccccccccccc.webm")
if err := st.UpdateMessageVoice(ctx, withVoice.ID, raw); err != nil {
t.Fatalf("UpdateMessageVoice: %v", err)
}
cols, err := st.VoiceColumns(ctx, []string{withVoice.ID, without.ID, "", withVoice.ID})
if err != nil {
t.Fatalf("VoiceColumns: %v", err)
}
if len(cols) != 2 {
t.Fatalf("映射大小 = %d, want 2", len(cols))
}
if cols[withVoice.ID] != raw {
t.Fatalf("语音行 = %q, want %q", cols[withVoice.ID], raw)
}
if got, ok := cols[without.ID]; !ok || got != "" {
t.Fatalf("文本行 = (%q, %v), want (\"\", true)", got, ok)
}
if empty, err := st.VoiceColumns(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("空 id 列表 ⇒ 空映射且无错误,得到 (%v, %v)", empty, err)
}
}
+118
View File
@@ -0,0 +1,118 @@
package voice
// 本文件承载**回放端点**(`GET /api/v1/media/{name}`)两侧的格式事实
// (design/24 §10.11 的端点表;契约 v0.67 已登记形状):**名字形态**
// (32 位小写十六进制主名 + 音频白名单扩展名)与**下发 MIME**。
//
// 两条口径与 design/24 §10.11 逐字一致:
// - 下发 `Content-Type` **只看存储名的扩展名**(`.wav → audio/wav` …),**不查库、
// 不重新嗅探** —— 「扩展名 ↔ 字节」在**保存那一刻**已成对裁决过(`documents.SaveAudio`);
// - 名字形态是 deny-by-default:主名必须是 32 位小写十六进制、扩展名必须在 10 项
// 音频白名单内,其余(含路径穿越的一切形态)一律拒绝,由调用方收敛成同一个 404。
import (
"path/filepath"
"sort"
"strings"
)
const (
// MediaPathPrefix 是语音副本对外的**服务端相对路径**前缀(契约 v0.67 §4.4):
// DTO 里的 `voice.url` 由服务端**一处**拼出(`MediaPath`),客户端绝不自己拼。
MediaPathPrefix = "/api/v1/media/"
// StoredNameHexLen 是内容寻址落盘名的十六进制主名长度(32 位 = 128 位)。
//
// 它与 `documents.HashHexLen` 是**同一个数**(同一套内容寻址)。这里刻意独立具名:
// `documents` 已经 import 本包(`documents → history → voice` 的方向),本包一旦
// import `documents` 就会成环。两处必须同值,由网关侧的交叉断言钉住
// (`media_test.go` 的 TestMediaStoredNameHexLenMatchesDocuments)。
StoredNameHexLen = 32
)
// mediaMIMEByExt 是音频白名单**唯一**的一份表:扩展名(小写、含点)→ 下发 MIME。
// 白名单成员由它派生(`MediaExtAllowed`),因此「能下发」与「下发的类型」不可能分叉。
//
// 10 项 = design/24 §10.11 的端点表定稿值:`.wav` `.mp3` `.aac` `.ogg` `.oga` `.opus`
// `.m4a` `.webm` `.flac` `.amr`。取值与 design/24 §10.11 的映射表逐字对应
// (`.ogg`/`.oga`/`.opus` 一律 `audio/ogg` —— 三者是同一容器的不同封装名)。
var mediaMIMEByExt = map[string]string{
".wav": "audio/wav",
".mp3": "audio/mpeg",
".aac": "audio/aac",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/ogg",
".m4a": "audio/mp4",
".webm": "audio/webm",
".flac": "audio/flac",
".amr": "audio/amr",
}
// MediaExtAllowed 报告扩展名(含点,大小写不敏感)是否在音频白名单内。
// deny-by-default:不认识的扩展名一律 false。
func MediaExtAllowed(ext string) bool {
return mediaMIMEByExt[normalizeExt(ext)] != ""
}
// MediaMIME 给出该扩展名要下发的 MIME。扩展名不在白名单内时返回 ""(调用方据此
// 拒绝,绝不回一个「看起来像音频」的兜底类型)。
func MediaMIME(ext string) string {
return mediaMIMEByExt[normalizeExt(ext)]
}
// MediaPath 拼出存储名对应的**服务端相对路径**(`/api/v1/media/<name>`)。它是
// DTO `voice.url` 的唯一派生点:客户端绝不自己拼路径(契约 v0.67 §4.4)。
func MediaPath(name string) string {
return MediaPathPrefix + name
}
// ValidMediaName 报告 name 是否恰好是「32 位小写十六进制主名 + 音频白名单扩展名」。
// 它与网关文档读侧的 `validDocumentName` 同一手法(双检):分隔符、点、双点及任何
// 其他形态都在触碰文件系统之前被拒绝 —— 调用方因此可以把「名字非法」与「文件不存在」
// 收敛成同一个 404,不泄露任何「存在与否」以外的信息。
func ValidMediaName(name string) bool {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") {
return false
}
stem, ext, ok := strings.Cut(name, ".")
if !ok || len(stem) != StoredNameHexLen || ext == "" {
return false
}
for _, c := range stem {
if !isLowerHex(c) {
return false
}
}
return MediaExtAllowed("." + strings.ToLower(ext))
}
// MediaExtensions 返回音频白名单的扩展名(含点,已排序)。它是**发现**用出口
// (面板/日志/测试能列出「当前认定哪些容器可回放」),调用方不得依赖它在
// 业务判定上的顺序 —— 判定一律走 MediaExtAllowed。
func MediaExtensions() []string {
out := make([]string, 0, len(mediaMIMEByExt))
for ext := range mediaMIMEByExt {
out = append(out, ext)
}
sort.Strings(out)
return out
}
// normalizeExt 把扩展名归一成「小写、带前导点」。归一只有这一处,使
// `.OGG` / `OGG` / `ogg` 三种写法落到同一个判定上(读侧与写侧同源)。
func normalizeExt(ext string) string {
e := strings.ToLower(strings.TrimSpace(ext))
if e == "" {
return ""
}
if !strings.HasPrefix(e, ".") {
e = "." + e
}
return e
}
// isLowerHex 报告 c 是否是 `0-9a-f`。主名形态据此拒绝大写十六进制与其他一切字符。
func isLowerHex(c rune) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
}
+126
View File
@@ -0,0 +1,126 @@
package voice
// 本文件锁定**回放端点**那一侧的格式事实(design/24 §10.11;契约 v0.67 的
// `GET /api/v1/media/{name}` 表):白名单**字面**成员、扩展名归一、名字形态的
// deny-by-default,以及唯一派生字段 `url` 的拼法。
//
// 白名单用**字面量**钉住(而不是从被测表反推):表改成员时本用例必须红 ——
// 与「拿被测常量当期望」的教训同一条纪律。
import (
"reflect"
"testing"
)
// TestMediaExtensionsAreTheDeclaredTen 钉住白名单成员与项数(10 项,逐字)。
func TestMediaExtensionsAreTheDeclaredTen(t *testing.T) {
t.Parallel()
want := []string{
".aac", ".amr", ".flac", ".m4a", ".mp3",
".oga", ".ogg", ".opus", ".wav", ".webm",
}
if got := MediaExtensions(); !reflect.DeepEqual(got, want) {
t.Fatalf("MediaExtensions() = %v, want %v", got, want)
}
}
// TestMediaExtAllowedNormalizesAndDeniesByDefault 覆盖归一与默认拒。
func TestMediaExtAllowedNormalizesAndDeniesByDefault(t *testing.T) {
t.Parallel()
allowed := []string{".ogg", "ogg", ".OGG", " .Opus ", ".wav", ".amr", ".webm"}
for _, ext := range allowed {
if !MediaExtAllowed(ext) {
t.Fatalf("MediaExtAllowed(%q) = false, want true", ext)
}
}
denied := []string{"", ".", ".txt", ".mp4", ".pdf", ".ogg2", "ogs", ".OGX"}
for _, ext := range denied {
if MediaExtAllowed(ext) {
t.Fatalf("MediaExtAllowed(%q) = true, want false(deny-by-default)", ext)
}
}
}
// TestMediaMIME 逐项钉住下发类型(契约 v0.67 §4.1 的映射表)。
func TestMediaMIME(t *testing.T) {
t.Parallel()
cases := map[string]string{
".wav": "audio/wav",
".mp3": "audio/mpeg",
".aac": "audio/aac",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/ogg",
".m4a": "audio/mp4",
".webm": "audio/webm",
".flac": "audio/flac",
".amr": "audio/amr",
}
for ext, want := range cases {
if got := MediaMIME(ext); got != want {
t.Fatalf("MediaMIME(%q) = %q, want %q", ext, got, want)
}
if got := MediaMIME("." + ext[1:]); got != want {
t.Fatalf("MediaMIME(无点 %q) = %q, want %q", ext, got, want)
}
}
// 未知扩展名绝不回一个「看起来像音频」的兜底类型。
if got := MediaMIME(".txt"); got != "" {
t.Fatalf("MediaMIME(.txt) = %q, want 空串", got)
}
}
// TestMediaPath 钉住唯一派生字段的拼法(服务端一处拼出,客户端绝不自己拼)。
func TestMediaPath(t *testing.T) {
t.Parallel()
name := "0123456789abcdef0123456789abcdef.ogg"
if got, want := MediaPath(name), "/api/v1/media/"+name; got != want {
t.Fatalf("MediaPath = %q, want %q", got, want)
}
if MediaPathPrefix != "/api/v1/media/" {
t.Fatalf("MediaPathPrefix = %q, want /api/v1/media/", MediaPathPrefix)
}
}
// TestStoredNameHexLen 钉住主名宽度(与 documents 的同名常量同值由网关侧交叉断言)。
func TestStoredNameHexLen(t *testing.T) {
t.Parallel()
if StoredNameHexLen != 32 {
t.Fatalf("StoredNameHexLen = %d, want 32", StoredNameHexLen)
}
}
// TestValidMediaName 覆盖名字形态的放行与拒绝(含路径穿越的一切常见形态)。
func TestValidMediaName(t *testing.T) {
t.Parallel()
valid := []string{
"0123456789abcdef0123456789abcdef.ogg",
"ffffffffffffffffffffffffffffffff.amr",
"0123456789abcdef0123456789abcdef.webm",
}
for _, name := range valid {
if !ValidMediaName(name) {
t.Fatalf("ValidMediaName(%q) = false, want true", name)
}
}
invalid := []string{
"",
"0123456789ABCDEF0123456789abcdef.ogg", // 大写十六进制
"0123456789abcdef0123456789abcde.ogg", // 主名不足 32 位
"0123456789abcdef0123456789abcdefg.ogg", // 主名超 32 位
"0123456789abcdef0123456789abcdef", // 没有扩展名
"0123456789abcdef0123456789abcdef.xml", // 扩展名不在白名单
"0123456789abcdef0123456789abcdef.exe", // 同上
"0123456789abcdef0123456789abcdef.o.ogg", // 多一个点
"../0123456789abcdef0123456789abcdef.ogg",
"..\\0123456789abcdef0123456789abcdef.ogg",
"/etc/passwd",
"sub/0123456789abcdef0123456789abcdef.ogg",
"voice.ogg",
}
for _, name := range invalid {
if ValidMediaName(name) {
t.Fatalf("ValidMediaName(%q) = true, want false", name)
}
}
}
@@ -0,0 +1,212 @@
package gateway
// 本文件承载**入库前审查(D3)的管理面**(design/23 §15 D3-8「面板可查」+ §8「回滚」;
// 切片 MT-B 已把能力做进 `internal/runtime/memory`,本片补 HTTP 入口)。
//
// 两条端点,分别对应 D3 留痕的**看**与**撤销**:
//
// - `GET /api/v1/admin/memories/reviews?limit=` —— 最近的审查留痕(谁被审、四态
// 判定、依据、合并去向、是否降级、时间)。留痕表 `memory_ingest_reviews` **不含
// 记忆正文**(列清单见 preinsert_schema.go),命中的既有 id 足以让面板回表取原文;
// - `POST /api/v1/admin/memories/{id}/revert` —— 撤销一次**软合并**(把被吸收的行
// 复原为 active 并清空 merged_into,同时把它的 id 从存活行的 merged_from 里摘掉)。
// 行永不物理删除,因此这是**无损可逆**操作(design/23 §8)。
//
// 门与审计:两条都走 `writeAuth`(owner|admin),与同族的 `GET /admin/memories` 同档 ——
// 留痕带 itemId / personaId / projectId / sessionId,是**私有记忆面**的一部分,
// design/23 §11 对同族读侧的既有取向是「含隐私正文的读侧不给 viewer,类比
// GET /admin/memories」;`viewer` 的定位是只读监控投影(design/11 §7),不是记忆面。
// 撤销是**写**,因此落审计(动作名 `memory.review.revert`,who/result 与既有口径一致)。
//
// ⚠️ 与 design/23 §11 表格的一处口径差异:那张表把同族的 `candidates/{id}/revert`
// 标为 owner-only。本片按任务书「两条都 writeAuth」实现 —— 若以 §11 为准,把
// routes.go 里那两行的 `writeAuth` 换成 `ownerAuth` 即可(一行一处,本文件不用改)。
import (
"context"
"errors"
"net/http"
"strconv"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// ingestReviewStore 是入库前审查留痕的读写视图(design/23 §15 D3-8 + §8)。
// 生产实现是 `*memory.Store`(`ListIngestReviews` / `RevertMerged`)。
//
// 与 voiceColumnReader 同一手法的可选接口:未实现它的存储(未被组合根接线的测试替身)
// 得到的是**诚实**的 500 `memory_review_unavailable`,而不是一份空列表 ——
// 「没接线」与「一条留痕都没有」必须能分辨。
type ingestReviewStore interface {
// ListIngestReviews 返回最近的审查留痕(最新在前,至多 limit 条)。
ListIngestReviews(ctx context.Context, limit int) ([]memory.IngestReview, error)
// RevertMerged 撤销一次软合并。该行不在 merged 状态时返回 memory.ErrNotFound。
RevertMerged(ctx context.Context, id string) error
}
const (
// memoryReviewDefaultLimit 是列表的默认页大小(未给/非正 limit)。
memoryReviewDefaultLimit = 100
// memoryReviewMaxLimit 是列表的硬上限:超出的 limit **钳到**它而不是报错
// (与渠道事件查询面同一约定)。
memoryReviewMaxLimit = 500
// memoryReviewUnavailableCode 是「留痕存储未接线」的线上错误码。两条端点共用:
// 它们背后是同一份存储,未接线时不存在「一条能用一条不能用」。
memoryReviewUnavailableCode = "memory_review_unavailable"
// memoryReviewRevertAction 是撤销动作的审计动作名(design/23 §16.4 的 `memory.*`
// 族;`memory.review.ingest` 是审查自身的动作名,本动作名不与之冲突)。
memoryReviewRevertAction = "memory.review.revert"
)
// AdminIngestReview 是一条审查留痕的线上结构(键名 camelCase,时间 UTC RFC3339,
// 与同族的管理 DTO —— 渠道事件行 —— 同一风格)。**不含记忆正文**。
type AdminIngestReview struct {
// ID 是留痕自身的 id(面板据此做去重/翻页键)。
ID string `json:"id"`
// ItemID 是被审的那条记忆(命中的既有 id 由调用方回表取原文)。
ItemID string `json:"itemId"`
// PersonaID / Scope / ProjectID 是这次审查发生的**分区**(§15 D3-7 只在同一分区内判定)。
PersonaID string `json:"personaId"`
Scope string `json:"scope"`
ProjectID string `json:"projectId,omitempty"`
// Decision ∈ skip | merge | modify | create(四态闭集)。
Decision string `json:"decision"`
// Reason 是判定依据(规则名,如完全等价/真包含/无相似)。
Reason string `json:"reason,omitempty"`
// Veto 是命中的否决规则;`merge`/`modify` 时为空。它是**结构化依据**,
// 面板据此聚合「为什么这一条没被合并」,而不是去解析 Reason 文案。
Veto string `json:"veto,omitempty"`
// TargetID 是这次判定命中的**既有条目** id(create 时为空)。
TargetID string `json:"targetId,omitempty"`
// MergedIDs 是被这次判定吸收(软合并)的行 id 列表;恒为非 null 数组。
MergedIDs []string `json:"mergedIds"`
// Score 是候选召回命中的相似度;RecallMode 是这次召回的路径(语义/词项)。
Score float64 `json:"score"`
RecallMode string `json:"recallMode,omitempty"`
// Degraded 报告这次判定是否**降级**完成(embedder 不可用 ⇒ 回落到词项余弦)。
Degraded bool `json:"degraded"`
// RuleVersion 是判定规则集版本(改规则必须同时升它,否则新旧留痕无法区分)。
RuleVersion string `json:"ruleVersion"`
// SessionID 是完成这次审查的记忆管理会话(独立的 sess-memorytidy,不碰主会话)。
SessionID string `json:"sessionId,omitempty"`
// CreatedAt 是审查时刻(UTC RFC3339)。
CreatedAt string `json:"createdAt"`
}
// adminMemoryReviews 处理 GET /api/v1/admin/memories/reviews。它只读、不审计
// (与同族只读管理端点同一档)。
func (s *Server) adminMemoryReviews(w http.ResponseWriter, r *http.Request) {
store := s.ingestReviews()
if store == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, memoryReviewUnavailableCode, "入库前审查留痕未接线"))
return
}
limit, ok := parseMemoryReviewLimit(r)
if !ok {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
return
}
reviews, err := store.ListIngestReviews(r.Context(), limit)
if err != nil {
s.log.Error("list ingest reviews", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "reviews_failed", "读取审查留痕失败"))
return
}
out := make([]AdminIngestReview, 0, len(reviews))
for _, rev := range reviews {
out = append(out, adminIngestReview(rev))
}
writeJSON(w, http.StatusOK, out)
}
// adminMemoryRevert 处理 POST /api/v1/admin/memories/{id}/revert:撤销一次软合并。
//
// 三个分支都如实作答:未接线 ⇒ 500;该行不在 merged 状态(id 拼错 / 已被回滚过)
// ⇒ **404 not_found**(绝不假装回滚成功);成功 ⇒ 200 + 被回滚的 id,并落审计。
func (s *Server) adminMemoryRevert(w http.ResponseWriter, r *http.Request) {
store := s.ingestReviews()
if store == nil {
s.auditClaims(r, memoryReviewRevertAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, memoryReviewUnavailableCode, "入库前审查留痕未接线"))
return
}
id := r.PathValue("id")
if id == "" {
s.auditClaims(r, memoryReviewRevertAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "memory id is required"))
return
}
if err := store.RevertMerged(r.Context(), id); err != nil {
s.auditClaims(r, memoryReviewRevertAction, "error")
if errors.Is(err, memory.ErrNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "记忆条目不在被合并状态,无法回滚"))
return
}
s.log.Error("revert merged memory", "err", err, "item", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "revert_failed", "回滚软合并失败"))
return
}
s.auditClaims(r, memoryReviewRevertAction, "ok")
writeJSON(w, http.StatusOK, map[string]any{"reverted": id})
}
// ingestReviews 返回当前接入的审查留痕存储;`Memories` 未实现该视图时为 nil。
func (s *Server) ingestReviews() ingestReviewStore {
if s.memories == nil {
return nil
}
store, ok := s.memories.(ingestReviewStore)
if !ok {
return nil
}
return store
}
// parseMemoryReviewLimit 解析并归一 limit:缺省/非正 ⇒ 默认页大小;超硬上限 ⇒ 钳位;
// 非整数 ⇒ ok=false(调用方回 400)。钳位而不是报错,与渠道事件查询面同一约定。
func parseMemoryReviewLimit(r *http.Request) (int, bool) {
raw := r.URL.Query().Get("limit")
if raw == "" {
return memoryReviewDefaultLimit, true
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
if v <= 0 {
return memoryReviewDefaultLimit, true
}
if v > memoryReviewMaxLimit {
return memoryReviewMaxLimit, true
}
return v, true
}
// adminIngestReview 把一条领域留痕投影成线上结构。MergedIDs 兜成非 null 数组
// (面板按数组读),时间为 UTC RFC3339(与渠道事件行同口径)。
func adminIngestReview(rev memory.IngestReview) AdminIngestReview {
ids := rev.MergedIDs
if ids == nil {
ids = []string{}
}
return AdminIngestReview{
ID: rev.ID,
ItemID: rev.ItemID,
PersonaID: rev.PersonaID,
Scope: string(rev.Scope),
ProjectID: rev.ProjectID,
Decision: string(rev.Decision),
Reason: rev.Reason,
Veto: string(rev.Veto),
TargetID: rev.TargetID,
MergedIDs: ids,
Score: rev.Score,
RecallMode: rev.RecallMode,
Degraded: rev.Degraded,
RuleVersion: rev.RuleVer,
SessionID: rev.SessionID,
CreatedAt: rev.CreatedAt.UTC().Format(time.RFC3339),
}
}
@@ -0,0 +1,250 @@
package gateway
// 本文件锁定**入库前审查(D3)管理面**的两条端点(design/23 §15 D3-8 + §8;切片 L45):
// ① 留痕列表的投影(camelCase、时间 UTC RFC3339、`mergedIds` 恒为非 null 数组);
// ② `limit` 的默认值 / 钳位 / 非法值(400);
// ③ 门与审计:owner|admin 200、viewer 403、匿名 401;回滚**成功与失败都落审计**;
// ④ 未接线时诚实作答 500 `memory_review_unavailable`(**绝不回空列表**假装「没有留痕」);
// ⑤ 回滚的三种结果:成功 200、不在被合并状态 404 `not_found`、存储报错 500。
import (
"context"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// fakeIngestReviews 是审查留痕存储的替身:既满足 Deps.Memories(MemoryStore),
// 又实现管理面要的窄视图(ingestReviewStore)。
type fakeIngestReviews struct {
reviews []memory.IngestReview
listErr error
revertErr error
limitGot int
reverted []string
}
func (f *fakeIngestReviews) List(context.Context) ([]memory.MemoryItem, error) {
return []memory.MemoryItem{{ID: "m1", Scope: memory.ScopeUser, Content: "一条记忆"}}, nil
}
func (f *fakeIngestReviews) ListIngestReviews(_ context.Context, limit int) ([]memory.IngestReview, error) {
f.limitGot = limit
return f.reviews, f.listErr
}
func (f *fakeIngestReviews) RevertMerged(_ context.Context, id string) error {
if f.revertErr != nil {
return f.revertErr
}
f.reverted = append(f.reverted, id)
return nil
}
// listOnlyMemories 只实现 Deps.Memories 的最低要求(没有留痕视图的存储)。
type listOnlyMemories struct{}
func (listOnlyMemories) List(context.Context) ([]memory.MemoryItem, error) {
return nil, nil
}
// recordingAudit 捕获审计链上的每一次 Append(谁/做了什么/结果)。
type recordingAudit struct{ entries []AuditRecord }
func (a *recordingAudit) Append(_ context.Context, who, what, result string, ts time.Time) error {
a.entries = append(a.entries, AuditRecord{Who: who, What: what, When: ts.Format(time.RFC3339), Result: result})
return nil
}
func (a *recordingAudit) List(context.Context, int) ([]AuditRecord, error) { return a.entries, nil }
// fixtureIngestReview 是一条四态留痕(merge:命中了既有条目并把新行软合并进去)。
func fixtureIngestReview() memory.IngestReview {
return memory.IngestReview{
ID: "rev-1",
ItemID: "item-new",
PersonaID: "yuki",
Scope: memory.ScopeUser,
ProjectID: "proj-1",
Decision: memory.IngestMerge,
Reason: "subset_of_existing",
TargetID: "item-existing",
MergedIDs: []string{"item-new"},
Score: 0.91,
RecallMode: "vector",
Degraded: false,
RuleVer: "d3.1",
SessionID: "sess-memorytidy",
CreatedAt: time.Date(2026, 10, 6, 3, 4, 5, 0, time.UTC),
}
}
func TestAdminMemoryReviewsListProjection(t *testing.T) {
t.Parallel()
fake := &fakeIngestReviews{reviews: []memory.IngestReview{fixtureIngestReview()}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if fake.limitGot != memoryReviewDefaultLimit {
t.Fatalf("默认 limit = %d, want %d", fake.limitGot, memoryReviewDefaultLimit)
}
var rows []map[string]json.RawMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
t.Fatalf("解析 data: %v (body=%s)", err, body)
}
if len(rows) != 1 {
t.Fatalf("留痕条数 = %d, want 1", len(rows))
}
want := map[string]string{
"id": `"rev-1"`,
"itemId": `"item-new"`,
"personaId": `"yuki"`,
"scope": `"user"`,
"projectId": `"proj-1"`,
"decision": `"merge"`,
"reason": `"subset_of_existing"`,
"targetId": `"item-existing"`,
"score": `0.91`,
"recallMode": `"vector"`,
"degraded": `false`,
"ruleVersion": `"d3.1"`,
"sessionId": `"sess-memorytidy"`,
"createdAt": `"2026-10-06T03:04:05Z"`,
}
for key, val := range want {
raw, ok := rows[0][key]
if !ok {
t.Fatalf("缺键 %q(row=%v)", key, rows[0])
}
if string(raw) != val {
t.Fatalf("%s = %s, want %s", key, raw, val)
}
}
if string(rows[0]["mergedIds"]) != `["item-new"]` {
t.Fatalf("mergedIds = %s, want [\"item-new\"]", rows[0]["mergedIds"])
}
// 无否决规则时不发 veto 键(omitempty)。
if raw, ok := rows[0]["veto"]; ok {
t.Fatalf("未命中否决规则时不该有 veto 键:%s", raw)
}
}
func TestAdminMemoryReviewsLimitAndEmpty(t *testing.T) {
t.Parallel()
fake := &fakeIngestReviews{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=3", nil, tok)
if resp.StatusCode != http.StatusOK || fake.limitGot != 3 {
t.Fatalf("limit=3 ⇒ status/limit = %d/%d, want 200/3 (body=%s)", resp.StatusCode, fake.limitGot, body)
}
// 超硬上限 ⇒ 钳位(不报错,与渠道事件查询面同一约定)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=9999", nil, tok)
if resp.StatusCode != http.StatusOK || fake.limitGot != memoryReviewMaxLimit {
t.Fatalf("limit=9999 ⇒ status/limit = %d/%d, want 200/%d (body=%s)",
resp.StatusCode, fake.limitGot, memoryReviewMaxLimit, body)
}
// 非正 ⇒ 默认。
resp, _ = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=0", nil, tok)
if resp.StatusCode != http.StatusOK || fake.limitGot != memoryReviewDefaultLimit {
t.Fatalf("limit=0 ⇒ status/limit = %d/%d, want 200/%d", resp.StatusCode, fake.limitGot, memoryReviewDefaultLimit)
}
// 非整数 ⇒ 400 bad_request。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews?limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
t.Fatalf("limit=abc ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
}
// 空留痕 ⇒ 200 + 非 null 空数组(「算过了,这一条都没有」与「没接线」必须可区分)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, tok)
if resp.StatusCode != http.StatusOK || string(decodeBody(t, body)["data"]) != "[]" {
t.Fatalf("空留痕 ⇒ %d/%s, want 200/[]", resp.StatusCode, body)
}
}
func TestAdminMemoryReviewsRBACAndUnavailable(t *testing.T) {
t.Parallel()
fake := &fakeIngestReviews{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
if resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("匿名 status = %d, want 401", resp.StatusCode)
}
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
if resp, _ := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, viewer); resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer status = %d, want 403(留痕属私有记忆面)", resp.StatusCode)
}
admin := signAny(t, jwt, "acct", auth.RoleAdmin)
if resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/reviews", nil, admin); resp.StatusCode != http.StatusOK {
t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
// 存储没有留痕视图 ⇒ 500 memory_review_unavailable(绝不回空列表)。
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
tok := signAny(t, bareJWT, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, bare.URL+"/api/v1/admin/memories/reviews", nil, tok)
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != memoryReviewUnavailableCode {
t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, memoryReviewUnavailableCode)
}
}
func TestAdminMemoryRevertOutcomesAndAudit(t *testing.T) {
t.Parallel()
fake := &fakeIngestReviews{}
audit := &recordingAudit{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 成功:200 + 被回滚的 id,并落一条 ok 审计。
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/item-new/revert", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("revert status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(fake.reverted) != 1 || fake.reverted[0] != "item-new" {
t.Fatalf("回滚调用 = %v, want [item-new]", fake.reverted)
}
var payload map[string]string
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
t.Fatalf("解析 data: %v", err)
}
if payload["reverted"] != "item-new" {
t.Fatalf("data.reverted = %q, want item-new", payload["reverted"])
}
if len(audit.entries) != 1 || audit.entries[0].What != memoryReviewRevertAction || audit.entries[0].Result != "ok" {
t.Fatalf("成功后的审计 = %+v, want 一条 %s/ok", audit.entries, memoryReviewRevertAction)
}
// 该行不在被合并状态 ⇒ 404 not_found,且**失败也落审计**。
fake.revertErr = memory.ErrNotFound
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/plain-item/revert", nil, tok)
if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "not_found" {
t.Fatalf("非合并行 ⇒ %d/%s, want 404/not_found", resp.StatusCode, body)
}
if len(audit.entries) != 2 || audit.entries[1].Result != "error" {
t.Fatalf("失败后的审计 = %+v, want 一条 error", audit.entries)
}
// 存储报错 ⇒ 500 revert_failed(不谎报成功)。
fake.revertErr = errors.New("db exploded")
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/item-new/revert", nil, tok)
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "revert_failed" {
t.Fatalf("存储报错 ⇒ %d/%s, want 500/revert_failed", resp.StatusCode, body)
}
// 未接线 ⇒ 500 memory_review_unavailable。
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
btok := signAny(t, bareJWT, "acct", auth.RoleOwner)
resp, body = doReq(t, http.MethodPost, bare.URL+"/api/v1/admin/memories/item-new/revert", nil, btok)
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != memoryReviewUnavailableCode {
t.Fatalf("未接线 revert ⇒ %d/%s, want 500/%s", resp.StatusCode, body, memoryReviewUnavailableCode)
}
}
+10 -1
View File
@@ -119,7 +119,10 @@ type historyMessage struct {
// 它**只由读接口填充**(`handleListMessages`):其余共用本结构的出口
// (`message_persisted` / `message_edited` / 握手回放 / 渠道日志)逐字不变。
ToolCalls []historyToolCall `json:"toolCalls,omitempty"`
CreatedAt int64 `json:"createdAt"`
// Voice 是这条消息的**语音面**(契约 v0.67 §4.4;形状与缺席语义见 history_voice.go):
// 非语音消息**整键缺席**(指针 nil,不是 `{}` 也不是 `null`)⇒ **🎤 判据 = 本键存在**。
Voice *historyVoice `json:"voice,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions 先返回主会话,再返回每个子会话(最旧
@@ -299,6 +302,8 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
// (按页内路标识集合,禁 N+1)。图取不到 ⇒ 字段整体缺席(attachToolCalls 只在
// 真的回看到工具调用时才写),绝不回空数组。
s.attachToolCalls(r.Context(), id, out)
// 语音面(契约 v0.67 §4.4):同样一次批量读列,列里没有语音事实 ⇒ 整体缺席。
s.attachVoice(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
@@ -360,6 +365,8 @@ func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
// 语音面(契约 v0.67 §4.4):渠道日志页也是读取面,同样补(一次批量)。
s.attachVoice(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
@@ -417,6 +424,8 @@ func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
// 语音面(契约 v0.67 §4.4):IM 适配器日志页也是读取面之一,同样补(一次批量)。
s.attachVoice(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
+134
View File
@@ -0,0 +1,134 @@
package gateway
// 本文件是消息 DTO 的**语音面**(契约 v0.67 §4.4 的 `voice` 对象;design/24 §10.4)。
//
// 它回答客户端那个问题:「这条消息来自一条语音吗?转写成不成?有没有可回放的副本?」
// 三个答案都在 `chat_messages.voice` 列里(JSON 单对象,迁移 0020),本文件只做
// **读侧投影**:解码 → 映射为 DTO → 拼出唯一的派生字段 `url`。它不写列、不改正文,
// 也不判身份(来源是渠道事实,与谁在说话无关 —— 身份永远由 actor 元数据决定)。
//
// 四条硬口径:
//
// - **🎤 判据 = `voice` 键存在**(契约明写):非语音消息整键缺席(`omitempty` 的
// 指针 nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
// - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现
// (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现
// 「`url` 指着一个不存在的 `name`」。
// - **`url` 只由服务端拼**(`voice.MediaPath`):客户端绝不自己拼路径。
// - **坏列可见地缺席**:列被外部改坏(非法 JSON / 闭集破坏)时**不静默当「不是语音」**
// —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice`
// 没有「读不出」这一态,因此缺席是唯一诚实的表达(与文档列回一条 `unreadable`
// 不同:文档有那个态,语音没有)。
//
// 覆盖范围(**如实**):本文件的补齐点挂在会用 DTO 的读侧出口上(会话消息页、渠道
// 消息日志两个端点、编辑消息的响应与其广播帧)。**尚未覆盖**:`message_persisted` /
// `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)——
// 前者还额外需要「随 INSERT 写入」的写路径(设计 §10.5 的首选口径),见交付报告。
import (
"context"
"strings"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// historyVoice 是消息 DTO 里的 `voice` 对象(契约 v0.67 §4.4 逐字形状):
// `{source,status,name?,mime?,size?,durationMs?,url?}`。
//
// `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余
// 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。
type historyVoice struct {
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)。
Source string `json:"source"`
// Status ∈ transcribed(正文即转写文本)| empty(转写成功但没识别出文字)|
// failed(没拿到转写文本,正文是一行中文诚实提示)。闭集三态。
Status string `json:"status"`
// Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。
Name string `json:"name,omitempty"`
// MIME 是保存时成对裁决所得的下发类型(`audio/ogg` 等)。
MIME string `json:"mime,omitempty"`
// Size 是副本字节数。
Size int64 `json:"size,omitempty"`
// DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。
DurationMs int64 `json:"durationMs,omitempty"`
// URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。
URL string `json:"url,omitempty"`
}
// voiceColumnReader 是历史存储的**可选读侧视图**:按消息 id 批量取 voice 列原文
// (一次 `IN (...)`,禁 N+1)。生产实现是 `*history.Store`(`VoiceColumns`)。
//
// 它刻意是**可选**接口(与 PersonaMoodStore / voiceProviderReporter 同一手法):
// 未实现它的存储(测试替身、只做基本读写的实现)不会因为缺少这一面而失败 ——
// 这条路径上「没有这一面」的诚实结果是**字段缺席**,而不是 500。
type voiceColumnReader interface {
VoiceColumns(ctx context.Context, ids []string) (map[string]string, error)
}
// attachVoice 给一页消息补上各自的语音面(就地写进 DTO 切片)。它只查页内**非空**
// 的消息 id,且**一次批量**(页内 id 集合交给存储侧一条 SQL 查完)。
//
// 失败处理与工具回看面同一纪律:查不到就只让这一面缺席并记 WARN —— 消息本身已经
// 读到了,让一个可选列的故障把整页历史变成 500 是反的。
func (s *Server) attachVoice(ctx context.Context, msgs []historyMessage) {
ids := pageMessageIDs(msgs)
if len(ids) == 0 {
return
}
reader, ok := s.history.(voiceColumnReader)
if !ok {
// 存储未提供这一面(未接线 / 测试替身):整体缺席,不是错误。
return
}
cols, err := reader.VoiceColumns(ctx, ids)
if err != nil {
s.log.Warn("history voice unavailable", "err", err, "messages", len(ids))
return
}
for i := range msgs {
raw, found := cols[msgs[i].ID]
if !found {
continue // 该行取不到(已删除):缺席
}
ref, err := voiceref.Decode(raw)
if err != nil {
// 坏列**可见地**缺席:绝不静默当成「不是语音」。日志只带消息 id 与错误,
// 绝不带列内容或正文。
s.log.Warn("history voice column malformed", "err", err, "message", msgs[i].ID)
continue
}
msgs[i].Voice = historyVoiceOf(ref)
}
}
// pageMessageIDs 收集页内非空且**互不相同**的消息 id(保持首次出现顺序)。去重让
// 同一页的「提问 + 回答」或编辑场景里的两份 DTO 共用同一次查询。
func pageMessageIDs(msgs []historyMessage) []string {
seen := make(map[string]bool, len(msgs))
out := make([]string, 0, len(msgs))
for _, m := range msgs {
id := strings.TrimSpace(m.ID)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
// historyVoiceOf 把一条已解码的语音事实投影成 DTO;不是语音消息(闭集外/零值)时
// 返回 nil,使 `voice` 键整体缺席(契约:非语音消息与引入语音之前逐字相同)。
func historyVoiceOf(ref voiceref.Ref) *historyVoice {
if !ref.IsVoice() {
return nil
}
out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)}
if ref.HasCopy() {
// 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。
out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size
out.URL = voiceref.MediaPath(ref.Name)
}
out.DurationMs = ref.DurationMs
return out
}
@@ -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)
}
}
+9 -4
View File
@@ -177,10 +177,15 @@ func (s *Server) handlePatchMessage(w http.ResponseWriter, r *http.Request) {
// 气泡(message_persisted 实时同步契约,D3 编辑
// 路径)。thinking 会从帧中剥掉,以匹配持久化/重放
// 契约;REST 回复保留它,因为消息路由仅 owner/admin。
edited := toHistoryMessage(msg)
edited.Thinking = ""
s.hub.broadcastMessageEdited(edited)
writeJSON(w, http.StatusOK, toHistoryMessage(msg))
//
// 语音面(契约 v0.67 §4.4;切片 L45):响应与广播帧**都要**带 voice(编辑不改变
// 语音事实,但读取面必须一致)。两条 DTO 共用**一次**批量读列 —— attachVoice 按
// id 去重,同一条消息只查一次。
pair := []historyMessage{toHistoryMessage(msg), toHistoryMessage(msg)}
pair[0].Thinking = ""
s.attachVoice(r.Context(), pair)
s.hub.broadcastMessageEdited(pair[0])
writeJSON(w, http.StatusOK, pair[1])
}
// handleDeleteMessage 移除单条消息(D3)。未知 id 返回 404,
+108
View File
@@ -0,0 +1,108 @@
package gateway
// 本文件承载**语音副本的回放端点**:`GET /api/v1/media/{name}`(design/24 §10.11;
// 契约 v0.67 已登记形状,本片落地)。
//
// 它与文档读侧(`GET /api/v1/documents/{name}`)刻意分道,三处不同:
//
// - **下发类型恒为 `audio/*`**,按**存储名的扩展名**映射(`voice.MediaMIME`),
// **不查库、不重新嗅探** —— 「扩展名 ↔ 字节」在保存那一刻已经成对裁决
// (`documents.SaveAudio`:音频字节的嗅探结果恒为 `application/octet-stream`,
// 嗅探对音频没有意义);
// - **内联而非附件**(`inline; filename=…`):这份字节就是要被播放器消费的。
// 因下发类型恒在 `audio/*` + `nosniff`,内联不构成文档侧那类 XSS 面(文档读侧
// 之所以永不内联,是因为它可能装着 HTML/SVG 这类可渲染内容);
// - **鉴权与文档同档**(Bearer `owner|admin`,门在路由表):语音是私密内容,**不**
// 走图片那套「随机名即凭据」的免鉴权读法 —— 客户端因此必须带 token 取字节
// (Web:`fetch` → `blob:` → 播放),音频 URL **不能**直接贴进 `<audio src>`。
//
// `Range` / 条件请求 / `HEAD` 一律交给标准库的 `http.ServeContent`:`206`/`304`/`416`
// 的语义因此是 Go 的既有语义,而不是本项目自研的一套。⚠️ 其中 **`416` 不是 JSON
// 信封**(标准库直写 `text/plain` + `Content-Range: bytes */<size>`),契约已登记。
//
// 名字形态非法 / 路径穿越 / 文件不存在**三者同一个 404 `media_not_found`**:不泄露
// 「存在与否」以外的任何信息。404 是**可预期的输入问题**,因此不记错误日志。
import (
"net/http"
"os"
"path/filepath"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// mediaNotFoundCode 是语音副本缺失的线上错误码(契约 v0.67 §4.1)。
const mediaNotFoundCode = "media_not_found"
// handleMediaFile 下发一份语音副本。门在路由表(owner|admin,与文档读侧同一道门)。
//
// 它**不读数据库**:落盘的文件就是唯一真源,存储名既决定路径也决定下发类型。
// 因此一个「库里有引用、盘上没文件」的状态在这里如实表现为 404,而不是一个
// 看起来成功却播放不了的响应。
func (s *Server) handleMediaFile(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !voiceref.ValidMediaName(name) {
writeError(w, mediaNotFoundError())
return
}
dir, err := s.documentsDir()
if err != nil {
writeError(w, mediaNotFoundError())
return
}
path := filepath.Join(dir, name)
// 名字已由 ValidMediaName 双检过;这里是第二道门(与文档读侧同一手法)。
if filepath.Dir(path) != filepath.Clean(dir) {
writeError(w, mediaNotFoundError())
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, mediaNotFoundError())
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil || info.IsDir() {
writeError(w, mediaNotFoundError())
return
}
w.Header().Set("Content-Type", mediaReadMIME(name))
w.Header().Set("Content-Disposition", "inline; filename=\""+name+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
// 内容寻址 ⇒ 名字变则内容变,可以放心 immutable。
w.Header().Set("Cache-Control", "private, max-age=86400, immutable")
// 条件请求与 Range 交给标准库:Last-Modified 来自文件 mtime(**不设 ETag**,
// 契约明写「不自造语义」)。它同时处理 HEAD(GET 模式匹配 HEAD)。
http.ServeContent(w, r, name, info.ModTime(), f)
}
// mediaReadMIME 按存储名的扩展名给出下发类型。白名单以外的扩展名不可能走到这里
// (名字形态已判),因此兜底分支只防「表与判定漂移」,绝不回一个「看起来像音频」的
// 类型:未知就退化为 `application/octet-stream`。
func mediaReadMIME(name string) string {
if mime := voiceref.MediaMIME(documentExtOf(name)); mime != "" {
return mime
}
return documentFallbackMIME
}
// mediaNotFoundError 构造语音副本缺失错误:404 + `media_not_found`。名字形态非法与
// 路径穿越都在此统一收敛。
func mediaNotFoundError() error {
return apperr.Wrap(apperr.ErrNotFound, mediaNotFoundCode, "语音副本不存在")
}
// guardMediaPaths 在 ServeMux 把含 `..` 段的媒体路径清理并 301 重定向之前把它拦下,
// 统一回 404 `media_not_found`。与 guardUploadPaths / guardDocumentPaths 同一套理由。
func guardMediaPaths(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, voiceref.MediaPathPrefix) && hasDotDotSegment(r.URL.Path) {
writeError(w, mediaNotFoundError())
return
}
next.ServeHTTP(w, r)
})
}
+243
View File
@@ -0,0 +1,243 @@
package gateway
// 本文件锁定**语音副本回放端点** `GET /api/v1/media/{name}`(design/24 §10.11;契约
// v0.67 登记、本片落地)的六条口径:
// ① 200 + `audio/*` + 内联 + `nosniff` + `Accept-Ranges`(**不设** ETag);
// ② `Range` ⇒ 206 + `Content-Range`;`If-Modified-Since` ⇒ 304;`HEAD` ⇒ 200 无正文
// —— 三者都由标准库的 `ServeContent` 给出;
// ③ 不可满足的 `Range` ⇒ 416,且**不是** JSON 信封(标准库直写 `text/plain`);
// ④ 名字形态非法 / 路径穿越 / 文件不存在 ⇒ **同一个** 404 `media_not_found`;
// ⑤ 门与文档读侧同档(无凭据 401 / viewer 403 / owner|admin 200);
// ⑥ 主名宽度与 documents 包的内容寻址常量同值(32 位小写十六进制)。
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// mediaFixture 是一份已落盘的语音副本 + 一个只带 DataDir 的网关。
type mediaFixture struct {
ts *httptest.Server
jwt *auth.JWTService
name string
body []byte
dir string
}
// newMediaFixture 在 `$DATA_DIR/uploads/documents` 下写一份内容寻址命名的音频副本
// (与 `documents.SaveAudio` 的落盘口径逐字一致:sha256[:32] + ext、0600)。
func newMediaFixture(t *testing.T, body []byte, ext string) *mediaFixture {
t.Helper()
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "uploads", documentSubDir)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("建副本目录: %v", err)
}
sum := sha256.Sum256(body)
name := hex.EncodeToString(sum[:])[:documents.HashHexLen] + ext
if err := os.WriteFile(filepath.Join(dir, name), body, 0o600); err != nil {
t.Fatalf("写副本: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DataDir: dataDir})
return &mediaFixture{ts: ts, jwt: jwt, name: name, body: body, dir: dir}
}
// doMediaReq 发一次可带额外请求头的媒体请求(doReq 不支持自定义头)。
func doMediaReq(t *testing.T, f *mediaFixture, method, path string, headers map[string]string, token string) (*http.Response, []byte) {
t.Helper()
req, err := http.NewRequest(method, f.ts.URL+path, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(resp.Body); err != nil {
t.Fatalf("read body: %v", err)
}
return resp, buf.Bytes()
}
func TestMediaEndpointServesInlineAudio(t *testing.T) {
t.Parallel()
body := []byte("RIFF\x24\x00\x00\x00WAVEfmt voice-bytes")
f := newMediaFixture(t, body, ".ogg")
tok := signAny(t, f.jwt, "acct", auth.RoleOwner)
resp, got := doMediaReq(t, f, http.MethodGet, voiceref.MediaPathPrefix+f.name, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, got)
}
if ct := resp.Header.Get("Content-Type"); ct != "audio/ogg" {
t.Fatalf("Content-Type = %q, want audio/ogg(按扩展名裁决,不嗅探)", ct)
}
if cd := resp.Header.Get("Content-Disposition"); cd != `inline; filename="`+f.name+`"` {
t.Fatalf("Content-Disposition = %q, want inline; filename=%q", cd, f.name)
}
if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("缺 nosniff: %v", resp.Header)
}
if resp.Header.Get("Accept-Ranges") != "bytes" {
t.Fatalf("Accept-Ranges = %q, want bytes", resp.Header.Get("Accept-Ranges"))
}
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "private") || !strings.Contains(cc, "immutable") {
t.Fatalf("Cache-Control = %q, want private + immutable", cc)
}
if resp.Header.Get("ETag") != "" {
t.Fatalf("不该自造 ETag,得到 %q", resp.Header.Get("ETag"))
}
if !bytes.Equal(got, body) {
t.Fatalf("正文 = %q, want %q", got, body)
}
}
func TestMediaEndpointRangeConditionalAndHead(t *testing.T) {
t.Parallel()
body := []byte("0123456789abcdef")
f := newMediaFixture(t, body, ".wav")
tok := signAny(t, f.jwt, "acct", auth.RoleOwner)
path := voiceref.MediaPathPrefix + f.name
size := strconv.Itoa(len(body))
// Range ⇒ 206 部分内容。
resp, got := doMediaReq(t, f, http.MethodGet, path, map[string]string{"Range": "bytes=0-3"}, tok)
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("Range status = %d, want 206 (body=%s)", resp.StatusCode, got)
}
if cr := resp.Header.Get("Content-Range"); cr != "bytes 0-3/"+size {
t.Fatalf("Content-Range = %q, want bytes 0-3/%s", cr, size)
}
if !bytes.Equal(got, body[:4]) {
t.Fatalf("Range 正文 = %q, want %q", got, body[:4])
}
// If-Modified-Since(不早于 mtime)⇒ 304,无正文。
lastMod := resp.Header.Get("Last-Modified")
if lastMod == "" {
t.Fatal("缺 Last-Modified:条件请求无从判定")
}
resp, got = doMediaReq(t, f, http.MethodGet, path, map[string]string{"If-Modified-Since": lastMod}, tok)
if resp.StatusCode != http.StatusNotModified {
t.Fatalf("条件请求 status = %d, want 304 (body=%s)", resp.StatusCode, got)
}
if len(got) != 0 {
t.Fatalf("304 不该带正文:%q", got)
}
// HEAD ⇒ 200 + 空正文 + 正确长度(GET 模式同时匹配 HEAD)。
resp, got = doMediaReq(t, f, http.MethodHead, path, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("HEAD status = %d, want 200", resp.StatusCode)
}
if len(got) != 0 {
t.Fatalf("HEAD 不该带正文:%q", got)
}
if cl := resp.Header.Get("Content-Length"); cl != size {
t.Fatalf("HEAD Content-Length = %q, want %s", cl, size)
}
// 不可满足的 Range ⇒ 416,且**不是** JSON 信封(契约 v0.67 明写)。
resp, got = doMediaReq(t, f, http.MethodGet, path, map[string]string{"Range": "bytes=9999-10000"}, tok)
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("越界 Range status = %d, want 416 (body=%s)", resp.StatusCode, got)
}
if cr := resp.Header.Get("Content-Range"); cr != "bytes */"+size {
t.Fatalf("416 Content-Range = %q, want bytes */%s", cr, size)
}
if len(got) > 0 && got[0] == '{' {
t.Fatalf("416 不该是 JSON 信封:%q", got)
}
}
func TestMediaEndpointNotFoundIsUniform(t *testing.T) {
t.Parallel()
f := newMediaFixture(t, []byte("audio-bytes"), ".mp3")
tok := signAny(t, f.jwt, "acct", auth.RoleOwner)
missing := hex.EncodeToString(bytes.Repeat([]byte{0xab}, 32)) + ".mp3"
cases := []struct {
desc string
path string
}{
{"名字形态非法(没有扩展名)", voiceref.MediaPathPrefix + "0123456789abcdef0123456789abcdef"},
{"扩展名不在音频白名单", voiceref.MediaPathPrefix + "0123456789abcdef0123456789abcdef.txt"},
{"主名不是小写十六进制", voiceref.MediaPathPrefix + "0123456789ABCDEF0123456789abcdef.mp3"},
{"路径穿越", "/api/v1/media/../cyrene.db"},
{"穿越到子目录", "/api/v1/media/%2e%2e/cyrene.db"},
{"文件不存在", voiceref.MediaPathPrefix + missing},
}
for _, tc := range cases {
resp, body := doMediaReq(t, f, http.MethodGet, tc.path, nil, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("%s: status = %d, want 404 (body=%s)", tc.desc, resp.StatusCode, body)
}
if code := mediaErrorCode(t, body); code != mediaNotFoundCode {
t.Fatalf("%s: code = %q, want %q (body=%s)", tc.desc, code, mediaNotFoundCode, body)
}
}
}
func TestMediaEndpointRequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
body := []byte("audio-bytes")
f := newMediaFixture(t, body, ".flac")
path := voiceref.MediaPathPrefix + f.name
if resp, _ := doMediaReq(t, f, http.MethodGet, path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("匿名 status = %d, want 401", resp.StatusCode)
}
viewer := signAny(t, f.jwt, "acct", auth.RoleViewer)
if resp, _ := doMediaReq(t, f, http.MethodGet, path, nil, viewer); resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer status = %d, want 403", resp.StatusCode)
}
admin := signAny(t, f.jwt, "acct", auth.RoleAdmin)
if resp, got := doMediaReq(t, f, http.MethodGet, path, nil, admin); resp.StatusCode != http.StatusOK {
t.Fatalf("admin status = %d, want 200 (body=%s)", resp.StatusCode, got)
}
}
// TestMediaStoredNameHexLenMatchesDocuments 钉住「内容寻址主名宽度」两处同值:
// voice 包不 import documents(会成环),因此这条交叉断言就是它们的连接点。
func TestMediaStoredNameHexLenMatchesDocuments(t *testing.T) {
t.Parallel()
if voiceref.StoredNameHexLen != documents.HashHexLen {
t.Fatalf("voice.StoredNameHexLen = %d, documents.HashHexLen = %d, want 同值",
voiceref.StoredNameHexLen, documents.HashHexLen)
}
}
// mediaErrorCode 从 `{error:{code}}` 信封里取出错误码(解析失败即终止用例)。
func mediaErrorCode(t *testing.T, body []byte) string {
t.Helper()
var env struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("解析错误信封失败: %v (body=%s)", err, body)
}
return env.Error.Code
}
+20
View File
@@ -93,6 +93,13 @@ func (s *Server) registerUserRoutes(
// 「随机名即凭据」的免鉴权读法,也不会放开内联服务 HTML/SVG 的口子。
mux.Handle("POST /api/v1/documents", writeAuth(http.HandlerFunc(s.handleDocumentUpload)))
mux.Handle("GET /api/v1/documents/{name}", writeAuth(http.HandlerFunc(s.handleDocumentFile)))
// 语音副本回放(design/24 §10.11;契约 v0.67 登记、本片落地):与文档读侧**同一
// 道门**(owner|admin,必须 Bearer)—— 语音是私密内容,刻意**不**走图片那套
// 「随机名即凭据」的免鉴权读法(客户端带 token 取字节 → `blob:` 播放)。音频副本
// 与文档共用 `<DataDir>/uploads/documents` 目录,但下发类型恒为 `audio/*` 且内联,
// 理由见 media.go。
mux.Handle("GET /api/v1/media/{name}", writeAuth(http.HandlerFunc(s.handleMediaFile)))
}
// registerHistoryRoutes 挂载持久化聊天历史表面(design/16)
@@ -214,6 +221,16 @@ func (s *Server) registerAdminRoutes(
// 语义、同样**不审计**。结果只活在进程内存(TTL 30 分钟),进程重启后旧 runId
// 如实 404。
mux.Handle("GET /api/v1/admin/memories/tidy/dry-run/{runId}", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRunStatus)))
// 入库前审查(D3)的管理面(design/23 §15 D3-8「面板可查」+ §8「回滚」;切片 L45):
// - GET .../memories/reviews —— 审查留痕列表(谁被审 / 四态 / 依据 / 合并去向 /
// 是否降级 / 时间),**不含记忆正文**。只读、不审计,走 writeAuth(owner|admin):
// 留痕带 itemId/personaId/projectId/sessionId,属私有记忆面,与同族的
// GET /admin/memories 同档,**不给 viewer**(design/23 §11 的既有取向 + design/11 §7
// 对 viewer 的定义是「只读监控投影」)。
// - POST .../memories/{id}/revert —— 撤销一次软合并(无损可逆,行永不物理删除)。
// 它是**写**,因此落审计(动作名 memory.review.revert,见 admin_memory_review.go)。
mux.Handle("GET /api/v1/admin/memories/reviews", writeAuth(http.HandlerFunc(s.adminMemoryReviews)))
mux.Handle("POST /api/v1/admin/memories/{id}/revert", writeAuth(http.HandlerFunc(s.adminMemoryRevert)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
@@ -325,6 +342,9 @@ func (s *Server) withMiddleware(mux http.Handler) http.Handler {
// 文档路径的同类防护(design/24 §3.2「路径穿越」):`..` 形态必须在 ServeMux
// 清理路径并重定向之前被拦下,否则穿越请求会变成一个非文档路径的重定向。
h = guardDocumentPaths(h)
// 语音副本的同类防护(design/24 §10.11):三条读侧路(files/documents/media)
// 各自的名字形态与错误码,因此各有一道门。
h = guardMediaPaths(h)
if s.frontend != nil {
h = s.spa(h)
// 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2):管理面板前端按 .env 的