Compare commits

...

2 Commits

Author SHA1 Message Date
novaalphastrikeomegaz663 69a0149bbe 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>
2026-06-20 09:38:27 +00:00
lixu cf255e2380 Merge pull request 'feat(api): API Key + Redis 限流 + 用量统计 (M5a)' (#7) from devin/1781943842-m5a-api-key-ratelimit into main
CI / Python (ingestion) (push) Successful in 11s
CI / Migrations (postgres) (push) Successful in 23s
CI / Go (api) (push) Successful in 48s
2026-06-20 16:27:13 +08:00
11 changed files with 613 additions and 48 deletions
+35 -14
View File
@@ -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
} }
+95
View File
@@ -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")
+186
View File
@@ -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
View File
@@ -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
View File
@@ -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` 时返回,01),未提供 `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),引用时请注明天工商品档案公共仓及原始来源。
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_product_country;
DROP INDEX IF EXISTS idx_brand_name_trgm;
+9
View File
@@ -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);
+15 -4
View File
@@ -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) =>
+58 -6
View File
@@ -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
}`} }`}
+26 -2
View File
@@ -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>
))} ))}
+3
View File
@@ -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 {