feat: 可扩展档案模式框架 + 内置 3C(电子) 模式
- 新增 category.archive_kind 与 kind_field 字段模板表(迁移 0010) - 种子 electronics 字段模板 + 3C 品类树(手机/笔记本/平板等) - 后端按档案模式动态计算完整度/合格:食品沿用 food_detail, 其它模式走 product.attributes + kind_field - 新增 GET /api/kind-fields?kind= 接口 - 后台编辑页按品类模式动态渲染规格参数表单 - 公开详情页/接口输出带标签的规格表 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -4,8 +4,10 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -56,6 +58,15 @@ type Barcode struct {
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
}
|
||||
|
||||
// ProductSpec is one labeled spec line for a non-food product, rendered from
|
||||
// the product's attributes JSONB against its archive kind's field template.
|
||||
type ProductSpec struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// Product is the full public view of a product.
|
||||
type Product struct {
|
||||
ID string `json:"id"`
|
||||
@@ -64,11 +75,13 @@ type Product struct {
|
||||
Brand *string `json:"brand"`
|
||||
CategoryPath *string `json:"category_path"`
|
||||
GPCBrickCode *string `json:"gpc_brick_code"`
|
||||
ArchiveKind string `json:"archive_kind"`
|
||||
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"`
|
||||
Specs []ProductSpec `json:"specs,omitempty"`
|
||||
Nutriments map[string]any `json:"nutriments,omitempty"`
|
||||
NutritionBasis *string `json:"nutrition_basis,omitempty"`
|
||||
NutriScore *string `json:"nutri_score,omitempty"`
|
||||
@@ -120,6 +133,7 @@ type SearchFilters struct {
|
||||
|
||||
const productSelect = `
|
||||
SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.gpc_brick_code,
|
||||
COALESCE(c.archive_kind, 'generic'), p.attributes,
|
||||
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
|
||||
@@ -129,21 +143,81 @@ 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) {
|
||||
func scanProduct(row pgx.Row) (*Product, []byte, error) {
|
||||
var p Product
|
||||
var attributes []byte
|
||||
err := row.Scan(
|
||||
&p.ID, &p.GTIN, &p.Name, &p.Brand, &p.CategoryPath, &p.GPCBrickCode,
|
||||
&p.ArchiveKind, &attributes,
|
||||
&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
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &p, attributes, nil
|
||||
}
|
||||
|
||||
// buildSpecs renders the labeled, ordered spec list for a non-food product from
|
||||
// its attributes JSONB against its archive kind's field template.
|
||||
func (s *Store) buildSpecs(ctx context.Context, kind string, attributes []byte) ([]ProductSpec, error) {
|
||||
if kind == "" || kind == "food" || len(attributes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
attrs := map[string]any{}
|
||||
if err := json.Unmarshal(attributes, &attrs); err != nil || len(attrs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
"SELECT field_key, label_zh, COALESCE(unit, '') FROM kind_field WHERE kind = $1 ORDER BY sort_order, field_key", kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
defer rows.Close()
|
||||
specs := []ProductSpec{}
|
||||
for rows.Next() {
|
||||
var key, label, unit string
|
||||
if err := rows.Scan(&key, &label, &unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, ok := attrs[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
val := stringifyAttr(v)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
specs = append(specs, ProductSpec{Key: key, Label: label, Value: val, Unit: unit})
|
||||
}
|
||||
return specs, rows.Err()
|
||||
}
|
||||
|
||||
// stringifyAttr renders a JSON attribute value as display text.
|
||||
func stringifyAttr(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case bool:
|
||||
if t {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
case []any:
|
||||
parts := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
parts = append(parts, stringifyAttr(e))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ProductByGTIN looks up an active product by any of its barcodes.
|
||||
@@ -154,26 +228,32 @@ func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error
|
||||
SELECT 1 FROM product_barcode pb
|
||||
WHERE pb.product_id = p.id AND pb.gtin = $1))
|
||||
LIMIT 1`, gtin)
|
||||
p, err := scanProduct(row)
|
||||
p, attrs, err := scanProduct(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); 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)
|
||||
p, attrs, err := scanProduct(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Barcodes, err = s.ProductBarcodes(ctx, p.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user