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:
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<th className="px-4 py-2 font-medium">级别</th>
|
||||
<th className="px-4 py-2 font-medium">速率/分钟</th>
|
||||
<th className="px-4 py-2 font-medium">用量(今日/累计)</th>
|
||||
<th className="px-4 py-2 font-medium">累计配额</th>
|
||||
<th className="px-4 py-2 font-medium">状态</th>
|
||||
<th className="px-4 py-2 font-medium"></th>
|
||||
</tr>
|
||||
@@ -118,7 +120,7 @@ export default function ApiKeysPage() {
|
||||
<tbody className="divide-y">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无密钥
|
||||
</td>
|
||||
</tr>
|
||||
@@ -139,6 +141,15 @@ export default function ApiKeysPage() {
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{k.usage.today} / {k.usage.total}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{k.quota_total > 0 ? (
|
||||
<span className={k.usage.total >= k.quota_total ? "text-red-600" : ""}>
|
||||
{k.usage.total.toLocaleString()} / {k.quota_total.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">不限</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{k.revoked_at ? (
|
||||
<span className="text-xs rounded px-2 py-0.5 bg-red-50 text-red-700">
|
||||
@@ -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)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-gray-500">累计配额(总调用次数,0=不限)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-full border rounded-md px-3 py-2 text-sm mt-1"
|
||||
value={quota}
|
||||
onChange={(e) => setQuota(Math.max(0, parseInt(e.target.value || "0", 10)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -199,6 +199,7 @@ export interface ApiKey {
|
||||
owner_email: string | null;
|
||||
tier: string;
|
||||
rate_limit_per_min: number;
|
||||
quota_total: number;
|
||||
revoked_at: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user