7434e5195e
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>
129 lines
4.0 KiB
Go
129 lines
4.0 KiB
Go
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,
|
|
})
|
|
}
|