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