Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69a0149bbe | |||
| cf255e2380 |
@@ -5,6 +5,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -19,6 +20,9 @@ import (
|
|||||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed openapi.json
|
||||||
|
var openAPISpec []byte
|
||||||
|
|
||||||
// APIVersion is the current public API version prefix.
|
// APIVersion is the current public API version prefix.
|
||||||
const APIVersion = "v1"
|
const APIVersion = "v1"
|
||||||
|
|
||||||
@@ -66,17 +70,22 @@ func (h *Handler) Router() http.Handler {
|
|||||||
r.Get("/healthz", h.Healthz)
|
r.Get("/healthz", h.Healthz)
|
||||||
|
|
||||||
r.Route("/api/"+APIVersion, func(r chi.Router) {
|
r.Route("/api/"+APIVersion, func(r chi.Router) {
|
||||||
r.Use(h.rateLimit)
|
// Machine-readable spec; not rate limited so tooling can always fetch it.
|
||||||
r.Route("/products", func(r chi.Router) {
|
r.Get("/openapi.json", h.OpenAPI)
|
||||||
r.Get("/barcode/{gtin}", h.ProductByBarcode)
|
|
||||||
r.Get("/search", h.SearchProducts)
|
r.Group(func(r chi.Router) {
|
||||||
r.Get("/{id}", h.ProductByID)
|
r.Use(h.rateLimit)
|
||||||
r.Get("/{id}/nutriments", h.ProductNutriments)
|
r.Route("/products", func(r chi.Router) {
|
||||||
r.Get("/{id}/msrp", h.ProductMSRP)
|
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
|
// 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"})
|
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.
|
// ProductByBarcode returns a product by its GTIN.
|
||||||
func (h *Handler) ProductByBarcode(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ProductByBarcode(w http.ResponseWriter, r *http.Request) {
|
||||||
p, err := h.store.ProductByGTIN(r.Context(), chi.URLParam(r, "gtin"))
|
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)
|
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) {
|
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query().Get("q")
|
qv := r.URL.Query()
|
||||||
category := r.URL.Query().Get("category")
|
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)
|
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) {
|
if h.handleErr(w, r, err) {
|
||||||
return
|
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) {
|
func TestListCategories(t *testing.T) {
|
||||||
h, _ := newTestHandler(t)
|
h, _ := newTestHandler(t)
|
||||||
rec := doGET(t, h, "/api/"+APIVersion+"/categories")
|
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.
|
// ProductSummary is a lightweight row used in search/listing responses.
|
||||||
type ProductSummary struct {
|
type ProductSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
GTIN *string `json:"gtin"`
|
GTIN *string `json:"gtin"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Brand *string `json:"brand"`
|
Brand *string `json:"brand"`
|
||||||
CategoryPath *string `json:"category_path"`
|
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 = `
|
const productSelect = `
|
||||||
@@ -148,34 +159,67 @@ func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchProducts performs a fuzzy name search with optional category subtree filter.
|
// fuzzyThreshold is the minimum word_similarity for a name to be considered a
|
||||||
func (s *Store) SearchProducts(ctx context.Context, q, category string, limit, offset int) ([]ProductSummary, int, error) {
|
// 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{}
|
args := []any{}
|
||||||
where := "WHERE p.status = 'active'"
|
where := "WHERE p.status = 'active'"
|
||||||
if q != "" {
|
|
||||||
args = append(args, q)
|
qIdx := 0
|
||||||
where += ` AND (p.name ILIKE '%' || $1 || '%'
|
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
|
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 != "" {
|
if f.Category != "" {
|
||||||
args = append(args, category)
|
args = append(args, f.Category)
|
||||||
where += " AND c.path <@ $" + strconv.Itoa(len(args)) + "::ltree"
|
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
|
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
|
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)
|
args = append(args, limit, offset)
|
||||||
listSQL := `
|
listSQL := "SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.country_of_origin, p.quality_score, " +
|
||||||
SELECT p.id, p.gtin, p.name, b.name, c.path::text
|
scoreExpr + " AS score " + from + where +
|
||||||
FROM product p
|
" ORDER BY " + orderBy +
|
||||||
LEFT JOIN brand b ON b.id = p.brand_id
|
" LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
|
||||||
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))
|
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, listSQL, args...)
|
rows, err := s.pool.Query(ctx, listSQL, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,7 +230,8 @@ LEFT JOIN category c ON c.id = p.category_id ` + where +
|
|||||||
out := []ProductSummary{}
|
out := []ProductSummary{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ps ProductSummary
|
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
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
out = append(out, ps)
|
out = append(out, ps)
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
# OpenGoods 公共 API 开发者文档
|
||||||
|
|
||||||
|
天工商品档案公共仓(OpenGoods)提供**公开、只读**的商品事实 REST API:按条码/名称查询商品的客观资料(品牌、品类、净含量、产地、配料、营养成分、Nutri-Score、厂商建议零售价快照等)。返回均为 JSON(UTF-8)。**本服务不含任何购买/交易接口。**
|
||||||
|
|
||||||
|
- 基础地址:`https://goods.tangshasha.com/api/v1`
|
||||||
|
- 机器可读规范(OpenAPI 3):`GET /api/v1/openapi.json`
|
||||||
|
- 交互式文档:站点「API 调用说明」页
|
||||||
|
|
||||||
|
## 鉴权
|
||||||
|
|
||||||
|
API 默认**匿名可用**,无需任何凭证即可调用。匿名请求按来源 IP 计入一个较低的默认每分钟额度。
|
||||||
|
|
||||||
|
如需更高额度并让用量归属到你,可向运营方申请一枚 **API Key**(形如 `og_live_xxxxxxxx`),请求时二选一携带:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: og_live_xxxxxxxx" \
|
||||||
|
"https://goods.tangshasha.com/api/v1/products/search?q=牛奶"
|
||||||
|
|
||||||
|
# 或
|
||||||
|
curl -H "Authorization: Bearer og_live_xxxxxxxx" \
|
||||||
|
"https://goods.tangshasha.com/api/v1/products/search?q=牛奶"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 仅在创建时返回一次明文 Key,请妥善保存。服务端只存储其 SHA-256 哈希。
|
||||||
|
|
||||||
|
## 限流
|
||||||
|
|
||||||
|
采用**固定窗口**限流(每分钟)。每个响应都会回写以下响应头:
|
||||||
|
|
||||||
|
| 响应头 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `X-RateLimit-Limit` | 当前窗口允许的最大请求数 |
|
||||||
|
| `X-RateLimit-Remaining` | 当前窗口剩余可用次数 |
|
||||||
|
| `X-RateLimit-Reset` | 窗口重置的 Unix 时间戳(秒) |
|
||||||
|
| `Retry-After` | 仅在超额(429)时返回,建议等待的秒数 |
|
||||||
|
|
||||||
|
- 超过额度:`429 Too Many Requests`,错误码 `rate_limited`。
|
||||||
|
- Key 无效或已吊销:`401 Unauthorized`,错误码 `invalid_api_key`。
|
||||||
|
|
||||||
|
## 错误格式
|
||||||
|
|
||||||
|
非 2xx 响应体统一为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": { "code": "not_found", "message": "…", "request_id": "…" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 分页
|
||||||
|
|
||||||
|
列表类接口支持 `page`(默认 `1`)与 `size`(默认 `20`,最大 `100`),响应含 `page`/`size`/`total`。
|
||||||
|
|
||||||
|
## 端点
|
||||||
|
|
||||||
|
### `GET /products/search` — 搜索商品
|
||||||
|
|
||||||
|
按名称做三元组(trigram)模糊搜索,**可容忍错别字**;支持品类/品牌/产地过滤;结果按相关度(名称相似度 × 数据质量分)排序。
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `q` | 否 | 关键词(名称/条码),模糊匹配;留空则按质量分返回全部 |
|
||||||
|
| `category` | 否 | 品类编码(含子树),如 `food.beverages` |
|
||||||
|
| `brand` | 否 | 品牌名(模糊匹配),如 `Ferrero` |
|
||||||
|
| `country` | 否 | 产地前缀(不区分大小写),如 `China` |
|
||||||
|
| `page` | 否 | 页码,默认 1 |
|
||||||
|
| `size` | 否 | 每页条数,默认 20,最大 100 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://goods.tangshasha.com/api/v1/products/search?q=nutela&country=Italy"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "…",
|
||||||
|
"gtin": "3017624010701",
|
||||||
|
"name": "Nutella",
|
||||||
|
"brand": "Ferrero",
|
||||||
|
"category_path": "food.snacks.chocolate",
|
||||||
|
"country_of_origin": "Italy",
|
||||||
|
"quality_score": 0.81,
|
||||||
|
"score": 0.71
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"size": 20,
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`score` 为名称相关度(提供 `q` 时返回,0–1),未提供 `q` 时为 `null`。
|
||||||
|
|
||||||
|
### `GET /products/barcode/{gtin}` — 按条码查询
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://goods.tangshasha.com/api/v1/products/barcode/5449000000996"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /products/{id}` — 商品详情
|
||||||
|
|
||||||
|
按商品 UUID 获取完整档案(含配料、营养、添加剂、图片、MSRP 等)。
|
||||||
|
|
||||||
|
### `GET /products/{id}/nutriments` — 商品营养成分
|
||||||
|
|
||||||
|
### `GET /products/{id}/msrp` — 厂商建议零售价快照
|
||||||
|
|
||||||
|
官方建议零售价历史快照,仅供参考,不含任何购买入口。
|
||||||
|
|
||||||
|
### `GET /brands` — 品牌列表(分页)
|
||||||
|
|
||||||
|
### `GET /categories` — 品类树
|
||||||
|
|
||||||
|
### `GET /sources/{id}` — 数据来源
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
数据可能存在误差或滞后,按「现状」提供,不构成医疗/购买建议。商品资料版权归各原始来源所有,请遵循其许可(如 OpenFoodFacts 的 ODbL),引用时请注明天工商品档案公共仓及原始来源。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_product_country;
|
||||||
|
DROP INDEX IF EXISTS idx_brand_name_trgm;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Search upgrade: trigram-based fuzzy matching + brand/country filters.
|
||||||
|
-- pg_trgm and the product.name GIN index already exist (see 0001). Add a
|
||||||
|
-- matching trigram index on brand.name so brand filtering / fuzzy brand
|
||||||
|
-- lookups can use an index instead of a sequential scan.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_brand_name_trgm ON brand USING gin (name gin_trgm_ops);
|
||||||
|
|
||||||
|
-- country_of_origin is filtered by exact/prefix match; a plain btree index
|
||||||
|
-- keeps that cheap as the catalog grows.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_country ON product (country_of_origin);
|
||||||
@@ -25,11 +25,22 @@ export interface SearchResult {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchFilters {
|
||||||
|
brand?: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
search: (q: string, page = 1, size = 20) =>
|
search: (q: string, page = 1, size = 20, filters: SearchFilters = {}) => {
|
||||||
req<SearchResult>(
|
const params = new URLSearchParams({
|
||||||
`/api/v1/products/search?q=${encodeURIComponent(q)}&page=${page}&size=${size}`,
|
q,
|
||||||
),
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
});
|
||||||
|
if (filters.brand) params.set("brand", filters.brand);
|
||||||
|
if (filters.country) params.set("country", filters.country);
|
||||||
|
return req<SearchResult>(`/api/v1/products/search?${params.toString()}`);
|
||||||
|
},
|
||||||
product: (id: string) => req<Product>(`/api/v1/products/${id}`),
|
product: (id: string) => req<Product>(`/api/v1/products/${id}`),
|
||||||
categories: () => req<{ items: Category[] }>(`/api/v1/categories`),
|
categories: () => req<{ items: Category[] }>(`/api/v1/categories`),
|
||||||
submit: (input: SubmissionInput) =>
|
submit: (input: SubmissionInput) =>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function ApiDocs() {
|
|||||||
基础地址:<code className="font-mono bg-gray-100 rounded px-1.5 py-0.5">{BASE}</code>
|
基础地址:<code className="font-mono bg-gray-100 rounded px-1.5 py-0.5">{BASE}</code>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-2 list-disc pl-5 text-gray-600 space-y-1">
|
<ul className="mt-2 list-disc pl-5 text-gray-600 space-y-1">
|
||||||
<li>无需 API Key / Token,直接 GET 即可。</li>
|
<li>无需 API Key / Token 即可直接 GET;带 Key 可获得更高频率上限(见下文「鉴权与限流」)。</li>
|
||||||
<li>
|
<li>
|
||||||
分页参数 <code className="font-mono">page</code>(默认 1)、
|
分页参数 <code className="font-mono">page</code>(默认 1)、
|
||||||
<code className="font-mono">size</code>(默认 20,最大 100)。
|
<code className="font-mono">size</code>(默认 20,最大 100)。
|
||||||
@@ -131,6 +131,53 @@ export default function ApiDocs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">鉴权与限流</h2>
|
||||||
|
<p className="mt-2 text-gray-600 text-sm leading-relaxed">
|
||||||
|
API 默认<strong>匿名可用</strong>:无需任何凭证即可调用,按来源 IP 计入一个较低的默认频率额度。
|
||||||
|
如需更高额度并让用量归属到你,可在运营方申请一枚 API Key,请求时通过请求头携带:
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Code>{`# 二选一
|
||||||
|
curl -H "X-API-Key: og_live_xxxxxxxx" ${BASE}/products/search?q=牛奶
|
||||||
|
curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛奶`}</Code>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-gray-600 text-sm leading-relaxed">
|
||||||
|
采用<strong>固定窗口</strong>限流(每分钟)。每个响应都会回写以下响应头,便于客户端自适应:
|
||||||
|
</p>
|
||||||
|
<table className="mt-3 w-full text-sm">
|
||||||
|
<thead className="text-gray-400 text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="font-medium pr-4 pb-1">响应头</th>
|
||||||
|
<th className="font-medium pb-1">含义</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="align-top">
|
||||||
|
<tr>
|
||||||
|
<td className="pr-4 py-0.5 font-mono text-gray-700">X-RateLimit-Limit</td>
|
||||||
|
<td className="py-0.5 text-gray-600">当前窗口允许的最大请求数</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="pr-4 py-0.5 font-mono text-gray-700">X-RateLimit-Remaining</td>
|
||||||
|
<td className="py-0.5 text-gray-600">当前窗口剩余可用次数</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="pr-4 py-0.5 font-mono text-gray-700">X-RateLimit-Reset</td>
|
||||||
|
<td className="py-0.5 text-gray-600">窗口重置的 Unix 时间戳(秒)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="pr-4 py-0.5 font-mono text-gray-700">Retry-After</td>
|
||||||
|
<td className="py-0.5 text-gray-600">超额时返回,建议等待的秒数</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="mt-3 text-gray-600 text-sm leading-relaxed">
|
||||||
|
超过额度返回 <code className="font-mono">429 Too Many Requests</code>,错误码
|
||||||
|
<code className="font-mono">rate_limited</code>;无效或已吊销的 Key 返回
|
||||||
|
<code className="font-mono">401</code>,错误码 <code className="font-mono">invalid_api_key</code>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Endpoint
|
<Endpoint
|
||||||
method="GET"
|
method="GET"
|
||||||
path="/healthz"
|
path="/healthz"
|
||||||
@@ -164,19 +211,24 @@ export default function ApiDocs() {
|
|||||||
method="GET"
|
method="GET"
|
||||||
path="/api/v1/products/search"
|
path="/api/v1/products/search"
|
||||||
title="搜索商品"
|
title="搜索商品"
|
||||||
desc="按名称模糊搜索,可按品类过滤,支持分页。"
|
desc="按名称做三元组(trigram)模糊搜索,可容忍错别字;支持品类/品牌/产地过滤;结果按相关度(名称相似度 × 数据质量分)排序。"
|
||||||
params={[
|
params={[
|
||||||
{ name: "q", desc: "关键词(名称/条码),留空返回全部" },
|
{ name: "q", desc: "关键词(名称/条码),支持模糊匹配;留空则按质量分返回全部" },
|
||||||
{ name: "category", desc: "品类编码过滤,如 food.beverages" },
|
{ name: "category", desc: "品类编码(含子树),如 food.beverages" },
|
||||||
|
{ name: "brand", desc: "品牌名(模糊匹配),如 Ferrero" },
|
||||||
|
{ name: "country", desc: "产地前缀(不区分大小写),如 China" },
|
||||||
{ name: "page", desc: "页码,默认 1" },
|
{ name: "page", desc: "页码,默认 1" },
|
||||||
{ name: "size", desc: "每页条数,默认 20,最大 100" },
|
{ name: "size", desc: "每页条数,默认 20,最大 100" },
|
||||||
]}
|
]}
|
||||||
example={`curl "${BASE}/products/search?q=nutella&page=1&size=20"`}
|
example={`curl "${BASE}/products/search?q=nutela&country=Italy&page=1&size=20"`}
|
||||||
response={`{
|
response={`{
|
||||||
"items": [
|
"items": [
|
||||||
{ "id": "…", "gtin": "3017624010701",
|
{ "id": "…", "gtin": "3017624010701",
|
||||||
"name": "Nutella", "brand": "Ferrero",
|
"name": "Nutella", "brand": "Ferrero",
|
||||||
"category_path": "food.snacks.chocolate" }
|
"category_path": "food.snacks.chocolate",
|
||||||
|
"country_of_origin": "Italy",
|
||||||
|
"quality_score": 0.81,
|
||||||
|
"score": 0.71 }
|
||||||
],
|
],
|
||||||
"page": 1, "size": 20, "total": 1
|
"page": 1, "size": 20, "total": 1
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default function Home({
|
|||||||
onApi: () => void;
|
onApi: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
const [brand, setBrand] = useState("");
|
||||||
|
const [country, setCountry] = useState("");
|
||||||
const [items, setItems] = useState<ProductSummary[]>([]);
|
const [items, setItems] = useState<ProductSummary[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
@@ -24,7 +26,10 @@ export default function Home({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const res = await api.search(q.trim(), 1, 30);
|
const res = await api.search(q.trim(), 1, 30, {
|
||||||
|
brand: brand.trim() || undefined,
|
||||||
|
country: country.trim() || undefined,
|
||||||
|
});
|
||||||
setItems(res.items);
|
setItems(res.items);
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
@@ -61,6 +66,20 @@ export default function Home({
|
|||||||
{loading ? "检索中…" : "检索"}
|
{loading ? "检索中…" : "检索"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="mt-3 max-w-2xl mx-auto flex flex-wrap items-center justify-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
value={brand}
|
||||||
|
onChange={(e) => setBrand(e.target.value)}
|
||||||
|
placeholder="按品牌筛选(如 Ferrero)"
|
||||||
|
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
placeholder="按产地筛选(如 China)"
|
||||||
|
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onApi}
|
onClick={onApi}
|
||||||
className="mt-4 inline-flex items-center gap-1.5 text-sm text-emerald-700 hover:underline"
|
className="mt-4 inline-flex items-center gap-1.5 text-sm text-emerald-700 hover:underline"
|
||||||
@@ -105,7 +124,12 @@ export default function Home({
|
|||||||
{p.gtin ? ` · ${p.gtin}` : ""}
|
{p.gtin ? ` · ${p.gtin}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400">{p.category_path || ""}</span>
|
<span className="text-xs text-gray-400 text-right">
|
||||||
|
<span className="block">{p.category_path || ""}</span>
|
||||||
|
{p.country_of_origin ? (
|
||||||
|
<span className="block text-gray-400">产地:{p.country_of_origin}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export interface ProductSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
brand: string | null;
|
brand: string | null;
|
||||||
category_path: string | null;
|
category_path: string | null;
|
||||||
|
country_of_origin?: string | null;
|
||||||
|
quality_score?: number;
|
||||||
|
score?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Barcode {
|
export interface Barcode {
|
||||||
|
|||||||
Reference in New Issue
Block a user