Files
Cyrene_v2/internal/services/gateway/session_read.go
T
AskaEth e8eccd2bb2 feat(history,gateway): D4 会话未读/已读游标 — unreadCount + POST /sessions/{id}/read + session_read 广播
- chat_sessions 增 read_at(毫秒,与 chat_messages.created_at 同单位);迁移 0010 并把既有会话回填为各自最新消息时间(升级当天不误报)
- ListSessions 单查询派生 unreadCount(无 N+1,复用 idx_chat_msgs_session_created 覆盖索引)
- MarkSessionRead 只前进 MAX(read_at, ?);未知会话 ErrSessionNotFound;游标方法挂在网关消费侧窄接口(沿用 GetMessage/messageLookup 先例,避免污染 runtime 接口与其 fake)
- POST /api/v1/sessions/{id}/read:writeAuth + 400/404/501,成功广播 session_read、失败路径不广播
- Docs/api-client v0.37(§1 unreadCount / 新行 8a / §3.9 session_read / §4.2 用法)
- 含迁移回填测试、store/handler 测试与变异验证;顺带把 migrate_pinned_test 的「回退一步=0009」硬编码改为对迁移数量免疫
2026-10-03 16:47:55 +08:00

98 lines
4.1 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 (
"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},
})
}