From 1d5f775d33d34bdf13c45f9e5e76ddf106c9a107 Mon Sep 17 00:00:00 2001 From: oyaegeli98668 Date: Sat, 20 Jun 2026 05:54:18 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(barcode):=20=E5=A4=9A=E6=9D=A1?= =?UTF-8?q?=E7=A0=81=E7=AE=A1=E7=90=86=EF=BC=88=E5=90=8E=E7=AB=AF+?= =?UTF-8?q?=E8=BF=81=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; -- 2.52.0 From 98b5f1575ded0878a6e524a443c90cef90ff7c8f Mon Sep 17 00:00:00 2001 From: novaalphastrikeomegaz663 Date: Sat, 20 Jun 2026 07:06:07 +0000 Subject: [PATCH 2/3] feat(barcode): admin barcode CRUD endpoints + UI; show extra barcodes publicly Wire the multi-barcode store layer to HTTP and the operator console: - adminhandler: add POST /products/{id}/barcodes, DELETE /products/{id}/barcodes/{barcodeID}, and POST .../primary. A barcode owned by another product returns 409 with the conflicting product (gtin/product_id/product_name); an invalid GTIN returns 400. - admin-frontend: BarcodesCard on the product detail page lists all barcodes (primary starred), adds with type/pack-level/region, sets primary, and deletes; audit labels for the new actions. - public-frontend: product detail surfaces non-primary barcodes so a case/region code resolves and is visible to consumers. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin-frontend/src/api.ts | 14 ++ .../src/components/ProductDetail.tsx | 164 ++++++++++++++++++ admin-frontend/src/types.ts | 10 ++ api/internal/adminhandler/handler.go | 66 +++++++ .../src/components/ProductView.tsx | 23 +++ public-frontend/src/types.ts | 9 + 6 files changed, 286 insertions(+) diff --git a/admin-frontend/src/api.ts b/admin-frontend/src/api.ts index a29fff3..fe2f69c 100644 --- a/admin-frontend/src/api.ts +++ b/admin-frontend/src/api.ts @@ -86,6 +86,20 @@ export const api = { request<{ status: string }>(`/products/${id}/msrp/${msrpId}`, { method: "DELETE", }), + addBarcode: (id: string, body: unknown) => + request(`/products/${id}/barcodes`, { + method: "POST", + body: JSON.stringify(body), + }), + deleteBarcode: (id: string, barcodeId: string) => + request<{ status: string }>(`/products/${id}/barcodes/${barcodeId}`, { + method: "DELETE", + }), + setPrimaryBarcode: (id: string, barcodeId: string) => + request( + `/products/${id}/barcodes/${barcodeId}/primary`, + { method: "POST" }, + ), listBrands: () => request<{ items: import("./types").Brand[] }>("/brands"), listCategories: () => diff --git a/admin-frontend/src/components/ProductDetail.tsx b/admin-frontend/src/components/ProductDetail.tsx index f18a3b2..3066803 100644 --- a/admin-frontend/src/components/ProductDetail.tsx +++ b/admin-frontend/src/components/ProductDetail.tsx @@ -14,8 +14,16 @@ import { Trash2, AlertCircle, History, + Star, } from "lucide-react"; +const GTIN_TYPES = ["EAN13", "EAN8", "UPC", "ITF14", "GTIN14"]; +const PACK_LEVELS: { value: string; label: string }[] = [ + { value: "each", label: "消费单元" }, + { value: "case", label: "箱" }, + { value: "pallet", label: "托盘" }, +]; + const NUTRIMENT_KEYS: { key: string; label: string }[] = [ { key: "energy_kcal", label: "能量 (kcal)" }, { key: "energy_kj", label: "能量 (kJ)" }, @@ -39,6 +47,9 @@ const ACTION_LABEL: Record = { delete_image: "删除图片", add_msrp: "新增建议零售价", delete_msrp: "删除建议零售价", + add_barcode: "新增条码", + delete_barcode: "删除条码", + set_primary_barcode: "设为主条码", }; function Card({ @@ -400,6 +411,7 @@ export default function ProductDetail({ + void; + onError: (m: string) => void; +}) { + const [gtin, setGtin] = useState(""); + const [gtinType, setGtinType] = useState("EAN13"); + const [packLevel, setPackLevel] = useState("each"); + const [region, setRegion] = useState(""); + const [busy, setBusy] = useState(false); + + async function add() { + if (!gtin.trim()) return; + setBusy(true); + try { + await api.addBarcode(product.id, { + gtin: gtin.trim(), + gtin_type: gtinType, + pack_level: packLevel, + region: region.trim() || null, + is_primary: false, + }); + setGtin(""); + setRegion(""); + onChange(); + } catch (e) { + onError(e instanceof Error ? e.message : "添加失败"); + } finally { + setBusy(false); + } + } + async function remove(barcodeId: string) { + try { + await api.deleteBarcode(product.id, barcodeId); + onChange(); + } catch (e) { + onError(e instanceof Error ? e.message : "删除失败"); + } + } + async function makePrimary(barcodeId: string) { + try { + await api.setPrimaryBarcode(product.id, barcodeId); + onChange(); + } catch (e) { + onError(e instanceof Error ? e.message : "设置失败"); + } + } + + return ( + +
+ {product.barcodes.length === 0 && ( + 暂无条码 + )} + {product.barcodes.map((b) => ( +
+ + + {b.gtin} + + + {b.gtin_type} + + + {PACK_LEVELS.find((p) => p.value === b.pack_level)?.label || + b.pack_level} + + {b.region || ""} + +
+ ))} +
+
+ + setGtin(e.target.value)} + placeholder="8/12/13/14 位" + /> + + + + + + + + + setRegion(e.target.value.toUpperCase())} + /> + + +
+
+ ); +} + function ImagesCard({ product, onChange, diff --git a/admin-frontend/src/types.ts b/admin-frontend/src/types.ts index 8279181..a07aeae 100644 --- a/admin-frontend/src/types.ts +++ b/admin-frontend/src/types.ts @@ -10,6 +10,15 @@ export interface ProductRow { updated_at: string; } +export interface Barcode { + id: string; + gtin: string; + gtin_type: string; + pack_level: string; + region: string | null; + is_primary: boolean; +} + export interface ProductImage { id: string; url: string; @@ -47,6 +56,7 @@ export interface ProductDetail { nutrition_basis: string | null; serving_size: string | null; nutri_score: string | null; + barcodes: Barcode[]; images: ProductImage[]; msrp: MSRP[]; missing: string[]; diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index 94da7a4..3eb7e3f 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -15,6 +15,7 @@ import ( "github.com/baicai2026-baicai/goods/api/internal/adminstore" "github.com/baicai2026-baicai/goods/api/internal/auth" + "github.com/baicai2026-baicai/goods/api/internal/gtin" ) // Handler holds the admin dependencies. @@ -62,6 +63,9 @@ func (h *Handler) Router() http.Handler { r.Delete("/api/products/{id}/images/{imageID}", h.DeleteImage) r.Post("/api/products/{id}/msrp", h.AddMSRP) r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP) + r.Post("/api/products/{id}/barcodes", h.AddBarcode) + r.Delete("/api/products/{id}/barcodes/{barcodeID}", h.DeleteBarcode) + r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode) r.Get("/api/brands", h.ListBrands) r.Get("/api/categories", h.ListCategories) @@ -231,6 +235,68 @@ func (h *Handler) DeleteMSRP(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } +// ---------- barcodes ---------- + +// AddBarcode validates and attaches a barcode to a product. A code already +// owned by another product yields 409 with the conflicting product so the +// operator can de-duplicate; an invalid GTIN yields 400. +func (h *Handler) AddBarcode(w http.ResponseWriter, r *http.Request) { + var in adminstore.BarcodeInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + b, err := h.store.AddBarcode(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in) + if h.handleBarcodeErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, b) +} + +// DeleteBarcode removes a barcode; a primary one is replaced automatically. +func (h *Handler) DeleteBarcode(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context())) + if h.handleErr(w, err) { + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin. +func (h *Handler) SetPrimaryBarcode(w http.ResponseWriter, r *http.Request) { + b, err := h.store.SetPrimaryBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context())) + if h.handleErr(w, err) { + return + } + writeJSON(w, http.StatusOK, b) +} + +// handleBarcodeErr maps barcode-specific errors (GTIN validation, ownership +// conflict) to client-facing statuses, falling back to handleErr otherwise. +func (h *Handler) handleBarcodeErr(w http.ResponseWriter, err error) bool { + if err == nil { + return false + } + var conflict *adminstore.ConflictError + if errors.As(err, &conflict) { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": map[string]string{"code": "barcode_conflict", "message": err.Error()}, + "conflict": map[string]string{ + "gtin": conflict.GTIN, + "product_id": conflict.ProductID, + "product_name": conflict.ProductName, + }, + }) + return true + } + if errors.Is(err, gtin.ErrEmpty) || errors.Is(err, gtin.ErrFormat) || + errors.Is(err, gtin.ErrCheck) || errors.Is(err, gtin.ErrRestricted) { + writeError(w, http.StatusBadRequest, "invalid_gtin", err.Error()) + return true + } + return h.handleErr(w, err) +} + // ListBrands returns brand options. func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) { items, err := h.store.ListBrands(r.Context()) diff --git a/public-frontend/src/components/ProductView.tsx b/public-frontend/src/components/ProductView.tsx index 06e4f78..bfa4987 100644 --- a/public-frontend/src/components/ProductView.tsx +++ b/public-frontend/src/components/ProductView.tsx @@ -60,6 +60,29 @@ export default function ProductView({ id, onBack }: { id: string; onBack: () =>
+ {(() => { + const others = (p.barcodes || []).filter( + (b) => !b.is_primary && b.gtin !== p.gtin, + ); + return others.length ? ( + + {others.map((b) => ( + + {b.gtin} + + ))} +
+ } + /> + ) : null; + })()} Date: Sat, 20 Jun 2026 07:33:10 +0000 Subject: [PATCH 3/3] style(api): gofmt gtin_test.go Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- api/internal/gtin/gtin_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/gtin/gtin_test.go b/api/internal/gtin/gtin_test.go index e380552..3e13f96 100644 --- a/api/internal/gtin/gtin_test.go +++ b/api/internal/gtin/gtin_test.go @@ -34,7 +34,7 @@ func TestNormalizeRejects(t *testing.T) { {"", ErrEmpty}, {"12ab5678", ErrFormat}, {"12345", ErrFormat}, - {"5449000000997", ErrCheck}, // bad check digit + {"5449000000997", ErrCheck}, // bad check digit {"2012345678903", ErrRestricted}, // 20-29 in-store EAN-13 {"0212345678909", ErrRestricted}, // 02x variable measure {"212345678909", ErrRestricted}, // UPC number system 2 -- 2.52.0