feat(M3): 只读 API 端点实现
- store: pgx 只读数据访问层(productByGTIN/ByID/search/nutriments/msrp/brands/categories/source) - handler: 真实查询替换 501 占位, 统一分页 + 错误信封, MSRP 带免责声明无购买入口 - main: pgxpool 连接池接线 - search: 名称模糊 + 分类子树过滤(ltree <@) - 测试: healthz/pageParams 单测 + DB-backed handler 集成测试(无库自动跳过) - CI: Go job 增加 postgres service + migrate up, 实跑 DB 测试 - 依赖: pgx v5.7.2 (固定到兼容 go1.23 的版本) - 本地实跑: 8 个端点对真实 OFF 数据返回正确(barcode/search/nutriments/msrp/brands/categories/source/404) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
+154
-15
@@ -1,5 +1,4 @@
|
||||
// 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.
|
||||
@@ -7,48 +6,188 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||
)
|
||||
|
||||
// APIVersion is the current public API version prefix.
|
||||
const APIVersion = "v1"
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
// Handler holds dependencies shared by the HTTP routes.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// New constructs a Handler backed by the given store.
|
||||
func New(s *store.Store) *Handler {
|
||||
return &Handler{store: s}
|
||||
}
|
||||
|
||||
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
||||
func Router() http.Handler {
|
||||
func (h *Handler) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Get("/healthz", Healthz)
|
||||
r.Get("/healthz", h.Healthz)
|
||||
|
||||
r.Route("/api/"+APIVersion, func(r chi.Router) {
|
||||
r.Route("/products", func(r chi.Router) {
|
||||
r.Get("/barcode/{gtin}", notImplemented)
|
||||
r.Get("/search", notImplemented)
|
||||
r.Get("/{id}", notImplemented)
|
||||
r.Get("/{id}/nutriments", notImplemented)
|
||||
r.Get("/{id}/msrp", notImplemented)
|
||||
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", notImplemented)
|
||||
r.Get("/categories", notImplemented)
|
||||
r.Get("/sources/{id}", notImplemented)
|
||||
r.Get("/brands", h.ListBrands)
|
||||
r.Get("/categories", h.ListCategories)
|
||||
r.Get("/sources/{id}", h.SourceByID)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Healthz reports liveness of the service.
|
||||
func Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// notImplemented is a placeholder for endpoints scoped to later milestones.
|
||||
func notImplemented(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, http.StatusNotImplemented, "not_implemented", "endpoint not implemented yet")
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user