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" ) // 本文件归属 D4 已读游标面(Android 协作 B3):推进会话已读游标的 // POST /api/v1/sessions/{id}/read 端点,以及它为清除账号其他浏览器 // 未读徽标(无需重读)而广播的 `session_read` 实时帧。游标本身存于 // chat_sessions.read_at(internal/runtime/history);GET /sessions 由它 // 推导 unreadCount(history.go)。游标是会话级、单所有者的: // 一个账号每个会话一个游标 —— 在任意一端阅读即为所有端标记该 // 会话已读。 // wsSessionRead 是已读游标推进的 WS 事件类型。 const wsSessionRead = "session_read" // sessionReadRequest 是 POST /api/v1/sessions/{id}/read 的请求体。 // Before 是指针,以便把字段缺失(空请求体)与显式取值区分开: // 缺失表示「现在」—— 客户端把它已看到的一切都标记为已读。 type sessionReadRequest struct { Before *int64 `json:"before"` } // handleMarkSessionRead 推进会话的已读游标,并以 Unix 毫秒返回结果 // 游标 {data:{sessionId, readAt}}。与其他历史写入一样走 owner/admin // 门(writeAuth)。 // // 请求体 {before?} 是可选毫秒时间戳,界定标记已读的范围 —— // created_at <= readAt 的消息变为已读。缺失(或空请求体)表示现在。 // 已给出但非正数或非数值的 before 返回 400:游标是真实的毫秒 // 时间戳,「负数游标造成的未读」不是有意义的客户端请求。未知 // 会话 id 返回 404;HistoryStore 缺失返回 501。 // // 成功时新游标作为 `session_read` 帧广播,使该账号其他浏览器也清除 // 徽标(一端阅读 = 处处已读)。失败路径不广播任何东西。 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 把一次已读游标推进作为 `session_read` 帧扇出到 // 每个客户端,携带会话的新游标,使同一账号的其他端无需重读即可 // 清除该会话的未读徽标。它属于 design/11 §7.1 的「状态机信号」 // (只报告游标,绝不报告消息正文),因此帧门让它留在全连接路径上。 // 与其他历史帧一样,它依赖 hub 没有按会话订阅这一点,由客户端按 // sessionId 过滤;游标的唯一事实来源仍是 chat_sessions.read_at, // 因此错过该帧的客户端会在下次 GET /sessions 时重新收敛。 func (h *hub) broadcastSessionRead(sessionID string, readAt int64) { h.broadcast(wsEvent{ Type: wsSessionRead, SessionID: sessionID, Data: map[string]any{"readAt": readAt}, }) }