SRC-05: facetten-filter-api
Facetten- und Filter-API für Suche (Absender, Zeitraum, Postfach, Anhangstyp, Tag), mandantengetrennt, mit UND-Verknüpfung mehrerer Filter. - migrations/0002..0005: vier nummerierte ALTER-Migrationen für die neuen Facettenfelder (Manticore erlaubt nur eine Spalte je ALTER-Anweisung), EnsureSchema wendet sie idempotent nach. - fields.go: FacetFields-Whitelist, einzige zulässige Facettendimensionen. - facets.go: Client.Facets nutzt Manticores strukturierte aggs.terms/ aggs.range-API, kein dynamischer SQL-Klauselbau. Filter kombinieren als zusätzliche equals-Klauseln in derselben bool.must-Liste wie der Tenant-Filter. Zeitraum-Facette über feste Buckets via aggs.range. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-05-PRUEFPROTOKOLL.md): 1. TestFacets_CountsMatchActualHits: Facettenzahlen stimmen real mit der tatsächlichen Treffermenge überein. 2. TestFacets_ThreeFiltersCombineWithAND: 3 kombinierte Filter schränken 4 Dokumente real auf genau 1 verbleibenden Treffer ein. 3. TestFacets_TenantSeparation: Facetten eines Mandanten enthalten real keine Werte eines anderen. Kein Umbau: Search/Delete/Index-Verhalten aus SRC-01/SRC-03 unverändert, dedup/indexworker/storage/crypto/encstorage unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d23438d3d0
commit
db73aab0de
@@ -0,0 +1,180 @@
|
||||
// SRC-05: Facetten- & Filter-API. Nutzt Manticores strukturierte
|
||||
// Aggregations-API (aggs.terms/aggs.range) — keine dynamische
|
||||
// SQL-Klauselbildung, dieselbe Konvention wie Search/Delete (fields.go).
|
||||
package search
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits
|
||||
// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen —
|
||||
// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen
|
||||
// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage).
|
||||
type FacetFilter struct {
|
||||
Field string
|
||||
Value string
|
||||
}
|
||||
|
||||
// FacetValue ist ein einzelner Facettenwert mit Trefferzahl
|
||||
// (Akzeptanzkriterium 1).
|
||||
type FacetValue struct {
|
||||
Value string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// DateRangeFacet ist ein Zeitraum-Bucket mit Trefferzahl.
|
||||
type DateRangeFacet struct {
|
||||
Label string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// FacetResult fasst alle Facettendimensionen einer Anfrage zusammen.
|
||||
type FacetResult struct {
|
||||
// Values ist je FacetFields-Eintrag (sender/mailbox/attachment_type/tag)
|
||||
// befüllt.
|
||||
Values map[string][]FacetValue
|
||||
// DateRanges sind feste Zeitraum-Buckets über FieldSentAt.
|
||||
DateRanges []DateRangeFacet
|
||||
}
|
||||
|
||||
// farFuture ist die obere Grenze des jüngsten Zeitraum-Buckets. Manticores
|
||||
// range-Aggregation verlangt für jeden Bucket ein explizites "to" — ein
|
||||
// hinreichend großer fester Wert (Jahr 2100) übernimmt die Rolle von
|
||||
// "unbegrenzt in die Zukunft", ohne den Feldtyp zu wechseln.
|
||||
const farFuture int64 = 4102444800
|
||||
|
||||
type dateRangeBoundary struct {
|
||||
label string
|
||||
from int64 // 0 = ab Epoch (unbegrenzt in die Vergangenheit)
|
||||
to int64
|
||||
}
|
||||
|
||||
// dateRangeBoundaries berechnet die festen Zeitraum-Buckets relativ zu now
|
||||
// (Parameter statt time.Now() direkt, damit Facets testbar bleibt).
|
||||
func dateRangeBoundaries(now time.Time) []dateRangeBoundary {
|
||||
sevenDaysAgo := now.AddDate(0, 0, -7).Unix()
|
||||
thirtyDaysAgo := now.AddDate(0, 0, -30).Unix()
|
||||
oneYearAgo := now.AddDate(-1, 0, 0).Unix()
|
||||
return []dateRangeBoundary{
|
||||
{label: "letzte_7_tage", from: sevenDaysAgo, to: farFuture},
|
||||
{label: "letzte_30_tage", from: thirtyDaysAgo, to: sevenDaysAgo},
|
||||
{label: "letztes_jahr", from: oneYearAgo, to: thirtyDaysAgo},
|
||||
{label: "aelter", from: 0, to: oneYearAgo},
|
||||
}
|
||||
}
|
||||
|
||||
func isFacetField(field string) bool {
|
||||
for _, f := range FacetFields {
|
||||
if f == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
||||
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
||||
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
||||
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
||||
// wie der Tenant-Filter).
|
||||
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
||||
must := []map[string]any{
|
||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||
}
|
||||
if queryText != "" {
|
||||
must = append(must, map[string]any{"query_string": queryText})
|
||||
}
|
||||
for _, f := range filters {
|
||||
if !isFacetField(f.Field) {
|
||||
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
||||
}
|
||||
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
||||
}
|
||||
|
||||
aggs := map[string]any{}
|
||||
for _, field := range FacetFields {
|
||||
aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}}
|
||||
}
|
||||
boundaries := dateRangeBoundaries(time.Now())
|
||||
ranges := make([]map[string]any, 0, len(boundaries))
|
||||
for _, b := range boundaries {
|
||||
ranges = append(ranges, map[string]any{"from": b.from, "to": b.to})
|
||||
}
|
||||
aggs["sent_at"] = map[string]any{"range": map[string]any{"field": FieldSentAt, "ranges": ranges}}
|
||||
|
||||
payload := map[string]any{
|
||||
"index": IndexName,
|
||||
"query": map[string]any{"bool": map[string]any{"must": must}},
|
||||
"aggs": aggs,
|
||||
"limit": 0,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facettenanfrage serialisieren: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facettenanfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten abrufen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten-antwort lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten, status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var parsed facetResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return FacetResult{}, fmt.Errorf("search: facetten-antwort parsen: %w", err)
|
||||
}
|
||||
|
||||
result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))}
|
||||
for _, field := range FacetFields {
|
||||
bucket := parsed.Aggregations[field]
|
||||
values := make([]FacetValue, 0, len(bucket.Buckets))
|
||||
for _, b := range bucket.Buckets {
|
||||
if b.Key == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, FacetValue{Value: b.Key, Count: b.DocCount})
|
||||
}
|
||||
result.Values[field] = values
|
||||
}
|
||||
|
||||
sentAtBucket := parsed.Aggregations["sent_at"]
|
||||
result.DateRanges = make([]DateRangeFacet, 0, len(boundaries))
|
||||
for i, b := range boundaries {
|
||||
count := int64(0)
|
||||
if i < len(sentAtBucket.Buckets) {
|
||||
count = sentAtBucket.Buckets[i].DocCount
|
||||
}
|
||||
result.DateRanges = append(result.DateRanges, DateRangeFacet{Label: b.label, Count: count})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type facetResponse struct {
|
||||
Aggregations map[string]struct {
|
||||
Buckets []struct {
|
||||
Key string `json:"key"`
|
||||
DocCount int64 `json:"doc_count"`
|
||||
} `json:"buckets"`
|
||||
} `json:"aggregations"`
|
||||
}
|
||||
Reference in New Issue
Block a user