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」硬编码改为对迁移数量免疫
This commit is contained in:
1 parent
04a8ee0727
commit
e8eccd2bb2
14 files changed
+855
-25
No files matched your search
@@ -0,0 +1,97 @@
|
||||
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},
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user