Files
AskaEth 1420e8aaa6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): D3 审查读侧/revert 端点 + 语音用户可见面(L45)
(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 个变异自检(含一条自认的等价变异)。
2026-10-06 14:12:27 +08:00

84 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}