feat(search): нечёткий поиск по сообщениям — по началу слова и по подстроке

Поиск требовал ввести слово целиком. Теперь:

- FTS5 ищет по началу слова: «пр» находит «Привет» (последнее слово запроса
  получает префикс), регистр не важен;
- добавлена нормализованная копия текста (`messages.content_lower`, миграция
  00007): подстрочные совпадения ищутся через LIKE без учёта регистра, включая
  кириллицу (SQLite lower() её не приводит — регистр опускает приложение);
- при старте индекс дополняется у старых сообщений (порциями по 500);
- результаты FTS и LIKE объединяются без дублей, сортировка по новизне;
- тест: «пр» и «ивет» находят все три сообщения с «Привет/приветствие/ПРИВЕТ»,
  посторонний текст не попадает.
This commit is contained in:
2026-09-20 11:01:32 +03:00
parent bcfc275bb3
commit 1aef57dfd9
5 changed files with 215 additions and 32 deletions
+1 -24
View File
@@ -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 ")
}
+54
View File
@@ -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)
}
}