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>
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Boxes, Search, PlusCircle, Code2 } from "lucide-react";
|
||||
import { Boxes, Search, PlusCircle, Code2, KeyRound } from "lucide-react";
|
||||
import Home from "./components/Home";
|
||||
import ProductView from "./components/ProductView";
|
||||
import Contribute from "./components/Contribute";
|
||||
import ApiDocs from "./components/ApiDocs";
|
||||
import Account from "./components/Account";
|
||||
import { api } from "./api";
|
||||
|
||||
type View =
|
||||
| { name: "home" }
|
||||
| { name: "product"; id: string }
|
||||
| { name: "contribute" }
|
||||
| { name: "api" };
|
||||
| { name: "api" }
|
||||
| { name: "account" };
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<View>({ name: "home" });
|
||||
@@ -59,6 +61,14 @@ export default function App() {
|
||||
>
|
||||
<Code2 className="w-4 h-4" /> API
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
|
||||
view.name === "account" ? "bg-emerald-50 text-emerald-700" : "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => setView({ name: "account" })}
|
||||
>
|
||||
<KeyRound className="w-4 h-4" /> API 密钥
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -77,7 +87,8 @@ export default function App() {
|
||||
{view.name === "contribute" && (
|
||||
<Contribute onDone={() => setView({ name: "home" })} />
|
||||
)}
|
||||
{view.name === "api" && <ApiDocs />}
|
||||
{view.name === "api" && <ApiDocs onRegister={() => setView({ name: "account" })} />}
|
||||
{view.name === "account" && <Account />}
|
||||
</main>
|
||||
|
||||
<footer className="border-t bg-white">
|
||||
|
||||
@@ -36,6 +36,23 @@ export interface Stats {
|
||||
min_score: number;
|
||||
}
|
||||
|
||||
export interface KeyResponse {
|
||||
email: string;
|
||||
api_key: string;
|
||||
key_prefix: string;
|
||||
rate_limit_per_min: number;
|
||||
quota_total: number;
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
email: string;
|
||||
key_prefix: string;
|
||||
rate_limit_per_min: number;
|
||||
quota_total: number;
|
||||
quota_used: number;
|
||||
quota_remaining: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
stats: () => req<Stats>(`/api/v1/stats`),
|
||||
search: (q: string, page = 1, size = 20, filters: SearchFilters = {}) => {
|
||||
@@ -55,4 +72,19 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
register: (email: string, password: string) =>
|
||||
req<KeyResponse>(`/api/v1/register`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
account: (email: string, password: string) =>
|
||||
req<AccountInfo>(`/api/v1/account`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
regenerate: (email: string, password: string) =>
|
||||
req<KeyResponse>(`/api/v1/account/regenerate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, KeyRound, AlertTriangle } from "lucide-react";
|
||||
import { api, type AccountInfo, type KeyResponse } from "../api";
|
||||
|
||||
function KeyReveal({ data }: { data: KeyResponse }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div className="flex items-start gap-2 text-amber-700 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>请立即复制保存此密钥,它只显示这一次,无法再次查看。</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<code className="flex-1 break-all bg-white border rounded-md px-3 py-2 text-sm font-mono text-gray-800">
|
||||
{data.api_key}
|
||||
</code>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.api_key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600 shrink-0"
|
||||
title="复制"
|
||||
>
|
||||
{copied ? <Check className="w-5 h-5 text-emerald-600" /> : <Copy className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
配额:每分钟 <strong>{data.rate_limit_per_min}</strong> 次 · 累计{" "}
|
||||
<strong>{data.quota_total.toLocaleString()}</strong> 次
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
调用时通过请求头携带:
|
||||
<code className="font-mono">X-API-Key: {data.api_key.slice(0, 12)}…</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
const [mode, setMode] = useState<"register" | "manage">("register");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [issued, setIssued] = useState<KeyResponse | null>(null);
|
||||
const [info, setInfo] = useState<AccountInfo | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setError(null);
|
||||
setIssued(null);
|
||||
setInfo(null);
|
||||
};
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
if (password.length < 8) {
|
||||
setError("密码至少需要 8 位");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "register") {
|
||||
setIssued(await api.register(email, password));
|
||||
} else {
|
||||
setInfo(await api.account(email, password));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
reset();
|
||||
setLoading(true);
|
||||
try {
|
||||
setIssued(await api.regenerate(email, password));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-5">
|
||||
<div className="bg-white border rounded-lg p-5">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-800">
|
||||
<KeyRound className="w-6 h-6 text-emerald-600" /> API 密钥
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 leading-relaxed">
|
||||
匿名调用免费,但每个来源 IP 累计共 <strong>1000</strong> 次。注册一个账号即可自助领取专属 API
|
||||
密钥,获得更高的每分钟频率与累计调用配额。
|
||||
</p>
|
||||
|
||||
<div className="mt-4 inline-flex rounded-md border bg-gray-50 p-0.5 text-sm">
|
||||
<button
|
||||
className={`px-4 py-1.5 rounded ${
|
||||
mode === "register" ? "bg-white shadow-sm text-emerald-700" : "text-gray-500"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
注册领取
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-1.5 rounded ${
|
||||
mode === "manage" ? "bg-white shadow-sm text-emerald-700" : "text-gray-500"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setMode("manage");
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
查看 / 重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-emerald-600 text-white rounded-md py-2 text-sm font-medium hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "处理中…" : mode === "register" ? "注册并领取密钥" : "查询账号"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{issued && <KeyReveal data={issued} />}
|
||||
|
||||
{info && (
|
||||
<div className="mt-4 rounded-lg border bg-gray-50 p-4 text-sm text-gray-700 space-y-1.5">
|
||||
<div>
|
||||
邮箱:<span className="font-medium">{info.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
密钥前缀:<code className="font-mono">{info.key_prefix}…</code>
|
||||
</div>
|
||||
<div>
|
||||
每分钟频率:<strong>{info.rate_limit_per_min}</strong> 次
|
||||
</div>
|
||||
<div>
|
||||
累计配额:已用 <strong>{info.quota_used.toLocaleString()}</strong> /{" "}
|
||||
{info.quota_total.toLocaleString()} 次(剩余{" "}
|
||||
<strong className="text-emerald-600">{info.quota_remaining.toLocaleString()}</strong>)
|
||||
</div>
|
||||
<button
|
||||
onClick={regenerate}
|
||||
disabled={loading}
|
||||
className="mt-2 text-emerald-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
忘记密钥?重置并生成新密钥
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function Endpoint({
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApiDocs() {
|
||||
export default function ApiDocs({ onRegister }: { onRegister?: () => void }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="bg-white border rounded-lg p-5">
|
||||
@@ -117,7 +117,11 @@ export default function ApiDocs() {
|
||||
基础地址:<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>
|
||||
无需 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)。
|
||||
@@ -132,10 +136,13 @@ export default function ApiDocs() {
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-800">鉴权与限流</h2>
|
||||
<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,请求时通过请求头携带:
|
||||
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>{`# 二选一
|
||||
@@ -143,7 +150,7 @@ 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>频率限制与<strong>累计总配额</strong>两层控制。每个响应都会回写以下响应头,便于客户端自适应:
|
||||
</p>
|
||||
<table className="mt-3 w-full text-sm">
|
||||
<thead className="text-gray-400 text-left">
|
||||
@@ -169,12 +176,26 @@ curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛
|
||||
<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>;无效或已吊销的 Key 返回
|
||||
<code className="font-mono">401</code>,错误码 <code className="font-mono">invalid_api_key</code>。
|
||||
每分钟超额返回 <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>
|
||||
|
||||
@@ -322,6 +343,70 @@ curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛
|
||||
}`}
|
||||
/>
|
||||
|
||||
<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)。
|
||||
|
||||
Reference in New Issue
Block a user