diff --git a/api/internal/handler/handler.go b/api/internal/handler/handler.go index d899761..bea9ea6 100644 --- a/api/internal/handler/handler.go +++ b/api/internal/handler/handler.go @@ -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 } diff --git a/api/internal/handler/handler_db_test.go b/api/internal/handler/handler_db_test.go index d383f32..3a54885 100644 --- a/api/internal/handler/handler_db_test.go +++ b/api/internal/handler/handler_db_test.go @@ -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") diff --git a/api/internal/handler/openapi.json b/api/internal/handler/openapi.json new file mode 100644 index 0000000..3d60ca2 --- /dev/null +++ b/api/internal/handler/openapi.json @@ -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 } + } + } + } + } +} diff --git a/api/internal/store/store.go b/api/internal/store/store.go index 645f1a9..6938fe1 100644 --- a/api/internal/store/store.go +++ b/api/internal/store/store.go @@ -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) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..1329e1f --- /dev/null +++ b/docs/api.md @@ -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),引用时请注明天工商品档案公共仓及原始来源。 diff --git a/migrations/0009_search.down.sql b/migrations/0009_search.down.sql new file mode 100644 index 0000000..6ad649e --- /dev/null +++ b/migrations/0009_search.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_product_country; +DROP INDEX IF EXISTS idx_brand_name_trgm; diff --git a/migrations/0009_search.up.sql b/migrations/0009_search.up.sql new file mode 100644 index 0000000..1c57164 --- /dev/null +++ b/migrations/0009_search.up.sql @@ -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); diff --git a/public-frontend/src/api.ts b/public-frontend/src/api.ts index ce79da0..0f34a13 100644 --- a/public-frontend/src/api.ts +++ b/public-frontend/src/api.ts @@ -25,11 +25,22 @@ export interface SearchResult { total: number; } +export interface SearchFilters { + brand?: string; + country?: string; +} + export const api = { - search: (q: string, page = 1, size = 20) => - req( - `/api/v1/products/search?q=${encodeURIComponent(q)}&page=${page}&size=${size}`, - ), + search: (q: string, page = 1, size = 20, filters: SearchFilters = {}) => { + const params = new URLSearchParams({ + 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(`/api/v1/products/search?${params.toString()}`); + }, product: (id: string) => req(`/api/v1/products/${id}`), categories: () => req<{ items: Category[] }>(`/api/v1/categories`), submit: (input: SubmissionInput) => diff --git a/public-frontend/src/components/ApiDocs.tsx b/public-frontend/src/components/ApiDocs.tsx index 12ce820..a2081f0 100644 --- a/public-frontend/src/components/ApiDocs.tsx +++ b/public-frontend/src/components/ApiDocs.tsx @@ -117,7 +117,7 @@ export default function ApiDocs() { 基础地址:{BASE}
    -
  • 无需 API Key / Token,直接 GET 即可。
  • +
  • 无需 API Key / Token 即可直接 GET;带 Key 可获得更高频率上限(见下文「鉴权与限流」)。
  • 分页参数 page(默认 1)、 size(默认 20,最大 100)。 @@ -131,6 +131,53 @@ export default function ApiDocs() { +
    +

    鉴权与限流

    +

    + API 默认匿名可用:无需任何凭证即可调用,按来源 IP 计入一个较低的默认频率额度。 + 如需更高额度并让用量归属到你,可在运营方申请一枚 API Key,请求时通过请求头携带: +

    +
    + {`# 二选一 +curl -H "X-API-Key: og_live_xxxxxxxx" ${BASE}/products/search?q=牛奶 +curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛奶`} +
    +

    + 采用固定窗口限流(每分钟)。每个响应都会回写以下响应头,便于客户端自适应: +

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    响应头含义
    X-RateLimit-Limit当前窗口允许的最大请求数
    X-RateLimit-Remaining当前窗口剩余可用次数
    X-RateLimit-Reset窗口重置的 Unix 时间戳(秒)
    Retry-After超额时返回,建议等待的秒数
    +

    + 超过额度返回 429 Too Many Requests,错误码 + rate_limited;无效或已吊销的 Key 返回 + 401,错误码 invalid_api_key。 +

    +
    + void; }) { const [q, setQ] = useState(""); + const [brand, setBrand] = useState(""); + const [country, setCountry] = useState(""); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [searched, setSearched] = useState(false); @@ -24,7 +26,10 @@ export default function Home({ setLoading(true); setError(""); 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); setTotal(res.total); setSearched(true); @@ -61,6 +66,20 @@ export default function Home({ {loading ? "检索中…" : "检索"} +
    + 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" + /> + 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" + /> +
  • ))} diff --git a/public-frontend/src/types.ts b/public-frontend/src/types.ts index d5a88a4..72cbece 100644 --- a/public-frontend/src/types.ts +++ b/public-frontend/src/types.ts @@ -4,6 +4,9 @@ export interface ProductSummary { name: string; brand: string | null; category_path: string | null; + country_of_origin?: string | null; + quality_score?: number; + score?: number | null; } export interface Barcode {