feat(search+docs): trigram fuzzy search, brand/country filters, developer docs
CI / Python (ingestion) (pull_request) Successful in 12s
CI / Migrations (postgres) (pull_request) Successful in 22s
CI / Go (api) (pull_request) Successful in 47s

Search:
- migration 0009: trigram GIN index on brand.name + btree on country_of_origin
- SearchProducts: typo-tolerant word_similarity matching (>=0.42) on top of
  ILIKE substring + barcode; new brand/country filters; rank by
  similarity * (0.5 + quality_score). Response gains country_of_origin,
  quality_score and per-result relevance score.
- public search UI: brand/country filter inputs; show country in results

Docs:
- serve embedded OpenAPI 3 spec at GET /api/v1/openapi.json (not rate limited)
- ApiDocs page: auth + rate-limit section, updated search params/response
- docs/api.md developer guide

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
novaalphastrikeomegaz663
2026-06-20 09:38:27 +00:00
parent cf255e2380
commit 69a0149bbe
11 changed files with 613 additions and 48 deletions
+35 -14
View File
@@ -5,6 +5,7 @@
package handler
import (
_ "embed"
"encoding/json"
"errors"
"io/fs"
@@ -19,6 +20,9 @@ import (
"github.com/baicai2026-baicai/goods/api/internal/store"
)
//go:embed openapi.json
var openAPISpec []byte
// APIVersion is the current public API version prefix.
const APIVersion = "v1"
@@ -66,17 +70,22 @@ func (h *Handler) Router() http.Handler {
r.Get("/healthz", h.Healthz)
r.Route("/api/"+APIVersion, func(r chi.Router) {
r.Use(h.rateLimit)
r.Route("/products", func(r chi.Router) {
r.Get("/barcode/{gtin}", h.ProductByBarcode)
r.Get("/search", h.SearchProducts)
r.Get("/{id}", h.ProductByID)
r.Get("/{id}/nutriments", h.ProductNutriments)
r.Get("/{id}/msrp", h.ProductMSRP)
// Machine-readable spec; not rate limited so tooling can always fetch it.
r.Get("/openapi.json", h.OpenAPI)
r.Group(func(r chi.Router) {
r.Use(h.rateLimit)
r.Route("/products", func(r chi.Router) {
r.Get("/barcode/{gtin}", h.ProductByBarcode)
r.Get("/search", h.SearchProducts)
r.Get("/{id}", h.ProductByID)
r.Get("/{id}/nutriments", h.ProductNutriments)
r.Get("/{id}/msrp", h.ProductMSRP)
})
r.Get("/brands", h.ListBrands)
r.Get("/categories", h.ListCategories)
r.Get("/sources/{id}", h.SourceByID)
})
r.Get("/brands", h.ListBrands)
r.Get("/categories", h.ListCategories)
r.Get("/sources/{id}", h.SourceByID)
})
// Public SPA (homepage + search + contribute). API routes above take
@@ -113,6 +122,12 @@ func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// OpenAPI serves the embedded OpenAPI 3 specification for the public API.
func (h *Handler) OpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(openAPISpec)
}
// ProductByBarcode returns a product by its GTIN.
func (h *Handler) ProductByBarcode(w http.ResponseWriter, r *http.Request) {
p, err := h.store.ProductByGTIN(r.Context(), chi.URLParam(r, "gtin"))
@@ -131,13 +146,19 @@ func (h *Handler) ProductByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, p)
}
// SearchProducts runs a fuzzy name search with optional category filter + paging.
// SearchProducts runs a trigram-fuzzy name search with optional
// category/brand/country filters, ranked by relevance, plus paging.
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
category := r.URL.Query().Get("category")
qv := r.URL.Query()
filters := store.SearchFilters{
Query: strings.TrimSpace(qv.Get("q")),
Category: strings.TrimSpace(qv.Get("category")),
Brand: strings.TrimSpace(qv.Get("brand")),
Country: strings.TrimSpace(qv.Get("country")),
}
page, size := pageParams(r)
items, total, err := h.store.SearchProducts(r.Context(), q, category, size, (page-1)*size)
items, total, err := h.store.SearchProducts(r.Context(), filters, size, (page-1)*size)
if h.handleErr(w, r, err) {
return
}