Files
Cyrene_v2/internal/services/gateway/history_write.go
T
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

243 lines
10 KiB
Go
Raw 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
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"unicode/utf8"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件拥有为 Android 协作批次 ③a 新增的会话/消息写端点:
// PATCH /sessions/{id}(A4 改名 + D5 置顶)、PATCH
// /messages/{id}(D3 编辑)与 DELETE /messages/{id}(D3 删除)。它们都是 owner/admin
// 历史写;读与 DTO 位于 history.go。D3 的编辑/删除路径还会
// 经 WS hub 广播其结果(message_edited / message_deleted,见
// ws_hub.go),使该账号的其他浏览器无需手动重读即保持同步。
// patchSessionRequest 是 PATCH /api/v1/sessions/{id} 的请求体。两个字段都是
// 指针,因此字段缺失与显式零值可区分:title 重命名该
// 会话(A4),pinned 设置/取消置顶(D5),且至少一个
// 必须出现。
//
// personaId 刻意不是可设置字段:会话角色在会话创建时
// 固定(design/22 §8-1),因此该键会被直接拒绝,而不是
// 被忽略。它以原始 JSON 留在结构体中,使 handler 能区分
// 「键缺失」与「键存在且为任意值」—— `"yuki"`、`""` 与
// `null` 一律被拒,只有不含该键的请求体才被接受。
type patchSessionRequest struct {
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
PersonaID json.RawMessage `json:"personaId"`
}
// maxSessionTitleRunes 限定客户端提供的会话标题,使显示行绝不
// 溢出。它只是显示边界:身份与权限绝不派生自
// 标题(项目红线)。
const maxSessionTitleRunes = 80
// handlePatchSession 更新持久化会话的显示元数据 —— 其标题
// (A4)与置顶标志(D5)。二者都是元数据:与 DELETE 不同,它们
// 允许作用于固定的主会话。未知会话 id 照常 404/500。
//
// 两种 400 刻意用不同文案区分「缺字段」与「字段在、值非法」:
// title 与 pinned 都没给 → 「至少需要 title 或 pinned」;
// 给了 title 但 trim 后为空白 → 「title 不得为空白」;过长 → 明确的上限提示。
// title 的空串/仅空白**没有**清空语义(不回落自动命名),
// 因而客户端不提供「恢复自动命名」入口;要换名只能给非空白标题。
//
// 携带 personaId 键的请求体对任何取值都返回 400,且该检查
// 在会话被查找之前执行:会话角色在创建时固定(design/22
// §8-1),因此切换既有对话的角色 —— 包括清除它 ——
// 不是受支持的操作;请改为新建会话。显式拒绝
// (而不是忽略该键)正是让客户端不会误以为重绑
// 生效的原因。它与其他历史写一样受 owner/admin 门控。响应只
// 回显被改动的字段,因此纯标题 PATCH 保持其 D5 之前的
// {id,title} 形状。
func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "session id is required"))
return
}
var req patchSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.PersonaID != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request",
"personaId cannot be changed: 会话角色在创建时确定,之后不可变,要换角色请新开会话 (design/22 §8-1)"))
return
}
if req.Title == nil && req.Pinned == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "at least one of title or pinned is required"))
return
}
resp := map[string]any{"id": id}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title must not be blank"))
return
}
if utf8.RuneCountInString(title) > maxSessionTitleRunes {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is too long (max 80 characters)"))
return
}
if err := s.history.RenameSession(r.Context(), id, title); err != nil {
s.writeSessionPatchError(w, err, id, "rename session")
return
}
resp["title"] = title
}
if req.Pinned != nil {
if err := s.history.SetPinned(r.Context(), id, *req.Pinned); err != nil {
s.writeSessionPatchError(w, err, id, "pin session")
return
}
resp["pinned"] = *req.Pinned
}
writeJSON(w, http.StatusOK, resp)
}
// writeSessionPatchError 把 RenameSession/SetPinned 的失败映射为其响应:
// ErrSessionNotFound → 404,其余 → 500(在 op 下记日志)。
func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op string) {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
s.log.Error(op, "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to update session"))
}
// patchMessageRequest 是 PATCH /api/v1/messages/{id} 的请求体。Content 是指针,
// 因此字段缺失会被拒绝,而不是被当作空编辑。
type patchMessageRequest struct {
Content *string `json:"content"`
}
// maxMessageContentRunes 限定被编辑的消息体。它只是编辑路径上的
// 存储/显示边界;入站聊天消息不受其约束。
const maxMessageContentRunes = 4000
// handlePatchMessage 编辑单条消息的内容(Android 协作批次 ③a,
// D3)。只有 content 会变 —— role/时间戳/platform 保持已存值 —— 响应
// 返回更新后的消息,使客户端能按 id upsert 而无需重读。
// 空/仅空白或过长的 content 返回 400,未知 id 返回 404,
// HistoryStore 缺失返回 501。它受 owner/admin 门控。
func (s *Server) handlePatchMessage(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "message id is required"))
return
}
var req patchMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.Content == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
content := *req.Content
if strings.TrimSpace(content) == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
return
}
if utf8.RuneCountInString(content) > maxMessageContentRunes {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is too long (max 4000 characters)"))
return
}
msg, err := s.history.UpdateMessage(r.Context(), id, content)
if err != nil {
if errors.Is(err, history.ErrMessageNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "message %q not found", id))
return
}
s.log.Error("history update message", "err", err, "message", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "message_failed", "failed to update message"))
return
}
// 广播该编辑,使该账号的其他每个浏览器无需刷新就重渲染同一个
// 气泡(message_persisted 实时同步契约,D3 编辑
// 路径)。thinking 会从帧中剥掉,以匹配持久化/重放
// 契约;REST 回复保留它,因为消息路由仅 owner/admin。
//
// 语音面(契约 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,
// HistoryStore 缺失返回 501。它受 owner/admin 门控。成功的删除会
// 以 `message_deleted` 帧广播,使该账号的每个浏览器无需刷新就丢掉
// 该行;所属会话在该行消失前被读取。
func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
return
}
id := r.PathValue("id")
if id == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "message id is required"))
return
}
sessionID := s.messageSession(r.Context(), id)
if err := s.history.DeleteMessage(r.Context(), id); err != nil {
if errors.Is(err, history.ErrMessageNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "message %q not found", id))
return
}
s.log.Error("history delete message", "err", err, "message", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "message_failed", "failed to delete message"))
return
}
s.hub.broadcastMessageDeleted(sessionID, id)
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// messageLookup 是 HistoryStore 可选的更窄视图,DELETE 用它
// 在移除前得知消息所属的会话(之后就没有可问的了)。
// *history.Store 实现了它;未实现的存储只是产出一个无作用域
// 的帧,客户端凭全局唯一的消息 id 解析它。
type messageLookup interface {
GetMessage(ctx context.Context, id string) (history.Message, error)
}
// messageSession 返回拥有 message id 的会话 id,存储无法作答时
// 返回 ""(无查找表面、未知 id 或读错误)。空白会话
// 对广播是安全的:消息 id 全局唯一,因此回退到
// 纯 id 过滤的客户端只可能触及该消息自己的对话。
func (s *Server) messageSession(ctx context.Context, id string) string {
lookup, ok := s.history.(messageLookup)
if !ok {
return ""
}
msg, err := lookup.GetMessage(ctx, id)
if err != nil {
s.log.Debug("history message session lookup", "err", err, "message", id)
return ""
}
return msg.SessionID
}