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:
@@ -0,0 +1,278 @@
|
||||
// Package store is the read-only data access layer for the OpenGoods API.
|
||||
// It only issues SELECT queries; all writes happen in the Python ingestion path.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a requested row does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Store wraps a PostgreSQL connection pool.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New constructs a Store from an existing pgx pool.
|
||||
func New(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// Ping verifies database connectivity.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// Product is the full public view of a product.
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
GTIN *string `json:"gtin"`
|
||||
Name string `json:"name"`
|
||||
Brand *string `json:"brand"`
|
||||
CategoryPath *string `json:"category_path"`
|
||||
GPCBrickCode *string `json:"gpc_brick_code"`
|
||||
NetContentValue *float64 `json:"net_content_value"`
|
||||
NetContentUnit *string `json:"net_content_unit"`
|
||||
CountryOfOrigin *string `json:"country_of_origin"`
|
||||
QualityScore float64 `json:"quality_score"`
|
||||
Nutriments map[string]any `json:"nutriments,omitempty"`
|
||||
NutritionBasis *string `json:"nutrition_basis,omitempty"`
|
||||
NutriScore *string `json:"nutri_score,omitempty"`
|
||||
Ingredients *string `json:"ingredients_text,omitempty"`
|
||||
Allergens []string `json:"allergens,omitempty"`
|
||||
Additives []string `json:"additives,omitempty"`
|
||||
}
|
||||
|
||||
// ProductSummary is a lightweight row used in search/listing responses.
|
||||
type ProductSummary struct {
|
||||
ID string `json:"id"`
|
||||
GTIN *string `json:"gtin"`
|
||||
Name string `json:"name"`
|
||||
Brand *string `json:"brand"`
|
||||
CategoryPath *string `json:"category_path"`
|
||||
}
|
||||
|
||||
const productSelect = `
|
||||
SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.gpc_brick_code,
|
||||
p.net_content_value, p.net_content_unit, p.country_of_origin, p.quality_score,
|
||||
f.nutriments, f.nutrition_basis, f.nutri_score, f.ingredients_text,
|
||||
f.allergens, f.additives
|
||||
FROM product p
|
||||
LEFT JOIN brand b ON b.id = p.brand_id
|
||||
LEFT JOIN category c ON c.id = p.category_id
|
||||
LEFT JOIN food_detail f ON f.product_id = p.id
|
||||
`
|
||||
|
||||
func scanProduct(row pgx.Row) (*Product, error) {
|
||||
var p Product
|
||||
err := row.Scan(
|
||||
&p.ID, &p.GTIN, &p.Name, &p.Brand, &p.CategoryPath, &p.GPCBrickCode,
|
||||
&p.NetContentValue, &p.NetContentUnit, &p.CountryOfOrigin, &p.QualityScore,
|
||||
&p.Nutriments, &p.NutritionBasis, &p.NutriScore, &p.Ingredients,
|
||||
&p.Allergens, &p.Additives,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ProductByGTIN looks up an active product by its barcode.
|
||||
func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) {
|
||||
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.gtin = $1 AND p.status = 'active'", gtin)
|
||||
return scanProduct(row)
|
||||
}
|
||||
|
||||
// ProductByID looks up a product by its UUID.
|
||||
func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
|
||||
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.id = $1", id)
|
||||
return scanProduct(row)
|
||||
}
|
||||
|
||||
// SearchProducts performs a fuzzy name search with optional category subtree filter.
|
||||
func (s *Store) SearchProducts(ctx context.Context, q, category string, limit, offset int) ([]ProductSummary, int, error) {
|
||||
args := []any{}
|
||||
where := "WHERE p.status = 'active'"
|
||||
if q != "" {
|
||||
args = append(args, q)
|
||||
where += " AND p.name ILIKE '%' || $1 || '%'"
|
||||
}
|
||||
if category != "" {
|
||||
args = append(args, category)
|
||||
where += " AND c.path <@ $" + strconv.Itoa(len(args)) + "::ltree"
|
||||
}
|
||||
|
||||
countSQL := "SELECT count(*) FROM product p LEFT JOIN category c ON c.id = p.category_id " + where
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
args = append(args, limit, offset)
|
||||
listSQL := `
|
||||
SELECT p.id, p.gtin, p.name, b.name, c.path::text
|
||||
FROM product p
|
||||
LEFT JOIN brand b ON b.id = p.brand_id
|
||||
LEFT JOIN category c ON c.id = p.category_id ` + where +
|
||||
" ORDER BY p.name LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
|
||||
|
||||
rows, err := s.pool.Query(ctx, listSQL, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ProductSummary{}
|
||||
for rows.Next() {
|
||||
var ps ProductSummary
|
||||
if err := rows.Scan(&ps.ID, &ps.GTIN, &ps.Name, &ps.Brand, &ps.CategoryPath); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, ps)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// Nutriments returns just the nutrition payload for a product.
|
||||
type Nutriments struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Basis *string `json:"basis"`
|
||||
NutriScore *string `json:"nutri_score"`
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// Nutriments fetches the nutrition facts of a product.
|
||||
func (s *Store) Nutriments(ctx context.Context, id string) (*Nutriments, error) {
|
||||
var n Nutriments
|
||||
n.ProductID = id
|
||||
err := s.pool.QueryRow(ctx,
|
||||
"SELECT nutriments, nutrition_basis, nutri_score FROM food_detail WHERE product_id = $1", id,
|
||||
).Scan(&n.Values, &n.Basis, &n.NutriScore)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// MSRP is an official suggested retail price snapshot (never a purchase link).
|
||||
type MSRP struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Region string `json:"region"`
|
||||
EffectiveDate *string `json:"effective_date"`
|
||||
SourceURL *string `json:"source_url"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
// ListMSRP returns all MSRP snapshots for a product.
|
||||
func (s *Store) ListMSRP(ctx context.Context, id string) ([]MSRP, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT amount, currency, region, effective_date::text, source_url, note
|
||||
FROM product_msrp WHERE product_id = $1 ORDER BY effective_date DESC NULLS LAST`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []MSRP{}
|
||||
for rows.Next() {
|
||||
var m MSRP
|
||||
if err := rows.Scan(&m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Brand is a public brand entry.
|
||||
type Brand struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ListBrands returns brands ordered by name.
|
||||
func (s *Store) ListBrands(ctx context.Context, limit, offset int) ([]Brand, int, error) {
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM brand").Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY name LIMIT $1 OFFSET $2", limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Brand{}
|
||||
for rows.Next() {
|
||||
var b Brand
|
||||
if err := rows.Scan(&b.ID, &b.Name); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// Category is a node in the self-built category tree.
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
NameZH string `json:"name_zh"`
|
||||
NameEN *string `json:"name_en"`
|
||||
Path string `json:"path"`
|
||||
GPCBrickCode *string `json:"gpc_brick_code"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// ListCategories returns the full category tree ordered by path.
|
||||
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
"SELECT id, name_zh, name_en, path::text, gpc_brick_code, level FROM category ORDER BY path")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Category{}
|
||||
for rows.Next() {
|
||||
var c Category
|
||||
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.GPCBrickCode, &c.Level); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Source describes a data source with its license and trust weight.
|
||||
type Source struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Homepage *string `json:"homepage"`
|
||||
License *string `json:"license"`
|
||||
TrustWeight float64 `json:"trust_weight"`
|
||||
}
|
||||
|
||||
// SourceByID fetches a single data source.
|
||||
func (s *Store) SourceByID(ctx context.Context, id string) (*Source, error) {
|
||||
var src Source
|
||||
err := s.pool.QueryRow(ctx,
|
||||
"SELECT id, name, homepage, license, trust_weight FROM source WHERE id = $1", id,
|
||||
).Scan(&src.ID, &src.Name, &src.Homepage, &src.License, &src.TrustWeight)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &src, nil
|
||||
}
|
||||
Reference in New Issue
Block a user