54175238ad
新增 docs/planning/ 规划文档归档: - 00-final-plan: 最终规划(决策+架构+M0~M5任务清单) - 01-detailed-design-v2.0: 分类/单位/数据库/API/治理/采集/部署/众包 - 02-advanced-topics-v3.0: OpenAPI/全表DDL/数据契约/OFF映射/中国合规/测试/安全/图片/SLA/竞品 - history/: v0.1~v1.0 演进记录 更新 README 为项目介绍并链接规划文档。 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
18 KiB
18 KiB
天工·商品标签 (OpenGoods) — 深化规划 (v3.0)
在最终版基础上,展开全部 10 个进阶方向。仍为规划,未写代码。 原则不变:只采集 + 只提供信息,绝不涉及购买/交易。
目录
- 完整 OpenAPI 规范草案
- 完整数据库 DDL
- 数据契约文档
- OFF 字段映射表
- 中国合规专项
- 测试与数据质量保障
- 安全与反滥用
- 商品图片处理
- 可用性与 SLA
- 竞品 / 同类项目分析
1. 完整 OpenAPI 规范草案(节选骨架,写代码时落到 docs/openapi.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(全部表)
-- 扩展
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、Pythonpip-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。