Files
AskaEth fe4c702f87
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 未读面 unreadCount 进 message_persisted 帧(契约 v0.76 ②)
- broadcastPersisted 是 Web 与 IM 两条路的**共用**函数 ⇒ 两处口径天然一致,不存在分叉
- 口径 = 绝对未读 + 主人单一已读游标(chat_sessions.read_at)⇒ 多端共享
- 可选键 omitempty:只在 >0 且确实算得出来时出现;0 与「算不出来」都不顶位
  (0 只能从「会话有游标」推出,否则那个 0 就是编的)⇒ 缺席 = 老行为
- Docs/api-client.md:v0.76 ① 按实测改标「未实现且无实现路径」(见下),② 已实现,
  并更正「GET /sessions 的 unreadCount 自 D4 起已在」、新增 §4.13 与 §7.4 第 23 条

⚠️ ① toolCalls[] 本版未落地(取证结论,非推断):chat_messages.turn_id(编排器随机路标识
turn-<16hex>)与 flow_graphs.turn_id(trace.FlowGraph.ID = <session>-<轮次序号>,进程内递增)
不同源,真实库中 66 条消息行 vs 21 条图行字面交集为空 ⇒ 无法 join;按时间窗硬凑属于猜,不做。
补齐路径:链路图带回合标识(新键 + 迁移 0023 起)+ 落盘列,之后 toolCalls[] 是一条批量 SQL。

我亲自复跑的变异:去掉帧里的 n > 0 守卫 ⇒ TestBroadcastPersistedUnreadCountAbsentWhenUnknown
与 TestBroadcastPersistedCarriesUnreadCount 双双 FAIL(断言「0 不该顶位」)⇒ 测试是真护栏。
2026-10-06 07:58:42 +08:00

144 lines
6.5 KiB
Go
Raw Permalink 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 (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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)。游标是会话级、单所有者的:
// 一个账号每个会话一个游标 —— 在任意一端阅读即为所有端标记该
// 会话已读。
//
// 同一条游标还喂养**落库那一刻**的实时面(契约 v0.76):单会话的绝对未读数
// 由 unreadCountOf 读回,随 `message_persisted` 帧的 `data.unreadCount` 下发,
// 使客户端不必为换角标再拉一次 GET /sessions。两条面共用同一个数字口径
// (`created_at > read_at`),因此不会互相打脸。
// 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},
})
}
// sessionUnreadCounter 是 HistoryStore 的一个**可选**更窄视图(与 clientMsgIDReader /
// turnIDWriter / documentStatusWriter 同一做法):按会话读回由已读游标推导的绝对未读数。
// 未实现该视图的存储替身(测试替身、未接线的构建)**不报错**,只是帧上不带该键 ——
// 未读面是增益,绝不能反过来卡住消息广播。
type sessionUnreadCounter interface {
UnreadCount(ctx context.Context, sessionID string) (int, error)
}
const (
// unreadCountTimeout 是**单次**未读数查询的时长上界。它跑在落库之后的广播路径上
// (写完一条就发一帧),必须有一个界:一次库卡死绝不允许把广播挂住。
unreadCountTimeout = 2 * time.Second
// unreadCountLogSource 是这条冷路径上日志的来源标签(AGENTS §10 禁无来源裸日志)。
unreadCountLogSource = "gateway/broadcast"
)
// unreadCountOf 返回该会话当前的**绝对未读数**(比 chat_sessions.read_at 新的消息数),
// 第二个返回值表示「这个数字是真的」—— false 时调用方必须**省略**该键,绝不用 0 冒充
// 「已读完」(缺席 = 老行为,客户端回落到 GET /sessions 收敛)。
//
// 三条不产生数字的情形,都不算错误:① 未注入 HistoryStore;② 注入的存储没实现上面那个
// 窄视图;③ 查询失败(记一条带来源的 WARN,因为「读不到」不是「没有未读」)。
func (s *Server) unreadCountOf(sessionID string) (int, bool) {
if s.history == nil || sessionID == "" {
return 0, false
}
counter, ok := s.history.(sessionUnreadCounter)
if !ok {
return 0, false
}
ctx, cancel := context.WithTimeout(context.Background(), unreadCountTimeout)
defer cancel()
n, err := counter.UnreadCount(ctx, sessionID)
if err != nil {
logger.WithSource(s.log, unreadCountLogSource).Warn("unread count read failed",
"err", err, "session", sessionID)
return 0, false
}
return n, true
}