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),
}
}