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
+128
View File
@@ -0,0 +1,128 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
// emailRe is a deliberately permissive sanity check; real validation is the
// unique constraint plus the user being able to receive their own key.
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
const minPasswordLen = 8
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
}
func decodeCredentials(w http.ResponseWriter, r *http.Request) (credentials, bool) {
var c credentials
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&c); err != nil {
writeError(w, r, http.StatusBadRequest, "invalid_body", "请求格式无效")
return credentials{}, false
}
c.Email = strings.TrimSpace(c.Email)
if !emailRe.MatchString(c.Email) {
writeError(w, r, http.StatusBadRequest, "invalid_email", "邮箱格式无效")
return credentials{}, false
}
if len(c.Password) < minPasswordLen {
writeError(w, r, http.StatusBadRequest, "weak_password", "密码至少需要 8 位")
return credentials{}, false
}
return c, true
}
// keyResponse is returned whenever a fresh plaintext key is issued; the key is
// shown exactly once and cannot be recovered afterwards.
type keyResponse struct {
Email string `json:"email"`
APIKey string `json:"api_key"`
KeyPrefix string `json:"key_prefix"`
RateLimitPerMin int `json:"rate_limit_per_min"`
QuotaTotal int64 `json:"quota_total"`
}
// Register creates an account and issues its first API key. POST {email, password}.
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
c, ok := decodeCredentials(w, r)
if !ok {
return
}
key, acct, err := h.store.RegisterUser(r.Context(), c.Email, c.Password, h.regRatePerMin, h.regQuotaTotal)
if errors.Is(err, store.ErrEmailTaken) {
writeError(w, r, http.StatusConflict, "email_taken", "该邮箱已注册,请直接登录查看或重置密钥")
return
}
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusCreated, keyResponse{
Email: acct.Email,
APIKey: key,
KeyPrefix: acct.KeyPrefix,
RateLimitPerMin: acct.RateLimitPerMin,
QuotaTotal: acct.QuotaTotal,
})
}
// AccountInfo verifies credentials and returns the account's key metadata plus
// cumulative usage. POST {email, password}. The plaintext key is not returned.
func (h *Handler) AccountInfo(w http.ResponseWriter, r *http.Request) {
c, ok := decodeCredentials(w, r)
if !ok {
return
}
acct, err := h.store.Authenticate(r.Context(), c.Email, c.Password)
if errors.Is(err, store.ErrNotFound) {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
return
}
if h.handleErr(w, r, err) {
return
}
used := h.limiter.TotalUsed(r.Context(), acct.KeyID)
remaining := acct.QuotaTotal - used
if remaining < 0 {
remaining = 0
}
writeJSON(w, http.StatusOK, map[string]any{
"email": acct.Email,
"key_prefix": acct.KeyPrefix,
"rate_limit_per_min": acct.RateLimitPerMin,
"quota_total": acct.QuotaTotal,
"quota_used": used,
"quota_remaining": remaining,
})
}
// RegenerateKey revokes the account's current key and issues a new one, carrying
// over cumulative usage so the quota cannot be reset. POST {email, password}.
func (h *Handler) RegenerateKey(w http.ResponseWriter, r *http.Request) {
c, ok := decodeCredentials(w, r)
if !ok {
return
}
key, acct, oldKeyID, err := h.store.RegenerateKey(r.Context(), c.Email, c.Password, h.regRatePerMin, h.regQuotaTotal)
if errors.Is(err, store.ErrNotFound) {
writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "邮箱或密码错误")
return
}
if h.handleErr(w, r, err) {
return
}
h.limiter.CopyTotal(r.Context(), oldKeyID, acct.KeyID)
writeJSON(w, http.StatusOK, keyResponse{
Email: acct.Email,
APIKey: key,
KeyPrefix: acct.KeyPrefix,
RateLimitPerMin: acct.RateLimitPerMin,
QuotaTotal: acct.QuotaTotal,
})
}
+149
View File
@@ -0,0 +1,149 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
func cleanupCounter(t *testing.T, subject string) {
t.Helper()
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
if redisURL == "" {
redisURL = "redis://localhost:6379/0"
}
opt, err := redis.ParseURL(redisURL)
if err != nil {
return
}
rdb := redis.NewClient(opt)
defer rdb.Close()
rdb.Del(context.Background(), "usage:total:"+subject)
}
// newQuotaHandler builds a handler backed by the test DB and a live Redis
// limiter, with a small anonymous quota so exhaustion is cheap to exercise.
func newQuotaHandler(t *testing.T, anonQuota int) *Handler {
t.Helper()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("no database: %v", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skipf("database not reachable: %v", err)
}
var hasUser bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.app_user') IS NOT NULL").Scan(&hasUser); err != nil || !hasUser {
pool.Close()
t.Skip("migrations not applied")
}
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
if redisURL == "" {
redisURL = "redis://localhost:6379/0"
}
limiter := ratelimit.New(redisURL)
pingCtx, pingCancel := context.WithTimeout(context.Background(), time.Second)
defer pingCancel()
if err := limiter.Ping(pingCtx); err != nil {
pool.Close()
t.Skipf("redis not reachable: %v", err)
}
t.Cleanup(pool.Close)
return New(store.New(pool), nil).
WithRateLimit(limiter, 1000).
WithQuotas(anonQuota, 300, 100000)
}
func cleanupAccount(t *testing.T, email string) {
t.Helper()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return
}
defer pool.Close()
_, _ = pool.Exec(ctx, "DELETE FROM app_user WHERE lower(email)=lower($1)", email)
_, _ = pool.Exec(ctx, "DELETE FROM api_key WHERE owner_email=$1", email)
}
func TestAnonTotalQuotaExhausts(t *testing.T) {
h := newQuotaHandler(t, 3)
// Unique client IP so the lifetime counter starts fresh for this test; the
// counter never expires, so drop it afterwards to keep runs independent.
n := time.Now().UnixNano()
ip := fmt.Sprintf("203.%d.%d.%d", n/65536%256, n/256%256, n%256)
t.Cleanup(func() { cleanupCounter(t, "ip:"+ip) })
call := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/stats", nil)
req.RemoteAddr = ip + ":12345"
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
return rec
}
for i := 1; i <= 3; i++ {
if rec := call(); rec.Code != http.StatusOK {
t.Fatalf("call %d should be allowed, got %d (%s)", i, rec.Code, rec.Body.String())
}
}
rec := call()
if rec.Code != http.StatusForbidden {
t.Fatalf("4th call should be 403 quota_exhausted, got %d (%s)", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "quota_exhausted") {
t.Fatalf("expected quota_exhausted error, got %s", rec.Body.String())
}
}
func TestRegisterIssuesHigherQuotaKey(t *testing.T) {
h := newQuotaHandler(t, 1000)
email := fmt.Sprintf("h-user-%d@example.com", time.Now().UnixNano())
t.Cleanup(func() { cleanupAccount(t, email) })
body := fmt.Sprintf(`{"email":%q,"password":"supersecret"}`, email)
req := httptest.NewRequest(http.MethodPost, "/api/"+APIVersion+"/register", strings.NewReader(body))
req.RemoteAddr = "198.51.100.7:9999"
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("register status = %d (%s)", rec.Code, rec.Body.String())
}
var resp keyResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp.APIKey == "" || resp.QuotaTotal != 100000 || resp.RateLimitPerMin != 300 {
t.Fatalf("unexpected register response: %+v", resp)
}
// A second registration with the same email conflicts.
req2 := httptest.NewRequest(http.MethodPost, "/api/"+APIVersion+"/register", strings.NewReader(body))
req2.RemoteAddr = "198.51.100.7:9999"
rec2 := httptest.NewRecorder()
h.Router().ServeHTTP(rec2, req2)
if rec2.Code != http.StatusConflict {
t.Fatalf("duplicate register status = %d (%s)", rec2.Code, rec2.Body.String())
}
}
+54 -5
View File
@@ -37,20 +37,43 @@ const (
// defaultAnonLimit is the per-minute request budget for unauthenticated
// callers (identified by client IP) when none is configured.
defaultAnonLimit = 60
// defaultAnonTotalQuota is the lifetime number of calls an anonymous caller
// (by IP) may make before being asked to register for a higher quota.
defaultAnonTotalQuota = 1000
// defaultRegRatePerMin / defaultRegQuotaTotal are the per-minute budget and
// cumulative quota granted to a self-registered API key.
defaultRegRatePerMin = 300
defaultRegQuotaTotal = 100000
// registerRatePerMin caps account registration/login attempts per IP to
// curb abuse; these endpoints sit outside the metered quota group.
registerRatePerMin = 10
)
// Handler holds dependencies shared by the HTTP routes.
type Handler struct {
store *store.Store
spa fs.FS
limiter *ratelimit.Limiter
anonLimit int
store *store.Store
spa fs.FS
limiter *ratelimit.Limiter
anonLimit int
anonTotalQuota int64
regRatePerMin int
regQuotaTotal int64
}
// New constructs a Handler backed by the given store. spa may be nil (JSON-only).
// Rate limiting is disabled until WithRateLimit is called.
func New(s *store.Store, spa fs.FS) *Handler {
return &Handler{store: s, spa: spa, anonLimit: defaultAnonLimit}
return &Handler{
store: s,
spa: spa,
anonLimit: defaultAnonLimit,
anonTotalQuota: defaultAnonTotalQuota,
regRatePerMin: defaultRegRatePerMin,
regQuotaTotal: defaultRegQuotaTotal,
}
}
// WithRateLimit attaches a Redis-backed limiter and the anonymous per-minute
@@ -64,6 +87,22 @@ func (h *Handler) WithRateLimit(l *ratelimit.Limiter, anonPerMin int) *Handler {
return h
}
// WithQuotas configures the cumulative free quota for anonymous callers and the
// per-minute rate + cumulative quota self-registered keys receive. Non-positive
// values keep the defaults.
func (h *Handler) WithQuotas(anonTotal, regPerMin, regTotal int) *Handler {
if anonTotal > 0 {
h.anonTotalQuota = int64(anonTotal)
}
if regPerMin > 0 {
h.regRatePerMin = regPerMin
}
if regTotal > 0 {
h.regQuotaTotal = int64(regTotal)
}
return h
}
// Router builds the top-level HTTP handler with middleware and routes mounted.
func (h *Handler) Router() http.Handler {
r := chi.NewRouter()
@@ -91,6 +130,16 @@ func (h *Handler) Router() http.Handler {
r.Get("/sources/{id}", h.SourceByID)
r.Get("/stats", h.Stats)
})
// Self-service account routes. Lightly IP-throttled to curb abuse but
// outside the metered quota group so a user can always register or
// check their key even after exhausting the free anonymous quota.
r.Group(func(r chi.Router) {
r.Use(h.registerLimit)
r.Post("/register", h.Register)
r.Post("/account", h.AccountInfo)
r.Post("/account/regenerate", h.RegenerateKey)
})
})
// Public SPA (homepage + search + contribute). API routes above take
+53 -1
View File
@@ -24,8 +24,11 @@ const apiKeyIDKey ctxKey = 0
// are set on every response; over-budget callers get 429 + Retry-After.
func (h *Handler) rateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "ip:" + clientIP(r)
ip := clientIP(r)
id := "ip:" + ip
subject := "ip:" + ip // cumulative-quota counter subject
limit := h.anonLimit
quota := h.anonTotalQuota
keyID := ""
if raw := presentedKey(r); raw != "" {
@@ -45,6 +48,8 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
keyID = k.ID
limit = k.RateLimitPerMin
id = "key:" + k.ID
subject = k.ID
quota = k.QuotaTotal
}
res := h.limiter.Allow(r.Context(), id, limit, time.Minute)
@@ -61,8 +66,36 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
return
}
// Attribute one call to the caller's lifetime counter, then enforce the
// cumulative quota (quota <= 0 means unlimited). Keys also get daily and
// last-used stats recorded for the admin console.
var used int64
if keyID != "" {
h.limiter.RecordUsage(r.Context(), keyID)
used = h.limiter.TotalUsed(r.Context(), keyID)
} else {
used = h.limiter.IncrTotal(r.Context(), subject)
}
if quota > 0 {
remaining := quota - used
if remaining < 0 {
remaining = 0
}
w.Header().Set("X-Quota-Limit", strconv.FormatInt(quota, 10))
w.Header().Set("X-Quota-Used", strconv.FormatInt(used, 10))
w.Header().Set("X-Quota-Remaining", strconv.FormatInt(remaining, 10))
if used > quota {
if keyID == "" {
writeError(w, r, http.StatusForbidden, "quota_exhausted",
"免费额度(共 "+strconv.FormatInt(quota, 10)+" 次)已用尽,请注册账号获取更高配额的 API 密钥")
} else {
writeError(w, r, http.StatusForbidden, "quota_exhausted", "API 密钥配额已用尽")
}
return
}
}
if keyID != "" {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), apiKeyIDKey, keyID)))
return
}
@@ -70,6 +103,25 @@ func (h *Handler) rateLimit(next http.Handler) http.Handler {
})
}
// registerLimit throttles self-service account endpoints per client IP without
// consuming the metered free quota, so a caller can still register or recover
// their key after exhausting the anonymous quota.
func (h *Handler) registerLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
res := h.limiter.Allow(r.Context(), "register:"+clientIP(r), h.regRatePerMin, time.Minute)
if !res.Allowed {
retry := res.ResetUnix - time.Now().Unix()
if retry < 1 {
retry = 1
}
w.Header().Set("Retry-After", strconv.FormatInt(retry, 10))
writeError(w, r, http.StatusTooManyRequests, "rate_limited", "操作过于频繁,请稍后再试")
return
}
next.ServeHTTP(w, r)
})
}
// presentedKey extracts an API key from the X-API-Key header or a Bearer token.
func presentedKey(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" {
+31 -1
View File
@@ -10,7 +10,8 @@
"tags": [
{ "name": "products" },
{ "name": "catalog" },
{ "name": "meta" }
{ "name": "meta" },
{ "name": "account" }
],
"security": [{ "ApiKeyHeader": [] }, { "BearerKey": [] }, {}],
"paths": {
@@ -114,6 +115,35 @@
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
"responses": { "200": { "description": "Source" }, "404": { "$ref": "#/components/responses/NotFound" } }
}
},
"/register": {
"post": {
"tags": ["account"],
"summary": "Register an account and issue an API key",
"description": "Self-service registration; returns the plaintext API key exactly once.",
"security": [],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string", "minLength": 8 } } } } } },
"responses": { "201": { "description": "Account created; plaintext key returned once" }, "400": { "description": "Invalid email or weak password" }, "409": { "description": "Email already registered" } }
}
},
"/account": {
"post": {
"tags": ["account"],
"summary": "View account key metadata and cumulative quota usage",
"security": [],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } } } } } },
"responses": { "200": { "description": "Account info with quota usage" }, "401": { "description": "Invalid credentials" } }
}
},
"/account/regenerate": {
"post": {
"tags": ["account"],
"summary": "Revoke the current key and issue a new one",
"description": "Cumulative usage carries over; returns the plaintext key exactly once.",
"security": [],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email", "password"], "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" } } } } } },
"responses": { "200": { "description": "New plaintext key returned once" }, "401": { "description": "Invalid credentials" } }
}
}
},
"components": {