Files
Cyrene_v2/internal/services/gateway/history_write.go
T
AskaEth 20d452a51a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway)+docs(api): F5 区分「字段缺失/值为空白」+ E4/E5 契约文档治理
- F5:PATCH /sessions/{id} 的 400 文案区分两类——都缺 → at least one of title or pinned is required;
  存在但空白 → title must not be blank(此前空白也回 title is required,误导排查方向)。
  「空串不能清空标题」的既有语义保持不变(用例显式钉住),新增 history_write_test.go 4 段断言
- E4:/auth/logout 的两种真实响应形状(单撤 {revoked} / 全撤 {revoked,revokedCount})+ 错误码;
  /auth/refresh 补 role;登录响应与一次性接入段补 refreshToken(两份文档一致)
- E5:api-reference §3.2 补 pinned/unreadCount/lastMessage/personaId 与准确排序规则
  (pinned DESC, (type='main') DESC, created_at ASC ⇒ 置顶的次级会话可排在主会话之前);
  新增 §3.6a PATCH /sessions/{id}(请求体/错误码/部分更新/title 不可清空)与 §3.6b POST /sessions/{id}/read;
  端点表补 read 行
- api-client 删掉「主会话恒在前」的错误断言(两处),PATCH 错误码文案同步
- DevDocs/02-development-constraints.md:上传上限 50MB → **硬 5 MiB**(常量、无配置项)
2026-10-04 14:13:27 +08:00

238 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。
edited := toHistoryMessage(msg)
edited.Thinking = ""
s.hub.broadcastMessageEdited(edited)
writeJSON(w, http.StatusOK, toHistoryMessage(msg))
}
// 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
}