feat(api): tiered cumulative quota + self-service registration
CI / Go (api) (pull_request) Successful in 15s
CI / Python (ingestion) (pull_request) Successful in 10s
CI / Migrations (postgres) (pull_request) Successful in 16s

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:
sulaimaannaasif6866
2026-06-21 07:26:41 +00:00
parent 7f66aad779
commit 7434e5195e
22 changed files with 1157 additions and 43 deletions
+14 -3
View File
@@ -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">
+32
View File
@@ -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 }),
}),
};
+192
View File
@@ -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>
);
}
+94 -9
View File
@@ -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