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

@@ -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 的