(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 个变异自检(含一条自认的等价变异)。
84 lines
4.4 KiB
Go
84 lines
4.4 KiB
Go
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
|
||
}
|