feat(search+docs): trigram fuzzy search, brand/country filters, developer docs
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -116,6 +116,101 @@ func TestSearchProducts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFuzzyAndFilters(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
ctx := context.Background()
|
||||
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
||||
}
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Skipf("no database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
_, err = pool.Exec(ctx,
|
||||
"INSERT INTO brand (name, normalized_name) VALUES ('ZZ Test Brand','zz test brand') ON CONFLICT DO NOTHING")
|
||||
if err != nil {
|
||||
t.Fatalf("seed brand: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO product (name, brand_id, country_of_origin, quality_score, status)
|
||||
VALUES ('ZZ Hazelnut Chocolate', (SELECT id FROM brand WHERE name='ZZ Test Brand'), 'Testland', 0.5, 'active')`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed product: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM product WHERE name='ZZ Hazelnut Chocolate'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM brand WHERE name='ZZ Test Brand'")
|
||||
})
|
||||
|
||||
decode := func(path string) []store.ProductSummary {
|
||||
rec := doGET(t, h, path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s -> status %d", path, rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Items []store.ProductSummary `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body.Items
|
||||
}
|
||||
has := func(items []store.ProductSummary, name string) *store.ProductSummary {
|
||||
for i := range items {
|
||||
if items[i].Name == name {
|
||||
return &items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Typo "choclate" should fuzzy-match via word_similarity and carry a score.
|
||||
got := has(decode("/api/"+APIVersion+"/products/search?q=choclate"), "ZZ Hazelnut Chocolate")
|
||||
if got == nil {
|
||||
t.Fatal("fuzzy query 'choclate' did not match 'ZZ Hazelnut Chocolate'")
|
||||
}
|
||||
if got.Score == nil || *got.Score <= 0 {
|
||||
t.Fatalf("expected positive fuzzy score, got %v", got.Score)
|
||||
}
|
||||
|
||||
// Brand filter.
|
||||
if has(decode("/api/"+APIVersion+"/products/search?brand=ZZ+Test+Brand"), "ZZ Hazelnut Chocolate") == nil {
|
||||
t.Fatal("brand filter did not return the product")
|
||||
}
|
||||
// Country filter (case-insensitive prefix).
|
||||
if has(decode("/api/"+APIVersion+"/products/search?country=test"), "ZZ Hazelnut Chocolate") == nil {
|
||||
t.Fatal("country filter did not return the product")
|
||||
}
|
||||
// Non-matching country excludes it.
|
||||
if has(decode("/api/"+APIVersion+"/products/search?country=france"), "ZZ Hazelnut Chocolate") != nil {
|
||||
t.Fatal("country filter 'france' should not return the product")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPISpec(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
rec := doGET(t, h, "/api/"+APIVersion+"/openapi.json")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var spec struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&spec); err != nil {
|
||||
t.Fatalf("openapi.json is not valid JSON: %v", err)
|
||||
}
|
||||
if spec.OpenAPI == "" || len(spec.Paths) == 0 {
|
||||
t.Fatalf("unexpected spec: %+v", spec)
|
||||
}
|
||||
if _, ok := spec.Paths["/products/search"]; !ok {
|
||||
t.Fatal("spec missing /products/search path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCategories(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
rec := doGET(t, h, "/api/"+APIVersion+"/categories")
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "OpenGoods / 天工商品档案公共仓 API",
|
||||
"version": "1.0.0",
|
||||
"description": "Public, read-only product-facts REST API. Anonymous access is allowed at a lower per-minute rate; an optional API key grants a higher rate limit and attributes usage. No purchase or commerce endpoints by design.",
|
||||
"license": { "name": "Data under each source's license (e.g. ODbL)" }
|
||||
},
|
||||
"servers": [{ "url": "https://goods.tangshasha.com/api/v1" }],
|
||||
"tags": [
|
||||
{ "name": "products" },
|
||||
{ "name": "catalog" },
|
||||
{ "name": "meta" }
|
||||
],
|
||||
"security": [{ "ApiKeyHeader": [] }, { "BearerKey": [] }, {}],
|
||||
"paths": {
|
||||
"/products/search": {
|
||||
"get": {
|
||||
"tags": ["products"],
|
||||
"summary": "Search products",
|
||||
"description": "Trigram-fuzzy name search (typo-tolerant) with optional category/brand/country filters, ranked by name similarity blended with data quality_score.",
|
||||
"parameters": [
|
||||
{ "name": "q", "in": "query", "schema": { "type": "string" }, "description": "Keyword (name or barcode); fuzzy-matched. Empty returns all, ordered by quality_score." },
|
||||
{ "name": "category", "in": "query", "schema": { "type": "string" }, "description": "Category code (matches the subtree), e.g. food.beverages." },
|
||||
{ "name": "brand", "in": "query", "schema": { "type": "string" }, "description": "Brand name (fuzzy)." },
|
||||
{ "name": "country", "in": "query", "schema": { "type": "string" }, "description": "Country of origin (case-insensitive prefix)." },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1, "minimum": 1 } },
|
||||
{ "name": "size", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Paged search results.",
|
||||
"headers": {
|
||||
"X-RateLimit-Limit": { "schema": { "type": "integer" }, "description": "Max requests in the current window." },
|
||||
"X-RateLimit-Remaining": { "schema": { "type": "integer" }, "description": "Remaining requests in the window." },
|
||||
"X-RateLimit-Reset": { "schema": { "type": "integer" }, "description": "Unix timestamp when the window resets." }
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": { "type": "array", "items": { "$ref": "#/components/schemas/ProductSummary" } },
|
||||
"page": { "type": "integer" },
|
||||
"size": { "type": "integer" },
|
||||
"total": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "$ref": "#/components/responses/InvalidApiKey" },
|
||||
"429": { "$ref": "#/components/responses/RateLimited" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/products/barcode/{gtin}": {
|
||||
"get": {
|
||||
"tags": ["products"],
|
||||
"summary": "Get product by barcode (GTIN)",
|
||||
"parameters": [{ "name": "gtin", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||
"responses": {
|
||||
"200": { "description": "Product", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Product" } } } },
|
||||
"404": { "$ref": "#/components/responses/NotFound" },
|
||||
"429": { "$ref": "#/components/responses/RateLimited" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/products/{id}": {
|
||||
"get": {
|
||||
"tags": ["products"],
|
||||
"summary": "Get product detail by UUID",
|
||||
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
|
||||
"responses": {
|
||||
"200": { "description": "Product", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Product" } } } },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/products/{id}/nutriments": {
|
||||
"get": {
|
||||
"tags": ["products"],
|
||||
"summary": "Get product nutriments",
|
||||
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
|
||||
"responses": { "200": { "description": "Nutriments" }, "404": { "$ref": "#/components/responses/NotFound" } }
|
||||
}
|
||||
},
|
||||
"/products/{id}/msrp": {
|
||||
"get": {
|
||||
"tags": ["products"],
|
||||
"summary": "Get manufacturer suggested retail price snapshots (reference only)",
|
||||
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
|
||||
"responses": { "200": { "description": "MSRP snapshots" } }
|
||||
}
|
||||
},
|
||||
"/brands": {
|
||||
"get": {
|
||||
"tags": ["catalog"],
|
||||
"summary": "List brands",
|
||||
"parameters": [
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "size", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } }
|
||||
],
|
||||
"responses": { "200": { "description": "Paged brands" } }
|
||||
}
|
||||
},
|
||||
"/categories": {
|
||||
"get": { "tags": ["catalog"], "summary": "List the category tree", "responses": { "200": { "description": "Category tree" } } }
|
||||
},
|
||||
"/sources/{id}": {
|
||||
"get": {
|
||||
"tags": ["meta"],
|
||||
"summary": "Get a data source",
|
||||
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
|
||||
"responses": { "200": { "description": "Source" }, "404": { "$ref": "#/components/responses/NotFound" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"ApiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API key, e.g. og_live_xxx. Optional." },
|
||||
"BearerKey": { "type": "http", "scheme": "bearer", "description": "Authorization: Bearer og_live_xxx. Optional." }
|
||||
},
|
||||
"responses": {
|
||||
"NotFound": {
|
||||
"description": "Resource not found.",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"RateLimited": {
|
||||
"description": "Rate limit exceeded.",
|
||||
"headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait before retrying." } },
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"InvalidApiKey": {
|
||||
"description": "API key invalid or revoked.",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"request_id": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProductSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"gtin": { "type": "string", "nullable": true },
|
||||
"name": { "type": "string" },
|
||||
"brand": { "type": "string", "nullable": true },
|
||||
"category_path": { "type": "string", "nullable": true },
|
||||
"country_of_origin": { "type": "string", "nullable": true },
|
||||
"quality_score": { "type": "number", "format": "float" },
|
||||
"score": { "type": "number", "format": "float", "nullable": true, "description": "Relevance (name word-similarity) when q is provided; null otherwise." }
|
||||
}
|
||||
},
|
||||
"Product": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "format": "uuid" },
|
||||
"gtin": { "type": "string", "nullable": true },
|
||||
"name": { "type": "string" },
|
||||
"brand": { "type": "string", "nullable": true },
|
||||
"category_path": { "type": "string", "nullable": true },
|
||||
"net_content_value": { "type": "number", "nullable": true },
|
||||
"net_content_unit": { "type": "string", "nullable": true },
|
||||
"country_of_origin": { "type": "string", "nullable": true },
|
||||
"quality_score": { "type": "number", "format": "float" },
|
||||
"nutriments": { "type": "object", "additionalProperties": true, "nullable": true },
|
||||
"nutrition_basis": { "type": "string", "nullable": true },
|
||||
"nutri_score": { "type": "string", "nullable": true },
|
||||
"ingredients_text": { "type": "string", "nullable": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-22
@@ -82,11 +82,22 @@ func (s *Store) ProductBarcodes(ctx context.Context, productID string) ([]Barcod
|
||||
|
||||
// ProductSummary is a lightweight row used in search/listing responses.
|
||||
type ProductSummary struct {
|
||||
ID string `json:"id"`
|
||||
GTIN *string `json:"gtin"`
|
||||
Name string `json:"name"`
|
||||
Brand *string `json:"brand"`
|
||||
CategoryPath *string `json:"category_path"`
|
||||
ID string `json:"id"`
|
||||
GTIN *string `json:"gtin"`
|
||||
Name string `json:"name"`
|
||||
Brand *string `json:"brand"`
|
||||
CategoryPath *string `json:"category_path"`
|
||||
Country *string `json:"country_of_origin"`
|
||||
QualityScore float64 `json:"quality_score"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// SearchFilters bundles the optional filters accepted by SearchProducts.
|
||||
type SearchFilters struct {
|
||||
Query string // fuzzy name / barcode query
|
||||
Category string // ltree path; matches the subtree
|
||||
Brand string // fuzzy brand name
|
||||
Country string // country_of_origin prefix (case-insensitive)
|
||||
}
|
||||
|
||||
const productSelect = `
|
||||
@@ -148,34 +159,67 @@ func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SearchProducts performs a fuzzy name search with optional category subtree filter.
|
||||
func (s *Store) SearchProducts(ctx context.Context, q, category string, limit, offset int) ([]ProductSummary, int, error) {
|
||||
// fuzzyThreshold is the minimum word_similarity for a name to be considered a
|
||||
// fuzzy match. ~0.42 tolerates common typos (e.g. "choclate"→"Chocolate")
|
||||
// without returning unrelated products.
|
||||
const fuzzyThreshold = "0.42"
|
||||
|
||||
// SearchProducts runs a trigram-fuzzy name search with optional category /
|
||||
// brand / country filters. When a query is present, matching is inclusive
|
||||
// (substring OR trigram-similar OR barcode), and results are ranked by name
|
||||
// similarity blended with quality_score so the best, most-complete records
|
||||
// surface first. Without a query, results are ordered by quality_score.
|
||||
func (s *Store) SearchProducts(ctx context.Context, f SearchFilters, limit, offset int) ([]ProductSummary, int, error) {
|
||||
args := []any{}
|
||||
where := "WHERE p.status = 'active'"
|
||||
if q != "" {
|
||||
args = append(args, q)
|
||||
where += ` AND (p.name ILIKE '%' || $1 || '%'
|
||||
|
||||
qIdx := 0
|
||||
if f.Query != "" {
|
||||
args = append(args, f.Query)
|
||||
qIdx = len(args)
|
||||
q := "$" + strconv.Itoa(qIdx)
|
||||
where += ` AND (p.name ILIKE '%' || ` + q + ` || '%'
|
||||
OR word_similarity(` + q + `, p.name) >= ` + fuzzyThreshold + `
|
||||
OR EXISTS (SELECT 1 FROM product_barcode pb
|
||||
WHERE pb.product_id = p.id AND pb.gtin ILIKE '%' || $1 || '%'))`
|
||||
WHERE pb.product_id = p.id AND pb.gtin ILIKE '%' || ` + q + ` || '%'))`
|
||||
}
|
||||
if category != "" {
|
||||
args = append(args, category)
|
||||
if f.Category != "" {
|
||||
args = append(args, f.Category)
|
||||
where += " AND c.path <@ $" + strconv.Itoa(len(args)) + "::ltree"
|
||||
}
|
||||
if f.Brand != "" {
|
||||
args = append(args, f.Brand)
|
||||
where += " AND b.name ILIKE '%' || $" + strconv.Itoa(len(args)) + " || '%'"
|
||||
}
|
||||
if f.Country != "" {
|
||||
args = append(args, f.Country)
|
||||
where += " AND p.country_of_origin ILIKE $" + strconv.Itoa(len(args)) + " || '%'"
|
||||
}
|
||||
|
||||
from := `FROM product p
|
||||
LEFT JOIN brand b ON b.id = p.brand_id
|
||||
LEFT JOIN category c ON c.id = p.category_id `
|
||||
|
||||
countSQL := "SELECT count(*) FROM product p LEFT JOIN category c ON c.id = p.category_id " + where
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
if err := s.pool.QueryRow(ctx, "SELECT count(*) "+from+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Ranking: when querying, similarity drives order, multiplied by a
|
||||
// quality factor floored at 0.5 so low-quality records aren't zeroed out.
|
||||
scoreExpr := "NULL::real"
|
||||
orderBy := "p.quality_score DESC, p.name"
|
||||
if f.Query != "" {
|
||||
q := "$" + strconv.Itoa(qIdx)
|
||||
scoreExpr = "word_similarity(" + q + ", p.name)"
|
||||
orderBy = scoreExpr + " * (0.5 + p.quality_score) DESC, p.quality_score DESC, p.name"
|
||||
}
|
||||
|
||||
args = append(args, limit, offset)
|
||||
listSQL := `
|
||||
SELECT p.id, p.gtin, p.name, b.name, c.path::text
|
||||
FROM product p
|
||||
LEFT JOIN brand b ON b.id = p.brand_id
|
||||
LEFT JOIN category c ON c.id = p.category_id ` + where +
|
||||
" ORDER BY p.name LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
|
||||
listSQL := "SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.country_of_origin, p.quality_score, " +
|
||||
scoreExpr + " AS score " + from + where +
|
||||
" ORDER BY " + orderBy +
|
||||
" LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
|
||||
|
||||
rows, err := s.pool.Query(ctx, listSQL, args...)
|
||||
if err != nil {
|
||||
@@ -186,7 +230,8 @@ LEFT JOIN category c ON c.id = p.category_id ` + where +
|
||||
out := []ProductSummary{}
|
||||
for rows.Next() {
|
||||
var ps ProductSummary
|
||||
if err := rows.Scan(&ps.ID, &ps.GTIN, &ps.Name, &ps.Brand, &ps.CategoryPath); err != nil {
|
||||
if err := rows.Scan(&ps.ID, &ps.GTIN, &ps.Name, &ps.Brand, &ps.CategoryPath,
|
||||
&ps.Country, &ps.QualityScore, &ps.Score); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, ps)
|
||||
|
||||
Reference in New Issue
Block a user