Files
goods/public-frontend/src/components/ApiDocs.tsx
T
novaalphastrikeomegaz663 69a0149bbe
CI / Python (ingestion) (pull_request) Successful in 12s
CI / Migrations (postgres) (pull_request) Successful in 22s
CI / Go (api) (pull_request) Successful in 47s
feat(search+docs): trigram fuzzy search, brand/country filters, developer docs
Search:
- migration 0009: trigram GIN index on brand.name + btree on country_of_origin
- SearchProducts: typo-tolerant word_similarity matching (>=0.42) on top of
  ILIKE substring + barcode; new brand/country filters; rank by
  similarity * (0.5 + quality_score). Response gains country_of_origin,
  quality_score and per-result relevance score.
- public search UI: brand/country filter inputs; show country in results

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

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 09:38:27 +00:00

332 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { Check, Copy } from "lucide-react";
const ORIGIN = typeof window !== "undefined" ? window.location.origin : "https://goods.tangshasha.com";
const BASE = `${ORIGIN}/api/v1`;
function CopyBtn({ text }: { text: string }) {
const [done, setDone] = useState(false);
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
/* clipboard unavailable */
}
}}
className="text-gray-400 hover:text-gray-600"
title="复制"
>
{done ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
</button>
);
}
function Code({ children }: { children: string }) {
return (
<div className="relative group">
<pre className="bg-gray-900 text-gray-100 text-xs rounded-md p-3 overflow-x-auto whitespace-pre">
{children}
</pre>
<div className="absolute top-2 right-2 opacity-70 group-hover:opacity-100">
<CopyBtn text={children} />
</div>
</div>
);
}
function Method({ m }: { m: string }) {
const color = m === "GET" ? "bg-sky-100 text-sky-700" : "bg-emerald-100 text-emerald-700";
return <span className={`text-xs font-mono font-semibold rounded px-1.5 py-0.5 ${color}`}>{m}</span>;
}
type Param = { name: string; required?: boolean; desc: string };
function Endpoint({
method,
path,
title,
desc,
params,
example,
response,
}: {
method: string;
path: string;
title: string;
desc: string;
params?: Param[];
example: string;
response: string;
}) {
return (
<div className="bg-white border rounded-lg p-5">
<div className="flex items-center gap-2 flex-wrap">
<Method m={method} />
<code className="text-sm text-gray-800 font-mono break-all">{path}</code>
<span className="ml-auto" />
<CopyBtn text={`${ORIGIN}${path}`} />
</div>
<div className="mt-2 font-medium text-gray-800">{title}</div>
<p className="text-sm text-gray-500 mt-0.5">{desc}</p>
{params && params.length > 0 && (
<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 pr-4 pb-1"></th>
<th className="font-medium pb-1"></th>
</tr>
</thead>
<tbody className="align-top">
{params.map((p) => (
<tr key={p.name}>
<td className="pr-4 py-0.5 font-mono text-gray-700">{p.name}</td>
<td className="pr-4 py-0.5 text-gray-500">{p.required ? "是" : "否"}</td>
<td className="py-0.5 text-gray-600">{p.desc}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="mt-3 text-xs text-gray-400 mb-1"></div>
<Code>{example}</Code>
<div className="mt-3 text-xs text-gray-400 mb-1"></div>
<Code>{response}</Code>
</div>
);
}
export default function ApiDocs() {
return (
<div className="space-y-5">
<div className="bg-white border rounded-lg p-5">
<h1 className="text-2xl font-bold text-gray-800">API </h1>
<p className="mt-2 text-gray-600 text-sm leading-relaxed">
<strong></strong> REST API
/Nutri-Score
JSONUTF-8/
</p>
<div className="mt-3 text-sm text-gray-700">
<div>
<code className="font-mono bg-gray-100 rounded px-1.5 py-0.5">{BASE}</code>
</div>
<ul className="mt-2 list-disc pl-5 text-gray-600 space-y-1">
<li> API Key / Token GET Key </li>
<li>
<code className="font-mono">page</code> 1
<code className="font-mono">size</code> 20 100
</li>
<li>
<code className="font-mono">404</code>
<code className="font-mono">{` {"error":{"code","message","request_id"}}`}</code>
</li>
<li></li>
</ul>
</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
method="GET"
path="/healthz"
title="健康检查"
desc="服务存活探针。"
example={`curl ${ORIGIN}/healthz`}
response={`{ "status": "ok" }`}
/>
<Endpoint
method="GET"
path="/api/v1/products/barcode/{gtin}"
title="按条码查询商品"
desc="按 GTIN(条码)精确查询单个商品档案。"
params={[{ name: "gtin", required: true, desc: "商品条码(路径参数),如 5449000000996" }]}
example={`curl ${BASE}/products/barcode/5449000000996`}
response={`{
"id": "…",
"gtin": "5449000000996",
"name": "可口可乐 经典原味",
"brand": "Coca-Cola",
"category_path": "food.beverages.carbonated",
"net_content_value": 33,
"net_content_unit": "cl",
"country_of_origin": "Algeria",
"quality_score": 0.81
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/search"
title="搜索商品"
desc="按名称做三元组(trigram)模糊搜索,可容忍错别字;支持品类/品牌/产地过滤;结果按相关度(名称相似度 × 数据质量分)排序。"
params={[
{ name: "q", desc: "关键词(名称/条码),支持模糊匹配;留空则按质量分返回全部" },
{ name: "category", desc: "品类编码(含子树),如 food.beverages" },
{ name: "brand", desc: "品牌名(模糊匹配),如 Ferrero" },
{ name: "country", desc: "产地前缀(不区分大小写),如 China" },
{ name: "page", desc: "页码,默认 1" },
{ name: "size", desc: "每页条数,默认 20,最大 100" },
]}
example={`curl "${BASE}/products/search?q=nutela&country=Italy&page=1&size=20"`}
response={`{
"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
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/{id}"
title="商品详情"
desc="按商品 UUID 获取完整档案(含配料、营养、添加剂、图片、MSRP 等)。"
params={[{ name: "id", required: true, desc: "商品 UUID(路径参数)" }]}
example={`curl ${BASE}/products/{id}`}
response={`{
"id": "…", "name": "…", "brand": "…",
"ingredients_text": "…",
"nutriments": { "energy_kcal": 42, "sugars_g": 10.6 },
"nutrition_basis": "per_100g",
"nutri_score": "E",
"images": [], "msrp": [],
"quality_score": 0.81
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/{id}/nutriments"
title="商品营养成分"
desc="仅返回该商品的营养字段。"
params={[{ name: "id", required: true, desc: "商品 UUID(路径参数)" }]}
example={`curl ${BASE}/products/{id}/nutriments`}
response={`{
"basis": "per_100g",
"values": { "energy_kcal": 42, "sugars_g": 10.6, "salt_g": 0 }
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/{id}/msrp"
title="厂商建议零售价快照"
desc="官方建议零售价历史快照,仅供参考,不含任何购买入口。"
params={[{ name: "id", required: true, desc: "商品 UUID(路径参数)" }]}
example={`curl ${BASE}/products/{id}/msrp`}
response={`{
"items": [
{ "amount": 3.5, "currency": "CNY", "region": "CN", "effective_date": "2025-01-01" }
],
"disclaimer": "厂商建议零售价历史快照,仅供参考,不构成购买建议…"
}`}
/>
<Endpoint
method="GET"
path="/api/v1/brands"
title="品牌列表"
desc="分页列出全部品牌。"
params={[
{ name: "page", desc: "页码,默认 1" },
{ name: "size", desc: "每页条数,默认 20,最大 100" },
]}
example={`curl "${BASE}/brands?page=1&size=20"`}
response={`{
"items": [ { "id": "…", "name": "Ferrero" } ],
"page": 1, "size": 20, "total": 4
}`}
/>
<Endpoint
method="GET"
path="/api/v1/categories"
title="品类树"
desc="返回完整品类层级(编码 + 名称)。"
example={`curl ${BASE}/categories`}
response={`{
"items": [
{ "id": "…", "code": "food", "name": "食品饮料", "parent_id": null }
]
}`}
/>
<Endpoint
method="GET"
path="/api/v1/sources/{id}"
title="数据来源"
desc="按 ID 查询单个数据来源(含许可、信任权重)。"
params={[{ name: "id", required: true, desc: "来源 UUID(路径参数)" }]}
example={`curl ${BASE}/sources/{id}`}
response={`{
"id": "…", "name": "openfoodfacts",
"license": "ODbL", "trust_weight": 0.7
}`}
/>
<div className="text-xs text-gray-400 leading-relaxed">
/
OpenFoodFacts ODbL
</div>
</div>
);
}