feat(history): 消息搜索/编辑/删除 + 会话置顶(D2/D3/D5)
- history/message_ops.go(新): Search(会话内, q/after互斥, LIKE参数化+%/_转义+大小写不敏感, 严格限定会话doc) / UpdateMessage(返回更新后Message) / DeleteMessage / messageByID / escapeLike
- history/session_pin.go(新): SetPinned; 迁移0009 chat_sessions加pinned INTEGER DEFAULT 0; store.go SessionMeta.Pinned + createSessionsTable加列
- session_preview.go: 查询加s.pinned + ORDER BY pinned DESC,(type=main) DESC,created_at ASC
- gateway history_write.go(新,保持history.go≤500): handlePatchSession(pinned+title) / handlePatchMessage / handleDeleteMessage; server.go加PATCH/DELETE /api/v1/messages/{id} + /sessions/{id}置顶
- history.go: handleListMessages读q分流Search; 契约PATCH title/置顶
- D2会话内搜索(不做跨会话,隔离会话不外泄红线); D3不做重发端点(客户端编辑后再发); D5用pinned bool
- 测试(message_ops/history_messages/migrate_pinned), build/vet/test绿+3平台交叉编译; Docs/api-client v0.30
- 未做: 编辑/删除WS实时广播(message_edited/deleted), 下批
This commit is contained in:
1 parent
c7b2cd8ae0
commit
844b2a74c1
15 files changed
+1091
-73
No files matched your search
@@ -0,0 +1,113 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file owns the single-message operations behind the Android collaboration batch
|
||||
// ③a (D2 search, D3 edit/delete). Both are strictly session-scoped or id-scoped: a
|
||||
// message is only ever reached through the session it belongs to (Search) or its own
|
||||
// opaque id (UpdateMessage/DeleteMessage), so an isolated group/stranger session can
|
||||
// never surface in another session's results (project red line).
|
||||
|
||||
// Search returns up to limit messages for sessionID whose content contains query,
|
||||
// newest first. The match is a case-insensitive ASCII LIKE (SQLite's default for
|
||||
// LIKE) with the query's %/_/\ escaped so they match literally; before is the same
|
||||
// millisecond cursor as List (strictly older than before) and limit is clamped to
|
||||
// [1, maxPageLimit]. rowid breaks created_at ties so same-millisecond messages keep a
|
||||
// stable order. An empty query returns an empty (non-nil) slice — the caller uses
|
||||
// List for plain paging instead. Search never crosses the session boundary.
|
||||
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 id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
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 replaces the content of the message with id and returns the updated
|
||||
// message (like AppendFull, so the caller can return/broadcast it without a second
|
||||
// read). Only content changes; role, platform, timestamps and the rest stay as stored.
|
||||
// An unknown id returns 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 removes a single message. An unknown id returns ErrMessageNotFound.
|
||||
func (s *Store) DeleteMessage(ctx context.Context, id string) error {
|
||||
res, err := s.db.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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// messageByID reads one message by id, returning ErrMessageNotFound when the row is
|
||||
// absent.
|
||||
func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
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 escapes the LIKE wildcards in a user query so %, _ and \ match literally
|
||||
// (the caller pairs it with ESCAPE '\'). It is applied before the %...% wrapping.
|
||||
func escapeLike(query string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query)
|
||||
}
|
||||
Reference in new issue
Block a user