package gateway import ( "context" "errors" "fmt" "net/http" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 本文件归属读路径上**「这条会话到底还在不在」**的唯一判断(契约 v0.63)。 // // 背景(客户端登记,2026-10-05):`GET /api/v1/sessions/{id}/messages` 此前对 // **已删除 / 根本不存在**的会话回 `200 {"data":[]}`,客户端因此分不清「这是一条 // 空会话」与「这条会话已经不在了」(主人的真实遭遇:正在看的临时会话被清理掉, // 界面上只剩一片空白)。同一个 API 里 `PATCH /sessions/{id}`、`POST // /sessions/{id}/read` 对未知会话本来就是 `404` —— 让 messages 单独例外会让每个 // 新客户端都踩一次。 // // 因此自 v0.63 起:不存在 / 已删除 ⇒ `404 {error:{code:"session_not_found"}}`; // **存在但没有消息**仍是 `200 {"data":[]}`(空会话绝不能被答成 404)。 // // 判据**复用既有存储面**,不新写 SQL、也不新增存储方法: // `history.Store.SessionPersona` 是既有存储里唯一一个「按 id 读**单个**会话 + // 未知 id 报 `history.ErrSessionNotFound`」的方法(`ListSessions` 是全量列表扫描, // 在一个热分页端点上代价不成比例)。该口径由 `internal/runtime/history` 的 // `TestSessionPersonaUnknownSession` 钉住,装配期角色解析(persona_env.go)用的 // 也正是这个更窄视图。 // // 名字口径(与既有 API 一致):`chat_sessions` 是**会话注册表**,隔离的 IM 会话 // (群聊 / 陌生人的 `sess-xxx`,以及 `sess-backthink`)不在其中 —— 它们与 // `POST /sessions/{id}/read` 拿到的是同一个答案(404:在那条路径上不可寻址), // 其消息仍可由 `/api/v1/admin/channels/{platform}/messages` 读出。 // // 未实现该视图的存储(测试替身 / 未接库的窄存储)视为**无从判定**:此时保持旧的 // 「空集合」语义 —— 判不出来**绝不等于**「不存在」,把一次本可成功的读答成 404 // 比答成空列表更糟。 // sessionNotFoundCode 是「会话不存在 / 已删除」的机器可读错误码(契约 v0.63, // 见 Docs/api-client.md §1 行 6 与 §4.4)。 const sessionNotFoundCode = "session_not_found" // sessionMissing 报告存储是否**明确否认** sessionID 的存在。 // // 第三个返回值是读取本身的失败:调用方必须按「不静默」处理(回 500 并留带来源的 // 日志),绝不把「问不出来」当成「不存在」。 func (s *Server) sessionMissing(ctx context.Context, sessionID string) (bool, error) { reader, ok := s.history.(sessionPersonaReader) if !ok { return false, nil } _, err := reader.SessionPersona(ctx, sessionID) switch { case errors.Is(err, history.ErrSessionNotFound): return true, nil case err != nil: return false, fmt.Errorf("gateway: session lookup %s: %w", sessionID, err) default: return false, nil } } // rejectMissingSession 在会话不存在时写出 `404 session_not_found` 并返回 true, // 使调用方一行收口:`if s.rejectMissingSession(w, r, id) { return }`。 // // 判定失败(存储读不出来)回 `500 messages_failed` 并返回 true —— 宁可让这一次读 // 失败并留下可诊断的日志,也不假装它是空会话。 func (s *Server) rejectMissingSession(w http.ResponseWriter, r *http.Request, id string) bool { missing, err := s.sessionMissing(r.Context(), id) if err != nil { s.log.Error("history messages session lookup", "err", err, "session", id) writeError(w, apperr.Wrap(apperr.ErrInternal, "messages_failed", "failed to list messages")) return true } if !missing { return false } writeError(w, apperr.Wrap(apperr.ErrNotFound, sessionNotFoundCode, "session %q not found", id)) return true }