6.1 KiB
契约:商品文件夹与 product.json
状态:设计(待确认) 上游:总体架构 §3 Ozon API:ProductAPI_ImportProductsV3
插件、发布工作台、服务端三方唯一的耦合点。改这份文档等于改三端接口。
1. 文件夹结构
<商品名>/
├── product.json
├── sources.json
└── images/
├── main/ main-001.jpg …
├── sku/ sku-001-синий.jpg …
├── detail/ detail-001.jpg …
└── video/ video-001.mp4
命名规则:
| 项 | 规则 | 理由 |
|---|---|---|
| 文件夹名 | 商品名清洗后取前 80 字符 | 保留可读性,避开文件系统长度限制 |
| 图片文件名 | <组>-<3位序号>[-<规格名>].<ext> |
序号补零保证字典序 = 展示序 |
| 规格名 | 保留原文(含俄文/中文),清洗非法字符 | 跟卖时规格名要对应回 Ozon 变体 |
| 非法字符 | `< > : " / \ | ? *→_` |
序号从 1 开始,按页面上的出现顺序,不重排。main-001 即主图第一张,通常就是 Ozon 的封面图。
2. product.json
{
"_meta": {
"schemaVersion": 1,
"stage": "collected", // collected | edited | published
"createdAt": "2026-08-11T06:12:00Z",
"updatedAt": "2026-08-11T06:12:00Z"
},
// ── Ozon 字段(对齐 ImportProductsV3)──
"offer_id": "", // 自己的货号,采集阶段必空
"name": "Термокружка детская 316",
"description": "…",
"description_category_id": null,
"type_id": null,
"price": "1290", // 采到的竞品价,仅参考
"old_price": "",
"currency_code": "RUB",
"vat": "0",
"depth": null, "width": null, "height": null,
"dimension_unit": "mm",
"weight": null,
"weight_unit": "g",
"images": [], // 发布时才填公网 URL
"primary_image": "",
"images360": [],
"color_image": "",
"attributes": [], // 需类目字典映射,见 §4
"complex_attributes": [],
// ── 本地扩展字段(下划线前缀,提交 Ozon 前剥离)──
"_images": {
"main": [{ "file": "images/main/main-001.jpg", "sourceUrl": "https://…", "w": 1200, "h": 1200 }],
"sku": [{ "file": "images/sku/sku-001-синий.jpg", "variantName": "синий", "sourceUrl": "https://…" }],
"detail": [],
"video": []
},
"_raw": {
"title": "Термокружка детская 316",
"price": "1 290 ₽",
"params": [{ "key": "Материал", "value": "Нержавеющая сталь" }],
"desc": "…",
"sellingPoints": "…"
},
"_pricing": null // studio 计价结果,结构见 §5
}
2.1 为什么分 Ozon 字段 / _ 扩展字段
提交 Ozon 时把所有 _ 开头的键剥掉,剩下的就是请求体的 items[0]。这样避免了维护两套结构和一层映射代码。
_raw 保留采集原文:name 会被工作台改写(翻译/优化),改坏了要能回溯原始值。
3. stage 状态机
collected ──(工作台编辑)──> edited ──(发布成功)──> published
| stage | 谁写 | 必须满足 |
|---|---|---|
collected |
插件 | name 非空,_images 至少一张 main |
edited |
工作台 | offer_id、description_category_id、尺寸重量、_pricing 均已填 |
published |
服务端 | 追加 _ozon.product_id、_ozon.publishedAt |
工作台导入时按 stage 决定界面:collected 走完整编辑流程,edited 直接进复核,published 只读 + 提示"已发布"。
4. attributes 的处理边界
插件不碰 attributes。 Ozon 的属性需要 { id, complex_id, values[{ dictionary_value_id | value }] },其中 id 和 dictionary_value_id 都得查类目属性字典(/v1/description-category/attribute 与 /v1/description-category/attribute/values),而字典依赖类目——采集阶段还不知道类目。
所以:
插件 → _raw.params 存原始 kv 文本
工作台 → 定类目 → 拉字典 → 映射成 attributes
服务端 → 提交前按字典校验必填项
映射交互(自动匹配 + 人工确认未匹配项)属于 studio 设计范围,见 docs/studio/。
5. _pricing
复用 v1 计价器(web/js/app.js)的公式,结构对齐它现有的输出:
"_pricing": {
"purchasePrice": 18.5, // 进货价 ¥
"profitRate": 30, // 净利率 %
"logisticsLevel": "high", // low | high | high2
"weightG": 320,
"dims": { "l": 12, "w": 8, "h": 20 },
"logisticsFee": 0,
"fullCommission": 0,
"totalCost": 0,
"sellingPriceCny": 0,
"sellingPriceRub": 0,
"discountReserve": 50,
"fxRate": 11.8,
"calculatedAt": "2026-08-11T06:30:00Z"
}
字段名沿用 v1 页面里的 id 命名,方便对照。
6. sources.json
{
"sources": [
{
"platform": "ozon",
"itemId": "123456789",
"url": "https://www.ozon.ru/product/…",
"collectedAt": "2026-08-11T06:12:00Z",
"counts": { "main": 6, "sku": 4, "detail": 9, "video": 0 }
},
{
"platform": "1688",
"itemId": "987654321",
"url": "https://detail.1688.com/offer/987654321.html",
"collectedAt": "2026-08-11T07:40:00Z",
"counts": { "main": 5, "detail": 12 }
}
],
"dedupeKeys": ["https://cdn1.ozon.ru/…", "…"]
}
dedupeKeys 是已采集图片的归一化 URL 指纹。二次采集时插件读这个文件,命中的图标记「已收集」并默认不勾选——这是跨平台追加采集不重复的机制,纯本地实现,不需要服务端。
7. 双语言实现
| 端 | 位置 | 角色 |
|---|---|---|
| Python | server/schemas/product.py |
真源,Pydantic 模型 + 运行时校验 |
| TS | packages/schema/src/product.ts |
手写 interface,与真源对齐 |
| fixture | packages/schema/fixtures/*.json |
两端都跑一遍,防漂移 |
schemaVersion 变更时两端同步改,fixture 加一份新版本样例。当前 v1。