- 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 不该顶位」)⇒ 测试是真护栏。
85 lines
4.2 KiB
Go
85 lines
4.2 KiB
Go
package history
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
// 本文件负责每会话的读游标(design/16 §4.2,Android 协同批次 D4/B3)。本层
|
||
// 只有一个主人账号、没有按设备的身份,因此游标是会话作用域的:在任一端已读
|
||
// 即为该账号的每一端把该会话标为已读。游标存放在 chat_sessions.read_at,与
|
||
// chat_messages.created_at 同为毫秒单位,因此未读角标就是一个朴素的
|
||
// `created_at > read_at` 计数(见 listSessionsQuery)。
|
||
|
||
// MarkSessionRead 把会话的读游标推进到 before,并把结果游标作为 Unix 毫秒
|
||
// 时间戳返回。before 为零意味着「现在」—— 调用方「把我看到的全部标为已读」
|
||
// 的场景,无需自己读时钟。
|
||
//
|
||
// 游标只向前移动:该 UPDATE 使用 SQLite 的双参数标量 MAX,因此一个过期或
|
||
// 乱序的请求(两端竞争、请求重试)绝不能让消息重新变为未读。负的 before
|
||
// 因此不会挪动已存的值,而不是把它向后移。
|
||
//
|
||
// 未知的会话 id 返回 ErrSessionNotFound,与 RenameSession/SetPinned 一致
|
||
// —— 隔离(群聊/陌生人)会话没有 chat_sessions 行,在此不可寻址。
|
||
func (s *Store) MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error) {
|
||
ms := before.UnixMilli()
|
||
if before.IsZero() {
|
||
ms = time.Now().UnixMilli()
|
||
}
|
||
res, err := s.db.ExecContext(ctx,
|
||
`UPDATE chat_sessions SET read_at = MAX(read_at, ?) WHERE id = ?`, ms, sessionID)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("history: mark session read %s: %w", sessionID, err)
|
||
}
|
||
n, err := res.RowsAffected()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("history: mark session read %s rows: %w", sessionID, err)
|
||
}
|
||
if n == 0 {
|
||
return 0, fmt.Errorf("history: mark session read %s: %w", sessionID, ErrSessionNotFound)
|
||
}
|
||
var readAt int64
|
||
if err := s.db.QueryRowContext(ctx,
|
||
`SELECT read_at FROM chat_sessions WHERE id = ?`, sessionID).Scan(&readAt); err != nil {
|
||
return 0, fmt.Errorf("history: read session cursor %s: %w", sessionID, err)
|
||
}
|
||
return readAt, nil
|
||
}
|
||
|
||
// unreadCountQuery 统计**单个**会话里严格新于读游标的行数。它与
|
||
// listSessionsQuery 的关联子查询是同一条谓词(等值 + 范围扫描,吃
|
||
// (session_id, created_at DESC) 索引),只是从「按会话列表一次算全部」变
|
||
// 「按单个会话算一次」—— 列表接口无 N+1 靠的是关联子查询,这里则是
|
||
// 一次主键点查,供落库那一刻的单会话实时帧用。
|
||
//
|
||
// INNER JOIN chat_sessions 是**刻意**的:只有有会话行的会话(Web 主会话与
|
||
// Web 子会话)才存在「读游标」这个概念。没有会话行的消息流(隔离渠道会话
|
||
// sess-*、后台思考会话)因此恒得 0 —— 绝不把它们的全部历史当成未读,
|
||
// 那种「未读」在被已读游标面(GET /sessions 根本不列它们)里没有归宿。
|
||
const unreadCountQuery = `SELECT COUNT(*) FROM chat_messages c
|
||
JOIN chat_sessions s ON s.id = c.session_id
|
||
WHERE c.session_id = ? AND c.created_at > s.read_at`
|
||
|
||
// UnreadCount 返回该会话中**比读游标新**的消息数(绝对未读,非「自上次以来」)。
|
||
// 它服务落库那一刻的实时面:`message_persisted` 帧要在同一口径下把角标换成
|
||
// 权威数字,使客户端不必为清角标再拉一次 GET /sessions。
|
||
//
|
||
// 口径与 listSessionsQuery 逐字一致(同一个 `created_at > read_at`、同一条游标),
|
||
// 因此两条面在同一时刻给出的数字相同;多端共享同一游标的取舍见 session_read.go
|
||
// 顶部的包内说明,本函数不改它。
|
||
//
|
||
// 空 sessionID 与「会话不存在」都返回 0:没有游标的会话就没有未读这个概念,
|
||
// 这与它们在 GET /sessions 里根本不出现是一致的。真正的查询失败**如实上抛**
|
||
// (调用方据此省略该字段,绝不拿 0 冒充「已读完」)。
|
||
func (s *Store) UnreadCount(ctx context.Context, sessionID string) (int, error) {
|
||
if sessionID == "" {
|
||
return 0, nil
|
||
}
|
||
var n int
|
||
if err := s.db.QueryRowContext(ctx, unreadCountQuery, sessionID).Scan(&n); err != nil {
|
||
return 0, fmt.Errorf("history: unread count %s: %w", sessionID, err)
|
||
}
|
||
return n, nil
|
||
}
|