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>
71 lines
2.2 KiB
Go
71 lines
2.2 KiB
Go
package adminhandler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/baicai2026-baicai/goods/api/internal/adminstore"
|
|
"github.com/baicai2026-baicai/goods/api/internal/auth"
|
|
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
|
|
)
|
|
|
|
// apiKeyView is an issued key plus its usage counters.
|
|
type apiKeyView struct {
|
|
adminstore.APIKeyRow
|
|
Usage ratelimit.UsageStat `json:"usage"`
|
|
}
|
|
|
|
// ListAPIKeys returns all issued keys with usage stats merged in.
|
|
func (h *Handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|
keys, err := h.store.ListAPIKeys(r.Context())
|
|
if h.handleErr(w, err) {
|
|
return
|
|
}
|
|
views := make([]apiKeyView, 0, len(keys))
|
|
for _, k := range keys {
|
|
v := apiKeyView{APIKeyRow: k}
|
|
if h.usage != nil {
|
|
v.Usage = h.usage.Usage(r.Context(), k.ID)
|
|
}
|
|
views = append(views, v)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": views})
|
|
}
|
|
|
|
// CreateAPIKey issues a new key and returns its plaintext exactly once.
|
|
func (h *Handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
var in adminstore.APIKeyInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(in.Name) == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "名称不能为空")
|
|
return
|
|
}
|
|
if in.Tier != "" && in.Tier != "free" && in.Tier != "registered" && in.Tier != "partner" && in.Tier != "internal" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "tier 取值无效")
|
|
return
|
|
}
|
|
plaintext, row, err := h.store.CreateAPIKey(r.Context(), in, auth.UserFrom(r.Context()))
|
|
if h.handleErr(w, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"key": plaintext,
|
|
"item": row,
|
|
"warning": "请立即复制保存此密钥,它只显示这一次,无法再次查看。",
|
|
})
|
|
}
|
|
|
|
// RevokeAPIKey disables a key. Subsequent requests with it are rejected.
|
|
func (h *Handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.store.RevokeAPIKey(r.Context(), chi.URLParam(r, "id")); h.handleErr(w, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
|
}
|