design/11 §7.1(主人 2026-10-03 拍板)。此前 hub.broadcast 是全连接广播,而任意角色
(含 viewer)都能连 /ws/chat ⇒ viewer 能收到角色回复正文(message.*)、推理内容
(thinking.*)、消息增量、graph_update、mood 等。这与 design/19 §1/§3「flow 可视化仅
owner/admin 可见」冲突,也与 GET /sessions/{id}/messages 的 owner/admin 档位不一致。
收口:唯一分类点 internal/services/gateway/ws_frame_scope.go 的 frameReachesAll(typ)
(fail-closed switch,默认 false);hub.broadcast 投递前查表,白名单外对非 owner/admin
连接直接跳过。**不给 broadcast 加 bool 参数、不让调用点自己判断** —— 那正是"漏一处就是
泄漏"的形状。全包只有一个 fan-out 循环,14 个 .broadcast( 调用点全部落进该入口。
- 白名单只有 7 个状态机信号帧(不含任何内容):session_busy / pending_ack / pending_drain /
session_idle / session_interrupted / pending_full / session_read
- 其余全部(流式 message/thinking/tool_call、message_persisted/edited/deleted、graph_update、
subagent_event/result、task_output/task_done、automation_result、tool_used、capacity_awaken、
mode_switch、mood、device_update、reminder)仅 owner+admin
- 语义澄清:本门管「谁被主动推送」,数据可见性仍由 REST 决定(viewer 的 12 条只读监控端点
不变,含 GET /personas 的 mood);/ws/chat 连接鉴权与 voice_output 定向帧不动
契约:Docs/api-client.md → v0.48(对 viewer 是收紧;owner/admin 零变化;无端点/字段形状变更)
验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/... ok;
linux/amd64 + darwin/arm64 交叉编译绿。真 httptest WS + 真 JWT 按角色逐帧实测 37 帧:
viewer 只收到 7 个状态信号,owner/admin 37/37。
变异验证:① graph_update 加回白名单 → 真 WS 抓到 viewer 收到 graph_update,3 个测试 FAIL;
② session_idle 移出白名单 → 3 个测试 FAIL(viewer 等不到哨兵帧而超时)。均已还原。
遗留(已登记,待主人裁决):① 握手 history 回放对任意角色照发(viewer 连上即得主会话最近
50 条正文);② /ws/chat 上行未按角色授权(viewer 可发 chat = 真驱动模型并落库;
subagent_ask / task_output_query 的定向应答绕过本门)。
100 lines
4.2 KiB
Go
100 lines
4.2 KiB
Go
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. It is one of the
|
||
// design/11 §7.1 「状态机信号」 (it reports a cursor, never a message body), so the
|
||
// frame gate keeps it on the all-connections path. Like the other history frames it
|
||
// relies on the hub having 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},
|
||
})
|
||
}
|