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

251 lines
10 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 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)
}
}