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 }