Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e750501b44 | |||
| 766a573989 | |||
| b0b816b0ee | |||
| 786b7d3721 |
@@ -0,0 +1,93 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
go:
|
||||||
|
name: Go (api)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: api
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: opengoods
|
||||||
|
POSTGRES_PASSWORD: opengoods
|
||||||
|
POSTGRES_DB: opengoods
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U opengoods"
|
||||||
|
--health-interval 5s --health-timeout 5s --health-retries 10
|
||||||
|
env:
|
||||||
|
OPENGOODS_DATABASE_URL: postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.23"
|
||||||
|
cache-dependency-path: api/go.sum
|
||||||
|
- name: Apply migrations
|
||||||
|
working-directory: .
|
||||||
|
run: |
|
||||||
|
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1
|
||||||
|
migrate -path migrations -database "$OPENGOODS_DATABASE_URL" up
|
||||||
|
- name: Verify gofmt
|
||||||
|
run: test -z "$(gofmt -l .)"
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go build ./...
|
||||||
|
- run: go test ./...
|
||||||
|
|
||||||
|
python:
|
||||||
|
name: Python (ingestion)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ingestion
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Install
|
||||||
|
run: pip install -e ".[dev]"
|
||||||
|
- name: Ruff lint
|
||||||
|
run: ruff check .
|
||||||
|
- name: Ruff format check
|
||||||
|
run: ruff format --check .
|
||||||
|
- name: Pytest
|
||||||
|
run: pytest -q
|
||||||
|
|
||||||
|
migrations:
|
||||||
|
name: Migrations (postgres)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: opengoods
|
||||||
|
POSTGRES_PASSWORD: opengoods
|
||||||
|
POSTGRES_DB: opengoods
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U opengoods"
|
||||||
|
--health-interval 5s --health-timeout 5s --health-retries 10
|
||||||
|
env:
|
||||||
|
DBURL: postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.23"
|
||||||
|
- name: Install golang-migrate
|
||||||
|
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1
|
||||||
|
- name: Migrate up
|
||||||
|
run: migrate -path migrations -database "$DBURL" up
|
||||||
|
- name: Migrate down (reversibility)
|
||||||
|
run: migrate -path migrations -database "$DBURL" down -all
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Go
|
||||||
|
/api/server
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Env / local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# OS / editors
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -1,24 +1,2 @@
|
|||||||
# 天工·商品标签 (OpenGoods)
|
# goods
|
||||||
|
商品档案公开API
|
||||||
商品档案公开 API —— 公益网站/服务:**采集全网商品信息,对外提供商品参数查询 API**。
|
|
||||||
|
|
||||||
> 核心原则:**只采集 + 只提供信息,绝不涉及任何购买/下单/比价导购。**
|
|
||||||
|
|
||||||
## 这是什么
|
|
||||||
|
|
||||||
- 开放、中立、可溯源的「商品参数百科 + 开放 API」
|
|
||||||
- 首批聚焦 **食品快消**,对外提供按条码/名称查询商品参数(成分、营养、规格、官方建议零售价等)
|
|
||||||
- 技术栈:**Go**(对外只读 API) + **Python**(采集/ETL),经 PostgreSQL + Redis/队列解耦
|
|
||||||
|
|
||||||
## 项目状态
|
|
||||||
|
|
||||||
规划阶段。完整方案见 [`docs/planning/`](./docs/planning/README.md):
|
|
||||||
|
|
||||||
- [最终规划](./docs/planning/00-final-plan.md)(决策 + 架构 + 任务清单)
|
|
||||||
- [详细设计 v2.0](./docs/planning/01-detailed-design-v2.0.md)(分类/单位/数据库/API/治理/采集/部署)
|
|
||||||
- [进阶专题 v3.0](./docs/planning/02-advanced-topics-v3.0.md)(OpenAPI/DDL/合规/测试/安全/SLA/竞品)
|
|
||||||
|
|
||||||
## 许可
|
|
||||||
|
|
||||||
- 代码:拟用 Apache-2.0 / MIT(待定)
|
|
||||||
- 数据:拟用 **ODbL + 署名**(因采用 Open Food Facts 等开放数据源)
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.23-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=build /out/server /server
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/server"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Command server starts the OpenGoods public read-only API.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/baicai2026-baicai/goods/api/internal/config"
|
||||||
|
"github.com/baicai2026-baicai/goods/api/internal/handler"
|
||||||
|
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create db pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := pool.Ping(pingCtx); err != nil {
|
||||||
|
log.Printf("warning: database not reachable at startup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := handler.New(store.New(pool))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: cfg.Addr,
|
||||||
|
Handler: h.Router(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("OpenGoods API listening on %s", cfg.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
module github.com/baicai2026-baicai/goods/api
|
||||||
|
|
||||||
|
go 1.23.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
)
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds runtime configuration for the OpenGoods API server.
|
||||||
|
// Values are read from environment variables with sensible defaults so the
|
||||||
|
// server can boot in a local Docker Compose setup without extra configuration.
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DatabaseURL string
|
||||||
|
RedisURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from the environment.
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Addr: getenv("OPENGOODS_ADDR", ":8080"),
|
||||||
|
DatabaseURL: getenv("OPENGOODS_DATABASE_URL", "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"),
|
||||||
|
RedisURL: getenv("OPENGOODS_REDIS_URL", "redis://localhost:6379/0"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, fallback string) string {
|
||||||
|
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Package handler wires up the public, read-only OpenGoods HTTP API.
|
||||||
|
// The OpenGoods service is a public-good product information API: it only
|
||||||
|
// collects and serves product facts. It exposes no purchase, checkout, or
|
||||||
|
// commerce endpoints by design.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIVersion is the current public API version prefix.
|
||||||
|
const APIVersion = "v1"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPageSize = 20
|
||||||
|
maxPageSize = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler holds dependencies shared by the HTTP routes.
|
||||||
|
type Handler struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Handler backed by the given store.
|
||||||
|
func New(s *store.Store) *Handler {
|
||||||
|
return &Handler{store: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
||||||
|
func (h *Handler) Router() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/healthz", h.Healthz)
|
||||||
|
|
||||||
|
r.Route("/api/"+APIVersion, func(r chi.Router) {
|
||||||
|
r.Route("/products", func(r chi.Router) {
|
||||||
|
r.Get("/barcode/{gtin}", h.ProductByBarcode)
|
||||||
|
r.Get("/search", h.SearchProducts)
|
||||||
|
r.Get("/{id}", h.ProductByID)
|
||||||
|
r.Get("/{id}/nutriments", h.ProductNutriments)
|
||||||
|
r.Get("/{id}/msrp", h.ProductMSRP)
|
||||||
|
})
|
||||||
|
r.Get("/brands", h.ListBrands)
|
||||||
|
r.Get("/categories", h.ListCategories)
|
||||||
|
r.Get("/sources/{id}", h.SourceByID)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthz reports liveness of the service.
|
||||||
|
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductByBarcode returns a product by its GTIN.
|
||||||
|
func (h *Handler) ProductByBarcode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, err := h.store.ProductByGTIN(r.Context(), chi.URLParam(r, "gtin"))
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductByID returns a product by its UUID.
|
||||||
|
func (h *Handler) ProductByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, err := h.store.ProductByID(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchProducts runs a fuzzy name search with optional category filter + paging.
|
||||||
|
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
category := r.URL.Query().Get("category")
|
||||||
|
page, size := pageParams(r)
|
||||||
|
|
||||||
|
items, total, err := h.store.SearchProducts(r.Context(), q, category, size, (page-1)*size)
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductNutriments returns just the nutrition facts of a product.
|
||||||
|
func (h *Handler) ProductNutriments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := h.store.Nutriments(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductMSRP returns official suggested retail price snapshots (no purchase link).
|
||||||
|
func (h *Handler) ProductMSRP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.store.ListMSRP(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"disclaimer": "厂商建议零售价历史快照,仅供参考,不构成购买建议,本服务不提供任何购买入口。",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBrands returns a paginated list of brands.
|
||||||
|
func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page, size := pageParams(r)
|
||||||
|
items, total, err := h.store.ListBrands(r.Context(), size, (page-1)*size)
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items, "page": page, "size": size, "total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCategories returns the full category tree.
|
||||||
|
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.store.ListCategories(r.Context())
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceByID returns a single data source.
|
||||||
|
func (h *Handler) SourceByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
src, err := h.store.SourceByID(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if h.handleErr(w, r, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleErr writes an appropriate error response; returns true if it handled one.
|
||||||
|
func (h *Handler) handleErr(w http.ResponseWriter, r *http.Request, err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, r, http.StatusNotFound, "not_found", "resource not found")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeError(w, r, http.StatusInternalServerError, "internal_error", "internal server error")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageParams(r *http.Request) (page, size int) {
|
||||||
|
page = atoiDefault(r.URL.Query().Get("page"), 1)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
size = atoiDefault(r.URL.Query().Get("size"), defaultPageSize)
|
||||||
|
if size < 1 {
|
||||||
|
size = defaultPageSize
|
||||||
|
}
|
||||||
|
if size > maxPageSize {
|
||||||
|
size = maxPageSize
|
||||||
|
}
|
||||||
|
return page, size
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiDefault(s string, fallback int) int {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) {
|
||||||
|
writeJSON(w, status, map[string]any{
|
||||||
|
"error": map[string]string{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"request_id": middleware.GetReqID(r.Context()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/baicai2026-baicai/goods/api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestHandler connects to the test database, skipping if unavailable or
|
||||||
|
// unmigrated. It inserts a known product (cleaned up via t.Cleanup) so the
|
||||||
|
// endpoint assertions are deterministic.
|
||||||
|
func newTestHandler(t *testing.T) (*Handler, string) {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no database: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
t.Skipf("database not reachable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasProduct bool
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.product') IS NOT NULL").Scan(&hasProduct); err != nil || !hasProduct {
|
||||||
|
pool.Close()
|
||||||
|
t.Skip("migrations not applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
gtin := "4006381333931"
|
||||||
|
_, err = pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO product (gtin, name, category_id, net_content_value, net_content_unit)
|
||||||
|
VALUES ($1, 'Test Cola', (SELECT id FROM category WHERE path='food.beverages.carbonated'), 330, 'ml')
|
||||||
|
ON CONFLICT (gtin) WHERE gtin IS NOT NULL DO UPDATE SET name = EXCLUDED.name`, gtin)
|
||||||
|
if err != nil {
|
||||||
|
pool.Close()
|
||||||
|
t.Fatalf("seed insert failed: %v", err)
|
||||||
|
}
|
||||||
|
var pid string
|
||||||
|
_ = pool.QueryRow(context.Background(), "SELECT id FROM product WHERE gtin=$1", gtin).Scan(&pid)
|
||||||
|
_, _ = pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO food_detail (product_id, nutrition_basis, nutriments)
|
||||||
|
VALUES ($1, 'per_100ml', '{"energy_kcal": 42}'::jsonb)
|
||||||
|
ON CONFLICT (product_id) DO UPDATE SET nutriments = EXCLUDED.nutriments`, pid)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), "DELETE FROM product WHERE gtin=$1", gtin)
|
||||||
|
pool.Close()
|
||||||
|
})
|
||||||
|
return New(store.New(pool)), gtin
|
||||||
|
}
|
||||||
|
|
||||||
|
func doGET(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Router().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductByBarcode(t *testing.T) {
|
||||||
|
h, gtin := newTestHandler(t)
|
||||||
|
rec := doGET(t, h, "/api/"+APIVersion+"/products/barcode/"+gtin)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var p store.Product
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Name != "Test Cola" || p.GTIN == nil || *p.GTIN != gtin {
|
||||||
|
t.Fatalf("unexpected product: %+v", p)
|
||||||
|
}
|
||||||
|
if p.CategoryPath == nil || *p.CategoryPath != "food.beverages.carbonated" {
|
||||||
|
t.Fatalf("category not joined: %+v", p.CategoryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProductByBarcodeNotFound(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t)
|
||||||
|
rec := doGET(t, h, "/api/"+APIVersion+"/products/barcode/0000000000000")
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchProducts(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t)
|
||||||
|
rec := doGET(t, h, "/api/"+APIVersion+"/products/search?q=Cola&category=food.beverages")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Items []store.ProductSummary `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body.Total < 1 {
|
||||||
|
t.Fatalf("expected at least 1 result, got %d", body.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCategories(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t)
|
||||||
|
rec := doGET(t, h, "/api/"+APIVersion+"/categories")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Items []store.Category `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(body.Items) < 20 {
|
||||||
|
t.Fatalf("expected seeded categories, got %d", len(body.Items))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthz(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
New(nil).Router().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("failed to decode body: %v", err)
|
||||||
|
}
|
||||||
|
if body["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %q", body["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageParams(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
query string
|
||||||
|
wantPage, wantSz int
|
||||||
|
}{
|
||||||
|
{"", 1, defaultPageSize},
|
||||||
|
{"page=3&size=10", 3, 10},
|
||||||
|
{"page=0&size=-5", 1, defaultPageSize},
|
||||||
|
{"size=1000", 1, maxPageSize},
|
||||||
|
{"page=abc", 1, defaultPageSize},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/?"+c.query, nil)
|
||||||
|
page, size := pageParams(req)
|
||||||
|
if page != c.wantPage || size != c.wantSz {
|
||||||
|
t.Errorf("query %q: got page=%d size=%d, want page=%d size=%d",
|
||||||
|
c.query, page, size, c.wantPage, c.wantSz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: opengoods
|
||||||
|
POSTGRES_PASSWORD: opengoods
|
||||||
|
POSTGRES_DB: opengoods
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U opengoods"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: opengoods
|
||||||
|
MINIO_ROOT_PASSWORD: opengoods123
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- miniodata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
OPENGOODS_ADDR: ":8080"
|
||||||
|
OPENGOODS_DATABASE_URL: "postgres://opengoods:opengoods@postgres:5432/opengoods?sslmode=disable"
|
||||||
|
OPENGOODS_REDIS_URL: "redis://redis:6379/0"
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
miniodata:
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 数据契约 (Data Contract) v0.1
|
||||||
|
|
||||||
|
本契约是 Go(API) 与 Python(ingestion) 两端共享的"事实约定",避免两端对字段含义理解不一致。
|
||||||
|
|
||||||
|
> 写入责任:**仅 Python (ingestion) 通过 ETL 写入数据库**;Go (API) **只读**。所有写入必须经过单位归一化与字段级溯源。
|
||||||
|
|
||||||
|
## 1. 边界原则
|
||||||
|
- 系统只采集与提供**客观商品信息**;不包含任何购买/交易语义的字段或端点。
|
||||||
|
- 价格仅收录**官方建议零售价 (MSRP)** 的静态快照,必须带 `currency`/`region`/`source`/`effective_date`。
|
||||||
|
|
||||||
|
## 2. 固定枚举
|
||||||
|
| 字段 | 取值 |
|
||||||
|
|------|------|
|
||||||
|
| `product.status` | `active` / `merged` / `deprecated` |
|
||||||
|
| `food_detail.nutrition_basis` | `per_100g` / `per_100ml` / `per_serving` |
|
||||||
|
| `unit.dimension` | `mass` / `volume` / `energy` / `count` / `ratio` / `length` / `duration` |
|
||||||
|
| `source.license` | `ODbL` / `CC0` / `proprietary` / ... |
|
||||||
|
| `product_image.kind` | `front` / `ingredients` / `nutrition` / `other` |
|
||||||
|
|
||||||
|
## 3. 单位规则
|
||||||
|
- 数值字段同时保存**原始值 + 单位**与**归一化值 + 基准单位**(canonical)。
|
||||||
|
- 质量 → `g`,体积 → `ml`,能量 → `kJ`(同时保留 `kcal`)。
|
||||||
|
- 归一化逻辑由 `ingestion/opengoods/units.py` 提供(纯函数,含测试),换算因子是唯一事实来源。
|
||||||
|
- 营养成分统一折算到品类模板规定的基准(`per_100g` / `per_100ml`)。
|
||||||
|
|
||||||
|
## 4. 标识与可空性
|
||||||
|
- `product.gtin`:8/12/13/14 位数字,可空(无条码商品),非空时全局唯一。
|
||||||
|
- `product.quality_score` ∈ [0, 1]。
|
||||||
|
- 货币用 ISO 4217(`CNY` 等),国家/地区用简短代码(`CN` 等)。
|
||||||
|
|
||||||
|
## 5. 溯源 (Provenance)
|
||||||
|
- 每条数据通过 `product_source` 记录来源、URL、贡献字段、抓取时间与原始快照。
|
||||||
|
- 对外 API 在 `sources` 中透明返回来源与其许可。
|
||||||
|
|
||||||
|
## 6. 版本
|
||||||
|
- 本契约随 schema 演进版本化;任何 schema 变更需同步更新:迁移(SQL) + 本契约 + `docs/openapi.yaml`。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 免责声明 (Disclaimer)
|
||||||
|
|
||||||
|
天工·商品标签 (OpenGoods) 是一个**公益信息平台**。
|
||||||
|
|
||||||
|
- 本站**仅提供商品参数信息,不提供任何购买、下单、比价或导购服务**,不包含任何购买入口或交易链接。
|
||||||
|
- 商品参数(成分、营养、规格等)来自多个数据来源并标注出处,可能存在误差或滞后;**请以商品实物标签为准**。
|
||||||
|
- 价格字段仅为**官方建议零售价 (MSRP) 的历史快照**,标注来源与时间,实际售价以零售商为准,**不构成消费或购买建议**。
|
||||||
|
- 本站不提供医疗、健康或功效宣称。
|
||||||
|
- 数据按各来源许可使用(详见各条数据的 `sources` 字段与来源说明);权利方可通过公开渠道申请更正或下架。
|
||||||
|
|
||||||
|
> The OpenGoods service only collects and serves product information for public benefit. It provides **no purchase, checkout, price-comparison, or shopping-guide functionality**.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ETL: Open Food Facts 导入 (M2)
|
||||||
|
|
||||||
|
把 Open Food Facts (OFF, ODbL 许可) 的食品数据采集、转换并入库。只有 Python 采集侧写库,每条记录都以 `openfoodfacts` 为来源记录**字段级溯源**。
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
```
|
||||||
|
OFF API / dump(jsonl[.gz])
|
||||||
|
→ adapters/openfoodfacts.py # 读取(限速 + User-Agent) / 解析 dump
|
||||||
|
→ etl/transform.py # 字段映射 + 单位归一 + 营养 per_100g + 分类映射(关键词)
|
||||||
|
→ etl/load.py # psycopg upsert(product/food_detail/product_image) + product_source 溯源
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
先确保本地依赖与迁移就绪:`docker compose up -d postgres` + `migrate ... up`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 用 OFF API 拉指定条码(客户端限速, 默认 4s/次)
|
||||||
|
python -m opengoods.jobs.seed_off --barcodes 3017624010701 5449000000996
|
||||||
|
|
||||||
|
# 用下载好的 OFF dump 批量导入(可 .gz), 限制条数
|
||||||
|
python -m opengoods.jobs.seed_off --dump products.jsonl.gz --limit 1000
|
||||||
|
```
|
||||||
|
DSN 默认读 `OPENGOODS_DATABASE_URL`。
|
||||||
|
|
||||||
|
## 字段映射要点
|
||||||
|
| OFF | OpenGoods | 处理 |
|
||||||
|
|-----|-----------|------|
|
||||||
|
| `code` | `product.gtin` | GTIN-8/12/13/14 校验位验证, 不合法则不作为 gtin |
|
||||||
|
| `product_name_zh/_/_en` | `product.name` | 优先中文 |
|
||||||
|
| `brands` | `brand` | 取第一个, normalized_name 去重 |
|
||||||
|
| `quantity` | `net_content_*` | 解析 "500 g"/"1,5 L" → 经 `units.py` 归一(原始+归一双存) |
|
||||||
|
| `nutriments.*_100g` | `food_detail.nutriments` | per_100g; 能量 kJ/kcal 双存, 缺一自动换算 |
|
||||||
|
| `allergens_tags`/`additives_tags` | `allergens`/`additives` | 去 `en:` 前缀 |
|
||||||
|
| `nutriscore_grade` | `nutri_score` | 大写单字母 |
|
||||||
|
| `categories*`/name | `category_id` | 关键词映射到自建品类树(起步版, 后续换 OFF 分类→GPC 映射表) |
|
||||||
|
| `image_front_url` | `product_image` | 标 CC-BY-SA 许可 |
|
||||||
|
|
||||||
|
> 全量 dump 约数 GB;CI 与单测用 fixture 离线验证 transform,DB 集成测试在无库时自动跳过。
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# 天工·商品标签 (OpenGoods) — 最终规划 (Final)
|
|
||||||
|
|
||||||
> 公益网站/服务:采集全网商品信息,提供商品参数查询 API。
|
|
||||||
> **核心原则:只采集 + 只提供信息,绝不涉及任何购买/下单/比价导购。**
|
|
||||||
> 本文档为前几版(v0.1 → v2.0)的最终收敛版,所有关键决策已锁定。详细设计见 v2.0 附件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 项目标识
|
|
||||||
- **中文名**:天工·商品标签(呼应《天工开物》)
|
|
||||||
- **英文名**:OpenGoods
|
|
||||||
- **定位**:开放、中立、可溯源的"商品参数百科 + 开放 API"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 已锁定的全部决策
|
|
||||||
|
|
||||||
| 维度 | 决策 |
|
|
||||||
|------|------|
|
|
||||||
| 首批品类 | **食品快消** |
|
|
||||||
| 价格 | 只收 **官方标准零售价 (MSRP)**:静态字段,带 currency/region/source/effective_date + 免责;**不收实时电商价、无购买入口** |
|
|
||||||
| 技术栈 | **Go**(对外只读 API/核心服务) + **Python**(采集/ETL/爬虫),经 **PostgreSQL + Redis/队列** 解耦 |
|
|
||||||
| 种子数据 | **Open Food Facts 食品 dump** 先导入,最快有真实数据 |
|
|
||||||
| 数据许可 | 对外数据库用 **ODbL + 署名**;CC0 来源(USDA)自由混入;每条数据按来源标注许可 |
|
|
||||||
| 商品分类 | **GS1 GPC 四层标准码 (Segment→Family→Class→Brick)** 为骨架 + **自建中文品类树** 映射 + 保留来源原始分类 |
|
|
||||||
| 单位管理 | 量纲字典;**原始值 + 归一化值双存**;营养统一折算到 `per_100g/per_100ml`;能量 **双存 kJ+kcal**;用十进制(NUMERIC)防误差 |
|
|
||||||
| 质量评分 | `0.4*完整度 + 0.3*来源权威 + 0.2*多源一致 + 0.1*新鲜度` |
|
|
||||||
| 众包 | **一期不做众包,先纯采集**;二期再开放贡献/纠错(带审核与版本化) |
|
|
||||||
| Go 框架 | `chi` + 标准库 `net/http`(轻量) |
|
|
||||||
| 迁移工具 | `golang-migrate`(纯 SQL,两端共享 schema) |
|
|
||||||
| 部署 | 初期 Docker Compose(postgres+redis+minio+go-api+python-worker)→ 后期 K8s |
|
|
||||||
| 地域 | 先用 OFF 全球食品库起步,后接 GS1-China 补强中国数据 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 架构(定稿)
|
|
||||||
|
|
||||||
```
|
|
||||||
数据源: OFF dump / OFF API / USDA(CC0) / GS1-China
|
|
||||||
│
|
|
||||||
▼ Python: 采集 adapters → ETL(清洗/单位归一/分类映射/去重/质量评分)
|
|
||||||
│ 写入
|
|
||||||
┌────▼─────────┐ 图片 ┌──────────┐
|
|
||||||
│ PostgreSQL │◀───────▶│ MinIO/S3 │
|
|
||||||
│ (商品档案主库)│ └──────────┘
|
|
||||||
└────▲─────────┘
|
|
||||||
│ 只读 (+Redis 缓存/限流)
|
|
||||||
▼ Go: 公开 REST API + OpenAPI 文档
|
|
||||||
各种软件 / 开发者 (无任何交易端点)
|
|
||||||
```
|
|
||||||
两端不直接互调,通过共享 PostgreSQL schema + 《数据契约文档》协作。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 仓库结构(写代码时落地)
|
|
||||||
```
|
|
||||||
goods/ (OpenGoods 天工·商品标签)
|
|
||||||
├── README.md
|
|
||||||
├── LICENSE # 代码: Apache-2.0/MIT; 数据: ODbL 说明
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── docs/{data-contract.md, openapi.yaml, disclaimer.md}
|
|
||||||
├── migrations/ # golang-migrate 共享 SQL
|
|
||||||
├── api/ # Go 只读 API (chi)
|
|
||||||
│ ├── cmd/server/main.go
|
|
||||||
│ └── internal/{handler,store,model,middleware}/
|
|
||||||
└── ingestion/ # Python 采集 + ETL
|
|
||||||
├── adapters/{openfoodfacts,usda,gs1}.py
|
|
||||||
├── etl/{normalize_units,map_category,dedup,quality}.py
|
|
||||||
└── jobs/{seed_off_dump,scheduler}.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 最终可执行任务清单(按里程碑)
|
|
||||||
|
|
||||||
**M0 — 工程地基**(~3–5d)
|
|
||||||
- [ ] Go module + Python 项目骨架
|
|
||||||
- [ ] docker-compose(postgres+redis+minio)
|
|
||||||
- [ ] CI(Go build/vet/test;Python ruff/pytest)
|
|
||||||
- [ ] `docs/data-contract.md`、`docs/disclaimer.md`(不提供购买声明)初版
|
|
||||||
|
|
||||||
**M1 — 数据模型 + 分类 + 单位**(~4–6d)
|
|
||||||
- [ ] migrations:product / food_detail / product_msrp / product_source / brand / manufacturer / category / category_schema / unit / attribute_definition / merge_log
|
|
||||||
- [ ] 导入 GS1 GPC 骨架 + 建自建中文品类树 + 映射表
|
|
||||||
- [ ] 单位字典 + 归一化规则
|
|
||||||
- [ ] 索引:gtin 唯一、name trigram、JSONB GIN、category ltree
|
|
||||||
|
|
||||||
**M2 — 种子数据 (Python)**(~5–8d)
|
|
||||||
- [ ] 下载 OFF 食品 dump → 字段映射(成分/营养/过敏原/图片)
|
|
||||||
- [ ] 单位归一 + 分类映射入库
|
|
||||||
- [ ] USDA(CC0) 营养补全(可选)
|
|
||||||
|
|
||||||
**M3 — MVP API (Go)**(~5–8d)
|
|
||||||
- [ ] 端点:barcode / id / search / nutriments / msrp / brands / categories / sources / healthz
|
|
||||||
- [ ] 统一响应信封、分页、`fields=` 裁剪、错误码
|
|
||||||
- [ ] Redis 缓存 + IP 限流 + OpenAPI 文档
|
|
||||||
|
|
||||||
**M4 — 采集管线 (Python)**(~8–12d)
|
|
||||||
- [ ] adapter:OFF API 增量 + GS1 条码补全
|
|
||||||
- [ ] ETL:清洗/归一/去重合并/冲突解决/质量评分/字段级溯源
|
|
||||||
- [ ] 调度(定时增量更新)
|
|
||||||
|
|
||||||
**M5 — 开放与规模化**(~10–15d)
|
|
||||||
- [ ] 搜索引擎(PG 全文 → OpenSearch)、CDN
|
|
||||||
- [ ] 免费 API Key(防滥用+统计)
|
|
||||||
- [ ] 众包贡献后台(提交/审核/版本/信誉)
|
|
||||||
- [ ] 开发者文档站 + 开放数据许可与免责声明上线
|
|
||||||
|
|
||||||
> 关键路径:M0→M1→M2→M3(最快拿到可查询 MVP);M4/M5 后续并行迭代。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 下一步
|
|
||||||
规划已全部定稿。你之前说"先不写代码",所以我**停在这里待命**。
|
|
||||||
等你说"开始",我从 **M0 工程地基** 动手,搭好骨架后开 PR 给你看(也可指定先只做某几个里程碑,例如 M0+M1)。
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
# 商品档案公益 API 系统 — 深化规划 (v2.0)
|
|
||||||
|
|
||||||
> 在 v1.0 定稿基础上,全面展开 8 个方向,并新增 **商品分类体系** 与 **单位管理体系** 两章。
|
|
||||||
> 不变原则:**只采集 + 只提供信息,绝不涉及购买/交易行为。** 全文仍为规划,未写代码。
|
|
||||||
|
|
||||||
**目录**
|
|
||||||
- A. 商品分类体系(新增)
|
|
||||||
- B. 单位管理体系(新增)
|
|
||||||
- 1. 数据库详细设计
|
|
||||||
- 2. API 详细契约
|
|
||||||
- 3. 数据治理(去重/冲突/质量评分/溯源)
|
|
||||||
- 4. 采集合规细则
|
|
||||||
- 5. 部署与运维
|
|
||||||
- 6. 众包贡献流程
|
|
||||||
- 7. 项目治理(域名/许可/免责)
|
|
||||||
- 8. 时间与里程碑估算
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 商品分类体系(Taxonomy)
|
|
||||||
|
|
||||||
商品分类是整个档案库的骨架,直接影响搜索、参数模板、去重。建议**对齐国际标准 + 自建可读品类树**双轨。
|
|
||||||
|
|
||||||
### A.1 采用 GS1 GPC 作为标准骨架
|
|
||||||
GS1 **GPC(Global Product Classification)** 是四层、规则化的全球商品分类,8 位数字编码:
|
|
||||||
|
|
||||||
```
|
|
||||||
Segment(段) → Family(族) → Class(类) → Brick(砖)
|
|
||||||
47000000 47100000 47101800 10000xxx
|
|
||||||
清洁/卫生 清洁用品 ... 具体品类(GTIN挂这里)
|
|
||||||
```
|
|
||||||
- 全球 44 个 Segment,食品快消主要落在 **Food/Beverage/Tobacco** 与 **Cleaning/Hygiene** 等段。
|
|
||||||
- **Brick** 是最细粒度,商品(GTIN)挂在 brick 上;每个 brick 可带 ≤25 个属性,正好对应我们的"品类参数模板"。
|
|
||||||
- 好处:与 GS1/电商/数据池天然对齐,便于将来对接 OFF、USDA、GS1-China。
|
|
||||||
|
|
||||||
### A.2 三层映射策略
|
|
||||||
| 层 | 用途 | 来源 |
|
|
||||||
|----|------|------|
|
|
||||||
| **标准码 (gpc_brick_code)** | 机器对齐、跨源映射 | GS1 GPC |
|
|
||||||
| **自建品类树 (category)** | 人类可读、网站导航、中文友好 | 自建,映射到 GPC |
|
|
||||||
| **来源原始分类 (source_category)** | 保留溯源 | OFF categories / USDA / GS1 |
|
|
||||||
|
|
||||||
> OFF 有自己的 categories taxonomy(标签式、多语言),导入时做 `OFF category → 自建 category → GPC brick` 的映射表,未命中的进人工/众包校对队列。
|
|
||||||
|
|
||||||
### A.3 品类参数模板(Category Schema)
|
|
||||||
每个叶子品类定义"应有哪些参数",用于:① 数据完整度评分 ② 录入/校验约束 ③ API 返回结构提示。
|
|
||||||
```jsonc
|
|
||||||
// category_schema 示例: 包装水
|
|
||||||
{
|
|
||||||
"category_id": "beverage/packaged_water",
|
|
||||||
"gpc_brick_code": "10000159",
|
|
||||||
"required_attributes": ["net_content", "shelf_life"],
|
|
||||||
"recommended_attributes": ["ph", "tds", "water_type"],
|
|
||||||
"nutriment_basis": "per_100ml"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### A.4 分类落地要点
|
|
||||||
- 分类树存为**邻接表 + 物化路径**(`path` 列,便于子树查询)。
|
|
||||||
- 多对一:一个商品归一个主品类(primary),可挂多个辅助标签(labels)。
|
|
||||||
- 分类可演进:用 `category_version` 管理重命名/合并,旧 ID 重定向不破坏 API。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 单位管理体系(Units)
|
|
||||||
|
|
||||||
食品参数单位混乱(g/kg/ml/L/份/%/kcal/kJ…),必须有统一的**单位字典 + 量纲 + 归一化**机制,否则无法比较和检索。
|
|
||||||
|
|
||||||
### B.1 量纲与基准单位
|
|
||||||
| 量纲 (dimension) | 基准单位 (canonical) | 常见单位 |
|
|
||||||
|------------------|----------------------|----------|
|
|
||||||
| 质量 mass | g | mg, g, kg, 斤, oz, lb |
|
|
||||||
| 体积 volume | ml | ml, L, cl, fl oz |
|
|
||||||
| 能量 energy | kJ | kJ, kcal(同时存两者) |
|
|
||||||
| 数量 count | 个 | 个/瓶/包/片/粒 |
|
|
||||||
| 比例 ratio | %(或无量纲) | %, ‰, mg/100g |
|
|
||||||
| 长度 length | mm | mm, cm, m, in |
|
|
||||||
| 时间(保质期) duration | 天 | 天/月/年 |
|
|
||||||
|
|
||||||
### B.2 单位字典 `unit`
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"code": "kg",
|
|
||||||
"dimension": "mass",
|
|
||||||
"to_canonical_factor": 1000, // 1 kg = 1000 g
|
|
||||||
"canonical": "g",
|
|
||||||
"aliases": ["千克", "公斤", "kgs"],
|
|
||||||
"display": "kg"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### B.3 归一化规则
|
|
||||||
- **入库双存**:原始值/单位 `{value, unit}` + 归一化值 `{canonical_value, canonical_unit}`,原始保留供溯源与展示。
|
|
||||||
- **营养基准统一**:全部折算到 `per_100g` 或 `per_100ml`(按品类模板决定),并保留 `serving_size` 原值。
|
|
||||||
- **能量双单位**:同时存 kJ + kcal(1 kcal ≈ 4.184 kJ),缺一个则自动换算并标记 `derived=true`。
|
|
||||||
- **不可换算**:count(个/瓶)等不跨量纲换算;只做单位别名归一。
|
|
||||||
- **精度与舍入**:用十进制(`NUMERIC`)避免浮点误差;记录有效数字。
|
|
||||||
- **冲突处理**:单位无法识别 → 入"待清洗队列",不丢数据。
|
|
||||||
|
|
||||||
### B.4 单位与 API
|
|
||||||
- API 默认返回**原始单位 + 归一化值**两套;可加 `?unit_system=metric|original` 控制展示。
|
|
||||||
- 搜索/过滤一律基于 canonical 值(如"热量<200kcal/100g")。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 数据库详细设计
|
|
||||||
|
|
||||||
PostgreSQL。核心:关系表 + JSONB 灵活属性 + 结构化营养子表。以下为 DDL 草案(写代码时落到 `migrations/`)。
|
|
||||||
|
|
||||||
### 1.1 ER 概览
|
|
||||||
```
|
|
||||||
brand 1───* product *───1 category ───* category_schema
|
|
||||||
manufacturer 1───* product
|
|
||||||
product 1───1 food_detail
|
|
||||||
product 1───* product_msrp
|
|
||||||
product 1───* product_source (溯源, 字段级)
|
|
||||||
product *───* attribute (via product_attribute, 或 JSONB)
|
|
||||||
unit (字典) attribute_definition (参数字典)
|
|
||||||
contribution / merge_log / source (治理与登记)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 关键建表草案(节选)
|
|
||||||
```sql
|
|
||||||
CREATE TABLE product (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
gtin VARCHAR(14) UNIQUE, -- 可空(无条码商品)
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
brand_id UUID REFERENCES brand(id),
|
|
||||||
manufacturer_id UUID REFERENCES manufacturer(id),
|
|
||||||
category_id UUID REFERENCES category(id),
|
|
||||||
gpc_brick_code VARCHAR(8),
|
|
||||||
net_content_value NUMERIC,
|
|
||||||
net_content_unit VARCHAR(16),
|
|
||||||
net_content_canonical NUMERIC, -- 归一化(g/ml)
|
|
||||||
country_of_origin VARCHAR(64),
|
|
||||||
shelf_life_days INT,
|
|
||||||
storage TEXT,
|
|
||||||
attributes JSONB DEFAULT '{}', -- 灵活参数
|
|
||||||
quality_score NUMERIC(4,3) DEFAULT 0,
|
|
||||||
status VARCHAR(16) DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE food_detail (
|
|
||||||
product_id UUID PRIMARY KEY REFERENCES product(id) ON DELETE CASCADE,
|
|
||||||
ingredients_text TEXT,
|
|
||||||
ingredients JSONB, -- [{name,rank}]
|
|
||||||
allergens TEXT[],
|
|
||||||
additives TEXT[],
|
|
||||||
nutriments JSONB, -- 见单位章, 归一到 per_100g/ml
|
|
||||||
nutrition_basis VARCHAR(16),
|
|
||||||
serving_size VARCHAR(32),
|
|
||||||
nutri_score CHAR(1),
|
|
||||||
labels TEXT[]
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE product_msrp (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
|
|
||||||
amount NUMERIC(12,2) NOT NULL,
|
|
||||||
currency CHAR(3) NOT NULL, -- ISO 4217
|
|
||||||
region VARCHAR(8) DEFAULT 'CN',
|
|
||||||
source_id UUID REFERENCES source(id),
|
|
||||||
source_url TEXT,
|
|
||||||
effective_date DATE,
|
|
||||||
note TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE product_source ( -- 字段级溯源
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
|
|
||||||
source_id UUID REFERENCES source(id),
|
|
||||||
url TEXT,
|
|
||||||
fields TEXT[], -- 该来源贡献了哪些字段
|
|
||||||
fetched_at TIMESTAMPTZ,
|
|
||||||
raw JSONB -- 原始快照
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 索引策略
|
|
||||||
- `product.gtin` 唯一索引;`product.name` 用 `pg_trgm` GIN(模糊搜索)。
|
|
||||||
- `product.attributes` 与 `food_detail.nutriments` 建 **JSONB GIN** 索引(参数检索)。
|
|
||||||
- `category.path` 用 `ltree` 或前缀索引(子树查询)。
|
|
||||||
- 全文检索:初期 `tsvector`(name+brand+ingredients) GIN;规模化后迁 OpenSearch。
|
|
||||||
- 时间列 `updated_at` 索引(增量同步)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. API 详细契约
|
|
||||||
|
|
||||||
只读、版本化、统一信封。下面给核心端点的示例。
|
|
||||||
|
|
||||||
### 2.1 按条码查询(最常用)
|
|
||||||
```
|
|
||||||
GET /api/v1/products/barcode/3017624010701?fields=name,brand,nutriments,msrp
|
|
||||||
```
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "…", "gtin": "3017624010701",
|
|
||||||
"name": "示例牌 巧克力榛子酱 400g", "brand": "示例牌",
|
|
||||||
"category": "食品/酱料/巧克力酱",
|
|
||||||
"net_content": {"value":400,"unit":"g","canonical":{"value":400,"unit":"g"}},
|
|
||||||
"food": {
|
|
||||||
"nutriments": {"energy_kcal":539,"energy_kj":2255,"fat_g":30.9,"sugars_g":56.3,"salt_g":0.107},
|
|
||||||
"nutrition_basis":"per_100g", "allergens":["坚果","乳","大豆"]
|
|
||||||
},
|
|
||||||
"msrp": {"amount":29.90,"currency":"CNY","region":"CN","effective_date":"2026-01-01",
|
|
||||||
"note":"官方建议零售价, 本站不提供购买"}
|
|
||||||
},
|
|
||||||
"meta": {"version":"v1"},
|
|
||||||
"sources": [{"source":"Open Food Facts","url":"…","fetched_at":"…","license":"ODbL"}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 搜索
|
|
||||||
```
|
|
||||||
GET /api/v1/products/search?q=巧克力&brand=示例牌&category=酱料&allergen_free=花生&page=1&size=20&fields=…
|
|
||||||
```
|
|
||||||
返回 `data:[…]` + `meta:{page,size,total,total_pages}`。
|
|
||||||
|
|
||||||
### 2.3 端点清单 & 错误码
|
|
||||||
| 端点 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `GET /products/barcode/{gtin}` | 条码查 |
|
|
||||||
| `GET /products/{id}` | ID 查 |
|
|
||||||
| `GET /products/search` | 搜索/过滤/分页 |
|
|
||||||
| `GET /products/{id}/nutriments` | 仅营养 |
|
|
||||||
| `GET /products/{id}/msrp` | 仅官方价(含免责) |
|
|
||||||
| `GET /brands` `GET /categories` | 品牌 / 品类树 |
|
|
||||||
| `GET /sources/{id}` | 数据来源透明说明 |
|
|
||||||
| `GET /healthz` `GET /openapi.json` | 健康检查 / 机读文档 |
|
|
||||||
|
|
||||||
错误码:`400`(参数错) `404`(未找到, 返回 `{error:{code:"not_found"}}`) `429`(限流, 带 `Retry-After`) `5xx`(服务端)。统一错误信封 `{error:{code,message,request_id}}`。
|
|
||||||
|
|
||||||
### 2.4 跨切面
|
|
||||||
- **版本化** `/v1/`;破坏性变更升 `/v2/`,旧版保留过渡期。
|
|
||||||
- **限流**:匿名 IP 默认 60 req/min(可调);免费 API Key 提配额。响应头 `X-RateLimit-*`。
|
|
||||||
- **缓存**:`Cache-Control` + ETag;CDN + Redis;条码查命中率高。
|
|
||||||
- **CORS**:开放 GET(公益 API)。
|
|
||||||
- **分页**:`page/size`(上限 100);大结果集用 `search_after` 游标(OpenSearch 阶段)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 数据治理
|
|
||||||
|
|
||||||
### 3.1 实体去重 / 匹配
|
|
||||||
1. **强匹配**:相同 `gtin` → 同一商品(条码是天然主键)。
|
|
||||||
2. **弱匹配**(无 gtin 或 gtin 缺失):`(规范化品牌 + 规范化型号/名称 + 净含量)` 相似度(trigram/编辑距离)+ 阈值;命中候选进**人工/众包确认**,不自动硬合并。
|
|
||||||
3. **合并**:保留一条 canonical,其余标 `status=merged` 并写 `merge_log`(可回滚)。
|
|
||||||
|
|
||||||
### 3.2 多源字段冲突解决
|
|
||||||
- 每个字段记录来源 + 时间 + 来源可信度权重。
|
|
||||||
- 冲突时:① 按**来源可信度**(GS1官方 > 厂商官网 > OFF众包 > 第三方)② 同级取**最新** ③ 数值类可取多数/中位数。
|
|
||||||
- 保留所有来源值于 `product_source.raw`,对外 `sources` 字段透明展示"该字段来自谁"。
|
|
||||||
|
|
||||||
### 3.3 质量评分公式(0~1)
|
|
||||||
```
|
|
||||||
quality_score = 0.4*完整度 + 0.3*来源权威度 + 0.2*多源一致性 + 0.1*新鲜度
|
|
||||||
完整度 = 命中品类模板 required/recommended 字段的比例
|
|
||||||
权威度 = 贡献字段的来源权重加权
|
|
||||||
一致性 = 多源同字段一致的比例
|
|
||||||
新鲜度 = 最近更新时间衰减
|
|
||||||
```
|
|
||||||
低分商品在搜索中降权,并进入"待补全"队列(可派给众包)。
|
|
||||||
|
|
||||||
### 3.4 溯源(Provenance)
|
|
||||||
字段级溯源:每条数据可回答"这个营养值/价格来自哪个来源、什么时间、什么许可"。这是公益项目可信度的核心,也用于许可合规标注。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 采集合规细则
|
|
||||||
|
|
||||||
### 4.1 通用护栏
|
|
||||||
- 严格遵守 `robots.txt` 与各源服务条款;礼貌限速(OFF 读 ≤15 req/min/IP);错峰;明确 `User-Agent` 标识本项目身份与联系方式。
|
|
||||||
- 只采**客观参数**;不照搬受版权保护的营销文案/评测原文(链接来源即可)。
|
|
||||||
- 增量优先:用 `last_modified`/dump 差异做增量,避免重复抓取。
|
|
||||||
|
|
||||||
### 4.2 各源接入步骤
|
|
||||||
| 源 | 步骤 | 许可 |
|
|
||||||
|----|------|------|
|
|
||||||
| **OFF dump**(首批种子) | 下载 `en.openfoodfacts.org.products.csv.gz`(~0.9G压缩) → 解析 → 映射字段 → 入库 | ODbL(衍生库需 ODbL+署名) |
|
|
||||||
| **OFF API**(增量) | 按 gtin 拉取/按更新时间增量;遵守限速 | 同上 |
|
|
||||||
| **USDA FoodData Central** | 申请免费 API key;或下载 Branded/Foundation JSON;补全营养 | CC0(最宽松) |
|
|
||||||
| **GS1 / 中国商品信息服务平台** | 条码→品牌/规格/厂商;API ≤1000 GTIN/次(需授权) | 受限,按授权使用 |
|
|
||||||
| **厂商官网** | 逐站 adapter,遵守 robots,取官方规格表/MSRP | 取客观参数 |
|
|
||||||
|
|
||||||
### 4.3 许可合规
|
|
||||||
- OFF=ODbL(传染性,衍生数据库须同样开放+署名 OFF);USDA=CC0。
|
|
||||||
- 对外数据库整体采用 **ODbL + 署名**;每条数据按 `sources[].license` 标注其来源许可,避免冲突。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 部署与运维
|
|
||||||
|
|
||||||
### 5.1 演进路径
|
|
||||||
- **初期**:Docker Compose 一键起 `postgres + redis + minio + go-api + python-worker`,单机即可跑通 MVP。
|
|
||||||
- **成长期**:API 多副本 + 读副本数据库 + CDN;worker 横向扩展。
|
|
||||||
- **规模化**:K8s(API Deployment + HPA、worker Job/CronJob)、OpenSearch 集群、对象存储用云 S3。
|
|
||||||
|
|
||||||
### 5.2 可观测性
|
|
||||||
- 指标:Prometheus(QPS、延迟、缓存命中、限流计数、采集成功率)。
|
|
||||||
- 日志:结构化日志 + request_id 贯穿。
|
|
||||||
- 链路:OpenTelemetry(API → DB)。
|
|
||||||
- 告警:错误率/延迟/采集失败/磁盘。
|
|
||||||
|
|
||||||
### 5.3 备份与可靠性
|
|
||||||
- Postgres 每日全量 + WAL 归档;定期恢复演练。
|
|
||||||
- 对象存储多版本/冗余。
|
|
||||||
- 采集 worker 幂等 + 重试 + 死信队列。
|
|
||||||
|
|
||||||
### 5.4 成本(量级估算,公益项目控成本)
|
|
||||||
- MVP:单台小型云主机(2C4G)+ 对象存储即可(月成本很低)。
|
|
||||||
- OFF 食品子集约数百万条,PG 单实例可承载;图片走对象存储 + CDN(按流量)。
|
|
||||||
- 详细预算待定(取决于云厂商与访问量),可后续出一版成本表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 众包贡献流程
|
|
||||||
|
|
||||||
公益库靠社区补全/纠错。流程:
|
|
||||||
1. **提交**:用户对某商品提交新增/修改(带可选来源链接、照片)。
|
|
||||||
2. **校验**:单位/格式/品类模板校验 + 反垃圾(限频、信誉分、验证码)。
|
|
||||||
3. **审核**:低风险字段自动接受并标 `source=community`;高风险(价格、品牌)进人工/资深用户审核队列。
|
|
||||||
4. **版本化**:每次修改存历史版本,可 diff、可回滚(类似 wiki)。
|
|
||||||
5. **信誉系统**:贡献被采纳提升信誉;高信誉用户审核权更大。
|
|
||||||
6. **溯源透明**:众包数据与官方数据在 `sources` 中明确区分。
|
|
||||||
|
|
||||||
> 注意:众包内容也要遵守"只客观信息、不导购",并保留权利方下架通道。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 项目治理(域名/许可/免责)
|
|
||||||
|
|
||||||
- **品牌/域名**:建议中性、表意清晰的名字(如 *商品档案 / OpenGoods* 之类),后续选定。
|
|
||||||
- **代码许可**:开源(如 MIT/Apache-2.0),鼓励复用。
|
|
||||||
- **数据许可**:**ODbL + 署名**(因含 OFF);API 文档明示再利用条款。
|
|
||||||
- **隐私**:不收集个人数据(PII),只处理商品信息;众包账号信息最小化。
|
|
||||||
- **免责声明(站点显著位置)**:
|
|
||||||
- "本站为公益信息平台,**仅提供商品参数信息,不提供任何购买/交易服务**。"
|
|
||||||
- "价格为官方建议零售价历史快照,实际售价以零售商为准,**不构成消费或购买建议**。"
|
|
||||||
- "数据来自多来源并标注出处,可能存在误差;欢迎纠错,权利方可申请更正/下架。"
|
|
||||||
- **下架/纠错渠道**:公开邮箱/表单,承诺响应时限。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 时间与里程碑估算
|
|
||||||
|
|
||||||
> 仅为相对工作量估算(以"理想工作日"计,非承诺排期);实际取决于投入人力与数据源接入难度。
|
|
||||||
|
|
||||||
| 里程碑 | 内容 | 估算 | 依赖 | 主要风险 |
|
|
||||||
|--------|------|------|------|----------|
|
|
||||||
| **M0 地基** | Go/Python 骨架、Compose、CI、数据契约 | 3–5 d | — | 低 |
|
|
||||||
| **M1 数据模型** | 迁移、分类树、单位字典、参数模板 | 4–6 d | M0 | 分类/单位建模需打磨 |
|
|
||||||
| **M2 种子数据** | OFF dump 导入 + 单位归一 + 分类映射 | 5–8 d | M1 | dump 体量大、字段映射脏 |
|
|
||||||
| **M3 MVP API(Go)** | 端点 + 缓存/限流 + OpenAPI | 5–8 d | M1,M2 | 检索性能调优 |
|
|
||||||
| **M4 采集管线(Python)** | OFF/GS1 adapter + ETL + 去重 + 质量分 + 调度 | 8–12 d | M2 | 去重/冲突算法、合规 |
|
|
||||||
| **M5 开放/规模化** | 搜索引擎、CDN、API Key、众包后台、文档站 | 10–15 d | M3,M4 | 众包审核与防滥用 |
|
|
||||||
|
|
||||||
关键路径:M0→M1→M2→M3(最快拿到可查询 MVP);M4/M5 可与后续并行迭代。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 待确认(本版新增点)
|
|
||||||
1. **分类标准**:认同以 **GS1 GPC** 为标准骨架 + 自建中文品类树映射吗?
|
|
||||||
2. **单位策略**:营养统一折算到 `per_100g/per_100ml`、能量双存 kJ+kcal,认同吗?
|
|
||||||
3. **质量评分权重**:上面的 0.4/0.3/0.2/0.1 权重是否合适,或你有偏好?
|
|
||||||
4. **众包**:第一阶段就要做众包贡献,还是先纯采集、后期再开放众包?
|
|
||||||
5. **项目命名/域名**:有想好的名字吗?没有的话我可以提几个候选。
|
|
||||||
|
|
||||||
> 确认后我把 v2.0 收敛为可执行的工程任务清单。需要动手写代码时你说一声,我从 M0 开始搭骨架开 PR。
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
# 天工·商品标签 (OpenGoods) — 深化规划 (v3.0)
|
|
||||||
|
|
||||||
> 在最终版基础上,展开全部 10 个进阶方向。仍为规划,未写代码。
|
|
||||||
> 原则不变:**只采集 + 只提供信息,绝不涉及购买/交易。**
|
|
||||||
|
|
||||||
**目录**
|
|
||||||
1. 完整 OpenAPI 规范草案
|
|
||||||
2. 完整数据库 DDL
|
|
||||||
3. 数据契约文档
|
|
||||||
4. OFF 字段映射表
|
|
||||||
5. 中国合规专项
|
|
||||||
6. 测试与数据质量保障
|
|
||||||
7. 安全与反滥用
|
|
||||||
8. 商品图片处理
|
|
||||||
9. 可用性与 SLA
|
|
||||||
10. 竞品 / 同类项目分析
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 完整 OpenAPI 规范草案(节选骨架,写代码时落到 `docs/openapi.yaml`)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: OpenGoods API (天工·商品标签)
|
|
||||||
version: "1.0.0"
|
|
||||||
description: >
|
|
||||||
公益商品参数查询 API。只提供信息,不提供购买/交易。
|
|
||||||
数据采用 ODbL 许可并署名来源。
|
|
||||||
license: {name: ODbL-1.0, url: https://opendatacommons.org/licenses/odbl/}
|
|
||||||
servers:
|
|
||||||
- {url: https://api.opengoods.org/api/v1}
|
|
||||||
paths:
|
|
||||||
/products/barcode/{gtin}:
|
|
||||||
get:
|
|
||||||
summary: 按条码查询商品档案
|
|
||||||
parameters:
|
|
||||||
- {name: gtin, in: path, required: true, schema: {type: string, pattern: '^[0-9]{8,14}$'}}
|
|
||||||
- {name: fields, in: query, schema: {type: string}, description: 逗号分隔字段裁剪}
|
|
||||||
responses:
|
|
||||||
'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/ProductEnvelope'}}}}
|
|
||||||
'404': {description: 未找到, content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}}}
|
|
||||||
'429': {description: 限流, headers: {Retry-After: {schema: {type: integer}}}}
|
|
||||||
/products/{id}:
|
|
||||||
get: { summary: 按ID查询, parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}], responses: {'200': {description: OK}} }
|
|
||||||
/products/search:
|
|
||||||
get:
|
|
||||||
summary: 搜索/过滤/分页
|
|
||||||
parameters:
|
|
||||||
- {name: q, in: query, schema: {type: string}}
|
|
||||||
- {name: brand, in: query, schema: {type: string}}
|
|
||||||
- {name: category, in: query, schema: {type: string}}
|
|
||||||
- {name: allergen_free, in: query, schema: {type: string}}
|
|
||||||
- {name: page, in: query, schema: {type: integer, default: 1}}
|
|
||||||
- {name: size, in: query, schema: {type: integer, default: 20, maximum: 100}}
|
|
||||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/SearchEnvelope'}}}}}
|
|
||||||
/products/{id}/nutriments: {get: {summary: 仅营养}}
|
|
||||||
/products/{id}/msrp: {get: {summary: 仅官方零售价(含免责)}}
|
|
||||||
/brands: {get: {summary: 品牌列表}}
|
|
||||||
/categories: {get: {summary: 品类树}}
|
|
||||||
/sources/{id}:{get: {summary: 数据来源透明说明}}
|
|
||||||
/healthz: {get: {summary: 健康检查}}
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
ProductEnvelope:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
data: {$ref: '#/components/schemas/Product'}
|
|
||||||
meta: {type: object}
|
|
||||||
sources: {type: array, items: {$ref: '#/components/schemas/SourceRef'}}
|
|
||||||
Product:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id: {type: string, format: uuid}
|
|
||||||
gtin: {type: string}
|
|
||||||
name: {type: string}
|
|
||||||
brand: {type: string}
|
|
||||||
category: {type: string}
|
|
||||||
net_content: {$ref: '#/components/schemas/Quantity'}
|
|
||||||
food: {$ref: '#/components/schemas/FoodDetail'}
|
|
||||||
msrp: {$ref: '#/components/schemas/Msrp'}
|
|
||||||
quality_score: {type: number}
|
|
||||||
Quantity:
|
|
||||||
type: object
|
|
||||||
properties: {value: {type: number}, unit: {type: string}, canonical: {type: object}}
|
|
||||||
FoodDetail:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
ingredients_text: {type: string}
|
|
||||||
allergens: {type: array, items: {type: string}}
|
|
||||||
additives: {type: array, items: {type: string}}
|
|
||||||
nutriments: {type: object}
|
|
||||||
nutrition_basis: {type: string, enum: [per_100g, per_100ml, per_serving]}
|
|
||||||
nutri_score: {type: string}
|
|
||||||
Msrp:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
amount: {type: number}
|
|
||||||
currency: {type: string}
|
|
||||||
region: {type: string}
|
|
||||||
effective_date: {type: string, format: date}
|
|
||||||
note: {type: string, default: "官方建议零售价, 本站不提供购买"}
|
|
||||||
SourceRef:
|
|
||||||
type: object
|
|
||||||
properties: {source: {type: string}, url: {type: string}, fetched_at: {type: string}, license: {type: string}}
|
|
||||||
Error:
|
|
||||||
type: object
|
|
||||||
properties: {error: {type: object, properties: {code: {type: string}, message: {type: string}, request_id: {type: string}}}}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 该 `openapi.yaml` 既是契约也是文档源:Go 端用它做路由校验/生成 Swagger UI,客户端可由它生成 SDK。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 完整数据库 DDL(全部表)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 扩展
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
||||||
CREATE EXTENSION IF NOT EXISTS ltree;
|
|
||||||
-- gen_random_uuid() 由 pgcrypto 提供
|
|
||||||
|
|
||||||
CREATE TABLE source (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL, -- Open Food Facts / USDA / GS1-China ...
|
|
||||||
homepage TEXT,
|
|
||||||
license TEXT, -- ODbL / CC0 / proprietary
|
|
||||||
trust_weight NUMERIC(3,2) DEFAULT 0.5, -- 来源可信度(冲突解决用)
|
|
||||||
notes TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE brand (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
normalized_name TEXT, -- 规范化(去空格/大小写/全半角)用于匹配
|
|
||||||
aliases TEXT[],
|
|
||||||
UNIQUE(normalized_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE manufacturer (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
normalized_name TEXT,
|
|
||||||
country VARCHAR(64),
|
|
||||||
UNIQUE(normalized_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE category (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name_zh TEXT NOT NULL,
|
|
||||||
name_en TEXT,
|
|
||||||
parent_id UUID REFERENCES category(id),
|
|
||||||
path LTREE, -- 物化路径, 子树查询
|
|
||||||
gpc_brick_code VARCHAR(8), -- 映射到 GS1 GPC
|
|
||||||
level INT,
|
|
||||||
UNIQUE(path)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE category_schema ( -- 品类参数模板
|
|
||||||
category_id UUID PRIMARY KEY REFERENCES category(id),
|
|
||||||
required_attributes TEXT[],
|
|
||||||
recommended_attributes TEXT[],
|
|
||||||
nutriment_basis VARCHAR(16)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE unit ( -- 单位字典
|
|
||||||
code VARCHAR(16) PRIMARY KEY,
|
|
||||||
dimension VARCHAR(16) NOT NULL, -- mass/volume/energy/count/ratio/length/duration
|
|
||||||
canonical VARCHAR(16) NOT NULL,
|
|
||||||
to_canonical_factor NUMERIC, -- code -> canonical 的换算因子
|
|
||||||
aliases TEXT[],
|
|
||||||
display TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE attribute_definition ( -- 参数字典(标准名/别名/单位)
|
|
||||||
key VARCHAR(64) PRIMARY KEY,
|
|
||||||
label_zh TEXT, label_en TEXT,
|
|
||||||
dimension VARCHAR(16),
|
|
||||||
default_unit VARCHAR(16) REFERENCES unit(code),
|
|
||||||
aliases TEXT[]
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE product (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
gtin VARCHAR(14) UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
brand_id UUID REFERENCES brand(id),
|
|
||||||
manufacturer_id UUID REFERENCES manufacturer(id),
|
|
||||||
category_id UUID REFERENCES category(id),
|
|
||||||
gpc_brick_code VARCHAR(8),
|
|
||||||
net_content_value NUMERIC,
|
|
||||||
net_content_unit VARCHAR(16),
|
|
||||||
net_content_canonical NUMERIC,
|
|
||||||
country_of_origin VARCHAR(64),
|
|
||||||
shelf_life_days INT,
|
|
||||||
storage TEXT,
|
|
||||||
attributes JSONB DEFAULT '{}',
|
|
||||||
quality_score NUMERIC(4,3) DEFAULT 0,
|
|
||||||
status VARCHAR(16) DEFAULT 'active', -- active/merged/deprecated
|
|
||||||
canonical_id UUID REFERENCES product(id), -- 被合并到哪个
|
|
||||||
search_tsv TSVECTOR,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE food_detail (
|
|
||||||
product_id UUID PRIMARY KEY REFERENCES product(id) ON DELETE CASCADE,
|
|
||||||
ingredients_text TEXT,
|
|
||||||
ingredients JSONB,
|
|
||||||
allergens TEXT[],
|
|
||||||
additives TEXT[],
|
|
||||||
nutriments JSONB,
|
|
||||||
nutrition_basis VARCHAR(16),
|
|
||||||
serving_size VARCHAR(32),
|
|
||||||
nutri_score CHAR(1),
|
|
||||||
labels TEXT[]
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE product_msrp (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
|
|
||||||
amount NUMERIC(12,2) NOT NULL,
|
|
||||||
currency CHAR(3) NOT NULL,
|
|
||||||
region VARCHAR(8) DEFAULT 'CN',
|
|
||||||
source_id UUID REFERENCES source(id),
|
|
||||||
source_url TEXT,
|
|
||||||
effective_date DATE,
|
|
||||||
note TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE product_image (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
|
|
||||||
url TEXT, -- 对象存储 URL
|
|
||||||
kind VARCHAR(16), -- front/ingredients/nutrition
|
|
||||||
license TEXT,
|
|
||||||
source_id UUID REFERENCES source(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE product_source ( -- 字段级溯源
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID REFERENCES product(id) ON DELETE CASCADE,
|
|
||||||
source_id UUID REFERENCES source(id),
|
|
||||||
url TEXT,
|
|
||||||
fields TEXT[],
|
|
||||||
fetched_at TIMESTAMPTZ,
|
|
||||||
raw JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE merge_log ( -- 合并/回滚
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
kept_id UUID, merged_id UUID,
|
|
||||||
reason TEXT, by TEXT, created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 索引
|
|
||||||
CREATE UNIQUE INDEX idx_product_gtin ON product(gtin) WHERE gtin IS NOT NULL;
|
|
||||||
CREATE INDEX idx_product_name_trgm ON product USING gin (name gin_trgm_ops);
|
|
||||||
CREATE INDEX idx_product_attrs ON product USING gin (attributes);
|
|
||||||
CREATE INDEX idx_food_nutriments ON food_detail USING gin (nutriments);
|
|
||||||
CREATE INDEX idx_product_tsv ON product USING gin (search_tsv);
|
|
||||||
CREATE INDEX idx_category_path ON category USING gist (path);
|
|
||||||
CREATE INDEX idx_product_updated ON product(updated_at);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 数据契约文档(`docs/data-contract.md` 概要)
|
|
||||||
|
|
||||||
两端共享的"事实约定",避免 Go/Python 理解不一致:
|
|
||||||
- **枚举固定**:`status`(active/merged/deprecated)、`nutrition_basis`(per_100g/per_100ml/per_serving)、`dimension`、`source.license`、`image.kind`。
|
|
||||||
- **字段含义与可空性**:逐字段写明(如 `gtin` 可空、唯一;`quality_score` ∈ [0,1])。
|
|
||||||
- **单位规则**:原始 + canonical 双存;能量双存 kJ/kcal;换算因子来自 `unit` 表。
|
|
||||||
- **写入责任**:仅 Python(ingestion) 写库;Go 只读。所有写入走 ETL,保证归一化与溯源。
|
|
||||||
- **版本**:契约本身版本化;schema 变更需同步更新契约 + 迁移 + OpenAPI。
|
|
||||||
- **示例**:附 1 条完整 product JSON 作为"黄金样例",两端测试都对它断言。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. OFF(Open Food Facts)字段映射表(导入直接用)
|
|
||||||
|
|
||||||
| OFF 字段 (CSV) | OpenGoods 字段 | 处理 |
|
|
||||||
|----------------|----------------|------|
|
|
||||||
| `code` | `product.gtin` | 校验 8/12/13/14 位 + 校验位 |
|
|
||||||
| `product_name` / `product_name_zh` | `product.name` | 优先中文, 回退英文 |
|
|
||||||
| `brands` | `brand.name` | 拆分取首个, 规范化, upsert brand |
|
|
||||||
| `categories` / `categories_tags` | `source_category` → `category` | 走 OFF→自建→GPC 映射表 |
|
|
||||||
| `quantity` | `net_content_*` | 解析数值+单位 → 归一化 |
|
|
||||||
| `countries` | `country_of_origin` | 取销售国/产地 |
|
|
||||||
| `ingredients_text` | `food_detail.ingredients_text` | 原文保留 |
|
|
||||||
| `allergens_tags` | `food_detail.allergens` | 标签清洗为中文 |
|
|
||||||
| `additives_tags` | `food_detail.additives` | E-number 解析 |
|
|
||||||
| `energy-kj_100g` / `energy-kcal_100g` | `nutriments.energy_kj/kcal` | 缺一个则换算, 标 derived |
|
|
||||||
| `fat_100g` `saturated-fat_100g` `carbohydrates_100g` `sugars_100g` `proteins_100g` `salt_100g` | `nutriments.*` | 归一 per_100g |
|
|
||||||
| `nutriscore_grade` | `food_detail.nutri_score` | A–E |
|
|
||||||
| `serving_size` | `food_detail.serving_size` | 原值 |
|
|
||||||
| `image_url` / `image_front_url` 等 | `product_image.url` | 下载转存对象存储, 记 CC-BY-SA |
|
|
||||||
| `last_modified_t` | `product_source.fetched_at` | 增量基准 |
|
|
||||||
| (整行) | `product_source.raw` | 存原始快照 |
|
|
||||||
|
|
||||||
> 许可:OFF 数据=ODbL(衍生库需同样开放+署名);图片=CC-BY-SA。映射时全程记 `source_id=OFF`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 中国合规专项(公益网站落地关键)
|
|
||||||
|
|
||||||
> 以下为工程与运营层面的合规要点梳理,**非法律意见**;正式上线前建议咨询专业法务。
|
|
||||||
|
|
||||||
### 5.1 网站备案
|
|
||||||
- 服务器在中国大陆 → 需 **ICP 备案**(公益网站可走非经营性 ICP 备案);部分地区/类目可能涉 **公安联网备案**。
|
|
||||||
- 若用境外/港澳服务器可免 ICP,但访问速度与合规另作权衡。
|
|
||||||
|
|
||||||
### 5.2 数据合规(网络安全法 / 数据安全法 / 个人信息保护法)
|
|
||||||
- 本项目**只处理商品信息、不收集个人信息(PII)**,PIPL 风险低;众包阶段涉及用户账号时再做最小化收集 + 隐私政策。
|
|
||||||
- 《数据安全法》要求数据收集合法正当;做好数据分级与安全保护义务。
|
|
||||||
|
|
||||||
### 5.3 网络爬虫法律边界(重点)
|
|
||||||
依据中央网信办公开文章与司法实践,判断标准是**客观结果**——是否妨碍目标网站正常运行 / 危害合法权益:
|
|
||||||
- **守 robots.txt**、礼貌限速、错峰,**不得对目标站造成 DDoS 式压力**(否则可能触及破坏计算机信息系统罪等)。
|
|
||||||
- **不抓取非公开/需登录/绕过反爬**的数据(可能涉非法获取计算机信息系统数据罪)。
|
|
||||||
- 只采**客观公开的商品参数**;不抓取受版权保护内容、不抓个人信息。
|
|
||||||
- 优先用**官方开放数据/API/数据 dump**(OFF dump、USDA、GS1 授权)——从源头规避爬虫风险。
|
|
||||||
|
|
||||||
### 5.4 食品信息合规
|
|
||||||
- 展示食品参数时注明"信息仅供参考,以实物标签为准";营养/成分以官方/厂商标签为准。
|
|
||||||
- 不做医疗/功效宣称;不构成消费建议。
|
|
||||||
|
|
||||||
### 5.5 价格与"不导购"
|
|
||||||
- 价格仅为**官方建议零售价历史快照**,显著标注;**全站无购买/下单/跳转购买链接**,避免被认定为经营性电商导购。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 测试与数据质量保障
|
|
||||||
|
|
||||||
### 6.1 代码测试
|
|
||||||
- **Go**:handler 单元测试 + store 层用 `testcontainers`/临时 PG 集成测试 + API 契约测试(对 openapi.yaml 校验响应)。
|
|
||||||
- **Python**:ETL 纯函数单测(单位归一、分类映射、去重打分)+ adapter 用录制的样例数据测试(不打真实站点)。
|
|
||||||
- **CI**:PR 必跑 lint + test;覆盖率门槛(如 ETL 核心 ≥80%)。
|
|
||||||
|
|
||||||
### 6.2 数据质量
|
|
||||||
- **入库校验**:gtin 校验位、单位可识别、营养数值合理区间、必填字段(按品类模板)。
|
|
||||||
- **质量评分**:见 v2.0 公式,低分进"待补全"队列。
|
|
||||||
- **数据回归**:黄金样例集 + 定期跑"数据健康检查"(孤儿记录、单位异常、重复 gtin、营养越界)。
|
|
||||||
- **可观测**:导入报表(新增/更新/拒绝条数、拒绝原因 top)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 安全与反滥用
|
|
||||||
|
|
||||||
- **API 防刷**:IP 限流 + 可选 API Key 分级配额;异常流量识别(突发高频降级/挑战)。
|
|
||||||
- **缓存挡压**:热点条码走 CDN/Redis,降低数据库压力,也抗刷。
|
|
||||||
- **输入校验**:所有参数严格校验(gtin 正则、size 上限),防注入(参数化查询,禁拼 SQL)。
|
|
||||||
- **密钥管理**:DB/对象存储/第三方 key 走环境变量/密钥管理,不入库不入仓。
|
|
||||||
- **最小权限**:Go 端用**只读** DB 账号;写权限仅 ingestion。
|
|
||||||
- **采集端被封应对**:合规限速 + 失败退避 + 死信队列 + 切换为官方 dump/API。
|
|
||||||
- **DDoS**:CDN + 速率限制 + 云厂商防护;公益服务以可降级(只读缓存)保命。
|
|
||||||
- **依赖安全**:Go `govulncheck`、Python `pip-audit`,CI 中扫描。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 商品图片处理
|
|
||||||
|
|
||||||
- **版权**:OFF 图片为 CC-BY-SA,须署名 + 同样开放;逐图记 `license` 与来源。
|
|
||||||
- **存储**:对象存储(MinIO/S3),路径按 `gtin/kind`;原图 + 生成多档缩略图(thumb/medium)。
|
|
||||||
- **处理管线**:下载 → 校验(类型/大小) → 去重(感知哈希避免重复) → 压缩 → 生成缩略图 → 记录。
|
|
||||||
- **分发**:CDN 加速;API 只返回图片 URL,不内嵌二进制。
|
|
||||||
- **合规**:不展示含个人信息的图;提供权利方下架通道。
|
|
||||||
- **降级**:图片缺失返回占位;图片服务故障不影响参数 API。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 可用性与 SLA
|
|
||||||
|
|
||||||
| 项 | 目标(建议) |
|
|
||||||
|----|-----------|
|
|
||||||
| API 可用性 | 99.5%(公益项目务实目标,先保只读可用) |
|
|
||||||
| 读延迟 | p95 < 200ms(缓存命中 < 50ms) |
|
|
||||||
| 数据新鲜度 | 增量同步 T+1(每日) |
|
|
||||||
| 降级策略 | DB 故障 → 只读缓存兜底;图片/搜索故障不影响核心参数查询 |
|
|
||||||
| 灾备 | 每日备份 + 异地副本;恢复演练季度一次 |
|
|
||||||
| 维护窗口 | 采集/重建索引放低峰;API 滚动发布不停服 |
|
|
||||||
|
|
||||||
> 公益项目优先"省成本 + 稳定只读";写入(采集)可异步、可补偿,读路径要稳。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 竞品 / 同类项目分析
|
|
||||||
|
|
||||||
| 项目 | 性质 | 数据 | 借鉴点 | 与我们差异 |
|
|
||||||
|------|------|------|--------|------------|
|
|
||||||
| **Open Food Facts** | 公益食品库 | ODbL, 海量, 可贡献, 有 dump/API | 字段模型、众包、Nutri-Score、API 设计 | 我们多语言架构(Go API)、聚焦中文/GPC、收 MSRP |
|
|
||||||
| **USDA FoodData Central** | 政府营养库 | CC0, 权威营养 | 营养数据补全、公共领域许可 | 偏美国/营养, 无条码生态 |
|
|
||||||
| **GS1 / Verified by GS1** | 官方条码登记 | 受限, 权威, 2亿+ | 条码→品牌/规格权威源 | 非开放、需授权 |
|
|
||||||
| **Wikidata** | 通用知识库 | CC0, 有 GTIN 属性(P3962) | 实体链接、结构化、开放 | 非商品专用、参数不规整 |
|
|
||||||
| **schema.org Product/gtin** | 数据标准 | 标准而非数据 | 用其词汇做对外结构化(SEO/互操作) | 仅规范, 需我们填数据 |
|
|
||||||
| **brocade.io / 各条码库** | 开放/商业条码库 | 参差 | 条码补全兜底 | 数据量/质量有限或收费 |
|
|
||||||
|
|
||||||
**结论与定位**:
|
|
||||||
- 我们不是再造 OFF,而是做**面向中文世界、与 GS1 GPC 对齐、聚焦"商品参数标签"**的公益 API;
|
|
||||||
- **站在巨人肩上**:OFF/USDA 做种子与营养,GS1 做条码权威,Wikidata/schema.org 做实体与互操作标准;
|
|
||||||
- 差异化:中文优先、品类参数模板规整、官方 MSRP、字段级溯源、Go 高并发只读 API。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 小结
|
|
||||||
v3.0 已把工程落地与公益合规的关键面全部展开。规划层面已相当完整。
|
|
||||||
你之前说先不写代码,我**继续待命**:可以再深化任何一块,或等你说"开始",从 M0 搭骨架开 PR。
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 天工·商品标签 (OpenGoods) — 规划文档归档
|
|
||||||
|
|
||||||
本目录归档了项目从立项到方案定稿的全部规划文档。
|
|
||||||
|
|
||||||
## 项目一句话
|
|
||||||
公益网站/服务:**采集全网商品信息,对外提供商品参数查询 API**。
|
|
||||||
核心原则:**只采集 + 只提供信息,绝不涉及任何购买/下单/比价导购。**
|
|
||||||
|
|
||||||
## 当前文档(最新,建议优先阅读)
|
|
||||||
|
|
||||||
| 文档 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| [00-final-plan.md](./00-final-plan.md) | **最终规划**:锁定的全部决策 + 架构 + 仓库结构 + M0~M5 可执行任务清单 |
|
|
||||||
| [01-detailed-design-v2.0.md](./01-detailed-design-v2.0.md) | **详细设计**:商品分类体系(GS1 GPC)、单位管理、数据库设计、API 契约、数据治理、采集合规、部署运维、众包、里程碑估算 |
|
|
||||||
| [02-advanced-topics-v3.0.md](./02-advanced-topics-v3.0.md) | **进阶专题**:完整 OpenAPI、全表 DDL、数据契约、OFF 字段映射、中国合规专项、测试、安全反滥用、图片处理、SLA、竞品分析 |
|
|
||||||
|
|
||||||
## 演进历史(History)
|
|
||||||
|
|
||||||
| 文档 | 阶段 |
|
|
||||||
|------|------|
|
|
||||||
| [history/v0.1-initial-plan.md](./history/v0.1-initial-plan.md) | 初版总体规划 |
|
|
||||||
| [history/v0.2-go-python-foodfmcg.md](./history/v0.2-go-python-foodfmcg.md) | 确定 Go+Python 架构、聚焦食品快消、数据源调研 |
|
|
||||||
| [history/v1.0-locked-decisions.md](./history/v1.0-locked-decisions.md) | 决策定稿(默认值) |
|
|
||||||
|
|
||||||
## 已锁定的关键决策(速览)
|
|
||||||
|
|
||||||
| 维度 | 决策 |
|
|
||||||
|------|------|
|
|
||||||
| 首批品类 | 食品快消 |
|
|
||||||
| 价格 | 只收官方标准零售价 (MSRP),静态字段,无购买入口 |
|
|
||||||
| 技术栈 | Go(对外只读 API) + Python(采集/ETL),经 PostgreSQL + Redis/队列解耦 |
|
|
||||||
| 种子数据 | Open Food Facts 食品 dump |
|
|
||||||
| 数据许可 | 对外 ODbL + 署名;CC0 来源(USDA)自由混入 |
|
|
||||||
| 商品分类 | GS1 GPC 四层标准码 + 自建中文品类树映射 |
|
|
||||||
| 单位管理 | 原始值+归一化双存;营养统一 per_100g/ml;能量双存 kJ+kcal |
|
|
||||||
| 质量评分 | 0.4 完整度 + 0.3 来源权威 + 0.2 多源一致 + 0.1 新鲜度 |
|
|
||||||
| 众包 | 一期不做,先纯采集;二期开放 |
|
|
||||||
| Go 框架 / 迁移 / 部署 | chi + 标准库 / golang-migrate / Docker Compose |
|
|
||||||
|
|
||||||
> 注:文档中"中国合规专项"为工程与运营层面梳理,**非法律意见**;正式上线前请咨询专业法务。
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# 商品档案公益 API 系统 — 规划方案 (v0.1)
|
|
||||||
|
|
||||||
> 一个公益性质的网站/服务:**采集全网商品信息**,对外提供**商品参数查询 API**。
|
|
||||||
> 核心原则:**只收集信息、只提供信息,不涉及任何购买、下单、比价导购等交易行为。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 项目定位与原则
|
|
||||||
|
|
||||||
| 维度 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 定位 | 公益的"商品参数百科 / 商品档案库",类似商品界的 Wikipedia + 开放 API |
|
|
||||||
| 提供什么 | 商品的客观参数(规格、型号、成分、能效、尺寸、条码等) |
|
|
||||||
| **不提供什么** | 价格交易、加购物车、下单、跳转购买链接、联盟分佣、比价导购 |
|
|
||||||
| 服务对象 | 第三方软件 / 开发者,通过 API 查询商品参数 |
|
|
||||||
| 数据态度 | 客观、中立、可溯源(每条数据标注来源与采集时间) |
|
|
||||||
|
|
||||||
> ⚠️ 关于价格:建议**默认不收录价格**。价格属于交易属性,且实时性强、争议大。如果一定要做,只做"历史参考价"且明确标注来源时间,绝不提供购买入口。**这一点需要你确认。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 系统总体架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────┐
|
|
||||||
│ 数据来源 (Sources) │
|
|
||||||
│ 官网/厂商 / 开放数据 / GS1 │
|
|
||||||
│ 条码库 / 用户贡献 / 监管公开 │
|
|
||||||
└──────────────┬──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────────────────▼──────────────────────────┐
|
|
||||||
│ 采集层 Ingestion (Workers) │
|
|
||||||
│ 爬虫调度 + 适配器 + 限速 + robots 合规 + 去重 │
|
|
||||||
└──────────────────────────┬──────────────────────────┘
|
|
||||||
│ 原始数据 (raw)
|
|
||||||
┌──────────────────────────▼──────────────────────────┐
|
|
||||||
│ 清洗/标准化 ETL (Normalize & Dedup) │
|
|
||||||
│ 字段映射 / 单位归一 / 实体匹配 / 质量评分 │
|
|
||||||
└──────────────────────────┬──────────────────────────┘
|
|
||||||
│ 结构化商品档案
|
|
||||||
┌──────────────────────────▼──────────────────────────┐
|
|
||||||
│ 存储层 Storage │
|
|
||||||
│ PostgreSQL(主) + 对象存储(图片) + 搜索引擎(检索) │
|
|
||||||
└──────────────────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────────────────▼──────────────────────────┐
|
|
||||||
│ 公开 API 服务 (FastAPI) │
|
|
||||||
│ REST/JSON + 文档 + 限流 + 缓存 + API Key(可选) │
|
|
||||||
└──────────────────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────▼──────────────┐
|
|
||||||
│ 消费者:各种软件/开发者 │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
分为四个相对独立的子系统:
|
|
||||||
1. **采集子系统**(爬虫/适配器,离线运行)
|
|
||||||
2. **数据处理子系统**(清洗、标准化、去重、质量评分)
|
|
||||||
3. **存储子系统**(关系库 + 搜索 + 对象存储)
|
|
||||||
4. **API 子系统**(对外只读公开 API + 文档站)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 核心数据模型(商品档案 Schema)
|
|
||||||
|
|
||||||
商品的本质是"一个实体 + 一组可扩展的参数"。建议采用 **核心字段 + 灵活属性(KV)** 的混合模型,以适配不同品类(手机、食品、家电、化妆品……参数差异极大)。
|
|
||||||
|
|
||||||
### 3.1 核心实体
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// Product 商品档案
|
|
||||||
{
|
|
||||||
"id": "uuid", // 内部唯一ID
|
|
||||||
"gtin": "6901234567892", // 全球贸易项目代码(条码), 可空
|
|
||||||
"name": "示例牌 1.5L 纯净水",
|
|
||||||
"brand": "示例牌", // -> Brand 实体
|
|
||||||
"manufacturer": "示例食品有限公司",
|
|
||||||
"category": "饮料/包装水", // -> Category 树
|
|
||||||
"model": "型号/SKU标识",
|
|
||||||
"description": "客观描述, 非营销文案",
|
|
||||||
"images": ["对象存储URL", ...],
|
|
||||||
"attributes": [ // 灵活参数(见下)
|
|
||||||
{"key": "容量", "value": "1.5", "unit": "L"},
|
|
||||||
{"key": "保质期", "value": "12", "unit": "月"}
|
|
||||||
],
|
|
||||||
"identifiers": { // 其他标识
|
|
||||||
"ean": "...", "upc": "...", "asin": "...", "mpn": "..."
|
|
||||||
},
|
|
||||||
"sources": [ // 数据溯源(每个字段可标来源)
|
|
||||||
{"source_id": "...", "url": "...", "fetched_at": "2026-06-08T...", "field": "容量"}
|
|
||||||
],
|
|
||||||
"quality_score": 0.87, // 数据质量/可信度评分
|
|
||||||
"status": "active|merged|deprecated",
|
|
||||||
"created_at": "...", "updated_at": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 灵活属性 (EAV / JSONB)
|
|
||||||
- 不同品类参数差异巨大,核心表存通用字段,品类专属参数存 `attributes`(PostgreSQL `JSONB`,可建 GIN 索引)。
|
|
||||||
- 配合**品类参数模板**(Category Schema)约束某品类应有哪些参数,保证质量。
|
|
||||||
|
|
||||||
### 3.3 辅助实体
|
|
||||||
- `Brand`(品牌)、`Manufacturer`(厂商)、`Category`(品类树)、`Source`(数据来源登记)、`AttributeDefinition`(参数字典:标准名/别名/单位)。
|
|
||||||
- 实体去重/合并需要 `merge` 机制(同一商品多来源 → 合并为一条,保留溯源)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 数据采集策略(最关键、也最需合规)
|
|
||||||
|
|
||||||
### 4.1 来源优先级(从"最合规"到"需谨慎")
|
|
||||||
1. **官方开放数据 / 标准库**:GS1 条码库、各国监管公开数据(能效标识、食品备案、药品/化妆品备案等)。✅ 最佳
|
|
||||||
2. **厂商官网 / 官方规格表**:参数最权威。需遵守 robots.txt。
|
|
||||||
3. **厂商/平台开放 API**:若有官方 API 走 API。
|
|
||||||
4. **用户/社区贡献**:众包补全与纠错(带审核)。
|
|
||||||
5. **第三方网页抓取**:⚠️ 合规风险最高,需严格遵守 robots、限速、只取客观参数、标注来源。
|
|
||||||
|
|
||||||
### 4.2 采集器设计
|
|
||||||
- **适配器模式**:每个来源一个 adapter(解析规则独立、可热插拔)。
|
|
||||||
- **调度**:任务队列(Celery / RQ / arq)+ 定时(cron)+ 增量更新。
|
|
||||||
- **合规护栏**:尊重 `robots.txt`、礼貌限速、`User-Agent` 标识身份、错峰、缓存避免重复抓取。
|
|
||||||
- **去重与匹配**:以 GTIN/条码为主键,无条码时用 (品牌+型号+关键参数) 做模糊匹配。
|
|
||||||
|
|
||||||
### 4.3 数据质量
|
|
||||||
- 每个字段记录来源 + 时间;多来源冲突时按来源可信度加权。
|
|
||||||
- 质量评分 `quality_score`:字段完整度 + 来源权威度 + 一致性。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 公开 API 设计(只读、RESTful)
|
|
||||||
|
|
||||||
基础原则:**只读、无副作用、无购买入口、稳定版本化、有文档**。
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/products/{id} # 按内部ID查询商品档案
|
|
||||||
GET /api/v1/products/barcode/{gtin} # 按条码(GTIN/EAN/UPC)查询 ★最常用
|
|
||||||
GET /api/v1/products/search # 搜索: ?q=&brand=&category=&page=&size=
|
|
||||||
GET /api/v1/products/{id}/attributes # 仅取参数
|
|
||||||
GET /api/v1/brands / categories # 品牌/品类树
|
|
||||||
GET /api/v1/sources/{id} # 数据来源说明(透明溯源)
|
|
||||||
GET /healthz / /api/v1/openapi.json # 健康检查 / 机读文档
|
|
||||||
```
|
|
||||||
|
|
||||||
设计要点:
|
|
||||||
- **版本化** `/api/v1/`,破坏性变更升 `v2`。
|
|
||||||
- **分页 + 字段筛选**(`fields=` 减少传输)。
|
|
||||||
- **限流**:匿名按 IP 限流;可选 API Key 提升配额(免费,仅用于防滥用与统计)。
|
|
||||||
- **缓存**:CDN + 服务端缓存(商品参数变化慢,缓存命中率高)。
|
|
||||||
- **响应统一**:JSON,含 `data` / `meta`(分页) / `sources`(溯源)。
|
|
||||||
- **开放协议**:数据采用开放许可(如 CC BY / ODbL),鼓励署名引用。
|
|
||||||
- **自动文档**:FastAPI 自带 Swagger UI / ReDoc。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 技术选型建议
|
|
||||||
|
|
||||||
| 层 | 选型 | 理由 |
|
|
||||||
|----|------|------|
|
|
||||||
| API 框架 | **Python + FastAPI** | 与仓库定位一致、异步性能好、自带 OpenAPI 文档 |
|
|
||||||
| 主数据库 | **PostgreSQL** (JSONB) | 关系 + 灵活属性兼得,GIN 索引支持检索 |
|
|
||||||
| 搜索 | **OpenSearch / Elasticsearch / 或 PG 全文** | 商品名/参数全文与分面检索 |
|
|
||||||
| 缓存 | **Redis** | 热点缓存 + 限流计数 + 任务队列后端 |
|
|
||||||
| 采集任务 | **arq / Celery / RQ** | 异步调度爬虫与 ETL |
|
|
||||||
| 爬虫 | **httpx + selectolax/BeautifulSoup**,动态页用 **Playwright** | 轻量为主,必要时浏览器渲染 |
|
|
||||||
| 对象存储 | **S3 兼容 (MinIO / 云)** | 存商品图片 |
|
|
||||||
| 部署 | **Docker + Compose**(初期) → K8s(规模化) | 渐进式 |
|
|
||||||
| 文档站 | FastAPI 文档 + 静态站(MkDocs) | 开发者文档 |
|
|
||||||
|
|
||||||
> 如果你更偏好 Node.js / Go 也可以,我按你的偏好调整。仓库描述像是 FastAPI,所以我默认 Python。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 合规与法律(公益项目尤其重要)
|
|
||||||
|
|
||||||
- **爬取合规**:遵守 robots.txt、服务条款、合理限速;只采集**客观商品参数**,不抓取受版权保护的营销文案/评测原文(可链接来源)。
|
|
||||||
- **数据来源透明**:每条数据可溯源,标注来源与时间,尊重原始来源。
|
|
||||||
- **隐私**:只处理商品信息,不涉及个人数据(无 PII)。
|
|
||||||
- **商标/品牌**:品牌名仅用于客观标识商品,不做背书或贬损。
|
|
||||||
- **明确边界**:网站显著声明"仅提供信息、不提供购买、不构成消费建议"。
|
|
||||||
- **数据开放许可**:选择 CC BY 4.0 或 ODbL,明确他人使用条款。
|
|
||||||
- **下架机制**:提供来源方/权利方的纠错与下架联系渠道。
|
|
||||||
|
|
||||||
> 建议这块后续找法务/合规确认,我可以先把"合规护栏"写进采集器与站点声明。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 建议的实施路线图(分阶段,先跑起来再扩展)
|
|
||||||
|
|
||||||
**阶段 0 — 地基(先做)**
|
|
||||||
- 仓库工程化:FastAPI 项目骨架、PostgreSQL、Docker Compose、CI、代码规范。
|
|
||||||
- 定义核心数据模型 + 迁移。
|
|
||||||
|
|
||||||
**阶段 1 — MVP API**
|
|
||||||
- 实现只读 API:按条码/ID 查询 + 搜索 + 自动文档。
|
|
||||||
- 导入一批种子数据(用开放数据/手工录入几百条),让 API 真正可用。
|
|
||||||
|
|
||||||
**阶段 2 — 采集管线**
|
|
||||||
- 1~2 个合规来源的 adapter + 调度 + 清洗/去重/入库。
|
|
||||||
- 质量评分与溯源。
|
|
||||||
|
|
||||||
**阶段 3 — 规模化与开放**
|
|
||||||
- 搜索引擎、缓存/CDN、限流、API Key、众包贡献与审核后台。
|
|
||||||
- 开放数据许可与开发者文档站上线。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 需要你拍板的关键问题
|
|
||||||
|
|
||||||
1. **是否收录价格?**(建议默认不收录,只做客观参数)
|
|
||||||
2. **首批聚焦哪些品类?**(如:食品/快消、3C 数码、家电…… 建议先聚焦 1~2 个品类把模型打磨好)
|
|
||||||
3. **技术栈确认 Python + FastAPI 吗?**(还是有其他偏好)
|
|
||||||
4. **数据来源**:你手上有没有已知的合规数据源 / 官方 API / 已有数据集?
|
|
||||||
5. **首要交付**:先搭"可查询的 MVP API + 一批种子数据",还是先做"采集管线"?(建议先 MVP API)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> 这是 v0.1 规划草案,确认大方向后我会细化成可执行的工程任务,并开始搭建仓库骨架 + MVP。
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# 商品档案公益 API 系统 — 规划方案 (v0.2)
|
|
||||||
|
|
||||||
> 公益网站/服务:**采集全网商品信息**,对外提供**商品参数查询 API**。
|
|
||||||
> 原则:**只收集 + 只提供信息,不涉及任何购买/下单/比价导购**。
|
|
||||||
> 本版根据你的反馈定稿四件事:① 收录**官方标准零售价(MSRP)** ② 首批聚焦**食品快消** ③ **Go(系统) + Python(采集)** 多语言架构 ④ 附**开放数据源清单**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 你已确认的决策
|
|
||||||
|
|
||||||
| # | 决策 | 说明 |
|
|
||||||
|---|------|------|
|
|
||||||
| 1 | **价格 = 官方标准零售价 (MSRP)** | 厂商指导价/官方建议零售价,属**静态属性**,带来源+时间+币种标注;**不收录实时电商售价、不提供购买入口** |
|
|
||||||
| 2 | **首批品类 = 食品快消 (Food & FMCG)** | 先把食品的数据模型打磨好(成分、营养、过敏原、规格、保质期…) |
|
|
||||||
| 3 | **技术栈 = Go + Python** | Go 写对外 API/核心服务;Python 写采集/ETL/爬虫;通过 PostgreSQL + 消息队列解耦 |
|
|
||||||
| 4 | **数据源 = 暂无,后期提供** | 本版先给出可立即接入的开放数据源清单 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Go + Python 多语言架构(核心)
|
|
||||||
|
|
||||||
这是一个很经典且合理的组合。两端**不直接互相调用**,而是通过**共享数据库 + 消息队列**解耦,各自独立部署、独立扩展。
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────── Python 侧 (采集/数据) ──────────────────────┐
|
|
||||||
│ │
|
|
||||||
数据源 ─▶│ 采集 Workers (爬虫/适配器) ─▶ ETL 清洗/标准化/去重 ─▶ 入库 │
|
|
||||||
│ httpx / Playwright / scrapy pandas / 规则引擎 │
|
|
||||||
└───────────────────────────┬────────────────────────────────────────┘
|
|
||||||
│ 写入
|
|
||||||
┌───────▼────────┐ ┌──────────────┐
|
|
||||||
│ PostgreSQL │◀──────▶│ 对象存储 S3 │ (商品图)
|
|
||||||
│ (商品档案主库) │ └──────────────┘
|
|
||||||
└───────▲────────┘
|
|
||||||
│ 只读
|
|
||||||
┌───────────────────────────┴────────────────────────────────────────┐
|
|
||||||
│ Go 侧 (对外服务) │
|
|
||||||
│ 公开 API (REST/JSON) + Redis 缓存/限流 + 搜索网关 + OpenAPI │
|
|
||||||
│ Gin/Echo/Chi/标准库 │
|
|
||||||
└───────────────────────────┬────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
各种软件 / 开发者消费
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.1 职责划分
|
|
||||||
|
|
||||||
| 子系统 | 语言 | 职责 |
|
|
||||||
|--------|------|------|
|
|
||||||
| **公开 API 服务** | **Go** | 对外只读 API、限流、缓存、鉴权(可选 API Key)、检索网关、高并发承载 |
|
|
||||||
| **采集 Workers** | **Python** | 每个数据源一个 adapter,抓取/调用 API、遵守 robots、限速、产出原始数据 |
|
|
||||||
| **ETL / 数据处理** | **Python** | 清洗、字段映射、单位归一、实体去重与合并、质量评分 |
|
|
||||||
| **调度 / 队列** | Python(worker) + Redis/消息队列 | 定时任务、增量更新、任务分发 |
|
|
||||||
| **存储** | PostgreSQL + Redis + S3 | 主库 / 缓存+限流 / 图片 |
|
|
||||||
| **检索** | 初期 PG 全文 → 后期 OpenSearch | 商品名/参数搜索与分面 |
|
|
||||||
|
|
||||||
### 1.2 为什么这样分?
|
|
||||||
- **Go 做 API**:编译型、单二进制部署、并发模型适合高 QPS 的只读公益 API,运维简单。
|
|
||||||
- **Python 做采集**:爬虫/解析/数据处理生态最强(scrapy、playwright、pandas),迭代快。
|
|
||||||
- **解耦点 = 数据库**:Go 端**只读**主库(或读副本),Python 端负责写入。两端通过稳定的表结构约定协作,互不阻塞;将来任一端换语言/重写都不影响另一端。
|
|
||||||
- **契约**:用数据库 schema + 一份内部「数据契约文档」固定字段含义,避免两端理解不一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 食品快消数据模型(细化)
|
|
||||||
|
|
||||||
食品参数差异大,沿用 **核心字段 + JSONB 灵活属性 + 营养结构化子表**。字段设计大量参考 Open Food Facts(成熟的食品开放库)。
|
|
||||||
|
|
||||||
### 2.1 商品主表 `product`
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"id": "uuid",
|
|
||||||
"gtin": "6901234567892", // 条码(主键标识), EAN-13/UPC/EAN-8
|
|
||||||
"name": "示例牌 巧克力榛子酱 400g",
|
|
||||||
"brand": "示例牌", // -> brand
|
|
||||||
"manufacturer": "示例食品有限公司", // 生产商
|
|
||||||
"category": "食品/酱料/巧克力酱", // -> category 树 (可对齐 GS1 GPC / OFF categories)
|
|
||||||
"net_content": {"value": 400, "unit": "g"}, // 净含量
|
|
||||||
"country_of_origin": "中国",
|
|
||||||
"shelf_life": {"value": 12, "unit": "月"}, // 保质期
|
|
||||||
"storage": "常温避光保存",
|
|
||||||
"images": ["S3_URL", ...],
|
|
||||||
"msrp": { ... }, // 官方标准零售价, 见 2.3
|
|
||||||
"food": { ... }, // 食品专属结构化字段, 见 2.2
|
|
||||||
"attributes": [ {"key":"","value":"","unit":""} ], // 其余灵活参数(JSONB)
|
|
||||||
"identifiers": {"ean":"", "upc":"", "off_id":""},
|
|
||||||
"sources": [ {"source":"", "url":"", "fetched_at":"", "fields":["msrp"]} ],
|
|
||||||
"quality_score": 0.0,
|
|
||||||
"status": "active|merged|deprecated",
|
|
||||||
"created_at": "", "updated_at": ""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 食品专属字段 `food`(结构化)
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"ingredients_text": "白砂糖, 棕榈油, 榛子(13%), ...", // 配料表原文
|
|
||||||
"ingredients": [ {"name":"白砂糖","rank":1}, ... ], // 解析后(可选)
|
|
||||||
"allergens": ["坚果", "大豆", "乳"], // 过敏原
|
|
||||||
"additives": ["E322 卵磷脂"], // 添加剂
|
|
||||||
"nutriments": { // 营养成分(每100g/100ml)
|
|
||||||
"energy_kj": 2252, "energy_kcal": 539,
|
|
||||||
"fat_g": 30.9, "saturated_fat_g": 10.6,
|
|
||||||
"carbohydrates_g": 57.5, "sugars_g": 56.3,
|
|
||||||
"protein_g": 6.3, "salt_g": 0.107
|
|
||||||
},
|
|
||||||
"nutrition_basis": "per_100g", // per_100g | per_100ml | per_serving
|
|
||||||
"serving_size": "15g",
|
|
||||||
"is_vegetarian": null, "is_vegan": null, // 可空
|
|
||||||
"nutri_score": "C", // 若引用 OFF
|
|
||||||
"labels": ["无添加", "清真"] // 认证/标签
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 官方标准零售价 `msrp`(重点)
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"amount": 29.90,
|
|
||||||
"currency": "CNY",
|
|
||||||
"type": "msrp", // 仅 msrp/官方指导价; 不存实时电商成交价
|
|
||||||
"region": "CN", // 适用地区(价格随地区不同)
|
|
||||||
"source": "厂商官网/官方价目表",
|
|
||||||
"source_url": "https://...",
|
|
||||||
"effective_date": "2026-01-01", // 价格生效/采集时间
|
|
||||||
"note": "官方建议零售价, 实际售价以零售商为准; 本站不提供购买"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
> 设计要点:价格是**带时间戳的历史快照**而非实时报价;明确 `type=msrp`、标注地区与来源;响应里附免责说明。**坚决不出现购买/跳转链接。**
|
|
||||||
|
|
||||||
### 2.4 辅助实体
|
|
||||||
`brand` / `manufacturer` / `category`(品类树) / `source`(数据来源登记) / `attribute_definition`(参数字典: 标准名·别名·单位) / `merge_log`(实体合并记录, 保留溯源)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 可立即接入的开放数据源清单(食品快消)
|
|
||||||
|
|
||||||
按"合规性 / 可用性"排序。这些可作为**种子数据 + 采集 adapter 的首批对象**。
|
|
||||||
|
|
||||||
| 数据源 | 内容 | 许可 | 接入方式 | 备注 |
|
|
||||||
|--------|------|------|----------|------|
|
|
||||||
| **Open Food Facts** ⭐ | 全球食品(成分/营养/过敏原/Nutri-Score/图片) | **ODbL**(数据)+DbCL+CC-BY-SA(图) | REST API + **每夜全量 dump**(CSV/MongoDB, ~9GB) | 食品首选;可贡献回写;限速 15 req/min/IP(读) |
|
|
||||||
| **USDA FoodData Central** ⭐ | 美国食品营养成分(含 Branded 品牌库) | **CC0(公共领域)** | REST API(需免费 key) + JSON/CSV 下载 | 营养数据权威;商业可用 |
|
|
||||||
| **GS1 / Verified by GS1**(中国商品信息服务平台) | 条码→品牌/规格/厂商(官方登记) | 受限(需企业/接口授权) | 网页查询 + API(≤1000 GTIN/次) | **条码→商品**最权威来源;2亿+条;中国数据首选 |
|
|
||||||
| **brocade.io** | 开放 GTIN/条码产品库 | 开源/开放 | 免费 REST(免鉴权读) | 数据量有限,可作补充 |
|
|
||||||
| **3023data 等条码接口** | 中国物品编码+UPC+ISBN | 商业(0.005~0.02元/次) | REST API | **付费**,作兜底补全,非首选 |
|
|
||||||
| 各国**监管公开数据** | 食品备案/标签/能效等 | 多为公开 | 各平台 | 后续按需逐个评估合规 |
|
|
||||||
|
|
||||||
**参考用开源项目(架构/数据模型借鉴,非数据源)**:
|
|
||||||
- Open Food Facts Server (Product Opener) — 食品库的完整实现,可学其字段与流程
|
|
||||||
- UnoPIM / PCMT / brocade.io — 开源 PIM / 商品主数据系统,借鉴建模与去重
|
|
||||||
|
|
||||||
> 建议:**先用 Open Food Facts 全量 dump 作种子数据**(直接有海量真实食品),再用 GS1/USDA 做补全与校验。这样 MVP 阶段就有真实可查的数据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 公开 API 契约(Go 实现,只读)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/products/barcode/{gtin} # ★最常用: 条码查档案
|
|
||||||
GET /api/v1/products/{id} # 内部ID查
|
|
||||||
GET /api/v1/products/search # ?q=&brand=&category=&allergen_free=&page=&size=&fields=
|
|
||||||
GET /api/v1/products/{id}/nutriments # 仅营养
|
|
||||||
GET /api/v1/products/{id}/msrp # 仅官方零售价(含来源/时间/免责)
|
|
||||||
GET /api/v1/brands | /categories # 品牌 / 品类树
|
|
||||||
GET /api/v1/sources/{id} # 数据来源透明说明
|
|
||||||
GET /healthz | /api/v1/openapi.json # 健康检查 / 机读文档
|
|
||||||
```
|
|
||||||
约定:版本化 `/v1/`;统一响应 `{data, meta(分页), sources(溯源)}`;分页 + `fields=` 裁剪;匿名按 IP 限流,可选免费 API Key 提配额;CDN+Redis 缓存(参数变化慢,命中率高);数据采用开放许可(CC BY / ODbL,注意 OFF 的 ODbL 传染性);**无任何购买/交易端点**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 合规与边界(公益项目重点)
|
|
||||||
|
|
||||||
- **数据源许可要分清**:OFF 是 **ODbL**(衍生数据库需同样开放+署名),USDA 是 **CC0**(最宽松)。混用时要按最严格许可对外标注,避免许可冲突。
|
|
||||||
- 爬取守 robots.txt / 服务条款,礼貌限速,标明 User-Agent 身份。
|
|
||||||
- 只采**客观参数**;营销文案/评测原文不照搬(链接来源即可)。
|
|
||||||
- 无个人数据(PII),只处理商品信息。
|
|
||||||
- 站点显著声明:**仅提供信息、不提供购买、不构成消费建议**;价格为官方指导价历史快照。
|
|
||||||
- 提供权利方**纠错/下架**联系渠道。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 里程碑(仍不写代码,仅规划,供确认)
|
|
||||||
|
|
||||||
| 阶段 | 目标 | 关键产出 |
|
|
||||||
|------|------|----------|
|
|
||||||
| **M0 工程地基** | 仓库骨架 | Go API 骨架 + Python 采集骨架 + PostgreSQL + Docker Compose + CI + 数据契约文档 |
|
|
||||||
| **M1 数据模型** | 食品 schema | 主表/食品字段/MSRP/辅助实体 的迁移与字典 |
|
|
||||||
| **M2 种子数据** | 有真实数据 | 导入 Open Food Facts dump(食品子集) + USDA 营养补全 |
|
|
||||||
| **M3 MVP API (Go)** | 可查询 | 条码/ID/搜索/营养/MSRP + OpenAPI 文档 + 限流缓存 |
|
|
||||||
| **M4 采集管线 (Python)** | 自动更新 | 1~2 个 adapter(OFF API / GS1) + ETL + 去重 + 质量评分 + 调度 |
|
|
||||||
| **M5 开放与规模化** | 上线 | 搜索引擎 + CDN + API Key + 众包纠错后台 + 开发者文档站 + 开放数据许可 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 待你确认/补充
|
|
||||||
|
|
||||||
1. **价格范围**:确认只收「官方指导价 (MSRP)」、不碰实时电商价?(建议是)
|
|
||||||
2. **OFF 的 ODbL 许可**:可接受(意味着我们对外的数据库也要用 ODbL 并署名 OFF)?还是更想用 CC0 来源(USDA)为主以保持宽松?
|
|
||||||
3. **种子数据**:同意先导入 Open Food Facts 食品 dump 作为启动数据吗?
|
|
||||||
4. **Go Web 框架偏好**:Gin / Echo / Chi / 标准库 net/http,有偏好吗?(无偏好我默认 Chi 或标准库,轻量)
|
|
||||||
5. **地域范围**:首批面向中国市场商品,还是中外都收?(影响优先用 GS1-China 还是 OFF 全球库)
|
|
||||||
|
|
||||||
> 你确认后,我把它定为 v1.0 规划,并据此拆成可执行的工程任务清单(仍按你的节奏,需要我动手写代码时再开始)。
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# 商品档案公益 API 系统 — 规划方案 (v1.0 定稿)
|
|
||||||
|
|
||||||
> 公益网站/服务:采集全网商品信息,提供商品参数查询 API。**只收集 + 只提供信息,不涉及任何购买行为。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 已锁定的决策
|
|
||||||
|
|
||||||
| # | 决策 | 结论 |
|
|
||||||
|---|------|------|
|
|
||||||
| 1 | 品类 | 首批 **食品快消** |
|
|
||||||
| 2 | 价格 | 只收 **官方标准零售价 (MSRP)**,静态字段,带来源/时间/地区/币种 + 免责说明;**不收实时电商价、不提供购买入口** |
|
|
||||||
| 3 | 技术栈 | **Go**(对外API/核心服务) + **Python**(采集/ETL/爬虫),经 PostgreSQL + Redis/队列解耦 |
|
|
||||||
| 4 | 种子数据 | ✅ **先导入 Open Food Facts 食品 dump**,最快拥有真实数据 |
|
|
||||||
| 5 | 数据许可 | 因采用 OFF → 对外数据库用 **ODbL** 并署名来源;CC0 来源(USDA)可自由混入 |
|
|
||||||
|
|
||||||
### 1.1 我先用的默认值(如不同意请指出,否则按此执行)
|
|
||||||
- **Go Web 框架**:`chi` + 标准库 `net/http`(轻量、稳定、易维护)。
|
|
||||||
- **地域范围**:先用 OFF **全球食品库**起步,后续接 **GS1-China** 补强中国市场数据。
|
|
||||||
- **数据库迁移工具**:Go 侧用 `golang-migrate`(纯 SQL 迁移,两端共享同一套 schema)。
|
|
||||||
- **部署**:初期 Docker Compose 一键起全套(Postgres/Redis/Go API/Python worker)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 目标架构(定稿)
|
|
||||||
|
|
||||||
```
|
|
||||||
数据源(OFF dump / OFF API / USDA / GS1)
|
|
||||||
│
|
|
||||||
▼ Python: 采集 adapters → ETL(清洗/归一/去重/质量评分)
|
|
||||||
│
|
|
||||||
┌────▼─────────┐ 图片 ┌──────────┐
|
|
||||||
│ PostgreSQL │◀───────▶│ S3/MinIO │
|
|
||||||
│ (商品档案主库)│ └──────────┘
|
|
||||||
└────▲─────────┘
|
|
||||||
│ 只读 (+Redis缓存/限流)
|
|
||||||
▼ Go: 公开 REST API + OpenAPI 文档
|
|
||||||
各种软件 / 开发者
|
|
||||||
```
|
|
||||||
|
|
||||||
- **解耦契约**:两端通过共享 PostgreSQL schema + 一份《数据契约文档》协作,互不直接调用。
|
|
||||||
- **Go 端只读主库**(或读副本);**Python 端负责写入**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 仓库结构(计划,写代码时落地)
|
|
||||||
|
|
||||||
```
|
|
||||||
goods/
|
|
||||||
├── README.md
|
|
||||||
├── docker-compose.yml # postgres + redis + minio + api + worker
|
|
||||||
├── docs/
|
|
||||||
│ ├── data-contract.md # 两端共享的字段契约
|
|
||||||
│ └── openapi.yaml # API 契约
|
|
||||||
├── migrations/ # 共享 SQL 迁移 (golang-migrate)
|
|
||||||
├── api/ # Go: 对外只读 API
|
|
||||||
│ ├── cmd/server/main.go
|
|
||||||
│ ├── internal/{handler,store,model,middleware}/
|
|
||||||
│ └── go.mod
|
|
||||||
└── ingestion/ # Python: 采集 + ETL
|
|
||||||
├── pyproject.toml
|
|
||||||
├── adapters/{openfoodfacts,usda,gs1}.py
|
|
||||||
├── etl/{normalize,dedup,quality}.py
|
|
||||||
└── jobs/{seed_off_dump,scheduler}.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 数据模型 & API 契约
|
|
||||||
(沿用 v0.2:`product` 主表 + `food` 食品字段 + `msrp` 价格 + 辅助实体;API 以 `GET /products/barcode/{gtin}` 为核心,全只读、无交易端点。详见 v0.2 附件。)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 可执行任务拆分(按里程碑,写代码时逐项落地)
|
|
||||||
|
|
||||||
**M0 — 工程地基**
|
|
||||||
- [ ] 初始化 Go module (`api/`) + Python 项目 (`ingestion/`)
|
|
||||||
- [ ] `docker-compose.yml`:Postgres + Redis + MinIO
|
|
||||||
- [ ] CI(Go: build/vet/test;Python: ruff/pytest)
|
|
||||||
- [ ] `docs/data-contract.md` 初版
|
|
||||||
|
|
||||||
**M1 — 数据模型**
|
|
||||||
- [ ] `migrations/`:product / food / msrp / brand / manufacturer / category / source / attribute_definition / merge_log
|
|
||||||
- [ ] JSONB + GIN 索引;gtin 唯一索引
|
|
||||||
|
|
||||||
**M2 — 种子数据 (Python)**
|
|
||||||
- [ ] 下载 OFF 食品 dump(CSV)
|
|
||||||
- [ ] `seed_off_dump`:字段映射 → 入库(含营养/成分/过敏原/图片URL)
|
|
||||||
- [ ] USDA(CC0) 营养补全(可选)
|
|
||||||
|
|
||||||
**M3 — MVP API (Go)**
|
|
||||||
- [ ] 路由 + handler:barcode / id / search / nutriments / msrp / brands / categories / sources
|
|
||||||
- [ ] 统一响应、分页、`fields=` 裁剪、错误处理
|
|
||||||
- [ ] Redis 缓存 + IP 限流;`/healthz` + OpenAPI 文档
|
|
||||||
|
|
||||||
**M4 — 采集管线 (Python)**
|
|
||||||
- [ ] adapter:OFF API(增量更新)+ GS1(条码补全)
|
|
||||||
- [ ] ETL:清洗/单位归一/去重合并/质量评分/溯源
|
|
||||||
- [ ] 调度(定时增量更新)
|
|
||||||
|
|
||||||
**M5 — 开放与规模化**
|
|
||||||
- [ ] 搜索引擎(PG 全文 → OpenSearch)、CDN 缓存
|
|
||||||
- [ ] 免费 API Key(防滥用+统计)、众包纠错后台
|
|
||||||
- [ ] 开发者文档站 + 开放数据许可声明 + 站点"不提供购买"声明
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 下一步
|
|
||||||
规划已定稿。**你说先不写代码,所以我暂停在这里**。等你说"开始",我就从 **M0 工程地基** 动手,搭好骨架后开 PR 给你看。也可以先只做某个里程碑(比如先 M0+M1 把骨架和数据模型立起来)。
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""OpenGoods (天工·商品标签) ingestion package.
|
||||||
|
|
||||||
|
Collects public product information from open data sources (e.g. Open Food
|
||||||
|
Facts) and normalizes it into the OpenGoods database. This package only
|
||||||
|
collects and processes product facts; it performs no purchase or commerce
|
||||||
|
actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Source adapters.
|
||||||
|
|
||||||
|
Each open data source (Open Food Facts, USDA FoodData Central, GS1, ...) gets
|
||||||
|
its own adapter that fetches raw records and yields them for the ETL layer.
|
||||||
|
Adapters must respect each source's robots.txt, rate limits and license.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Base adapter protocol shared by all source adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class SourceAdapter(Protocol):
|
||||||
|
"""A source adapter fetches raw product records from one data source."""
|
||||||
|
|
||||||
|
#: Stable identifier of the source, e.g. "openfoodfacts".
|
||||||
|
source_name: str
|
||||||
|
|
||||||
|
def fetch(self) -> Iterator[dict]:
|
||||||
|
"""Yield raw product records as dictionaries."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Open Food Facts (OFF) source adapter.
|
||||||
|
|
||||||
|
Fetches raw product records either from the OFF read API (one product per
|
||||||
|
barcode) or from a downloaded JSONL dump file. OFF data is licensed under the
|
||||||
|
Open Database License (ODbL); product images are CC-BY-SA. We record OFF as the
|
||||||
|
source for every field we ingest.
|
||||||
|
|
||||||
|
The adapter is read-only and rate-limited to stay well within OFF's API limits
|
||||||
|
(<= ~15 req/min/IP for product reads) and to be a good citizen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
SOURCE_NAME = "openfoodfacts"
|
||||||
|
OFF_LICENSE = "ODbL"
|
||||||
|
USER_AGENT = "OpenGoods/0.1 (+https://github.com/baicai2026-baicai/goods) public-good product API"
|
||||||
|
|
||||||
|
# Conservative client-side spacing between API calls (seconds).
|
||||||
|
_DEFAULT_MIN_INTERVAL = 4.0
|
||||||
|
_API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||||
|
|
||||||
|
|
||||||
|
class OpenFoodFactsAdapter:
|
||||||
|
"""Read product records from the OFF API."""
|
||||||
|
|
||||||
|
source_name = SOURCE_NAME
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
min_interval: float = _DEFAULT_MIN_INTERVAL,
|
||||||
|
) -> None:
|
||||||
|
self._client = client or httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=30.0)
|
||||||
|
self._min_interval = min_interval
|
||||||
|
self._last_call = 0.0
|
||||||
|
|
||||||
|
def _throttle(self) -> None:
|
||||||
|
elapsed = time.monotonic() - self._last_call
|
||||||
|
wait = self._min_interval - elapsed
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
self._last_call = time.monotonic()
|
||||||
|
|
||||||
|
def fetch_barcode(self, barcode: str) -> dict | None:
|
||||||
|
"""Fetch a single product by barcode; return the raw `product` dict."""
|
||||||
|
self._throttle()
|
||||||
|
resp = self._client.get(_API_URL.format(barcode=barcode))
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
if payload.get("status") != 1:
|
||||||
|
return None
|
||||||
|
return payload["product"]
|
||||||
|
|
||||||
|
def fetch(self, barcodes: list[str]) -> Iterator[dict]:
|
||||||
|
"""Yield raw product records for the given barcodes."""
|
||||||
|
for code in barcodes:
|
||||||
|
record = self.fetch_barcode(code)
|
||||||
|
if record is not None:
|
||||||
|
yield record
|
||||||
|
|
||||||
|
|
||||||
|
def read_dump(path: str | Path) -> Iterator[dict]:
|
||||||
|
"""Yield raw product records from an OFF JSONL dump file.
|
||||||
|
|
||||||
|
Each line is one product JSON object (the format of OFF's .jsonl export).
|
||||||
|
Supports plain or .gz files.
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
if p.suffix == ".gz":
|
||||||
|
import gzip
|
||||||
|
|
||||||
|
opener = lambda: gzip.open(p, "rt", encoding="utf-8") # noqa: E731
|
||||||
|
else:
|
||||||
|
opener = lambda: open(p, encoding="utf-8") # noqa: E731
|
||||||
|
with opener() as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
yield json.loads(line)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""ETL: clean, normalize, dedup and score raw records before loading."""
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Load transformed product records into the OpenGoods PostgreSQL database.
|
||||||
|
|
||||||
|
Only the ingestion side writes to the database. Every load records OFF as the
|
||||||
|
source with field-level provenance in `product_source`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from opengoods.adapters.openfoodfacts import OFF_LICENSE, SOURCE_NAME
|
||||||
|
|
||||||
|
OFF_HOMEPAGE = "https://world.openfoodfacts.org"
|
||||||
|
|
||||||
|
|
||||||
|
def default_dsn() -> str:
|
||||||
|
return os.environ.get(
|
||||||
|
"OPENGOODS_DATABASE_URL",
|
||||||
|
"postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_brand(name: str) -> str:
|
||||||
|
return " ".join(name.lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_source(conn: psycopg.Connection) -> str:
|
||||||
|
"""Upsert the Open Food Facts source row and return its id."""
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO source (name, homepage, license, trust_weight)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET homepage = EXCLUDED.homepage
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(SOURCE_NAME, OFF_HOMEPAGE, OFF_LICENSE, 0.7),
|
||||||
|
).fetchone()
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_brand(conn: psycopg.Connection, name: str | None) -> str | None:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO brand (name, normalized_name)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (normalized_name) DO UPDATE SET name = brand.name
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(name, _normalize_brand(name)),
|
||||||
|
).fetchone()
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _category_id(conn: psycopg.Connection, path: str | None) -> tuple[str | None, str | None]:
|
||||||
|
if not path:
|
||||||
|
return None, None
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, gpc_brick_code FROM category WHERE path = %s::ltree", (path,)
|
||||||
|
).fetchone()
|
||||||
|
return (row[0], row[1]) if row else (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def load_record(conn: psycopg.Connection, rec: dict[str, Any], source_id: str, raw: dict) -> str:
|
||||||
|
"""Upsert one transformed record; return the product id."""
|
||||||
|
brand_id = _ensure_brand(conn, rec.get("brand"))
|
||||||
|
category_id, gpc_brick = _category_id(conn, rec.get("category_path"))
|
||||||
|
|
||||||
|
fields = ["name", "brand", "net_content", "category", "country_of_origin"]
|
||||||
|
|
||||||
|
if rec.get("gtin"):
|
||||||
|
prod = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code,
|
||||||
|
net_content_value, net_content_unit, net_content_canonical,
|
||||||
|
country_of_origin, attributes)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (gtin) WHERE gtin IS NOT NULL DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
brand_id = COALESCE(EXCLUDED.brand_id, product.brand_id),
|
||||||
|
category_id = COALESCE(EXCLUDED.category_id, product.category_id),
|
||||||
|
gpc_brick_code = COALESCE(EXCLUDED.gpc_brick_code, product.gpc_brick_code),
|
||||||
|
net_content_value = EXCLUDED.net_content_value,
|
||||||
|
net_content_unit = EXCLUDED.net_content_unit,
|
||||||
|
net_content_canonical = EXCLUDED.net_content_canonical,
|
||||||
|
country_of_origin = EXCLUDED.country_of_origin
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
rec["gtin"],
|
||||||
|
rec["name"],
|
||||||
|
brand_id,
|
||||||
|
category_id,
|
||||||
|
gpc_brick,
|
||||||
|
rec.get("net_content_value"),
|
||||||
|
rec.get("net_content_unit"),
|
||||||
|
rec.get("net_content_canonical"),
|
||||||
|
rec.get("country_of_origin"),
|
||||||
|
Jsonb({}),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
prod = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO product (name, brand_id, category_id, gpc_brick_code,
|
||||||
|
net_content_value, net_content_unit, net_content_canonical,
|
||||||
|
country_of_origin, attributes)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
rec["name"],
|
||||||
|
brand_id,
|
||||||
|
category_id,
|
||||||
|
gpc_brick,
|
||||||
|
rec.get("net_content_value"),
|
||||||
|
rec.get("net_content_unit"),
|
||||||
|
rec.get("net_content_canonical"),
|
||||||
|
rec.get("country_of_origin"),
|
||||||
|
Jsonb({}),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
product_id = prod[0]
|
||||||
|
|
||||||
|
food = rec.get("food") or {}
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO food_detail (product_id, ingredients_text, allergens, additives,
|
||||||
|
nutriments, nutrition_basis, serving_size, nutri_score)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
|
ON CONFLICT (product_id) DO UPDATE SET
|
||||||
|
ingredients_text = EXCLUDED.ingredients_text,
|
||||||
|
allergens = EXCLUDED.allergens,
|
||||||
|
additives = EXCLUDED.additives,
|
||||||
|
nutriments = EXCLUDED.nutriments,
|
||||||
|
nutrition_basis = EXCLUDED.nutrition_basis,
|
||||||
|
serving_size = EXCLUDED.serving_size,
|
||||||
|
nutri_score = EXCLUDED.nutri_score
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
product_id,
|
||||||
|
food.get("ingredients_text"),
|
||||||
|
food.get("allergens") or [],
|
||||||
|
food.get("additives") or [],
|
||||||
|
Jsonb(food.get("nutriments") or {}),
|
||||||
|
food.get("nutrition_basis"),
|
||||||
|
food.get("serving_size"),
|
||||||
|
food.get("nutri_score"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if rec.get("image_url"):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO product_image (product_id, url, kind, license, source_id)
|
||||||
|
VALUES (%s,%s,'front',%s,%s)
|
||||||
|
""",
|
||||||
|
(product_id, rec["image_url"], "CC-BY-SA", source_id),
|
||||||
|
)
|
||||||
|
fields.append("image")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
|
||||||
|
VALUES (%s,%s,%s,%s, now(), %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
product_id,
|
||||||
|
source_id,
|
||||||
|
f"{OFF_HOMEPAGE}/product/{rec.get('gtin') or ''}",
|
||||||
|
fields,
|
||||||
|
Jsonb(_jsonable(raw)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return product_id
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(raw: dict) -> dict:
|
||||||
|
"""Drop values that are not JSON-serializable from a raw record."""
|
||||||
|
try:
|
||||||
|
json.dumps(raw)
|
||||||
|
return raw
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {k: v for k, v in raw.items() if _is_jsonable(v)}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_jsonable(v: object) -> bool:
|
||||||
|
try:
|
||||||
|
json.dumps(v)
|
||||||
|
return True
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Transform raw Open Food Facts records into the OpenGoods internal shape.
|
||||||
|
|
||||||
|
Pure functions (no DB, no network) so they are easy to unit-test against
|
||||||
|
fixtures. The output dict mirrors the columns the loader writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from opengoods.units import UnitError, normalize
|
||||||
|
|
||||||
|
# OFF nutriment key -> our attribute key. Energy handled separately.
|
||||||
|
_NUTRIMENT_KEYS = {
|
||||||
|
"proteins_100g": "proteins",
|
||||||
|
"fat_100g": "fat",
|
||||||
|
"saturated-fat_100g": "saturated_fat",
|
||||||
|
"carbohydrates_100g": "carbohydrates",
|
||||||
|
"sugars_100g": "sugars",
|
||||||
|
"salt_100g": "salt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Very small keyword -> category path map (starter; replaced by a proper
|
||||||
|
# OFF taxonomy -> GPC mapping table later).
|
||||||
|
_CATEGORY_KEYWORDS: list[tuple[tuple[str, ...], str]] = [
|
||||||
|
(("water", "eau", "饮用水", "矿泉水"), "food.beverages.water"),
|
||||||
|
(("soda", "carbonated", "汽水", "碳酸"), "food.beverages.carbonated"),
|
||||||
|
(("juice", "jus", "果汁"), "food.beverages.juice"),
|
||||||
|
(("milk", "lait", "牛奶"), "food.dairy.milk"),
|
||||||
|
(("yogurt", "yoghurt", "yaourt", "酸奶"), "food.dairy.yogurt"),
|
||||||
|
(("cheese", "fromage", "奶酪", "干酪"), "food.dairy.cheese"),
|
||||||
|
(("bread", "pain", "面包"), "food.bakery.bread"),
|
||||||
|
(("biscuit", "cookie", "饼干"), "food.bakery.biscuits"),
|
||||||
|
(("chips", "crisps", "薯片", "膨化"), "food.snacks.chips"),
|
||||||
|
(("chocolate", "chocolat", "巧克力"), "food.snacks.chocolate"),
|
||||||
|
(("rice", "riz", "大米", "稻米"), "food.staple.rice"),
|
||||||
|
(("noodle", "pasta", "面条", "挂面"), "food.staple.noodles"),
|
||||||
|
(("oil", "huile", "食用油", "食油"), "food.staple.cooking_oil"),
|
||||||
|
(("soy sauce", "酱油"), "food.condiments.soy_sauce"),
|
||||||
|
(("salt", "sel", "食盐"), "food.condiments.salt"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_QTY_RE = re.compile(r"(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>[a-zA-Z\u4e00-\u9fff%]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_gtin(code: str) -> bool:
|
||||||
|
"""Validate a GTIN-8/12/13/14 using the standard check digit."""
|
||||||
|
if not code.isdigit() or len(code) not in (8, 12, 13, 14):
|
||||||
|
return False
|
||||||
|
digits = [int(c) for c in code]
|
||||||
|
check = digits[-1]
|
||||||
|
body = digits[:-1][::-1]
|
||||||
|
total = sum(d * (3 if i % 2 == 0 else 1) for i, d in enumerate(body))
|
||||||
|
return (10 - total % 10) % 10 == check
|
||||||
|
|
||||||
|
|
||||||
|
def parse_quantity(text: str) -> tuple[Decimal, str] | None:
|
||||||
|
"""Parse a free-text quantity like '500 g' or '1,5 L' -> (value, unit)."""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
m = _QTY_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
value = Decimal(m.group("value").replace(",", "."))
|
||||||
|
return value, m.group("unit")
|
||||||
|
|
||||||
|
|
||||||
|
def map_category(raw: dict) -> str | None:
|
||||||
|
"""Best-effort map OFF categories/name to a self-built category path."""
|
||||||
|
haystack = " ".join(
|
||||||
|
str(raw.get(k, ""))
|
||||||
|
for k in ("categories", "categories_tags", "product_name", "product_name_en")
|
||||||
|
).lower()
|
||||||
|
for keywords, path in _CATEGORY_KEYWORDS:
|
||||||
|
if any(kw.lower() in haystack for kw in keywords):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_tags(tags: list[str] | None, prefix: str = "") -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for t in tags or []:
|
||||||
|
v = t.split(":", 1)[-1] if ":" in t else t
|
||||||
|
v = v.strip().replace("-", " ")
|
||||||
|
if v:
|
||||||
|
out.append(v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def transform_nutriments(off_nutriments: dict) -> dict:
|
||||||
|
"""Build a nutriments dict on a per_100g basis with dual energy units."""
|
||||||
|
out: dict[str, object] = {}
|
||||||
|
for off_key, our_key in _NUTRIMENT_KEYS.items():
|
||||||
|
if off_key in off_nutriments and off_nutriments[off_key] is not None:
|
||||||
|
out[our_key] = float(off_nutriments[off_key])
|
||||||
|
|
||||||
|
kj = off_nutriments.get("energy-kj_100g")
|
||||||
|
kcal = off_nutriments.get("energy-kcal_100g")
|
||||||
|
if kj is None and kcal is not None:
|
||||||
|
kj = float(Decimal(str(kcal)) * Decimal("4.184"))
|
||||||
|
if kcal is None and kj is not None:
|
||||||
|
kcal = float(Decimal(str(kj)) / Decimal("4.184"))
|
||||||
|
if kj is not None:
|
||||||
|
out["energy_kj"] = round(float(kj), 3)
|
||||||
|
if kcal is not None:
|
||||||
|
out["energy_kcal"] = round(float(kcal), 3)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def transform(raw: dict) -> dict | None:
|
||||||
|
"""Transform one raw OFF product record into an internal product dict.
|
||||||
|
|
||||||
|
Returns None if the record lacks a usable name.
|
||||||
|
"""
|
||||||
|
name = raw.get("product_name_zh") or raw.get("product_name") or raw.get("product_name_en")
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
code = str(raw.get("code", "")).strip()
|
||||||
|
gtin = code if code and is_valid_gtin(code) else None
|
||||||
|
|
||||||
|
brands = raw.get("brands") or ""
|
||||||
|
brand = brands.split(",")[0].strip() or None
|
||||||
|
|
||||||
|
net_value = net_unit = net_canonical = None
|
||||||
|
parsed = parse_quantity(raw.get("quantity", ""))
|
||||||
|
if parsed:
|
||||||
|
value, unit = parsed
|
||||||
|
try:
|
||||||
|
norm = normalize(value, unit)
|
||||||
|
net_value, net_unit, net_canonical = (
|
||||||
|
norm.value,
|
||||||
|
norm.unit,
|
||||||
|
norm.canonical_value,
|
||||||
|
)
|
||||||
|
except UnitError:
|
||||||
|
net_value, net_unit = value, unit
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gtin": gtin,
|
||||||
|
"name": str(name).strip(),
|
||||||
|
"brand": brand,
|
||||||
|
"category_path": map_category(raw),
|
||||||
|
"net_content_value": net_value,
|
||||||
|
"net_content_unit": net_unit,
|
||||||
|
"net_content_canonical": net_canonical,
|
||||||
|
"country_of_origin": (raw.get("countries") or "").split(",")[0].strip() or None,
|
||||||
|
"food": {
|
||||||
|
"ingredients_text": raw.get("ingredients_text") or None,
|
||||||
|
"allergens": _clean_tags(raw.get("allergens_tags")),
|
||||||
|
"additives": _clean_tags(raw.get("additives_tags")),
|
||||||
|
"nutriments": transform_nutriments(raw.get("nutriments") or {}),
|
||||||
|
"nutrition_basis": "per_100g",
|
||||||
|
"serving_size": raw.get("serving_size") or None,
|
||||||
|
"nutri_score": (raw.get("nutriscore_grade") or "").upper()[:1] or None,
|
||||||
|
},
|
||||||
|
"image_url": raw.get("image_front_url") or raw.get("image_url") or None,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Jobs: seed import and scheduled incremental ingestion."""
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Seed the database with Open Food Facts data.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# from a list of barcodes via the OFF API
|
||||||
|
python -m opengoods.jobs.seed_off --barcodes 3017624010701 5449000000996
|
||||||
|
|
||||||
|
# from a downloaded OFF JSONL dump (optionally .gz), limited to N records
|
||||||
|
python -m opengoods.jobs.seed_off --dump products.jsonl.gz --limit 1000
|
||||||
|
|
||||||
|
The OFF read API is rate-limited client-side; for large imports use a dump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
from opengoods.adapters.openfoodfacts import OpenFoodFactsAdapter, read_dump
|
||||||
|
from opengoods.etl.load import default_dsn, ensure_source, load_record
|
||||||
|
from opengoods.etl.transform import transform
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_records(args: argparse.Namespace) -> Iterator[dict]:
|
||||||
|
if args.dump:
|
||||||
|
records = read_dump(args.dump)
|
||||||
|
else:
|
||||||
|
adapter = OpenFoodFactsAdapter(min_interval=args.min_interval)
|
||||||
|
records = adapter.fetch(args.barcodes)
|
||||||
|
for i, rec in enumerate(records):
|
||||||
|
if args.limit and i >= args.limit:
|
||||||
|
break
|
||||||
|
yield rec
|
||||||
|
|
||||||
|
|
||||||
|
def run(args: argparse.Namespace) -> int:
|
||||||
|
loaded = skipped = 0
|
||||||
|
with psycopg.connect(args.dsn, autocommit=False) as conn:
|
||||||
|
source_id = ensure_source(conn)
|
||||||
|
for raw in _raw_records(args):
|
||||||
|
rec = transform(raw)
|
||||||
|
if rec is None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
load_record(conn, rec, source_id, raw)
|
||||||
|
loaded += 1
|
||||||
|
conn.commit()
|
||||||
|
print(f"loaded={loaded} skipped={skipped}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Seed OpenGoods from Open Food Facts")
|
||||||
|
src = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
src.add_argument("--barcodes", nargs="+", help="barcodes to fetch via the OFF API")
|
||||||
|
src.add_argument("--dump", help="path to an OFF JSONL dump (.jsonl or .jsonl.gz)")
|
||||||
|
parser.add_argument("--limit", type=int, default=0, help="max records to load (0 = all)")
|
||||||
|
parser.add_argument("--min-interval", type=float, default=4.0, help="API throttle seconds")
|
||||||
|
parser.add_argument("--dsn", default=default_dsn(), help="PostgreSQL DSN")
|
||||||
|
return run(parser.parse_args(argv))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Unit normalization for OpenGoods.
|
||||||
|
|
||||||
|
Product parameters arrive in many units (g/kg/ml/L, kcal/kJ, ...). To make
|
||||||
|
values comparable and searchable we store both the original value and a
|
||||||
|
normalized value expressed in a canonical unit per dimension.
|
||||||
|
|
||||||
|
This module is intentionally dependency-free and pure so it is easy to test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Conversion factor maps each unit to its canonical unit within a dimension.
|
||||||
|
# canonical_value = value * factor
|
||||||
|
_FACTORS: dict[str, tuple[str, str, Decimal]] = {
|
||||||
|
# mass -> g
|
||||||
|
"mg": ("mass", "g", Decimal("0.001")),
|
||||||
|
"g": ("mass", "g", Decimal("1")),
|
||||||
|
"kg": ("mass", "g", Decimal("1000")),
|
||||||
|
# volume -> ml
|
||||||
|
"ml": ("volume", "ml", Decimal("1")),
|
||||||
|
"cl": ("volume", "ml", Decimal("10")),
|
||||||
|
"l": ("volume", "ml", Decimal("1000")),
|
||||||
|
# energy -> kJ
|
||||||
|
"kj": ("energy", "kJ", Decimal("1")),
|
||||||
|
"kcal": ("energy", "kJ", Decimal("4.184")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alias map normalizes common spellings/locales to a canonical unit code.
|
||||||
|
_ALIASES: dict[str, str] = {
|
||||||
|
"kgs": "kg",
|
||||||
|
"千克": "kg",
|
||||||
|
"公斤": "kg",
|
||||||
|
"克": "g",
|
||||||
|
"毫升": "ml",
|
||||||
|
"升": "l",
|
||||||
|
"L": "l",
|
||||||
|
"litre": "l",
|
||||||
|
"liter": "l",
|
||||||
|
"kj": "kj",
|
||||||
|
"kJ": "kj",
|
||||||
|
"千焦": "kj",
|
||||||
|
"千卡": "kcal",
|
||||||
|
"大卡": "kcal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UnitError(ValueError):
|
||||||
|
"""Raised when a unit cannot be recognized."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Normalized:
|
||||||
|
"""Result of normalizing a (value, unit) pair to its canonical unit."""
|
||||||
|
|
||||||
|
value: Decimal
|
||||||
|
unit: str
|
||||||
|
dimension: str
|
||||||
|
canonical_value: Decimal
|
||||||
|
canonical_unit: str
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_unit_code(unit: str) -> str:
|
||||||
|
"""Resolve a raw unit string to a known canonical unit code."""
|
||||||
|
cleaned = unit.strip()
|
||||||
|
cleaned = _ALIASES.get(cleaned, cleaned).lower()
|
||||||
|
if cleaned not in _FACTORS:
|
||||||
|
raise UnitError(f"unknown unit: {unit!r}")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(value: Decimal | float | int | str, unit: str) -> Normalized:
|
||||||
|
"""Normalize a value+unit to its canonical unit within its dimension."""
|
||||||
|
code = canonical_unit_code(unit)
|
||||||
|
dimension, canonical, factor = _FACTORS[code]
|
||||||
|
dec = value if isinstance(value, Decimal) else Decimal(str(value))
|
||||||
|
return Normalized(
|
||||||
|
value=dec,
|
||||||
|
unit=code,
|
||||||
|
dimension=dimension,
|
||||||
|
canonical_value=dec * factor,
|
||||||
|
canonical_unit=canonical,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def kcal_to_kj(kcal: Decimal | float | int | str) -> Decimal:
|
||||||
|
"""Convert energy in kcal to kJ (1 kcal = 4.184 kJ)."""
|
||||||
|
dec = kcal if isinstance(kcal, Decimal) else Decimal(str(kcal))
|
||||||
|
return dec * Decimal("4.184")
|
||||||
|
|
||||||
|
|
||||||
|
def kj_to_kcal(kj: Decimal | float | int | str) -> Decimal:
|
||||||
|
"""Convert energy in kJ to kcal."""
|
||||||
|
dec = kj if isinstance(kj, Decimal) else Decimal(str(kj))
|
||||||
|
return dec / Decimal("4.184")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "opengoods-ingestion"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "OpenGoods (天工·商品标签) ingestion & ETL: collect product data and load it into the OpenGoods database."
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.27",
|
||||||
|
"psycopg[binary]>=3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.6",
|
||||||
|
"pytest>=8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["opengoods*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"code": "3017624010701",
|
||||||
|
"product_name": "Nutella",
|
||||||
|
"product_name_en": "Nutella hazelnut spread",
|
||||||
|
"brands": "Ferrero, Nutella",
|
||||||
|
"quantity": "400 g",
|
||||||
|
"countries": "France, China",
|
||||||
|
"categories": "Spreads, Hazelnut spreads, Chocolate spreads",
|
||||||
|
"categories_tags": ["en:spreads", "en:chocolate-spreads"],
|
||||||
|
"ingredients_text": "Sugar, palm oil, hazelnuts, cocoa, skimmed milk powder",
|
||||||
|
"allergens_tags": ["en:milk", "en:nuts"],
|
||||||
|
"additives_tags": ["en:e322"],
|
||||||
|
"serving_size": "15 g",
|
||||||
|
"nutriscore_grade": "e",
|
||||||
|
"image_front_url": "https://images.openfoodfacts.org/images/products/301/762/401/0701/front_en.jpg",
|
||||||
|
"nutriments": {
|
||||||
|
"energy-kj_100g": 2252,
|
||||||
|
"energy-kcal_100g": 539,
|
||||||
|
"fat_100g": 30.9,
|
||||||
|
"saturated-fat_100g": 10.6,
|
||||||
|
"carbohydrates_100g": 57.5,
|
||||||
|
"sugars_100g": 56.3,
|
||||||
|
"proteins_100g": 6.3,
|
||||||
|
"salt_100g": 0.107
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Integration test for the DB loader.
|
||||||
|
|
||||||
|
Skipped automatically when no database is reachable (e.g. local runs without
|
||||||
|
docker, or CI jobs without a postgres service). Requires migrations applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from opengoods.etl.load import default_dsn, ensure_source, load_record
|
||||||
|
from opengoods.etl.transform import transform
|
||||||
|
|
||||||
|
psycopg = pytest.importorskip("psycopg")
|
||||||
|
|
||||||
|
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "off_product.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def conn():
|
||||||
|
try:
|
||||||
|
c = psycopg.connect(default_dsn(), connect_timeout=3)
|
||||||
|
except psycopg.OperationalError as exc: # pragma: no cover - env dependent
|
||||||
|
pytest.skip(f"no database available: {exc}")
|
||||||
|
# ensure schema present
|
||||||
|
has_product = c.execute("SELECT to_regclass('public.product') IS NOT NULL").fetchone()[0]
|
||||||
|
if not has_product:
|
||||||
|
c.close()
|
||||||
|
pytest.skip("migrations not applied")
|
||||||
|
yield c
|
||||||
|
c.rollback()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_record_roundtrip(conn):
|
||||||
|
source_id = ensure_source(conn)
|
||||||
|
rec = transform(FIXTURE)
|
||||||
|
product_id = load_record(conn, rec, source_id, FIXTURE)
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT name, gtin, net_content_unit FROM product WHERE id = %s", (product_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row[0] == "Nutella"
|
||||||
|
assert row[1] == "3017624010701"
|
||||||
|
assert row[2] == "g"
|
||||||
|
|
||||||
|
nutri = conn.execute(
|
||||||
|
"SELECT nutriments ->> 'energy_kcal' FROM food_detail WHERE product_id = %s",
|
||||||
|
(product_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert nutri[0] == "539.0"
|
||||||
|
|
||||||
|
prov = conn.execute(
|
||||||
|
"SELECT count(*) FROM product_source WHERE product_id = %s", (product_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert prov[0] >= 1
|
||||||
|
|
||||||
|
conn.rollback() # keep the test DB clean
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from opengoods.etl.transform import (
|
||||||
|
is_valid_gtin,
|
||||||
|
map_category,
|
||||||
|
parse_quantity,
|
||||||
|
transform,
|
||||||
|
transform_nutriments,
|
||||||
|
)
|
||||||
|
|
||||||
|
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "off_product.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_gtin():
|
||||||
|
assert is_valid_gtin("3017624010701") # real EAN-13
|
||||||
|
assert is_valid_gtin("5449000000996") # Coca-Cola
|
||||||
|
assert not is_valid_gtin("3017624010700") # bad check digit
|
||||||
|
assert not is_valid_gtin("123")
|
||||||
|
assert not is_valid_gtin("notanumber")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_quantity():
|
||||||
|
assert parse_quantity("400 g") == (Decimal("400"), "g")
|
||||||
|
assert parse_quantity("1,5 L") == (Decimal("1.5"), "L")
|
||||||
|
assert parse_quantity("") is None
|
||||||
|
assert parse_quantity("family size") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_category():
|
||||||
|
assert map_category({"product_name": "Spring Water"}) == "food.beverages.water"
|
||||||
|
assert map_category({"categories": "Dark chocolate"}) == "food.snacks.chocolate"
|
||||||
|
assert map_category({"product_name": "Mystery"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_nutriments_dual_energy():
|
||||||
|
out = transform_nutriments(FIXTURE["nutriments"])
|
||||||
|
assert out["energy_kj"] == 2252.0
|
||||||
|
assert out["energy_kcal"] == 539.0
|
||||||
|
assert out["fat"] == 30.9
|
||||||
|
assert out["salt"] == 0.107
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_nutriments_fills_missing_energy():
|
||||||
|
out = transform_nutriments({"energy-kcal_100g": 100})
|
||||||
|
assert out["energy_kj"] == pytest.approx(418.4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_full_record():
|
||||||
|
rec = transform(FIXTURE)
|
||||||
|
assert rec is not None
|
||||||
|
assert rec["gtin"] == "3017624010701"
|
||||||
|
assert rec["name"] == "Nutella"
|
||||||
|
assert rec["brand"] == "Ferrero"
|
||||||
|
assert rec["net_content_unit"] == "g"
|
||||||
|
assert rec["net_content_canonical"] == Decimal("400")
|
||||||
|
assert rec["country_of_origin"] == "France"
|
||||||
|
assert rec["food"]["nutri_score"] == "E"
|
||||||
|
assert "milk" in rec["food"]["allergens"]
|
||||||
|
assert rec["image_url"].endswith(".jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_drops_unnamed():
|
||||||
|
assert transform({"code": "0000000000000"}) is None
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from opengoods.units import (
|
||||||
|
UnitError,
|
||||||
|
canonical_unit_code,
|
||||||
|
kcal_to_kj,
|
||||||
|
kj_to_kcal,
|
||||||
|
normalize,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_mass_kg_to_g():
|
||||||
|
result = normalize("1.5", "kg")
|
||||||
|
assert result.dimension == "mass"
|
||||||
|
assert result.canonical_unit == "g"
|
||||||
|
assert result.canonical_value == Decimal("1500.0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_volume_litre_alias():
|
||||||
|
result = normalize(2, "升")
|
||||||
|
assert result.dimension == "volume"
|
||||||
|
assert result.canonical_value == Decimal("2000")
|
||||||
|
assert result.canonical_unit == "ml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_energy_kcal_to_kj():
|
||||||
|
result = normalize("539", "kcal")
|
||||||
|
assert result.dimension == "energy"
|
||||||
|
assert result.canonical_unit == "kJ"
|
||||||
|
assert result.canonical_value == Decimal("539") * Decimal("4.184")
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_unit_code_alias():
|
||||||
|
assert canonical_unit_code("公斤") == "kg"
|
||||||
|
assert canonical_unit_code(" G ") == "g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_unit_raises():
|
||||||
|
with pytest.raises(UnitError):
|
||||||
|
normalize(1, "parsec")
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_roundtrip():
|
||||||
|
assert kcal_to_kj(1) == Decimal("4.184")
|
||||||
|
assert kj_to_kcal(Decimal("4.184")) == Decimal("1")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
DROP TRIGGER IF EXISTS trg_product_sync ON product;
|
||||||
|
DROP FUNCTION IF EXISTS product_sync_tsv();
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS merge_log;
|
||||||
|
DROP TABLE IF EXISTS product_source;
|
||||||
|
DROP TABLE IF EXISTS product_image;
|
||||||
|
DROP TABLE IF EXISTS product_msrp;
|
||||||
|
DROP TABLE IF EXISTS food_detail;
|
||||||
|
DROP TABLE IF EXISTS product;
|
||||||
|
DROP TABLE IF EXISTS attribute_definition;
|
||||||
|
DROP TABLE IF EXISTS unit;
|
||||||
|
DROP TABLE IF EXISTS category_schema;
|
||||||
|
DROP TABLE IF EXISTS category;
|
||||||
|
DROP TABLE IF EXISTS manufacturer;
|
||||||
|
DROP TABLE IF EXISTS brand;
|
||||||
|
DROP TABLE IF EXISTS source;
|
||||||
|
|
||||||
|
DROP EXTENSION IF EXISTS ltree;
|
||||||
|
DROP EXTENSION IF EXISTS pg_trgm;
|
||||||
|
-- keep pgcrypto (commonly shared); drop only if you are sure:
|
||||||
|
-- DROP EXTENSION IF EXISTS pgcrypto;
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
-- OpenGoods (天工·商品标签) initial schema.
|
||||||
|
-- Public-good product information store: facts only, no commerce.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- fuzzy name search
|
||||||
|
CREATE EXTENSION IF NOT EXISTS ltree; -- category subtree queries
|
||||||
|
|
||||||
|
-- Data sources (Open Food Facts / USDA / GS1 ...) with trust + license.
|
||||||
|
CREATE TABLE source (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
homepage TEXT,
|
||||||
|
license TEXT,
|
||||||
|
trust_weight NUMERIC(3,2) NOT NULL DEFAULT 0.5,
|
||||||
|
notes TEXT,
|
||||||
|
UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE brand (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
normalized_name TEXT NOT NULL,
|
||||||
|
aliases TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE (normalized_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE manufacturer (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
normalized_name TEXT NOT NULL,
|
||||||
|
country VARCHAR(64),
|
||||||
|
UNIQUE (normalized_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Self-built category tree, each node optionally mapped to a GS1 GPC brick.
|
||||||
|
CREATE TABLE category (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name_zh TEXT NOT NULL,
|
||||||
|
name_en TEXT,
|
||||||
|
parent_id UUID REFERENCES category(id),
|
||||||
|
path LTREE NOT NULL,
|
||||||
|
gpc_brick_code VARCHAR(10),
|
||||||
|
level INT NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (path)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Parameter template / constraints per category.
|
||||||
|
CREATE TABLE category_schema (
|
||||||
|
category_id UUID PRIMARY KEY REFERENCES category(id) ON DELETE CASCADE,
|
||||||
|
required_attributes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
recommended_attributes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
nutriment_basis VARCHAR(16)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Unit dictionary: each unit maps to a canonical unit within its dimension.
|
||||||
|
CREATE TABLE unit (
|
||||||
|
code VARCHAR(16) PRIMARY KEY,
|
||||||
|
dimension VARCHAR(16) NOT NULL,
|
||||||
|
canonical VARCHAR(16) NOT NULL,
|
||||||
|
to_canonical_factor NUMERIC,
|
||||||
|
aliases TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
display TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Parameter dictionary: standard attribute keys with default unit.
|
||||||
|
CREATE TABLE attribute_definition (
|
||||||
|
key VARCHAR(64) PRIMARY KEY,
|
||||||
|
label_zh TEXT,
|
||||||
|
label_en TEXT,
|
||||||
|
dimension VARCHAR(16),
|
||||||
|
default_unit VARCHAR(16) REFERENCES unit(code),
|
||||||
|
aliases TEXT[] NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE product (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
gtin VARCHAR(14),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
brand_id UUID REFERENCES brand(id),
|
||||||
|
manufacturer_id UUID REFERENCES manufacturer(id),
|
||||||
|
category_id UUID REFERENCES category(id),
|
||||||
|
gpc_brick_code VARCHAR(10),
|
||||||
|
net_content_value NUMERIC,
|
||||||
|
net_content_unit VARCHAR(16),
|
||||||
|
net_content_canonical NUMERIC,
|
||||||
|
country_of_origin VARCHAR(64),
|
||||||
|
shelf_life_days INT,
|
||||||
|
storage TEXT,
|
||||||
|
attributes JSONB NOT NULL DEFAULT '{}',
|
||||||
|
quality_score NUMERIC(4,3) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||||
|
canonical_id UUID REFERENCES product(id),
|
||||||
|
search_tsv TSVECTOR,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT product_status_chk CHECK (status IN ('active','merged','deprecated')),
|
||||||
|
CONSTRAINT product_quality_chk CHECK (quality_score >= 0 AND quality_score <= 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE food_detail (
|
||||||
|
product_id UUID PRIMARY KEY REFERENCES product(id) ON DELETE CASCADE,
|
||||||
|
ingredients_text TEXT,
|
||||||
|
ingredients JSONB,
|
||||||
|
allergens TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
additives TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
nutriments JSONB,
|
||||||
|
nutrition_basis VARCHAR(16),
|
||||||
|
serving_size VARCHAR(32),
|
||||||
|
nutri_score CHAR(1),
|
||||||
|
labels TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
CONSTRAINT food_basis_chk CHECK (nutrition_basis IS NULL OR nutrition_basis IN ('per_100g','per_100ml','per_serving'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Official manufacturer-suggested retail price snapshot (no purchase link).
|
||||||
|
CREATE TABLE product_msrp (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_id UUID NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||||
|
amount NUMERIC(12,2) NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL,
|
||||||
|
region VARCHAR(8) NOT NULL DEFAULT 'CN',
|
||||||
|
source_id UUID REFERENCES source(id),
|
||||||
|
source_url TEXT,
|
||||||
|
effective_date DATE,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE product_image (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_id UUID NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
kind VARCHAR(16) NOT NULL DEFAULT 'other',
|
||||||
|
license TEXT,
|
||||||
|
source_id UUID REFERENCES source(id),
|
||||||
|
CONSTRAINT image_kind_chk CHECK (kind IN ('front','ingredients','nutrition','other'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Field-level provenance: which source provided which fields.
|
||||||
|
CREATE TABLE product_source (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_id UUID NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||||
|
source_id UUID REFERENCES source(id),
|
||||||
|
url TEXT,
|
||||||
|
fields TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
fetched_at TIMESTAMPTZ,
|
||||||
|
raw JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE merge_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
kept_id UUID,
|
||||||
|
merged_id UUID,
|
||||||
|
reason TEXT,
|
||||||
|
actor TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE UNIQUE INDEX idx_product_gtin ON product (gtin) WHERE gtin IS NOT NULL;
|
||||||
|
CREATE INDEX idx_product_name_trgm ON product USING gin (name gin_trgm_ops);
|
||||||
|
CREATE INDEX idx_product_attrs ON product USING gin (attributes);
|
||||||
|
CREATE INDEX idx_product_tsv ON product USING gin (search_tsv);
|
||||||
|
CREATE INDEX idx_product_category ON product (category_id);
|
||||||
|
CREATE INDEX idx_product_brand ON product (brand_id);
|
||||||
|
CREATE INDEX idx_product_updated ON product (updated_at);
|
||||||
|
CREATE INDEX idx_food_nutriments ON food_detail USING gin (nutriments);
|
||||||
|
CREATE INDEX idx_category_path ON category USING gist (path);
|
||||||
|
CREATE INDEX idx_msrp_product ON product_msrp (product_id);
|
||||||
|
CREATE INDEX idx_psource_product ON product_source (product_id);
|
||||||
|
|
||||||
|
-- Keep search_tsv and updated_at in sync.
|
||||||
|
CREATE OR REPLACE FUNCTION product_sync_tsv() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_tsv := to_tsvector('simple', coalesce(NEW.name, ''));
|
||||||
|
NEW.updated_at := now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_product_sync
|
||||||
|
BEFORE INSERT OR UPDATE ON product
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION product_sync_tsv();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DELETE FROM attribute_definition;
|
||||||
|
DELETE FROM unit;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Unit dictionary seed. Keep factors aligned with ingestion/opengoods/units.py.
|
||||||
|
|
||||||
|
INSERT INTO unit (code, dimension, canonical, to_canonical_factor, aliases, display) VALUES
|
||||||
|
('mg', 'mass', 'g', 0.001, ARRAY['毫克'], 'mg'),
|
||||||
|
('g', 'mass', 'g', 1, ARRAY['克','gram','grams'], 'g'),
|
||||||
|
('kg', 'mass', 'g', 1000, ARRAY['kgs','千克','公斤'], 'kg'),
|
||||||
|
('ml', 'volume', 'ml', 1, ARRAY['毫升','milliliter'], 'mL'),
|
||||||
|
('cl', 'volume', 'ml', 10, ARRAY['厘升'], 'cL'),
|
||||||
|
('l', 'volume', 'ml', 1000, ARRAY['L','升','litre','liter'], 'L'),
|
||||||
|
('kj', 'energy', 'kJ', 1, ARRAY['kJ','千焦'], 'kJ'),
|
||||||
|
('kcal', 'energy', 'kJ', 4.184, ARRAY['千卡','大卡'], 'kcal'),
|
||||||
|
('pct', 'ratio', 'pct', 1, ARRAY['%','percent','百分比'], '%'),
|
||||||
|
('unit', 'count', 'unit',1, ARRAY['个','件','pcs','piece'], '个'),
|
||||||
|
('mm', 'length', 'mm', 1, ARRAY['毫米'], 'mm'),
|
||||||
|
('cm', 'length', 'mm', 10, ARRAY['厘米'], 'cm'),
|
||||||
|
('day', 'duration', 'day', 1, ARRAY['天','日','days'], 'day')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- A few common food attribute definitions referencing the unit dictionary.
|
||||||
|
INSERT INTO attribute_definition (key, label_zh, label_en, dimension, default_unit, aliases) VALUES
|
||||||
|
('energy', '能量', 'Energy', 'energy', 'kj', ARRAY['energy_kj']),
|
||||||
|
('proteins', '蛋白质', 'Proteins', 'mass', 'g', ARRAY['protein']),
|
||||||
|
('fat', '脂肪', 'Fat', 'mass', 'g', ARRAY['fats']),
|
||||||
|
('saturated_fat', '饱和脂肪','Saturated fat','mass', 'g', ARRAY['saturated-fat']),
|
||||||
|
('carbohydrates', '碳水化合物','Carbohydrates','mass', 'g', ARRAY['carbs']),
|
||||||
|
('sugars', '糖', 'Sugars', 'mass', 'g', ARRAY['sugar']),
|
||||||
|
('salt', '盐', 'Salt', 'mass', 'g', ARRAY['sodium_salt']),
|
||||||
|
('net_content', '净含量', 'Net content', NULL, NULL, ARRAY['quantity'])
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- remove seeded categories (children first via path depth)
|
||||||
|
DELETE FROM category_schema;
|
||||||
|
DELETE FROM category;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- Seed a FOOD-focused category skeleton.
|
||||||
|
-- Structure = GS1 GPC backbone (segment/family/class) mapped to a self-built
|
||||||
|
-- Chinese tree. ltree labels are english slugs (ltree forbids spaces/CJK);
|
||||||
|
-- Chinese names live in name_zh. gpc_brick_code on leaves is a representative
|
||||||
|
-- starter value to be replaced by a full official GPC import later.
|
||||||
|
|
||||||
|
-- Root segment: Food/Beverage/Tobacco (GPC segment 50000000)
|
||||||
|
INSERT INTO category (name_zh, name_en, parent_id, path, gpc_brick_code, level)
|
||||||
|
VALUES ('食品饮料', 'Food/Beverage', NULL, 'food', '50000000', 0);
|
||||||
|
|
||||||
|
-- Families (level 1)
|
||||||
|
INSERT INTO category (name_zh, name_en, parent_id, path, gpc_brick_code, level)
|
||||||
|
SELECT v.name_zh, v.name_en, c.id, v.path::ltree, v.code, 1
|
||||||
|
FROM (VALUES
|
||||||
|
('饮料', 'Beverages', 'food.beverages', '50130000'),
|
||||||
|
('乳制品蛋类','Dairy/Eggs', 'food.dairy', '50180000'),
|
||||||
|
('烘焙', 'Bakery', 'food.bakery', '50100000'),
|
||||||
|
('零食', 'Snacks', 'food.snacks', '50190000'),
|
||||||
|
('粮油', 'Staples/Oils', 'food.staple', '50160000'),
|
||||||
|
('调味品', 'Condiments', 'food.condiments', '50170000')
|
||||||
|
) AS v(name_zh, name_en, path, code)
|
||||||
|
JOIN category c ON c.path = 'food';
|
||||||
|
|
||||||
|
-- Classes / leaves (level 2) with representative GPC brick codes
|
||||||
|
INSERT INTO category (name_zh, name_en, parent_id, path, gpc_brick_code, level)
|
||||||
|
SELECT v.name_zh, v.name_en, c.id, v.path::ltree, v.code, 2
|
||||||
|
FROM (VALUES
|
||||||
|
('包装饮用水', 'Bottled water', 'food.beverages.water', '10000224', 'food.beverages'),
|
||||||
|
('碳酸饮料', 'Carbonated', 'food.beverages.carbonated', '10000225', 'food.beverages'),
|
||||||
|
('果汁', 'Juice', 'food.beverages.juice', '10000226', 'food.beverages'),
|
||||||
|
('牛奶', 'Milk', 'food.dairy.milk', '10000158', 'food.dairy'),
|
||||||
|
('酸奶', 'Yogurt', 'food.dairy.yogurt', '10000159', 'food.dairy'),
|
||||||
|
('奶酪', 'Cheese', 'food.dairy.cheese', '10000160', 'food.dairy'),
|
||||||
|
('面包', 'Bread', 'food.bakery.bread', '10000040', 'food.bakery'),
|
||||||
|
('饼干', 'Biscuits', 'food.bakery.biscuits', '10000041', 'food.bakery'),
|
||||||
|
('薯片膨化', 'Chips/Snacks', 'food.snacks.chips', '10000310', 'food.snacks'),
|
||||||
|
('巧克力', 'Chocolate', 'food.snacks.chocolate', '10000311', 'food.snacks'),
|
||||||
|
('大米', 'Rice', 'food.staple.rice', '10000500', 'food.staple'),
|
||||||
|
('面条', 'Noodles', 'food.staple.noodles', '10000501', 'food.staple'),
|
||||||
|
('食用油', 'Cooking oil', 'food.staple.cooking_oil', '10000502', 'food.staple'),
|
||||||
|
('酱油', 'Soy sauce', 'food.condiments.soy_sauce', '10000600', 'food.condiments'),
|
||||||
|
('食盐', 'Table salt', 'food.condiments.salt', '10000601', 'food.condiments')
|
||||||
|
) AS v(name_zh, name_en, path, code, parent_path)
|
||||||
|
JOIN category c ON c.path = v.parent_path::ltree;
|
||||||
|
|
||||||
|
-- Parameter templates: leaf food categories use per_100g/ml nutrition basis.
|
||||||
|
INSERT INTO category_schema (category_id, required_attributes, recommended_attributes, nutriment_basis)
|
||||||
|
SELECT id,
|
||||||
|
ARRAY['net_content'],
|
||||||
|
ARRAY['energy','proteins','fat','carbohydrates','sugars','salt'],
|
||||||
|
CASE WHEN path <@ 'food.beverages' THEN 'per_100ml' ELSE 'per_100g' END
|
||||||
|
FROM category
|
||||||
|
WHERE level = 2;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Database migrations (golang-migrate)
|
||||||
|
|
||||||
|
SQL migrations for the OpenGoods database, applied with
|
||||||
|
[golang-migrate](https://github.com/golang-migrate/migrate).
|
||||||
|
Naming: `NNNN_description.up.sql` / `NNNN_description.down.sql`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
| Version | Up | 内容 |
|
||||||
|
|---------|----|------|
|
||||||
|
| 0001 | `0001_init` | 扩展(pgcrypto/pg_trgm/ltree) + 全部核心表 + 索引 + tsvector 触发器 |
|
||||||
|
| 0002 | `0002_seed_units` | 单位字典(与 `ingestion/opengoods/units.py` 一致)+ 常用营养参数定义 |
|
||||||
|
| 0003 | `0003_seed_categories` | 食品品类骨架(GS1 GPC 映射 + 自建中文树)+ 品类参数模板 |
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
先起本地依赖:`docker compose up -d postgres`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DBURL="postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
|
||||||
|
migrate -path migrations -database "$DBURL" up # 升级到最新
|
||||||
|
migrate -path migrations -database "$DBURL" down -all # 全部回滚
|
||||||
|
migrate -path migrations -database "$DBURL" version # 查看当前版本
|
||||||
|
```
|
||||||
|
|
||||||
|
安装 CLI:`go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1`
|
||||||
|
|
||||||
|
> ltree 标签为英文 slug(不支持空格/中文),中文名存于 `category.name_zh`。
|
||||||
|
> `gpc_brick_code` 为食品子集的代表值,后续用官方 GPC 全量导入替换。
|
||||||
Reference in New Issue
Block a user