veza/veza-backend-api/internal/elasticsearch/search_service.go
senke ba88086f20 feat(v0.10.2): Recherche fulltext Elasticsearch - F361-F365
- Elasticsearch 8.x dans docker-compose.dev
- Package internal/elasticsearch: client, config, mappings, indices
- Sync PG→ES: reindex tracks/users/playlists, IndexTrack/DeleteTrack
- SearchService ES: multi_match + fuzziness (typo tolerance), highlighting
- Fallback gracieux: PostgreSQL si ELASTICSEARCH_URL absent
- Routes: GET /search, GET /search/suggestions, POST /admin/search/reindex
- Frontend: searchApi cursor/limit params (extensibilité)
- docs/ENV_VARIABLES: ELASTICSEARCH_URL, ELASTICSEARCH_INDEX, ELASTICSEARCH_AUTO_INDEX
- Roadmap v0.10.2 → DONE
2026-03-09 10:13:18 +01:00

244 lines
6.2 KiB
Go

package elasticsearch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"veza-backend-api/internal/services"
"go.uber.org/zap"
)
// SearchService implements search via Elasticsearch (v0.10.2 F363, F364, F365)
type SearchService struct {
client *Client
logger *zap.Logger
}
// NewSearchService creates an Elasticsearch-backed search service
func NewSearchService(client *Client, logger *zap.Logger) *SearchService {
return &SearchService{client: client, logger: logger}
}
// Search performs full-text search with BM25, fuzziness for typos (F364)
func (s *SearchService) Search(query string, types []string) (*services.SearchResult, error) {
return s.search(context.Background(), query, types, 10)
}
// Suggestions returns autocomplete suggestions (F365)
func (s *SearchService) Suggestions(query string, limit int) (*services.SearchResult, error) {
if limit <= 0 {
limit = 5
}
if limit > 20 {
limit = 20
}
return s.search(context.Background(), query, nil, limit)
}
func (s *SearchService) search(ctx context.Context, query string, types []string, limit int) (*services.SearchResult, error) {
if s.client == nil {
return nil, fmt.Errorf("elasticsearch client not configured")
}
searchAll := len(types) == 0
searchTracks := searchAll || strContains(types, "track")
searchUsers := searchAll || strContains(types, "user")
searchPlaylists := searchAll || strContains(types, "playlist")
results := &services.SearchResult{}
// F364: fuzziness for typo tolerance; no boost by popularity (ethical)
if searchTracks {
idx := indexName(s.client.Config.Index, IdxTracks)
mq := map[string]interface{}{
"multi_match": map[string]interface{}{
"query": query,
"fuzziness": "AUTO",
"fields": []string{"title^2", "artist^2", "description", "album", "tags", "genre"},
"type": "best_fields",
},
}
tracks, err := s.runSearch(ctx, idx, mq, limit, []string{"title", "artist", "description"})
if err != nil {
if s.logger != nil {
s.logger.Warn("ES track search failed", zap.Error(err))
}
} else {
results.Tracks = s.mapTracks(tracks)
}
}
if searchUsers {
idx := indexName(s.client.Config.Index, IdxUsers)
mq := map[string]interface{}{
"multi_match": map[string]interface{}{
"query": query,
"fuzziness": "AUTO",
"fields": []string{"username^2", "display_name^2", "bio"},
"type": "best_fields",
},
}
users, err := s.runSearch(ctx, idx, mq, limit, []string{"username", "display_name"})
if err != nil {
if s.logger != nil {
s.logger.Warn("ES user search failed", zap.Error(err))
}
} else {
results.Users = s.mapUsers(users)
}
}
if searchPlaylists {
idx := indexName(s.client.Config.Index, IdxPlaylists)
mq := map[string]interface{}{
"multi_match": map[string]interface{}{
"query": query,
"fuzziness": "AUTO",
"fields": []string{"name^2", "description"},
"type": "best_fields",
},
}
pls, err := s.runSearch(ctx, idx, mq, limit, []string{"name", "description"})
if err != nil {
if s.logger != nil {
s.logger.Warn("ES playlist search failed", zap.Error(err))
}
} else {
results.Playlists = s.mapPlaylists(pls)
}
}
return results, nil
}
type esHit struct {
Source map[string]interface{} `json:"_source"`
}
type esSearchResp struct {
Hits struct {
Hits []esHit `json:"hits"`
} `json:"hits"`
}
func (s *SearchService) runSearch(ctx context.Context, index string, query map[string]interface{}, limit int, highlightFields []string) ([]esHit, error) {
body := map[string]interface{}{
"query": map[string]interface{}{
"bool": map[string]interface{}{
"must": []interface{}{query},
},
},
"size": limit,
}
if len(highlightFields) > 0 {
hlFields := make(map[string]interface{})
for _, f := range highlightFields {
hlFields[f] = map[string]interface{}{}
}
body["highlight"] = map[string]interface{}{
"fields": hlFields,
"pre_tags": []string{"<em>"},
"post_tags": []string{"</em>"},
}
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return nil, err
}
res, err := s.client.Search(
s.client.Search.WithContext(ctx),
s.client.Search.WithIndex(index),
s.client.Search.WithBody(&buf),
)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.IsError() {
return nil, fmt.Errorf("ES search: %s", res.String())
}
var out esSearchResp
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, err
}
hits := make([]esHit, len(out.Hits.Hits))
for i, h := range out.Hits.Hits {
hits[i] = h
}
return hits, nil
}
func (s *SearchService) mapTracks(hits []esHit) []services.TrackResult {
out := make([]services.TrackResult, 0, len(hits))
for _, h := range hits {
tr := services.TrackResult{}
if v, ok := h.Source["id"].(string); ok {
tr.ID = v
}
if v, ok := h.Source["title"].(string); ok {
tr.Title = v
}
if v, ok := h.Source["artist"].(string); ok {
tr.Artist = v
}
if v, ok := h.Source["stream_manifest_url"].(string); ok && v != "" {
tr.URL = v
} else if v, ok := h.Source["file_path"].(string); ok {
tr.URL = v
}
if v, ok := h.Source["cover_art_path"].(string); ok {
tr.CoverArtPath = v
}
out = append(out, tr)
}
return out
}
func (s *SearchService) mapUsers(hits []esHit) []services.UserResult {
out := make([]services.UserResult, 0, len(hits))
for _, h := range hits {
ur := services.UserResult{}
if v, ok := h.Source["id"].(string); ok {
ur.ID = v
}
if v, ok := h.Source["username"].(string); ok {
ur.Username = v
}
if v, ok := h.Source["avatar"].(string); ok {
ur.AvatarURL = v
}
out = append(out, ur)
}
return out
}
func (s *SearchService) mapPlaylists(hits []esHit) []services.PlaylistResult {
out := make([]services.PlaylistResult, 0, len(hits))
for _, h := range hits {
pr := services.PlaylistResult{}
if v, ok := h.Source["id"].(string); ok {
pr.ID = v
}
if v, ok := h.Source["name"].(string); ok {
pr.Name = v
}
if v, ok := h.Source["cover_url"].(string); ok {
pr.Cover = v
}
out = append(out, pr)
}
return out
}
func strContains(slice []string, item string) bool {
for _, s := range slice {
if strings.EqualFold(s, item) {
return true
}
}
return false
}