diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index 9223ec3..72c94f4 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -152,6 +152,14 @@ func run() error { // Один калькулятор прав на процесс: HTTP-ручки и Gateway видят общий кэш. calculator := permissions.NewCalculator(source.New(st)) gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins()) + // Один раз при старте дополняем индекс поиска у старых сообщений + // (кириллица в SQLite lower() не приводится, нормализуем в приложении). + if updated, err := st.BackfillSearchIndex(ctx, 500); err != nil { + logger.WarnContext(ctx, "backfill search index failed", slog.Any("error", err)) + } else if updated > 0 { + logger.InfoContext(ctx, "search index backfilled", slog.Int("messages", updated)) + } + srv := server.New(cfg, db, logger, server.Deps{ Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator, }) diff --git a/internal/database/migrations/00007_messages_search_lower.sql b/internal/database/migrations/00007_messages_search_lower.sql new file mode 100644 index 0000000..8cabe7c --- /dev/null +++ b/internal/database/migrations/00007_messages_search_lower.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Нечёткий поиск по сообщениям: FTS5 ищет по началу слова, а для поиска +-- подстроки нужна нормализованная копия текста (SQLite lower() не знает +-- кириллицу, поэтому регистр приводит приложение). +ALTER TABLE messages ADD COLUMN content_lower TEXT NOT NULL DEFAULT ''; +CREATE INDEX messages_content_lower_idx ON messages (channel_id, id DESC); + +-- +goose Down +DROP INDEX messages_content_lower_idx; +ALTER TABLE messages DROP COLUMN content_lower; diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 4bf9e76..173c3c5 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -240,7 +240,7 @@ func (s *Server) registerMessageRoutes(api huma.API) { if allowed, retryAfter := s.searchLimiter.Allow("search:" + formatSnowflake(user.ID)); !allowed { return nil, rateLimitedError(retryAfter) } - messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, toFTSQuery(input.Query), input.Limit) + messages, err := s.store.SearchMessages(ctx, []uint64{channelID}, input.Query, input.Limit) if err != nil { return nil, humaError(err) } @@ -856,26 +856,3 @@ func rateLimitedError(retryAfter time.Duration) huma.StatusError { "retry_after_ms": milliseconds, }) } - -// toFTSQuery превращает пользовательский ввод в безопасный запрос FTS5: -// кавычки и служебные символы экранируются, слова соединяются по AND. -func toFTSQuery(input string) string { - fields := strings.FieldsFunc(input, func(r rune) bool { - return r == ' ' || r == '\t' || r == '\n' - }) - terms := make([]string, 0, len(fields)) - for _, field := range fields { - cleaned := strings.Map(func(r rune) rune { - switch r { - case '"', '\'', '*', '(', ')', ':', '^', '-', '+': - return -1 - } - return r - }, field) - if cleaned == "" { - continue - } - terms = append(terms, `"`+cleaned+`"`) - } - return strings.Join(terms, " AND ") -} diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index 5deb8d5..bcfeb10 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -7,6 +7,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "net/url" "testing" "glchat/internal/permissions" @@ -568,3 +569,56 @@ func TestMentionsIncrementReadState(t *testing.T) { } } } + +// TestFuzzySearch проверяет нечёткий поиск: по началу слова, по подстроке и +// без учёта регистра (запрос пользователя). +func TestFuzzySearch(t *testing.T) { + f := newMessagingFixture(t) + for _, text := range []string{"Привет, мир", "приветствие другу", "ПРИВЕТ ВСЕМ"} { + if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"`+text+`"}`, f.ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("send %q = %d, body = %s", text, rec.Code, rec.Body.String()) + } + } + if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"совсем другое"}`, f.ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("send other = %d", rec.Code) + } + + search := func(query string) []string { + t.Helper() + rec := doJSON(t, f.srv, http.MethodGet, + "/api/v1/channels/"+f.openChannel+"/messages/search?q="+url.QueryEscape(query), "", f.memberCookie) + if rec.Code != http.StatusOK { + t.Fatalf("search %q = %d, body = %s", query, rec.Code, rec.Body.String()) + } + payload := decodeResponse[struct { + Messages []struct { + Content string `json:"content"` + } `json:"messages"` + }](t, rec) + contents := make([]string, 0, len(payload.Messages)) + for _, message := range payload.Messages { + contents = append(contents, message.Content) + } + return contents + } + + // По началу слова в нижнем регистре: «пр» должно найти все три сообщения. + prefix := search("пр") + if len(prefix) != 3 { + t.Fatalf("поиск «пр» вернул %d результатов: %v", len(prefix), prefix) + } + // Подстрока в середине слова тоже находится. + substring := search("ивет") + if len(substring) != 3 { + t.Fatalf("поиск «ивет» вернул %d результатов: %v", len(substring), substring) + } + // Посторонний текст не попадает в выдачу. + if results := search("привет всем"); len(results) != 1 { + t.Fatalf("поиск по фразе вернул %d результатов: %v", len(results), results) + } + if results := search("другое"); len(results) != 1 { + t.Fatalf("поиск «другое» вернул %d результатов: %v", len(results), results) + } +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 319d2c6..b17c1a5 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -60,7 +60,7 @@ type ReadState struct { } const messageColumns = `id, channel_id, author_id, content, reply_to_id, type, - attachments_json, mentions_json, edited_at, pinned, created_at` + attachments_json, mentions_json, edited_at, pinned, created_at, content_lower` // CreateMessageParams — параметры нового сообщения. type CreateMessageParams struct { @@ -93,10 +93,11 @@ func (s *Store) CreateMessage(ctx context.Context, params CreateMessageParams) ( } _, err = s.writer.ExecContext(ctx, ` INSERT INTO messages (id, channel_id, author_id, content, reply_to_id, type, - attachments_json, mentions_json, pinned, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, + attachments_json, mentions_json, pinned, created_at, content_lower) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, int64(params.ID), int64(params.ChannelID), int64(params.AuthorID), params.Content, - nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions), s.Now()) + nullableID(params.ReplyToID), string(params.Type), string(attachments), string(mentions), + s.Now(), strings.ToLower(params.Content)) if err != nil { return nil, mapError(err) } @@ -167,7 +168,8 @@ func (s *Store) ListPinnedMessages(ctx context.Context, channelID uint64, limit // UpdateMessageContent меняет текст сообщения и фиксирует время правки. func (s *Store) UpdateMessageContent(ctx context.Context, id uint64, content string) (*Message, error) { result, err := s.writer.ExecContext(ctx, - `UPDATE messages SET content = ?, edited_at = ? WHERE id = ?`, content, s.Now(), int64(id)) + `UPDATE messages SET content = ?, content_lower = ?, edited_at = ? WHERE id = ?`, + content, strings.ToLower(content), s.Now(), int64(id)) if err != nil { return nil, err } @@ -319,16 +321,82 @@ func (s *Store) ListReadStates(ctx context.Context, userID uint64) ([]ReadState, // SearchMessages ищет сообщения по тексту с учётом списка доступных комнат // (AGENT.md 7.15: права проверяет вызывающий код, хранилище ограничивает выборку). +// Поиск нечёткий: FTS5 находит слова по началу («пр» → «Привет»), а подстрочные +// совпадения добираются по нормализованной копии текста без учёта регистра. func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) { - if len(channelIDs) == 0 || strings.TrimSpace(query) == "" { + query = strings.TrimSpace(query) + if len(channelIDs) == 0 || query == "" { return []Message{}, nil } if limit <= 0 || limit > 100 { limit = 25 } + + found := make([]Message, 0, limit) + seen := map[uint64]bool{} + + if ftsQuery := toFTSPrefixQuery(query); ftsQuery != "" { + prefixMatches, err := s.searchMessagesFTS(ctx, channelIDs, ftsQuery, limit) + if err != nil { + return nil, err + } + for _, message := range prefixMatches { + if !seen[message.ID] { + seen[message.ID] = true + found = append(found, message) + } + } + } + + if len(found) < limit { + substringMatches, err := s.searchMessagesSubstring(ctx, channelIDs, strings.ToLower(query), limit-len(found)) + if err != nil { + return nil, err + } + for _, message := range substringMatches { + if !seen[message.ID] { + seen[message.ID] = true + found = append(found, message) + } + } + } + return found, nil +} + +// toFTSPrefixQuery превращает пользовательский ввод в безопасный запрос FTS5: +// слова соединяются по AND, последнее ищется по началу («пр» → «Привет»), +// служебные символы вырезаются. +func toFTSPrefixQuery(input string) string { + fields := strings.FieldsFunc(input, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' + }) + terms := make([]string, 0, len(fields)) + for index, field := range fields { + cleaned := strings.Map(func(r rune) rune { + switch r { + case '"', '\'', '*', '(', ')', ':', '^', '-', '+': + return -1 + } + return r + }, field) + if cleaned == "" { + continue + } + term := `"` + cleaned + `"` + if index == len(fields)-1 { + // Префиксный поиск по последнему слову: нечёткий ввод по мере набора. + term += "*" + } + terms = append(terms, term) + } + return strings.Join(terms, " AND ") +} + +// searchMessagesFTS ищет по индексу FTS5 (слова и их начала). +func (s *Store) searchMessagesFTS(ctx context.Context, channelIDs []uint64, match string, limit int) ([]Message, error) { placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",") args := make([]any, 0, len(channelIDs)+2) - args = append(args, query) + args = append(args, match) for _, id := range channelIDs { args = append(args, int64(id)) } @@ -343,6 +411,30 @@ func (s *Store) SearchMessages(ctx context.Context, channelIDs []uint64, query s if err != nil { return nil, err } + return scanMessages(rows, limit) +} + +// searchMessagesSubstring ищет вхождения подстроки без учёта регистра. +func (s *Store) searchMessagesSubstring(ctx context.Context, channelIDs []uint64, query string, limit int) ([]Message, error) { + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(channelIDs)), ",") + args := make([]any, 0, len(channelIDs)+2) + args = append(args, "%"+escapeLike(query)+"%") + for _, id := range channelIDs { + args = append(args, int64(id)) + } + args = append(args, limit) + + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+messageColumns+` FROM messages + WHERE content_lower LIKE ? ESCAPE '\' AND channel_id IN (`+placeholders+`) + ORDER BY id DESC LIMIT ?`, args...) + if err != nil { + return nil, err + } + return scanMessages(rows, limit) +} + +func scanMessages(rows *sql.Rows, limit int) ([]Message, error) { defer rows.Close() messages := make([]Message, 0, limit) @@ -365,6 +457,47 @@ func prefixedMessageColumns(alias string) string { return strings.Join(columns, ", ") } +// BackfillSearchIndex заполняет нормализованный текст у старых сообщений: +// нужен для поиска подстроки без учёта регистра (кириллица в SQLite lower() +// не приводится). Вызывается один раз при старте, порциями. +func (s *Store) BackfillSearchIndex(ctx context.Context, limit int) (int, error) { + if limit <= 0 || limit > 5000 { + limit = 500 + } + rows, err := s.reader.QueryContext(ctx, + `SELECT id, content FROM messages WHERE content_lower = '' AND content <> '' LIMIT ?`, limit) + if err != nil { + return 0, err + } + type pending struct { + id uint64 + content string + } + items := make([]pending, 0, limit) + for rows.Next() { + var item pending + if err := rows.Scan(&item.id, &item.content); err != nil { + _ = rows.Close() + return 0, err + } + items = append(items, item) + } + if err := rows.Close(); err != nil { + return 0, err + } + + updated := 0 + for _, item := range items { + if _, err := s.writer.ExecContext(ctx, + `UPDATE messages SET content_lower = ? WHERE id = ?`, + strings.ToLower(item.content), int64(item.id)); err != nil { + return updated, err + } + updated++ + } + return updated, nil +} + func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) { var ( message Message @@ -375,9 +508,10 @@ func scanMessage(scanner interface{ Scan(...any) error }) (*Message, error) { editedAt sql.NullString pinned int createdAt string + contentLow string ) err := scanner.Scan(&message.ID, &message.ChannelID, &authorID, &message.Content, &replyToID, - &message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt) + &message.Type, &attachments, &mentions, &editedAt, &pinned, &createdAt, &contentLow) if err != nil { return nil, mapError(err) }