Files
Cyrene_v2/internal/services/gateway/session_read.go
T
AskaEth fe1ff33115
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): WS 帧门相关 11 个文件的注释改中文(只改注释,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」。本次先把本会话动过的文件就地翻;
不做全仓一次性翻译,存量按包分批排期(见 工作小结.md 待办)。

范围:ws_frame_scope.go / ws_frame_scope_test.go / broadcast.go / ws_hub_turn.go /
ws_hub_message_event.go / session_read.go / server.go / ws_hub_stream_scope_test.go /
history_write_broadcast_test.go / mood_test.go / extra_test.go
含中文的整行注释 18 → 382。

校验(判据 = "只改注释、零代码字节改动"):
- `git diff -U0 | 排除 +++/--- | 排除注释行/空行/行尾注释` → 为空
- 独立实现:两侧剥掉注释后逐行比对代码文本 → 11/11 文件全等,不一致数 0
- 行尾注释 7 行逐条列出,代码列与原文逐字节相同(gofmt 未要求重排)
- gofmt -l 干净;全树 go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;
  linux/amd64 + darwin/arm64 交叉编译绿
语义未删减:部分折行英文长句合并为更少行,故整行注释数 438 → 399。
2026-10-03 22:07:56 +08:00

95 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"
)
// 本文件归属 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},
})
}