Files
goods/public-frontend/src/api.ts
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

91 lines
2.4 KiB
TypeScript

import type { Category, Product, ProductSummary, SubmissionInput } from "./types";
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
});
if (!res.ok) {
let msg = `请求失败 (${res.status})`;
try {
const body = await res.json();
if (body?.error?.message) msg = body.error.message;
} catch {
/* ignore */
}
throw new Error(msg);
}
return res.json() as Promise<T>;
}
export interface SearchResult {
items: ProductSummary[];
page: number;
size: number;
total: number;
}
export interface SearchFilters {
brand?: string;
country?: string;
}
export interface Stats {
total: number;
qualified: number;
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 = {}) => {
const params = new URLSearchParams({
q,
page: String(page),
size: String(size),
});
if (filters.brand) params.set("brand", filters.brand);
if (filters.country) params.set("country", filters.country);
return req<SearchResult>(`/api/v1/products/search?${params.toString()}`);
},
product: (id: string) => req<Product>(`/api/v1/products/${id}`),
categories: () => req<{ items: Category[] }>(`/api/v1/categories`),
submit: (input: SubmissionInput) =>
req<{ id: string; status: string }>(`/api/public/submissions`, {
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 }),
}),
};