package gateway import ( "encoding/json" "errors" "io" "net/http" "time" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // This file owns the D4 read-cursor surface (Android collaboration B3): the // POST /api/v1/sessions/{id}/read endpoint that advances a session's read cursor, // plus the `session_read` realtime frame it broadcasts so the account's other // browsers clear the unread badge without a re-read. The cursor itself lives in // chat_sessions.read_at (internal/runtime/history); GET /sessions derives the // unreadCount from it (history.go). The cursor is session-scoped and single-owner: // one account, one cursor per session — reading on any one end marks the session // read for every end. // wsSessionRead is the WS event type for a read-cursor advance. const wsSessionRead = "session_read" // sessionReadRequest is the POST /api/v1/sessions/{id}/read body. Before is a // pointer so an absent field (an empty body) is distinguishable from an explicit // value: absent means "now" — the client marks everything it has seen read. type sessionReadRequest struct { Before *int64 `json:"before"` } // handleMarkSessionRead advances a session's read cursor and answers // {data:{sessionId, readAt}} with the resulting cursor in Unix milliseconds. // It is owner/admin-gated like the other history writes (writeAuth). // // body {before?} is an optional millisecond timestamp bounding what is marked // read — messages with created_at <= readAt become read. Absent (or an empty body) // means now. A present non-positive or non-numeric before answers 400: the cursor // is a real millisecond timestamp, and "unread by a negative cursor" is not a // meaningful client request. An unknown session id answers 404; a missing // HistoryStore answers 501. // // On success the new cursor is broadcast as a `session_read` frame so the account's // other browsers clear the badge too (reading on one end = read everywhere). A // failure path broadcasts nothing. func (s *Server) handleMarkSessionRead(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 sessionReadRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } var before time.Time if req.Before != nil { if *req.Before <= 0 { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "before must be a positive millisecond timestamp")) return } before = time.UnixMilli(*req.Before) } readAt, err := s.history.MarkSessionRead(r.Context(), id, before) if err != nil { if errors.Is(err, history.ErrSessionNotFound) { writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id)) return } s.log.Error("history mark session read", "err", err, "session", id) writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to mark session read")) return } s.hub.broadcastSessionRead(id, readAt) writeJSON(w, http.StatusOK, map[string]any{"sessionId": id, "readAt": readAt}) } // broadcastSessionRead fans a read-cursor advance out to every client as a // `session_read` frame carrying the session's new cursor, so the same account's // other ends clear the session's unread badge without a re-read. Like the other // history frames it is an all-connections broadcast (the hub has no per-session // subscription), and the client filters it by sessionId; the cursor's single source // of truth stays chat_sessions.read_at, so a client that missed the frame converges // on its next GET /sessions. func (h *hub) broadcastSessionRead(sessionID string, readAt int64) { h.broadcast(wsEvent{ Type: wsSessionRead, SessionID: sessionID, Data: map[string]any{"readAt": readAt}, }) }