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>
409 lines
18 KiB
Markdown
409 lines
18 KiB
Markdown
# 天工·商品标签 (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。
|