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 }