feat(M1): 数据模型迁移 + GS1 GPC 食品分类 + 单位字典
- migrations/0001_init: 全部核心表(product/food_detail/product_msrp/product_source/brand/manufacturer/category/category_schema/unit/attribute_definition/product_image/merge_log) + 索引(gtin唯一/name trigram/JSONB GIN/category ltree/tsvector) + tsvector/updated_at 触发器 - 0002_seed_units: 单位字典(与 units.py 一致, 含中文别名) + 常用营养参数定义 - 0003_seed_categories: 食品品类骨架(GS1 GPC 映射 + 自建中文树, ltree) + 品类参数模板(营养基准 per_100g/ml) - CI 增加 migrations job: 用 postgres service 跑 migrate up + down 验证可逆 - 本地实跑 up/down/re-up 全部通过 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user