1d5f775d33
- 迁移 0007: 新增 product_barcode 表(一品多码),回填旧 product.gtin 为主码, 全局唯一索引保证「一码一品」,每品至多一个主码 - internal/gtin: GS1 GTIN-8/12/13/14 校验(校验位 + 拒收店内码/变量重量码/优惠券码) - 公开只读 API: 任一条码命中商品、详情返回 barcodes、搜索匹配条码 - adminstore: 商品详情含 barcodes;新增 AddBarcode/DeleteBarcode/SetPrimaryBarcode, 一码命中其他商品返回 ConflictError 供后台去重 待办(按用户要求暂停): 后台 handler 路由、投稿/审核多条码、前后端 UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
331 lines
10 KiB
Go
331 lines
10 KiB
Go
// 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)
|
|
}
|
|
|
|
// Barcode is one GS1 trade item number attached to a product.
|
|
type Barcode struct {
|
|
GTIN string `json:"gtin"`
|
|
GTINType string `json:"gtin_type"`
|
|
PackLevel string `json:"pack_level"`
|
|
Region *string `json:"region"`
|
|
IsPrimary bool `json:"is_primary"`
|
|
}
|
|
|
|
// 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"`
|
|
Barcodes []Barcode `json:"barcodes"`
|
|
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"`
|
|
}
|
|
|
|
// ProductBarcodes returns every barcode attached to a product, primary first.
|
|
func (s *Store) ProductBarcodes(ctx context.Context, productID string) ([]Barcode, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT gtin, gtin_type, pack_level, region, is_primary
|
|
FROM product_barcode WHERE product_id = $1
|
|
ORDER BY is_primary DESC, gtin`, productID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Barcode{}
|
|
for rows.Next() {
|
|
var b Barcode
|
|
if err := rows.Scan(&b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// 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 any of its barcodes.
|
|
func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) {
|
|
row := s.pool.QueryRow(ctx, productSelect+`
|
|
WHERE p.status = 'active'
|
|
AND (p.gtin = $1 OR EXISTS (
|
|
SELECT 1 FROM product_barcode pb
|
|
WHERE pb.product_id = p.id AND pb.gtin = $1))
|
|
LIMIT 1`, gtin)
|
|
p, err := scanProduct(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// 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)
|
|
p, err := scanProduct(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// 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 || '%'
|
|
OR EXISTS (SELECT 1 FROM product_barcode pb
|
|
WHERE pb.product_id = p.id AND pb.gtin 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
|
|
}
|