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 }