From 1d5f775d33d34bdf13c45f9e5e76ddf106c9a107 Mon Sep 17 00:00:00 2001 From: oyaegeli98668 Date: Sat, 20 Jun 2026 05:54:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(barcode):=20=E5=A4=9A=E6=9D=A1=E7=A0=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=88=E5=90=8E=E7=AB=AF+=E8=BF=81?= =?UTF-8?q?=E7=A7=BB+GTIN=E6=A0=A1=E9=AA=8C=EF=BC=89WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 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> --- api/internal/adminstore/adminstore.go | 7 + api/internal/adminstore/barcode.go | 270 +++++++++++++++++++++++ api/internal/gtin/gtin.go | 110 +++++++++ api/internal/gtin/gtin_test.go | 49 ++++ api/internal/store/store.go | 62 +++++- migrations/0007_product_barcode.down.sql | 1 + migrations/0007_product_barcode.up.sql | 36 +++ 7 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 api/internal/adminstore/barcode.go create mode 100644 api/internal/gtin/gtin.go create mode 100644 api/internal/gtin/gtin_test.go create mode 100644 migrations/0007_product_barcode.down.sql create mode 100644 migrations/0007_product_barcode.up.sql diff --git a/api/internal/adminstore/adminstore.go b/api/internal/adminstore/adminstore.go index 7994fc1..3cad8dd 100644 --- a/api/internal/adminstore/adminstore.go +++ b/api/internal/adminstore/adminstore.go @@ -162,6 +162,7 @@ type ProductDetail struct { NutritionBasis *string `json:"nutrition_basis"` ServingSize *string `json:"serving_size"` NutriScore *string `json:"nutri_score"` + Barcodes []Barcode `json:"barcodes"` Images []ProductImage `json:"images"` MSRP []MSRP `json:"msrp"` Missing []string `json:"missing"` @@ -207,6 +208,12 @@ WHERE p.id = $1`, id).Scan( d.Additives = []string{} } + bcs, err := s.listBarcodes(ctx, id) + if err != nil { + return nil, err + } + d.Barcodes = bcs + imgs, err := s.listImages(ctx, id) if err != nil { return nil, err diff --git a/api/internal/adminstore/barcode.go b/api/internal/adminstore/barcode.go new file mode 100644 index 0000000..0e8ed4f --- /dev/null +++ b/api/internal/adminstore/barcode.go @@ -0,0 +1,270 @@ +package adminstore + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "github.com/baicai2026-baicai/goods/api/internal/gtin" +) + +// Barcode is one GS1 trade item number attached to a product. +type Barcode struct { + ID string `json:"id"` + GTIN string `json:"gtin"` + GTINType string `json:"gtin_type"` + PackLevel string `json:"pack_level"` + Region *string `json:"region"` + IsPrimary bool `json:"is_primary"` +} + +// BarcodeInput is the payload for attaching a barcode to a product. +type BarcodeInput struct { + GTIN string `json:"gtin"` + GTINType string `json:"gtin_type"` + PackLevel string `json:"pack_level"` + Region *string `json:"region"` + IsPrimary bool `json:"is_primary"` +} + +// ConflictError signals that a barcode is already attached to another product, +// so the operator must de-duplicate instead of creating a clash. +type ConflictError struct { + GTIN string + ProductID string + ProductName string +} + +func (e *ConflictError) Error() string { return "条码已被其他商品占用:" + e.GTIN } + +func validPackLevel(p string) string { + switch p { + case "each", "case", "pallet": + return p + default: + return "each" + } +} + +func validGTINType(t, normalized string) string { + switch t { + case "EAN8", "UPC", "EAN13", "ITF14", "GTIN14": + return t + default: + return gtin.InferType(normalized) + } +} + +func (s *Store) listBarcodes(ctx context.Context, productID string) ([]Barcode, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, 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.ID, &b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// barcodeOwner returns the product currently owning a barcode, if any. +func barcodeOwner(ctx context.Context, q pgx.Tx, code string) (productID, productName string, found bool, err error) { + err = q.QueryRow(ctx, + `SELECT pb.product_id, p.name FROM product_barcode pb + JOIN product p ON p.id = pb.product_id WHERE pb.gtin = $1`, code). + Scan(&productID, &productName) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + return productID, productName, true, nil +} + +// AddBarcode validates and attaches a barcode to a product, recording audit. +// A barcode already owned by another product yields a *ConflictError. +func (s *Store) AddBarcode(ctx context.Context, productID, actor string, in BarcodeInput) (*Barcode, error) { + code, err := gtin.Normalize(in.GTIN) + if err != nil { + return nil, err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + // Product must exist. + var exists bool + if err := tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM product WHERE id=$1)", productID).Scan(&exists); err != nil { + return nil, err + } + if !exists { + return nil, ErrNotFound + } + + // Globally unique: a barcode owned by any product (this one included) + // is a conflict the operator must resolve by de-duplicating. + if owner, name, found, err := barcodeOwner(ctx, tx, code); err != nil { + return nil, err + } else if found { + return nil, &ConflictError{GTIN: code, ProductID: owner, ProductName: name} + } + + // Make this the primary barcode when requested or when none exists yet. + makePrimary := in.IsPrimary + if !makePrimary { + var hasPrimary bool + if err := tx.QueryRow(ctx, + "SELECT EXISTS(SELECT 1 FROM product_barcode WHERE product_id=$1 AND is_primary)", productID). + Scan(&hasPrimary); err != nil { + return nil, err + } + makePrimary = !hasPrimary + } + if makePrimary { + if _, err := tx.Exec(ctx, + "UPDATE product_barcode SET is_primary=false WHERE product_id=$1 AND is_primary", productID); err != nil { + return nil, err + } + } + + srcID, _ := s.manualSourceID(ctx, tx) + var srcArg any + if srcID != "" { + srcArg = srcID + } + + var b Barcode + err = tx.QueryRow(ctx, ` +INSERT INTO product_barcode (product_id, gtin, gtin_type, pack_level, region, is_primary, source_id) +VALUES ($1,$2,$3,$4,$5,$6,$7) +RETURNING id, gtin, gtin_type, pack_level, region, is_primary`, + productID, code, validGTINType(in.GTINType, code), validPackLevel(in.PackLevel), + in.Region, makePrimary, srcArg). + Scan(&b.ID, &b.GTIN, &b.GTINType, &b.PackLevel, &b.Region, &b.IsPrimary) + if err != nil { + return nil, err + } + + if makePrimary { + if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, code); err != nil { + return nil, err + } + } + if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + _ = s.writeAudit(ctx, actor, "add_barcode", "product", &productID, []string{"gtin"}, nil, b) + return &b, nil +} + +// DeleteBarcode removes a barcode; if it was primary, another is promoted. +func (s *Store) DeleteBarcode(ctx context.Context, productID, barcodeID, actor string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var code string + var wasPrimary bool + err = tx.QueryRow(ctx, + "DELETE FROM product_barcode WHERE id=$1 AND product_id=$2 RETURNING gtin, is_primary", + barcodeID, productID).Scan(&code, &wasPrimary) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + + if wasPrimary { + var newID, newGTIN string + e := tx.QueryRow(ctx, + "SELECT id, gtin FROM product_barcode WHERE product_id=$1 ORDER BY gtin LIMIT 1", productID). + Scan(&newID, &newGTIN) + if e == nil { + if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=true WHERE id=$1", newID); err != nil { + return err + } + if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, newGTIN); err != nil { + return err + } + } else if errors.Is(e, pgx.ErrNoRows) { + if _, err := tx.Exec(ctx, "UPDATE product SET gtin=NULL WHERE id=$1", productID); err != nil { + return err + } + } else { + return e + } + } + + if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + _ = s.writeAudit(ctx, actor, "delete_barcode", "product", &productID, []string{"gtin"}, + map[string]string{"gtin": code}, nil) + return nil +} + +// SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin. +func (s *Store) SetPrimaryBarcode(ctx context.Context, productID, barcodeID, actor string) (*Barcode, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var code string + err = tx.QueryRow(ctx, "SELECT gtin FROM product_barcode WHERE id=$1 AND product_id=$2", barcodeID, productID).Scan(&code) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=false WHERE product_id=$1 AND is_primary", productID); err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, "UPDATE product_barcode SET is_primary=true WHERE id=$1", barcodeID); err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, "UPDATE product SET gtin=$2 WHERE id=$1", productID, code); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "set_primary_barcode", "product", &productID, []string{"gtin"}, nil, + map[string]string{"gtin": code}) + + bcs, err := s.listBarcodes(ctx, productID) + if err != nil { + return nil, err + } + for i := range bcs { + if bcs[i].ID == barcodeID { + return &bcs[i], nil + } + } + return nil, ErrNotFound +} diff --git a/api/internal/gtin/gtin.go b/api/internal/gtin/gtin.go new file mode 100644 index 0000000..e061332 --- /dev/null +++ b/api/internal/gtin/gtin.go @@ -0,0 +1,110 @@ +// Package gtin validates and normalizes GS1 trade item numbers (GTIN-8/12/13/14). +// Only globally-unique GS1 codes are accepted: store-internal / variable-weight / +// coupon codes (which are not globally unique) are rejected on purpose. +package gtin + +import ( + "errors" + "strings" +) + +// Validation errors. +var ( + ErrEmpty = errors.New("条码不能为空") + ErrFormat = errors.New("条码必须为 8/12/13/14 位数字") + ErrCheck = errors.New("条码校验位不正确") + ErrRestricted = errors.New("店内码/变量重量码/优惠券码等非全球唯一码,不予收录") +) + +// Normalize trims and validates a GTIN, returning the cleaned digit string. +// It enforces length, the GS1 mod-10 check digit, and rejects restricted +// (non-globally-unique) number ranges. +func Normalize(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", ErrEmpty + } + for _, c := range s { + if c < '0' || c > '9' { + return "", ErrFormat + } + } + switch len(s) { + case 8, 12, 13, 14: + default: + return "", ErrFormat + } + if !validCheckDigit(s) { + return "", ErrCheck + } + if restricted(s) { + return "", ErrRestricted + } + return s, nil +} + +// InferType returns the conventional GTIN type label for a normalized code. +func InferType(s string) string { + switch len(s) { + case 8: + return "EAN8" + case 12: + return "UPC" + case 14: + return "GTIN14" + default: + return "EAN13" + } +} + +// validCheckDigit verifies the trailing GS1 mod-10 check digit. The digit +// immediately left of the check digit carries weight 3, then weights alternate. +func validCheckDigit(s string) bool { + n := len(s) + sum := 0 + for i := 0; i < n-1; i++ { + d := int(s[i] - '0') + if (n-1-i)%2 == 1 { + sum += d * 3 + } else { + sum += d + } + } + check := (10 - (sum % 10)) % 10 + return check == int(s[n-1]-'0') +} + +// restricted reports whether a (length/check-digit valid) code falls in a +// number range reserved for non-globally-unique use. +func restricted(s string) bool { + switch len(s) { + case 13: + p2 := s[:2] + switch { + case s[0] == '2': // 20-29 restricted distribution / in-store + return true + case p2 == "02": // 020-029 variable-measure within a store + return true + case p2 == "04": // 040-049 restricted circulation within a company + return true + case p2 == "05": // 050-059 coupons + return true + case p2 == "98" || p2 == "99": // 980-989/99 coupons & refund receipts + return true + } + case 12: // UPC-A: leading number-system digit + switch s[0] { + case '2': // in-store / random weight + return true + case '4': // unrestricted in-store use + return true + case '5': // coupons + return true + } + case 8: // EAN-8: 0/2 prefixes reserved for in-store use + if s[0] == '0' || s[0] == '2' { + return true + } + } + return false +} diff --git a/api/internal/gtin/gtin_test.go b/api/internal/gtin/gtin_test.go new file mode 100644 index 0000000..e380552 --- /dev/null +++ b/api/internal/gtin/gtin_test.go @@ -0,0 +1,49 @@ +package gtin + +import "testing" + +func TestNormalizeValid(t *testing.T) { + cases := []struct{ in, want, typ string }{ + {" 5449000000996 ", "5449000000996", "EAN13"}, // Coca-Cola EAN-13 + {"3017624010701", "3017624010701", "EAN13"}, // Nutella EAN-13 + {"036000291452", "036000291452", "UPC"}, // UPC-A + {"96385074", "96385074", "EAN8"}, // EAN-8 + {"00012345600012", "00012345600012", "GTIN14"}, + {"6901234567892", "6901234567892", "EAN13"}, // China 690 prefix + } + for _, c := range cases { + got, err := Normalize(c.in) + if err != nil { + t.Errorf("Normalize(%q) unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("Normalize(%q) = %q, want %q", c.in, got, c.want) + } + if InferType(got) != c.typ { + t.Errorf("InferType(%q) = %q, want %q", got, InferType(got), c.typ) + } + } +} + +func TestNormalizeRejects(t *testing.T) { + cases := []struct { + in string + want error + }{ + {"", ErrEmpty}, + {"12ab5678", ErrFormat}, + {"12345", ErrFormat}, + {"5449000000997", ErrCheck}, // bad check digit + {"2012345678903", ErrRestricted}, // 20-29 in-store EAN-13 + {"0212345678909", ErrRestricted}, // 02x variable measure + {"212345678909", ErrRestricted}, // UPC number system 2 + {"02345673", ErrRestricted}, // EAN-8 in-store + } + for _, c := range cases { + _, err := Normalize(c.in) + if err != c.want { + t.Errorf("Normalize(%q) error = %v, want %v", c.in, err, c.want) + } + } +} diff --git a/api/internal/store/store.go b/api/internal/store/store.go index d24405b..f5d02ae 100644 --- a/api/internal/store/store.go +++ b/api/internal/store/store.go @@ -29,6 +29,15 @@ 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"` @@ -41,6 +50,7 @@ type Product struct { 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"` @@ -49,6 +59,27 @@ type Product struct { 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"` @@ -86,16 +117,35 @@ func scanProduct(row pgx.Row) (*Product, error) { return &p, nil } -// ProductByGTIN looks up an active product by its barcode. +// 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.gtin = $1 AND p.status = 'active'", gtin) - return scanProduct(row) + 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) - return scanProduct(row) + 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. @@ -104,7 +154,9 @@ func (s *Store) SearchProducts(ctx context.Context, q, category string, limit, o where := "WHERE p.status = 'active'" if q != "" { args = append(args, q) - where += " AND p.name ILIKE '%' || $1 || '%'" + 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) diff --git a/migrations/0007_product_barcode.down.sql b/migrations/0007_product_barcode.down.sql new file mode 100644 index 0000000..90e5f6f --- /dev/null +++ b/migrations/0007_product_barcode.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS product_barcode; diff --git a/migrations/0007_product_barcode.up.sql b/migrations/0007_product_barcode.up.sql new file mode 100644 index 0000000..a854b38 --- /dev/null +++ b/migrations/0007_product_barcode.up.sql @@ -0,0 +1,36 @@ +-- Multi-barcode support: one product can carry many GS1 barcodes +-- (consumer unit EAN-13/UPC, case ITF-14, regional re-labels, etc.). +-- product.gtin is kept as the denormalized "primary" barcode for +-- backward compatibility and is mirrored from the is_primary row here. + +CREATE TABLE product_barcode ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + product_id UUID NOT NULL REFERENCES product(id) ON DELETE CASCADE, + gtin VARCHAR(14) NOT NULL, + gtin_type VARCHAR(8) NOT NULL DEFAULT 'EAN13', + pack_level VARCHAR(8) NOT NULL DEFAULT 'each', + region VARCHAR(8), + is_primary BOOLEAN NOT NULL DEFAULT false, + source_id UUID REFERENCES source(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT product_barcode_type_chk CHECK (gtin_type IN ('EAN8','UPC','EAN13','ITF14','GTIN14')), + CONSTRAINT product_barcode_pack_chk CHECK (pack_level IN ('each','case','pallet')) +); + +-- A barcode is globally unique: one code maps to exactly one product. +CREATE UNIQUE INDEX idx_product_barcode_gtin ON product_barcode (gtin); +CREATE INDEX idx_product_barcode_product ON product_barcode (product_id); +-- At most one primary barcode per product. +CREATE UNIQUE INDEX idx_product_barcode_primary ON product_barcode (product_id) WHERE is_primary; + +-- Backfill: lift each product's existing gtin into the new table as primary. +INSERT INTO product_barcode (product_id, gtin, gtin_type, pack_level, is_primary) +SELECT id, gtin, + CASE WHEN length(gtin) = 8 THEN 'EAN8' + WHEN length(gtin) = 12 THEN 'UPC' + WHEN length(gtin) = 14 THEN 'GTIN14' + ELSE 'EAN13' END, + 'each', true +FROM product +WHERE gtin IS NOT NULL AND gtin <> '' +ON CONFLICT (gtin) DO NOTHING;