From 7434e5195e1a575c35ccd5ee1772b1915a030956 Mon Sep 17 00:00:00 2001 From: sulaimaannaasif6866 Date: Sun, 21 Jun 2026 07:26:41 +0000 Subject: [PATCH] feat(api): tiered cumulative quota + self-service registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- admin-frontend/src/api.ts | 1 + admin-frontend/src/components/ApiKeysPage.tsx | 36 +++- admin-frontend/src/types.ts | 1 + api/cmd/server/main.go | 3 +- api/internal/adminhandler/apikey.go | 2 +- api/internal/adminstore/apikey.go | 20 +- api/internal/config/config.go | 22 +- api/internal/handler/account.go | 128 ++++++++++++ api/internal/handler/account_db_test.go | 149 ++++++++++++++ api/internal/handler/handler.go | 59 +++++- api/internal/handler/middleware.go | 54 ++++- api/internal/handler/openapi.json | 32 ++- api/internal/ratelimit/ratelimit.go | 39 ++++ api/internal/store/account.go | 169 +++++++++++++++ api/internal/store/account_test.go | 99 +++++++++ api/internal/store/store.go | 5 +- migrations/0011_api_quota.down.sql | 10 + migrations/0011_api_quota.up.sql | 27 +++ public-frontend/src/App.tsx | 17 +- public-frontend/src/api.ts | 32 +++ public-frontend/src/components/Account.tsx | 192 ++++++++++++++++++ public-frontend/src/components/ApiDocs.tsx | 103 +++++++++- 22 files changed, 1157 insertions(+), 43 deletions(-) create mode 100644 api/internal/handler/account.go create mode 100644 api/internal/handler/account_db_test.go create mode 100644 api/internal/store/account.go create mode 100644 api/internal/store/account_test.go create mode 100644 migrations/0011_api_quota.down.sql create mode 100644 migrations/0011_api_quota.up.sql create mode 100644 public-frontend/src/components/Account.tsx diff --git a/admin-frontend/src/api.ts b/admin-frontend/src/api.ts index 83be9ec..16dbb38 100644 --- a/admin-frontend/src/api.ts +++ b/admin-frontend/src/api.ts @@ -168,6 +168,7 @@ export const api = { owner_email?: string; tier?: string; rate_limit_per_min?: number; + quota_total?: number; }) => request<{ key: string; item: import("./types").ApiKey; warning: string }>( "/keys", diff --git a/admin-frontend/src/components/ApiKeysPage.tsx b/admin-frontend/src/components/ApiKeysPage.tsx index 6eeb021..4b02ff4 100644 --- a/admin-frontend/src/components/ApiKeysPage.tsx +++ b/admin-frontend/src/components/ApiKeysPage.tsx @@ -4,9 +4,10 @@ import type { ApiKey } from "../types"; import { Copy, KeyRound, Plus, Trash2 } from "lucide-react"; const TIERS = [ - { key: "free", label: "免费 (free)", rate: 120 }, - { key: "partner", label: "合作方 (partner)", rate: 600 }, - { key: "internal", label: "内部 (internal)", rate: 6000 }, + { key: "free", label: "免费 (free)", rate: 120, quota: 1000 }, + { key: "registered", label: "注册用户 (registered)", rate: 300, quota: 100000 }, + { key: "partner", label: "合作方 (partner)", rate: 600, quota: 0 }, + { key: "internal", label: "内部 (internal)", rate: 6000, quota: 0 }, ]; function tierLabel(tier: string): string { @@ -111,6 +112,7 @@ export default function ApiKeysPage() { 级别 速率/分钟 用量(今日/累计) + 累计配额 状态 @@ -118,7 +120,7 @@ export default function ApiKeysPage() { {rows.length === 0 ? ( - + 暂无密钥 @@ -139,6 +141,15 @@ export default function ApiKeysPage() { {k.usage.today} / {k.usage.total} + + {k.quota_total > 0 ? ( + = k.quota_total ? "text-red-600" : ""}> + {k.usage.total.toLocaleString()} / {k.quota_total.toLocaleString()} + + ) : ( + 不限 + )} + {k.revoked_at ? ( @@ -182,13 +193,17 @@ function CreateKeyForm({ const [ownerEmail, setOwnerEmail] = useState(""); const [tier, setTier] = useState("free"); const [rate, setRate] = useState(120); + const [quota, setQuota] = useState(1000); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); function pickTier(t: string) { setTier(t); const def = TIERS.find((x) => x.key === t); - if (def) setRate(def.rate); + if (def) { + setRate(def.rate); + setQuota(def.quota); + } } async function submit() { @@ -204,6 +219,7 @@ function CreateKeyForm({ owner_email: ownerEmail.trim() || undefined, tier, rate_limit_per_min: rate, + quota_total: quota, }); onCreated(res.key); } catch (e) { @@ -260,6 +276,16 @@ function CreateKeyForm({ onChange={(e) => setRate(Math.max(1, parseInt(e.target.value || "1", 10)))} /> +
+
@@ -77,7 +87,8 @@ export default function App() { {view.name === "contribute" && ( setView({ name: "home" })} /> )} - {view.name === "api" && } + {view.name === "api" && setView({ name: "account" })} />} + {view.name === "account" && }