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 不该顶位」)⇒ 测试是真护栏。
This commit is contained in:
1 parent
cb232e7360
commit
fe4c702f87
6 files changed
+369
-8
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,11 @@ import (
|
||||
// 推导 unreadCount(history.go)。游标是会话级、单所有者的:
|
||||
// 一个账号每个会话一个游标 —— 在任意一端阅读即为所有端标记该
|
||||
// 会话已读。
|
||||
//
|
||||
// 同一条游标还喂养**落库那一刻**的实时面(契约 v0.76):单会话的绝对未读数
|
||||
// 由 unreadCountOf 读回,随 `message_persisted` 帧的 `data.unreadCount` 下发,
|
||||
// 使客户端不必为换角标再拉一次 GET /sessions。两条面共用同一个数字口径
|
||||
// (`created_at > read_at`),因此不会互相打脸。
|
||||
|
||||
// wsSessionRead 是已读游标推进的 WS 事件类型。
|
||||
const wsSessionRead = "session_read"
|
||||
@@ -92,3 +99,45 @@ func (h *hub) broadcastSessionRead(sessionID string, readAt int64) {
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user