Files
Cyrene_v2/internal/runtime/history/session_read.go
T
AskaEth e8eccd2bb2 feat(history,gateway): D4 会话未读/已读游标 — unreadCount + POST /sessions/{id}/read + session_read 广播
- chat_sessions 增 read_at(毫秒,与 chat_messages.created_at 同单位);迁移 0010 并把既有会话回填为各自最新消息时间(升级当天不误报)
- ListSessions 单查询派生 unreadCount(无 N+1,复用 idx_chat_msgs_session_created 覆盖索引)
- MarkSessionRead 只前进 MAX(read_at, ?);未知会话 ErrSessionNotFound;游标方法挂在网关消费侧窄接口(沿用 GetMessage/messageLookup 先例,避免污染 runtime 接口与其 fake)
- POST /api/v1/sessions/{id}/read:writeAuth + 400/404/501,成功广播 session_read、失败路径不广播
- Docs/api-client v0.37(§1 unreadCount / 新行 8a / §3.9 session_read / §4.2 用法)
- 含迁移回填测试、store/handler 测试与变异验证;顺带把 migrate_pinned_test 的「回退一步=0009」硬编码改为对迁移数量免疫
2026-10-03 16:47:55 +08:00

53 lines
2.1 KiB
Go

package history
import (
"context"
"fmt"
"time"
)
// This file owns the per-session read cursor (design/16 §4.2, Android 协同批次
// D4/B3). There is a single owner account and no per-device identity at this
// layer, so the cursor is session-scoped: reading on any one end marks the session
// read for every end of the account. The cursor lives on chat_sessions.read_at in
// the same millisecond unit as chat_messages.created_at, so the unread badge is a
// plain `created_at > read_at` count (see listSessionsQuery).
// MarkSessionRead advances the session's read cursor to before and returns the
// resulting cursor as a Unix millisecond timestamp. A zero before means "now" — the
// caller's "mark everything I have seen" case, which does not have to read the
// clock itself.
//
// The cursor only ever moves forward: the UPDATE applies SQLite's two-argument
// scalar MAX, so a stale or out-of-order request (two ends racing, a retried
// request) can never un-read messages. A negative before therefore leaves the stored
// value untouched rather than moving it backwards.
//
// An unknown session id returns ErrSessionNotFound, matching RenameSession/SetPinned
// — isolated (group/stranger) sessions have no chat_sessions row and are not
// addressable here.
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
}