// 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 ( _ "embed" "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" ) //go:embed openapi.json var openAPISpec []byte // APIVersion is the current public API version prefix. const APIVersion = "v1" // QualifiedMinScore is the quality_score threshold at or above which a product // record is considered "qualified" (合格) for public stats. const QualifiedMinScore = 0.6 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 // 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 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, anonTotalQuota: defaultAnonTotalQuota, regRatePerMin: defaultRegRatePerMin, regQuotaTotal: defaultRegQuotaTotal, } } // 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 } // 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() 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) { // Machine-readable spec; not rate limited so tooling can always fetch it. r.Get("/openapi.json", h.OpenAPI) r.Group(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) 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 // 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"}) } // Stats returns catalog totals and the count of qualified records. func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) { st, err := h.store.Stats(r.Context(), QualifiedMinScore) if h.handleErr(w, r, err) { return } writeJSON(w, http.StatusOK, st) } // OpenAPI serves the embedded OpenAPI 3 specification for the public API. func (h *Handler) OpenAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write(openAPISpec) } // 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 trigram-fuzzy name search with optional // category/brand/country filters, ranked by relevance, plus paging. func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) { qv := r.URL.Query() filters := store.SearchFilters{ Query: strings.TrimSpace(qv.Get("q")), Category: strings.TrimSpace(qv.Get("category")), Brand: strings.TrimSpace(qv.Get("brand")), Country: strings.TrimSpace(qv.Get("country")), } page, size := pageParams(r) items, total, err := h.store.SearchProducts(r.Context(), filters, 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()), }, }) }