Files
AskaEth fe4c702f87
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 不该顶位」)⇒ 测试是真护栏。
2026-10-06 07:58:42 +08:00

85 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}