-- 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();