2820823b36
Add an optional API-key layer to the public read-only API. Keys grant higher per-minute rate limits and attribute usage; anonymous callers are still allowed at a lower IP-based budget. - migration 0008_api_key: api_key table (sha256 hash only, plaintext shown once) - apikey pkg: key generation + hashing - ratelimit pkg: Redis fixed-window limiter + per-key usage counters; fails open - public API middleware: X-API-Key / Bearer auth, X-RateLimit-* headers, 429+Retry-After - admin: issue/list/revoke keys + usage view (API + UI tab) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
257 lines
7.2 KiB
Go
257 lines
7.2 KiB
Go
// Package handler wires up the public, read-only OpenGoods HTTP API.
|
|
// The OpenGoods service is a public-good product information API: it only
|
|
// collects and serves product facts. It exposes no purchase, checkout, or
|
|
// commerce endpoints by design.
|
|
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
|
|
"github.com/baicai2026-baicai/goods/api/internal/store"
|
|
)
|
|
|
|
// APIVersion is the current public API version prefix.
|
|
const APIVersion = "v1"
|
|
|
|
const (
|
|
defaultPageSize = 20
|
|
maxPageSize = 100
|
|
|
|
// defaultAnonLimit is the per-minute request budget for unauthenticated
|
|
// callers (identified by client IP) when none is configured.
|
|
defaultAnonLimit = 60
|
|
)
|
|
|
|
// Handler holds dependencies shared by the HTTP routes.
|
|
type Handler struct {
|
|
store *store.Store
|
|
spa fs.FS
|
|
limiter *ratelimit.Limiter
|
|
anonLimit int
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
// WithRateLimit attaches a Redis-backed limiter and the anonymous per-minute
|
|
// budget, enabling rate limiting + usage tracking on the public API routes.
|
|
// A non-positive anonPerMin keeps the default.
|
|
func (h *Handler) WithRateLimit(l *ratelimit.Limiter, anonPerMin int) *Handler {
|
|
h.limiter = l
|
|
if anonPerMin > 0 {
|
|
h.anonLimit = anonPerMin
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
|
func (h *Handler) Router() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.RealIP)
|
|
r.Use(middleware.Recoverer)
|
|
|
|
r.Get("/healthz", h.Healthz)
|
|
|
|
r.Route("/api/"+APIVersion, func(r chi.Router) {
|
|
r.Use(h.rateLimit)
|
|
r.Route("/products", func(r chi.Router) {
|
|
r.Get("/barcode/{gtin}", h.ProductByBarcode)
|
|
r.Get("/search", h.SearchProducts)
|
|
r.Get("/{id}", h.ProductByID)
|
|
r.Get("/{id}/nutriments", h.ProductNutriments)
|
|
r.Get("/{id}/msrp", h.ProductMSRP)
|
|
})
|
|
r.Get("/brands", h.ListBrands)
|
|
r.Get("/categories", h.ListCategories)
|
|
r.Get("/sources/{id}", h.SourceByID)
|
|
})
|
|
|
|
// Public SPA (homepage + search + contribute). API routes above take
|
|
// precedence; everything else falls back to the embedded single-page app.
|
|
if h.spa != nil {
|
|
r.Handle("/*", http.HandlerFunc(h.serveSPA))
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func (h *Handler) serveSPA(w http.ResponseWriter, r *http.Request) {
|
|
rel := strings.TrimPrefix(r.URL.Path, "/")
|
|
if rel == "" {
|
|
rel = "index.html"
|
|
}
|
|
if f, err := h.spa.Open(rel); err == nil {
|
|
f.Close()
|
|
http.FileServer(http.FS(h.spa)).ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// SPA fallback: serve index.html for client-side routes.
|
|
data, err := fs.ReadFile(h.spa, "index.html")
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
// Healthz reports liveness of the service.
|
|
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// ProductByBarcode returns a product by its GTIN.
|
|
func (h *Handler) ProductByBarcode(w http.ResponseWriter, r *http.Request) {
|
|
p, err := h.store.ProductByGTIN(r.Context(), chi.URLParam(r, "gtin"))
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
// ProductByID returns a product by its UUID.
|
|
func (h *Handler) ProductByID(w http.ResponseWriter, r *http.Request) {
|
|
p, err := h.store.ProductByID(r.Context(), chi.URLParam(r, "id"))
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
// SearchProducts runs a fuzzy name search with optional category filter + paging.
|
|
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query().Get("q")
|
|
category := r.URL.Query().Get("category")
|
|
page, size := pageParams(r)
|
|
|
|
items, total, err := h.store.SearchProducts(r.Context(), q, category, size, (page-1)*size)
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"page": page,
|
|
"size": size,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
// ProductNutriments returns just the nutrition facts of a product.
|
|
func (h *Handler) ProductNutriments(w http.ResponseWriter, r *http.Request) {
|
|
n, err := h.store.Nutriments(r.Context(), chi.URLParam(r, "id"))
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, n)
|
|
}
|
|
|
|
// ProductMSRP returns official suggested retail price snapshots (no purchase link).
|
|
func (h *Handler) ProductMSRP(w http.ResponseWriter, r *http.Request) {
|
|
items, err := h.store.ListMSRP(r.Context(), chi.URLParam(r, "id"))
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items,
|
|
"disclaimer": "厂商建议零售价历史快照,仅供参考,不构成购买建议,本服务不提供任何购买入口。",
|
|
})
|
|
}
|
|
|
|
// ListBrands returns a paginated list of brands.
|
|
func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) {
|
|
page, size := pageParams(r)
|
|
items, total, err := h.store.ListBrands(r.Context(), size, (page-1)*size)
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": items, "page": page, "size": size, "total": total,
|
|
})
|
|
}
|
|
|
|
// ListCategories returns the full category tree.
|
|
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
|
items, err := h.store.ListCategories(r.Context())
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
// SourceByID returns a single data source.
|
|
func (h *Handler) SourceByID(w http.ResponseWriter, r *http.Request) {
|
|
src, err := h.store.SourceByID(r.Context(), chi.URLParam(r, "id"))
|
|
if h.handleErr(w, r, err) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, src)
|
|
}
|
|
|
|
// handleErr writes an appropriate error response; returns true if it handled one.
|
|
func (h *Handler) handleErr(w http.ResponseWriter, r *http.Request, err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeError(w, r, http.StatusNotFound, "not_found", "resource not found")
|
|
return true
|
|
}
|
|
writeError(w, r, http.StatusInternalServerError, "internal_error", "internal server error")
|
|
return true
|
|
}
|
|
|
|
func pageParams(r *http.Request) (page, size int) {
|
|
page = atoiDefault(r.URL.Query().Get("page"), 1)
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
size = atoiDefault(r.URL.Query().Get("size"), defaultPageSize)
|
|
if size < 1 {
|
|
size = defaultPageSize
|
|
}
|
|
if size > maxPageSize {
|
|
size = maxPageSize
|
|
}
|
|
return page, size
|
|
}
|
|
|
|
func atoiDefault(s string, fallback int) int {
|
|
if s == "" {
|
|
return fallback
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) {
|
|
writeJSON(w, status, map[string]any{
|
|
"error": map[string]string{
|
|
"code": code,
|
|
"message": message,
|
|
"request_id": middleware.GetReqID(r.Context()),
|
|
},
|
|
})
|
|
}
|