69a0149bbe
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>
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
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、
|
||
厂商建议零售价快照等)。返回均为 JSON(UTF-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>
|
||
);
|
||
}
|