Files
goods/public-frontend/src/components/ApiDocs.tsx
T
sulaimaannaasif6866 7434e5195e
CI / Go (api) (pull_request) Successful in 15s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s
feat(api): tiered cumulative quota + self-service registration
Anonymous callers get a free cumulative quota (1000 calls per IP); once
exhausted they get 403 quota_exhausted and must register. Public users can
self-register (email+password) to obtain a higher-quota API key, view usage,
and regenerate the key. Quota counters live in Redis; the public API stays
read-only except for the registration writes.

- migration 0011: app_user table + api_key.quota_total + 'registered' tier
- ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters
- middleware: enforce cumulative quota + X-Quota-* headers
- store: RegisterUser/Authenticate/RegenerateKey (bcrypt)
- handlers: POST /api/v1/register, /account, /account/regenerate
- admin: quota_total column + registered tier
- public: 'API 密钥' account page + API docs quota section

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 07:26:41 +00:00

417 lines
16 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({ onRegister }: { onRegister?: () => void }) {
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 IP <strong>1000</strong>
<button onClick={onRegister} className="text-emerald-600 hover:underline"></button>
</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
<strong> 1000 </strong>
<code className="font-mono">403</code> <code className="font-mono">quota_exhausted</code>
<button onClick={onRegister} className="text-emerald-600 hover:underline"></button>
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><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>
<tr>
<td className="pr-4 py-0.5 font-mono text-gray-700">X-Quota-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-Quota-Used</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-Quota-Remaining</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>
<code className="font-mono">403</code> <code className="font-mono">quota_exhausted</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
}`}
/>
<Endpoint
method="POST"
path="/api/v1/register"
title="注册账号并领取 API 密钥"
desc="用邮箱 + 密码(至少 8 位)注册,自助领取一枚更高配额的 API 密钥。明文密钥只在本次响应返回一次,请妥善保存。"
params={[
{ name: "email", required: true, desc: "邮箱(唯一)" },
{ name: "password", required: true, desc: "密码,至少 8 位" },
]}
example={`curl -X POST ${BASE}/register \\
-H "Content-Type: application/json" \\
-d '{"email":"you@example.com","password":"your-password"}'`}
response={`{
"email": "you@example.com",
"api_key": "og_live_xxxxxxxxxxxx",
"key_prefix": "og_live_xxxx",
"rate_limit_per_min": 300,
"quota_total": 100000
}`}
/>
<Endpoint
method="POST"
path="/api/v1/account"
title="查看账号与配额用量"
desc="用邮箱 + 密码查询当前密钥前缀、频率/累计配额上限及已用量(不返回明文密钥)。"
params={[
{ name: "email", required: true, desc: "注册邮箱" },
{ name: "password", required: true, desc: "账号密码" },
]}
example={`curl -X POST ${BASE}/account \\
-H "Content-Type: application/json" \\
-d '{"email":"you@example.com","password":"your-password"}'`}
response={`{
"email": "you@example.com",
"key_prefix": "og_live_xxxx",
"rate_limit_per_min": 300,
"quota_total": 100000,
"quota_used": 1234,
"quota_remaining": 98766
}`}
/>
<Endpoint
method="POST"
path="/api/v1/account/regenerate"
title="重置 API 密钥"
desc="吊销当前密钥并生成新密钥(累计用量会延续,不会因重置而清零)。明文新密钥只返回一次。"
params={[
{ name: "email", required: true, desc: "注册邮箱" },
{ name: "password", required: true, desc: "账号密码" },
]}
example={`curl -X POST ${BASE}/account/regenerate \\
-H "Content-Type: application/json" \\
-d '{"email":"you@example.com","password":"your-password"}'`}
response={`{
"email": "you@example.com",
"api_key": "og_live_yyyyyyyyyyyy",
"key_prefix": "og_live_yyyy",
"rate_limit_per_min": 300,
"quota_total": 100000
}`}
/>
<div className="text-xs text-gray-400 leading-relaxed">
/
OpenFoodFacts ODbL
</div>
</div>
);
}