package history import ( "context" "database/sql" "errors" "fmt" "strings" ) // 本文件负责 Android 协同批次 ③a 背后的单条消息操作(D2 搜索、D3 编辑/删除), // 以及 D 删除广播所需的按 id 读取。它们都严格限定在会话作用域或 id 作用域: // 消息只能经由它所属的会话(Search)或它自己不透明的 id(GetMessage/ // UpdateMessage/DeleteMessage)到达,因此隔离的群聊/陌生人会话绝不浮现在 // 另一个会话的结果中(项目红线)。 // Search 按最新优先返回 sessionID 中内容包含 query 的至多 limit 条消息。匹配 // 是 ASCII 大小写不敏感的 LIKE(SQLite 对 LIKE 的默认行为),并把 query 中的 // %/_/\ 转义使它们按字面匹配;before 与 List 是同一个毫秒游标(严格早于 // before),limit 钳制到 [1, maxPageLimit]。rowid 打破 created_at 的并列,使 // 同一毫秒的消息保持稳定顺序。空 query 返回空(非 nil)切片 —— 调用方改用 // List 做普通翻页。Search 绝不跨越会话边界。 func (s *Store) Search(ctx context.Context, sessionID, query string, before int64, limit int) ([]Message, error) { limit = clampLimit(limit) if query == "" { return make([]Message, 0), nil } sqlText := `SELECT ` + messageColumns + ` FROM chat_messages WHERE session_id = ? AND content LIKE ? ESCAPE '\'` args := []any{sessionID, "%" + escapeLike(query) + "%"} if before > 0 { sqlText += ` AND created_at < ?` args = append(args, before) } sqlText += ` ORDER BY created_at DESC, rowid DESC LIMIT ?` args = append(args, limit) rows, err := s.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, fmt.Errorf("history: search %s: %w", sessionID, err) } defer func() { _ = rows.Close() }() msgs := make([]Message, 0) for rows.Next() { m, err := scanMessage(rows) if err != nil { return nil, fmt.Errorf("history: scan search %s: %w", sessionID, err) } msgs = append(msgs, m) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("history: iterate search %s: %w", sessionID, err) } return msgs, nil } // UpdateMessage 替换 id 所指消息的内容并返回更新后的消息(与 AppendFull // 一样,使调用方能无需二次读取就返回/广播它)。只有 content 变化;role、 // platform、时间戳及其余字段保持已存值。未知 id 返回 ErrMessageNotFound。 func (s *Store) UpdateMessage(ctx context.Context, id, content string) (Message, error) { res, err := s.db.ExecContext(ctx, `UPDATE chat_messages SET content = ? WHERE id = ?`, content, id) if err != nil { return Message{}, fmt.Errorf("history: update message %s: %w", id, err) } n, err := res.RowsAffected() if err != nil { return Message{}, fmt.Errorf("history: update message %s rows: %w", id, err) } if n == 0 { return Message{}, fmt.Errorf("history: update message %s: %w", id, ErrMessageNotFound) } return s.messageByID(ctx, id) } // DeleteMessage 删除单条消息。未知 id 返回 ErrMessageNotFound。 // // 该行携带的文档引用在**同一个事务**里撤销(切片 S7):删掉最后一条引用它的消息 // 之后,那份文档才可能(且只能在超过保留期之后)被 GC 回收。列要先读后删 —— // 行一旦删掉,引用就再也无从撤销。 func (s *Store) DeleteMessage(ctx context.Context, id string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("history: delete message %s: %w", id, err) } defer func() { _ = tx.Rollback() }() var document string err = tx.QueryRowContext(ctx, `SELECT document FROM chat_messages WHERE id = ?`, id).Scan(&document) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("history: read message %s document: %w", id, err) } res, err := tx.ExecContext(ctx, `DELETE FROM chat_messages WHERE id = ?`, id) if err != nil { return fmt.Errorf("history: delete message %s: %w", id, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("history: delete message %s rows: %w", id, err) } if n == 0 { return fmt.Errorf("history: delete message %s: %w", id, ErrMessageNotFound) } if err := s.releaseDocuments(ctx, tx, document); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("history: delete message %s: %w", id, err) } return nil } // GetMessage 读取 id 所指消息,没有该行时返回 ErrMessageNotFound。它是传输层 // 在移除消息之前使用的按 id 读取:DeleteMessage 之后已无从再问,而那条告知 // 其他客户端丢弃该行的广播是按会话划分的。 func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) { return s.messageByID(ctx, id) } // messageByID 按 id 读取一条消息,行不存在时返回 ErrMessageNotFound。 func (s *Store) messageByID(ctx context.Context, id string) (Message, error) { row := s.db.QueryRowContext(ctx, `SELECT `+messageColumns+` FROM chat_messages WHERE id = ?`, id) m, err := scanMessage(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Message{}, fmt.Errorf("history: read message %s: %w", id, ErrMessageNotFound) } return Message{}, fmt.Errorf("history: read message %s: %w", id, err) } return m, nil } // escapeLike 转义用户查询中的 LIKE 通配符,使 %、_ 与 \ 按字面匹配(调用方 // 配合 ESCAPE '\' 使用)。它在 %...% 包裹之前应用。 func escapeLike(query string) string { return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query) }