feat(search): нечёткий поиск по сообщениям — по началу слова и по подстроке
Поиск требовал ввести слово целиком. Теперь: - FTS5 ищет по началу слова: «пр» находит «Привет» (последнее слово запроса получает префикс), регистр не важен; - добавлена нормализованная копия текста (`messages.content_lower`, миграция 00007): подстрочные совпадения ищутся через LIKE без учёта регистра, включая кириллицу (SQLite lower() её не приводит — регистр опускает приложение); - при старте индекс дополняется у старых сообщений (порциями по 500); - результаты FTS и LIKE объединяются без дублей, сортировка по новизне; - тест: «пр» и «ивет» находят все три сообщения с «Привет/приветствие/ПРИВЕТ», посторонний текст не попадает.
This commit is contained in:
@@ -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 ")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user