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)
}
}
}