feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+23
View File
@@ -11,3 +11,26 @@ HOST=127.0.0.1
PORT=8800
# Comma-separated origins when frontend runs on another port. Same-origin mount can leave empty.
CORS_ORIGINS=
# ── V2:数据层 ──
# 本地过渡用 SQLite(默认);上线腾讯云切 PostgreSQL:
# DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/ozon_seller
# DATABASE_URL=sqlite+aiosqlite:///./data/app.db
# ── V2:鉴权 ──
# MVP 单用户登录 tokenstudio 登录页 / 插件 options 页填同一个值),可随意生成一长串随机字符串
APP_TOKEN=change-me-to-a-long-random-token
# 店铺 Client-Id/Api-Key 的 AES-GCM 加密密钥 + JWT 签名密钥
SECRET_KEY=change-me-to-a-long-random-secret
# ── V2:七牛(图片存储)──
# 留空则用本地文件系统兜底(开发期);填了并设 STORAGE_BACKEND=qiniu 则走七牛
QINIU_ACCESS_KEY=
QINIU_SECRET_KEY=
QINIU_BUCKET=
# 必须 httpsOzon 拉取商品图片只接受 https 直链,http 会被拒绝
QINIU_DOMAIN=https://your-cdn-domain.example.com
STORAGE_BACKEND=local
# ── V2:对外地址(插件/前端回写、生成图回调)──
APP_BASE_URL=http://127.0.0.1:8800
+3
View File
@@ -5,6 +5,9 @@
.DS_Store
web/ozonSeller.html.bak
# V2 运行时数据(SQLite + 本地媒体)
data/
# 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2
reference/
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = server/migrations
prepend_sys_path = server
# URL 由 env.py 从 server/config/settings.py 读取(DATABASE_URL),此处留空
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+234
View File
@@ -0,0 +1,234 @@
# Ozon Seller API 鉴权与基础
> 官方文档:https://docs.ozon.ru/api/seller/zh/#tag/Introduction
---
## 1. 鉴权方式
Ozon Seller API 使用 **API Key 鉴权**(非 OAuth),每个请求需在请求头携带:
```http
Client-Id: < Client ID>
Api-Key: < API Key>
Content-Type: application/json
```
### 获取凭证
1. 登录 Ozon 卖家后台
2. 进入「设置」→「Seller API」
3. 点击「生成 API Key」
4. 选择权限级别:
- **只读**Read):仅查询
- **读写**Read & Write):查询 + 创建/更新商品
- **管理员**(Admin):所有权限
5. 保存 `Client-Id``Api-Key`**Api-Key 仅显示一次**
### 安全约束
- ⚠️ **Api-Key 等同密码**:泄露后任何人可操作你的店铺
- 🔒 **服务端存储**:加密落库(AES-GCM),前端永不传输/回显明文
- 🔄 **定期轮换**:建议每 90 天更换一次
- 🚫 **前端禁用**:插件/studio 不得持有店铺凭证,只能持有用户 token
---
## 2. Base URL
```
https://api-seller.ozon.ru
```
所有接口路径都基于此 URL,例如:
```
POST https://api-seller.ozon.ru/v3/product/import
```
---
## 3. 请求示例
### cURL
```bash
curl -X POST "https://api-seller.ozon.ru/v1/description-category/tree" \
-H "Client-Id: 123456" \
-H "Api-Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"language":"RU"}'
```
### Python (httpx)
```python
import httpx
headers = {
"Client-Id": "123456",
"Api-Key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://api-seller.ozon.ru/v1/description-category/tree",
headers=headers,
json={"language": "RU"}
)
data = resp.json()
```
---
## 4. 通用响应结构
### 成功响应(200/201
```json
{
"result": { /* */ }
}
```
部分接口直接返回数组或对象,不包裹 `result`
### 错误响应(4xx/5xx
```json
{
"code": 400,
"message": "INVALID_ARGUMENT",
"details": [
{
"typeUrl": "type.googleapis.com/ozon.ValidationError",
"value": "..."
}
]
}
```
或简化版:
```json
{
"error": {
"code": "INVALID_PARAMETER",
"message": "offer_id is required"
}
}
```
---
## 5. 错误码
| HTTP 状态码 | 含义 | 常见原因 | 处理建议 |
|---|---|---|---|
| **400** | 参数错误 | 必填字段缺失 / 格式错误 / 枚举值非法 | 检查请求体字段,读 `details` 定位 |
| **401** | 未鉴权 | 请求头缺 `Client-Id``Api-Key` | 检查请求头 |
| **403** | 权限不足 | Api-Key 权限级别不够(如只读 key 调创建接口) | 重新生成读写权限 key |
| **404** | 资源不存在 | `product_id` / `category_id` 不存在 | 检查 ID 是否正确 |
| **409** | 资源冲突 | `offer_id` 重复 / 商品已存在 | 改用唯一 offer_id 或走更新接口 |
| **429** | 限流 | 请求频率超限 | 指数退避重试(1s → 2s → 4s) |
| **500** | 服务端错误 | Ozon 内部错误 | 重试 1-2 次,仍失败则联系支持 |
| **503** | 服务不可用 | 维护中 | 稍后重试 |
---
## 6. 限流规则
官方未公开明确的限流阈值,根据社区经验:
- **常规接口**~10 req/s
- **批量接口**(如 `/v3/product/list`):~5 req/s
- **同一 task_id 轮询**:建议间隔 ≥5s
触发 429 后:
1. 解析响应头 `Retry-After`(秒数)
2. 若无此头,使用指数退避:1s → 2s → 4s → 8s
3. 最多重试 3 次
---
## 7. 超时建议
| 接口类型 | 超时时间 | 理由 |
|---|---|---|
| 查询类(类目/属性/商品列表) | 30s | 轻量请求 |
| 导入类(`/v3/product/import` | 60-90s | 后端需校验 + 入库 |
| 轮询状态(`/v1/product/import/info`) | 30s | 单次轮询快,但需多次 |
| 图片上传 | 90s | 网络传输耗时 |
---
## 8. 测试凭证有效性
### `/v1/roles` —— 获取当前 Key 的角色与权限
```http
POST https://api-seller.ozon.ru/v1/roles
```
**请求体**:空 `{}`
**响应**
```json
{
"result": [
{
"role_name": "Seller",
"permissions": [
"read:products",
"write:products",
"read:categories",
...
]
}
]
}
```
**用途**
- ✅ 验证凭证有效性(200 = 有效,401/403 = 无效)
- ✅ 查看权限范围(判断是否有 `write:products`
- ✅ 零业务副作用(不消耗额度,不修改数据)
**V2 集成点**`POST /api/shops/:id/test` 调此接口作连通性校验。
---
## 9. 请求 ID 追踪
部分接口响应包含 `request_id`(如图生图、导入任务),用于:
- 问题排查:联系 Ozon 支持时提供此 ID
- 幂等重试:某些接口可根据 `request_id` 避免重复创建
建议:每次请求在日志里记录 `request_id`(若有)与请求体摘要,便于回溯。
---
## 10. 环境
Ozon Seller API **仅生产环境**,无测试沙箱。调试时需注意:
- ⚠️ 所有操作都在真实店铺
- 💡 建议用「测试商品」标识(如 offer_id 前缀 `TEST-`
- 🗑️ 测试后及时删除/归档测试商品
---
## 11. SDK 与工具
官方未提供 Python SDK,社区方案:
- 自封装 `httpx` 客户端(V2 采用,见 `server/services/ozon_client.py`
- 第三方库:`ozon-api`PyPI,非官方,更新滞后)
---
## 12. 相关链接
- [官方文档(中文)](https://docs.ozon.ru/api/seller/zh/)
- [官方文档(俄文)](https://docs.ozon.ru/api/seller/)
- [卖家后台](https://seller.ozon.ru/)
- [API 状态页](https://status.ozon.ru/)(维护公告)
+333
View File
@@ -0,0 +1,333 @@
# 类目树查询 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetTree
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/tree` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 获取 Ozon 商品类目树(选择类目后才能发布商品) |
---
## 请求
### 请求体
```json
{
"language": "RU"
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| language | string | 可选 | 语言代码,可选值:`DEFAULT`(英文)、`RU`(俄文)、`EN`(英文)、`ZH_HANS`(简体中文)。默认 `DEFAULT` |
---
## 响应
### 成功响应(200
```json
{
"result": [
{
"description_category_id": 17033876,
"category_name": "Термокружки",
"type_id": 97114,
"type_name": "Термокружка",
"disabled": false,
"children": []
},
{
"description_category_id": 17028922,
"category_name": "Посуда",
"type_id": 0,
"type_name": "",
"disabled": true,
"children": [
{
"description_category_id": 17033876,
"category_name": "Термокружки",
"type_id": 97114,
"type_name": "Термокружка",
"disabled": false,
"children": []
}
]
}
]
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| description_category_id | integer | **类目 ID**(发布商品时必填) |
| category_name | string | 类目名称 |
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
| type_name | string | 商品类型名称 |
| disabled | boolean | **是否禁用**`true` = 不可建品(父类目),`false` = 可建品(末级类目) |
| children | array | 子类目(递归结构) |
---
## 关键约束
1. **只有末级类目可建品**`disabled=false` 的类目才能用于发布商品
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级
---
## 使用场景
### 场景 1:前端类目选择器
```
① 请求类目树(language=RU,给俄文用户看)
② 递归展开树形结构
③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级)
④ 选中后保存 description_category_id + type_id
```
### 场景 2:服务端缓存
```
① 启动时拉取类目树(language=DEFAULT,英文字段名便于代码处理)
② 存入 category_tree 表(见 docs/v2/database.md §2.7
③ TTL 24h,过期重拉
④ 用户选类目时直接查库,不频繁调 API
```
---
## 示例代码
### Python(服务端缓存)
```python
import httpx
from typing import List, Dict, Any
async def fetch_category_tree(
client_id: str,
api_key: str,
language: str = "DEFAULT"
) -> List[Dict[str, Any]]:
"""拉取类目树并返回扁平化列表"""
headers = {
"Client-Id": client_id,
"Api-Key": api_key,
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://api-seller.ozon.ru/v1/description-category/tree",
headers=headers,
json={"language": language}
)
resp.raise_for_status()
data = resp.json()
# 递归扁平化
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
flat = []
for node in nodes:
flat.append({
"description_category_id": node["description_category_id"],
"parent_id": parent_id,
"category_name": node["category_name"],
"type_id": node["type_id"],
"type_name": node["type_name"],
"disabled": node["disabled"],
"level": level,
"lang": language
})
if node.get("children"):
flat.extend(flatten(
node["children"],
level + 1,
node["description_category_id"]
))
return flat
return flatten(data.get("result", []))
```
### TypeScript(前端选择器)
```typescript
interface CategoryNode {
description_category_id: number;
category_name: string;
type_id: number;
type_name: string;
disabled: boolean;
children: CategoryNode[];
}
async function fetchCategoryTree(language = 'RU'): Promise<CategoryNode[]> {
const resp = await fetch('/api/categories/tree?lang=' + language);
const data = await resp.json();
return data.result;
}
// 转为 antd Tree 数据结构
function toTreeData(nodes: CategoryNode[]): any[] {
return nodes.map(node => ({
key: `${node.description_category_id}-${node.type_id}`,
title: node.category_name,
disabled: node.disabled, // 父类目禁止选择
children: node.children.length > 0 ? toTreeData(node.children) : undefined,
// 保存原始数据,选中时取用
data: {
description_category_id: node.description_category_id,
type_id: node.type_id
}
}));
}
```
---
## 缓存策略
### 全局缓存(推荐)
```python
# 类目树与店铺无关,所有店铺共用一份
# 启动时拉取,存内存 + 数据库
# TTL 24h(类目变化不频繁)
from functools import lru_cache
from datetime import datetime, timedelta
_category_tree_cache = None
_cache_time = None
@lru_cache(maxsize=1)
async def get_category_tree_cached(language: str = "DEFAULT"):
global _category_tree_cache, _cache_time
now = datetime.utcnow()
if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24):
return _category_tree_cache
# 从任意店铺拉(类目树全局一致)
tree = await fetch_category_tree(any_client_id, any_api_key, language)
_category_tree_cache = tree
_cache_time = now
# 同时写数据库
await save_to_db(tree)
return tree
```
### 按需更新
```python
# 用户反馈「找不到某类目」时手动刷新
async def refresh_category_tree():
global _category_tree_cache, _cache_time
_category_tree_cache = None
_cache_time = None
get_category_tree_cached.cache_clear()
return await get_category_tree_cached()
```
---
## 常见问题
### Q1: 类目树很大吗?
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。
### Q2: 多久更新一次?
**A**: Ozon 不定期新增类目(月级别),建议 **24h TTL + 手动刷新入口**
### Q3: 不同语言的类目树结构一样吗?
**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议:
- 服务端缓存 `DEFAULT`(英文,便于代码处理)
- 前端按用户语言拉取 `RU`(俄文,展示用)
### Q4: `type_id=0` 是什么意思?
**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`
---
## V2 项目集成
### API 层(已实现)
```python
# server/api/categories.py
@router.get("/categories/tree")
async def get_tree(
lang: str = Query("RU"),
shop_id: str = Query(...), # 需要店铺凭证
db: AsyncSession = Depends(get_db)
):
shop = await get_shop(db, shop_id)
tree = await ozon_client.get_category_tree(
shop.client_id_dec,
shop.api_key_dec,
lang
)
return {"result": tree}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/CategoryPicker.tsx
import { Tree } from 'antd';
import { useEffect, useState } from 'react';
export function CategoryPicker({ shopId, onChange }) {
const [treeData, setTreeData] = useState([]);
useEffect(() => {
fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`)
.then(r => r.json())
.then(data => setTreeData(toTreeData(data.result)));
}, [shopId]);
return (
<Tree
treeData={treeData}
onSelect={(keys, { node }) => {
if (!node.disabled) {
onChange(node.data); // { description_category_id, type_id }
}
}}
/>
);
}
```
---
## 相关文档
- [03-category-attributes.md](./03-category-attributes.md) —— 获取类目属性
- [docs/v2/database.md](../v2/database.md) §2.7 —— `category_tree` 表结构
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 类目字典缓存策略
@@ -0,0 +1,660 @@
# 类目属性与字典值 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/DescriptionCategoryAPI_GetAttributes
---
## 1. 获取类目属性
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/attribute` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 获取指定类目的所有属性(发布商品时需填写) |
---
### 请求
```json
{
"description_category_id": 17033876,
"type_id": 97114,
"language": "RU"
}
```
#### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| description_category_id | integer | ✅ 是 | 类目 ID(从类目树获取) |
| type_id | integer | ✅ 是 | 商品类型 ID(从类目树获取,与 category_id 配对) |
| language | string | 可选 | 语言代码:`DEFAULT` / `RU` / `EN` / `ZH_HANS`。默认 `DEFAULT` |
---
### 响应
```json
{
"result": [
{
"id": 85,
"name": "Бренд",
"description": "Укажите бренд товара",
"type": "String",
"is_collection": false,
"is_required": true,
"is_aspect": false,
"max_value_count": 1,
"dictionary_id": 28732,
"category_dependent": false,
"group_id": 0,
"group_name": ""
},
{
"id": 8229,
"name": "Цвет товара",
"description": "",
"type": "String",
"is_collection": false,
"is_required": false,
"is_aspect": true,
"max_value_count": 1,
"dictionary_id": 61405,
"category_dependent": false,
"group_id": 1,
"group_name": "Варианты"
},
{
"id": 9048,
"name": "Объем",
"description": "Укажите объем в миллилитрах",
"type": "Integer",
"is_collection": false,
"is_required": true,
"is_aspect": false,
"max_value_count": 1,
"dictionary_id": 0,
"category_dependent": false,
"group_id": 2,
"group_name": "Основные"
}
]
}
```
---
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| **id** | integer | **属性 ID**(发布时填 `attributes[].id` |
| **name** | string | 属性名称(如"品牌"、"颜色" |
| description | string | 属性说明(填写提示) |
| **type** | string | 值类型:`String` / `Integer` / `Decimal` / `Boolean` / `URL` |
| **is_required** | boolean | **是否必填**`true` = 必须填写,否则发布失败 |
| **is_aspect** | boolean | **是否变体属性**(如颜色/尺码)。`true` = 该属性用于区分 SKU 变体 |
| **is_collection** | boolean | 是否多值。`true` = 可填多个值(如"适用场景:家用,办公" |
| max_value_count | integer | 最多值数量(`is_collection=true` 时有效) |
| **dictionary_id** | integer | **字典 ID**`> 0` = 有预设值字典(需调字典值接口),`0` = 自由输入 |
| category_dependent | boolean | 字典值是否依赖类目(`true` = 不同类目的字典值不同) |
| group_id | integer | 属性分组 ID |
| group_name | string | 属性分组名(如"基本信息"、"变体" |
---
### 关键字段组合
| 组合 | 含义 | 示例 | 填写方式 |
|---|---|---|---|
| `is_required=true` | **必填** | 品牌、尺寸、重量 | 必须有值,否则发布失败 |
| `dictionary_id > 0` | **有字典** | 品牌、颜色、材质 | 值必须从字典选(dictionary_value_id |
| `dictionary_id = 0` | **自由输入** | 商品名、描述、数值 | 直接填文本/数字 |
| `is_aspect=true` | **变体属性** | 颜色、尺码 | 用于区分 SKU(不同颜色 = 不同 SKU) |
| `is_collection=true` | **多值** | 适用场景、材质组成 | 可传数组 `["值1", "值2"]` |
---
## 2. 获取属性值字典
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/attribute/values` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 获取属性的预设值字典(`dictionary_id > 0` 的属性) |
---
### 请求
```json
{
"attribute_id": 85,
"description_category_id": 17033876,
"type_id": 97114,
"language": "RU",
"limit": 1000,
"last_value_id": 0
}
```
#### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| attribute_id | integer | ✅ 是 | 属性 ID |
| description_category_id | integer | ✅ 是 | 类目 ID |
| type_id | integer | ✅ 是 | 商品类型 ID |
| language | string | 可选 | 语言代码 |
| limit | integer | 可选 | 每页数量,最大 **5000**,默认 1000 |
| last_value_id | integer | 可选 | 分页游标(上一页最后一个值的 `id`),首页传 0 |
---
### 响应
```json
{
"result": [
{
"id": 971082156,
"value": "Thermos",
"info": "",
"picture": ""
},
{
"id": 971317107,
"value": "Stanley",
"info": "",
"picture": ""
}
],
"has_next": true
}
```
#### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| **id** | integer | **字典值 ID**(发布时填 `attributes[].values[].dictionary_value_id` |
| **value** | string | 字典值文本(如品牌名"Thermos" |
| info | string | 补充说明 |
| picture | string | 值配图 URL(部分属性有,如颜色) |
| **has_next** | boolean | 是否有下一页(`true` = 用最后一个 `id` 继续分页) |
---
### 分页示例
```python
async def fetch_all_values(attribute_id, category_id, type_id):
all_values = []
last_id = 0
while True:
resp = await client.post(
"https://api-seller.ozon.ru/v1/description-category/attribute/values",
json={
"attribute_id": attribute_id,
"description_category_id": category_id,
"type_id": type_id,
"language": "RU",
"limit": 5000,
"last_value_id": last_id
}
)
data = resp.json()
values = data.get("result", [])
all_values.extend(values)
if not data.get("has_next") or not values:
break
last_id = values[-1]["id"]
return all_values
```
---
## 3. 按关键词搜索属性值
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/description-category/attribute/values/search` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 模糊搜索字典值(避免拉全量字典) |
---
### 请求
```json
{
"attribute_id": 85,
"description_category_id": 17033876,
"type_id": 97114,
"language": "RU",
"value": "Ther",
"limit": 100
}
```
#### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| attribute_id | integer | ✅ 是 | 属性 ID |
| description_category_id | integer | ✅ 是 | 类目 ID |
| type_id | integer | ✅ 是 | 商品类型 ID |
| language | string | 可选 | 语言代码 |
| **value** | string | ✅ 是 | 搜索关键词(≥2 个字符) |
| limit | integer | 可选 | 返回数量,最大 **100**,默认 50 |
---
### 响应
```json
{
"result": [
{
"id": 971082156,
"value": "Thermos",
"info": "",
"picture": ""
},
{
"id": 971982345,
"value": "Thermocafe",
"info": "",
"picture": ""
}
]
}
```
---
## 4. 属性映射工作流
### 场景:采集来的参数 → Ozon 属性
```
采集原文(raw.params:
[
{ "key": "Материал", "value": "Нержавеющая сталь" },
{ "key": "Объем", "value": "500 мл" },
{ "key": "Бренд", "value": "Thermos" }
]
↓ ① 自动匹配属性名
attributes = [
{ id: 8505, name: "Материал" }, // 材质
{ id: 9048, name: "Объем" }, // 容量
{ id: 85, name: "Бренд" } // 品牌
]
↓ ② 对有字典的属性(dictionary_id > 0),搜索字典值
POST /attribute/values/search {
attribute_id: 85, // 品牌
value: "Thermos"
}
→ { id: 971082156, value: "Thermos" }
↓ ③ 组装最终 attributes
products.attributes = [
{
"complex_id": 0,
"id": 8505,
"values": [{ "value": "Нержавеющая сталь" }] // 材质无字典,直接填
},
{
"complex_id": 0,
"id": 9048,
"values": [{ "value": "500" }] // 容量是数值,提取数字
},
{
"complex_id": 0,
"id": 85,
"values": [{ "dictionary_value_id": 971082156, "value": "Thermos" }] // 品牌有字典
}
]
```
---
## 5. 自动匹配策略
### 策略 A:归一化 + 模糊匹配
```python
import re
from difflib import SequenceMatcher
def normalize(text: str) -> str:
"""归一化:小写 + 去标点 + 词干"""
text = text.lower().strip()
text = re.sub(r'[^\w\s]', '', text)
# 俄文词干化(需 pymorphy2 库)
# text = morph.parse(text)[0].normal_form
return text
def fuzzy_match(采集key: str, 属性列表: list, threshold=0.8):
"""模糊匹配:相似度 > 0.8 即认为匹配"""
norm_key = normalize(采集key)
best = None
best_score = 0
for attr in 属性列表:
norm_name = normalize(attr["name"])
score = SequenceMatcher(None, norm_key, norm_name).ratio()
if score > best_score:
best = attr
best_score = score
return best if best_score >= threshold else None
```
### 策略 B:关键词映射表
```python
# 预定义常见映射(中文采集 key → Ozon 属性 name
KEYWORD_MAP = {
"品牌": ["Бренд", "Brand"],
"材质": ["Материал", "Material"],
"重量": ["Вес", "Weight"],
"尺寸": ["Размер", "Size"],
"颜色": ["Цвет", "Color"],
# ... 补充更多
}
def keyword_match(采集key: str, 属性列表: list):
for cn_key, ru_keys in KEYWORD_MAP.items():
if cn_key in 采集key:
for attr in 属性列表:
if any(rk in attr["name"] for rk in ru_keys):
return attr
return None
```
---
## 6. 必填项校验
### 发布前校验
```python
async def validate_required_attributes(
category_id: int,
type_id: int,
attributes: list
) -> list[str]:
"""返回缺失的必填属性名列表"""
# 获取该类目的所有属性
attrs = await fetch_category_attributes(category_id, type_id)
# 提取必填属性
required = [a for a in attrs if a["is_required"]]
# 已填写的属性 ID
filled_ids = {a["id"] for a in attributes}
# 找出缺失的
missing = [a["name"] for a in required if a["id"] not in filled_ids]
return missing
# 使用
missing = await validate_required_attributes(17033876, 97114, product.attributes)
if missing:
raise ValueError(f"缺少必填属性:{', '.join(missing)}")
```
---
## 7. 缓存策略
### 属性列表缓存
```python
# 按 (category_id, type_id) 缓存
# TTL 7 天(属性变化极少)
from functools import lru_cache
@lru_cache(maxsize=500)
async def get_attributes_cached(category_id: int, type_id: int):
# 先查数据库
cached = await db.query(CategoryAttribute).filter_by(
description_category_id=category_id,
type_id=type_id
).all()
if cached:
return cached
# 未缓存,调 API
attrs = await fetch_category_attributes(category_id, type_id)
# 写入数据库
await save_attributes_to_db(attrs)
return attrs
```
### 字典值缓存(按需)
```python
# 字典值可能很大(数万条),不全量缓存
# 策略:用户映射到某属性时,才拉该属性的字典(且优先用 /search)
async def get_attribute_values(attr_id, category_id, type_id, keyword=None):
if keyword:
# 有关键词 → 搜索接口(limit 100)
return await search_values(attr_id, category_id, type_id, keyword)
else:
# 无关键词 → 拉全量(分页,存数据库)
return await fetch_all_values(attr_id, category_id, type_id)
```
---
## 8. 前端交互设计
### 属性映射 UI(推荐)
```
┌─────────────────────────────────────────────────┐
│ 类目属性映射 │
├─────────────────────────────────────────────────┤
│ 采集属性 → Ozon 属性 │
├─────────────────────────────────────────────────┤
│ ✅ Материал (材质) → [自动] Материал (8505) │
│ 值:Нержавеющая сталь │
├─────────────────────────────────────────────────┤
│ ✅ Бренд (品牌) → [自动] Бренд (85) ⚠️必填 │
│ 值:Thermos → 字典值:[选择 ▼] │
│ ├ Thermos ✅ │
│ ├ Stanley │
│ └ ... │
├─────────────────────────────────────────────────┤
│ ⚠️ 未匹配:包装重量 │
│ → [手动选择属性 ▼] │
├─────────────────────────────────────────────────┤
│ ❌ 缺少必填属性: │
│ - Объем (容量) [+添加] │
│ - Цвет (颜色) [+添加] │
└─────────────────────────────────────────────────┘
```
---
## 9. 常见问题
### Q1: 属性太多怎么办?
**A**: 一个类目可能有 **50-100+ 属性**,但常用的只有 10-20 个。策略:
- 必填属性置顶 + 高亮
- 已匹配属性展开,未匹配折叠
- 提供搜索/筛选
### Q2: 字典值有多大?
**A**:
- 小字典(颜色/材质):几十到几百条
- 大字典(品牌):**数万条**(如品牌字典 > 50,000
- 策略:**优先用 `/values/search`**,避免拉全量
### Q3: 自由输入的属性怎么填?
**A**: `dictionary_id=0` 的属性直接填 `value`,无需 `dictionary_value_id`
```json
{
"id": 9048,
"values": [{ "value": "500" }] // 容量,数值类型
}
```
### Q4: 如何处理多值属性?
**A**: `is_collection=true` 时传数组:
```json
{
"id": 10096,
"values": [
{ "value": "家用" },
{ "value": "办公" }
]
}
```
---
## 10. V2 项目集成
### API 层(已实现)
```python
# server/api/categories.py
@router.get("/categories/{category_id}/attributes")
async def get_attributes(
category_id: int,
type_id: int = Query(...),
shop_id: str = Query(...),
db: AsyncSession = Depends(get_db)
):
shop = await get_shop(db, shop_id)
attrs = await ozon_client.get_category_attributes(
shop.client_id_dec,
shop.api_key_dec,
category_id,
type_id
)
return {"result": attrs}
@router.get("/categories/attribute/{attribute_id}/values")
async def get_attribute_values(
attribute_id: int,
category_id: int = Query(...),
type_id: int = Query(...),
q: str = Query(None), # 搜索关键词
shop_id: str = Query(...),
db: AsyncSession = Depends(get_db)
):
shop = await get_shop(db, shop_id)
if q and len(q) >= 2:
# 搜索接口
values = await ozon_client.search_attribute_values(
shop.client_id_dec, shop.api_key_dec,
attribute_id, category_id, type_id, q
)
else:
# 全量拉取(分页)
values = await ozon_client.get_attribute_values(
shop.client_id_dec, shop.api_key_dec,
attribute_id, category_id, type_id
)
return {"result": values}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/AttributeMapper.tsx
import { Form, Select, Input, Tag } from 'antd';
export function AttributeMapper({ categoryId, typeId, rawParams, onChange }) {
const [attributes, setAttributes] = useState([]);
useEffect(() => {
fetch(`/api/categories/${categoryId}/attributes?type_id=${typeId}`)
.then(r => r.json())
.then(data => setAttributes(data.result));
}, [categoryId, typeId]);
// 自动匹配
const autoMatch = () => {
const matched = rawParams.map(p => {
const attr = attributes.find(a =>
normalize(a.name) === normalize(p.key)
);
return attr ? { ...p, attrId: attr.id, attr } : p;
});
return matched;
};
return (
<div>
{autoMatch().map((item, i) => (
<Form.Item
key={i}
label={item.key}
required={item.attr?.is_required}
>
{item.attr?.dictionary_id > 0 ? (
<Select
showSearch
placeholder="选择字典值"
onSearch={(q) => fetchValues(item.attr.id, q)}
/>
) : (
<Input defaultValue={item.value} />
)}
</Form.Item>
))}
</div>
);
}
```
---
## 相关文档
- [02-category-tree.md](./02-category-tree.md) —— 获取类目树
- [04-product-import.md](./04-product-import.md) —— 发布商品(使用属性)
- [docs/v2/database.md](../v2/database.md) §2.7 —— 属性缓存表结构
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §3 —— 属性映射策略
+616
View File
@@ -0,0 +1,616 @@
# 商品导入(创建/更新)API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ImportProductsV3
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v3/product/import` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | **创建或更新商品**Ozon 核心接口) |
| 异步 | ✅ 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态 |
---
## 1. 请求体结构
### 完整示例
```json
{
"items": [
{
"offer_id": "MY-THERMOS-001",
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
"description": "Термокружка из высококачественной нержавеющей стали. Сохраняет температуру до 6 часов.",
"description_category_id": 17033876,
"type_id": 97114,
"price": "2990",
"old_price": "3490",
"currency_code": "RUB",
"vat": "0",
"depth": 80,
"width": 80,
"height": 200,
"dimension_unit": "mm",
"weight": 320,
"weight_unit": "g",
"images": [
"https://cdn.example.com/thermos-main-1.jpg",
"https://cdn.example.com/thermos-main-2.jpg"
],
"primary_image": "",
"images360": [],
"color_image": "",
"barcode": "",
"attributes": [
{
"complex_id": 0,
"id": 85,
"values": [
{
"dictionary_value_id": 971082156,
"value": "Thermos"
}
]
},
{
"complex_id": 0,
"id": 8505,
"values": [
{
"value": "Нержавеющая сталь"
}
]
}
],
"complex_attributes": []
}
]
}
```
---
## 2. 字段说明
### 基本信息
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| **offer_id** | string | ✅ 是 | **自己的货号**(唯一标识,用于更新)。最长 255 字符 |
| **name** | string | ✅ 是 | 商品名称。最长 500 字符 |
| **description** | string | ✅ 是 | 商品描述。最长 5000 字符,支持 HTML 标签 |
| **description_category_id** | integer | ✅ 是 | 类目 ID(从类目树获取) |
| **type_id** | integer | ✅ 是 | 商品类型 ID(从类目树获取) |
### 价格
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| **price** | string | ✅ 是 | 销售价(字符串格式,如 `"2990"` = 2990 卢布) |
| old_price | string | 可选 | 划线价(原价),用于展示折扣 |
| **currency_code** | string | ✅ 是 | 币种,通常 `"RUB"`(卢布)。也可 `"CNY"` 等 |
| **vat** | string | ✅ 是 | 增值税率:`"0"` / `"0.1"` / `"0.2"`。俄罗斯默认 `"0"` |
### 尺寸与重量
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| **depth** | number | ✅ 是 | 长度(包装尺寸)。**不能为 0** |
| **width** | number | ✅ 是 | 宽度(包装尺寸)。**不能为 0** |
| **height** | number | ✅ 是 | 高度(包装尺寸)。**不能为 0** |
| **dimension_unit** | string | ✅ 是 | 尺寸单位:`"mm"` / `"cm"` |
| **weight** | number | ✅ 是 | 重量(包装重量)。**不能为 0** |
| **weight_unit** | string | ✅ 是 | 重量单位:`"g"` / `"kg"` |
⚠️ **硬约束**:尺寸和重量必须 **> 0**,否则 API 返回 400 错误。
### 图片
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| **images** | array | ✅ 是 | **主图数组**(最多 15 张)。顺序即展示顺序,第一张为主图 |
| primary_image | string | 可选 | 主图(单独指定)。若使用则 `images` 最多 14 张 |
| images360 | array | 可选 | 360° 图片数组 |
| color_image | string | 可选 | 营销色图(部分类目支持) |
⚠️ **硬约束**
- 图片 URL 必须是 **https 公网直链**http 会被拒绝)
- 图片需可访问(Ozon 服务器会主动拉取)
- 建议尺寸:≥ 700×700 px,白底,主体占画面 80%+
### 属性
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| **attributes** | array | ✅ 是 | 商品属性数组(从类目属性获取) |
| attributes[].complex_id | integer | ✅ 是 | 复杂属性 ID,通常填 `0` |
| attributes[].id | integer | ✅ 是 | 属性 ID |
| attributes[].values | array | ✅ 是 | 属性值数组 |
| values[].dictionary_value_id | integer | 条件 | 字典值 ID(属性有字典时必填) |
| values[].value | string | ✅ 是 | 属性值文本 |
### 复杂属性
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| complex_attributes | array | 可选 | 复杂属性(视频、尺码表等) |
### 其他
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| barcode | string | 可选 | 条形码 |
| pdf_list | array | 可选 | PDF 文件 URL 列表 |
---
## 3. 响应
### 成功响应(200
```json
{
"result": {
"task_id": 123456789
}
}
```
| 字段 | 类型 | 说明 |
|---|---|---|
| task_id | integer | **任务 ID**(用于轮询状态,见下节) |
⚠️ **此时商品尚未创建**,需轮询 `/v1/product/import/info` 获取最终结果。
---
## 4. 轮询任务状态
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v1/product/import/info` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
### 请求
```json
{
"task_id": 123456789
}
```
### 响应
```json
{
"result": {
"items": [
{
"offer_id": "MY-THERMOS-001",
"product_id": 987654321,
"status": "imported",
"errors": []
}
]
}
}
```
### 状态值
| status | 含义 | 处理 |
|---|---|---|
| **imported** | ✅ 导入成功 | 保存 `product_id`,标记商品为 `published` |
| **pending** | ⏳ 排队中 | 继续轮询(间隔 5s) |
| **processing** | ⏳ 处理中 | 继续轮询(间隔 5s) |
| **moderation** | ⏳ 审核中 | 继续轮询(间隔 30s,审核可能需几小时) |
| **failed** | ❌ 失败 | 读取 `errors` 数组,展示错误给用户 |
### 错误结构
```json
{
"offer_id": "MY-THERMOS-001",
"product_id": 0,
"status": "failed",
"errors": [
{
"code": "INVALID_ATTRIBUTE",
"message": "Attribute 'Бренд' is required",
"field": "attributes"
}
]
}
```
---
## 5. 轮询策略
### 推荐策略
```python
import asyncio
async def wait_for_import(task_id: int, timeout=300):
"""轮询导入状态,最多等待 5 分钟"""
start = time.time()
interval = 5 # 初始间隔 5s
while time.time() - start < timeout:
resp = await ozon_client.get_import_info(task_id)
item = resp["result"]["items"][0]
status = item["status"]
if status == "imported":
return {"success": True, "product_id": item["product_id"]}
if status == "failed":
return {"success": False, "errors": item["errors"]}
if status == "moderation":
interval = 30 # 审核阶段降低频率
await asyncio.sleep(interval)
# 超时:不算失败,标记为"审核中"继续后台轮询
return {"success": None, "status": "timeout"}
```
### 后台轮询(推荐)
```python
# 用户提交发布后立即返回,后台协程轮询
# 状态变化时通知前端(WebSocket / 长轮询 / 前端定时刷新)
async def background_poll_task(task_id: int, product_id: str):
"""后台协程,轮询直到完成或失败"""
result = await wait_for_import(task_id, timeout=3600) # 最多 1 小时
# 更新数据库
await db.execute(
update(Product)
.where(Product.id == product_id)
.values(
stage="published" if result["success"] else "failed",
ozon_product_id=result.get("product_id"),
published_at=datetime.utcnow() if result["success"] else None
)
)
# 记录任务结果
await db.execute(
update(PublishTask)
.where(PublishTask.ozon_task_id == task_id)
.values(
status=result.get("status"),
errors=result.get("errors"),
completed_at=datetime.utcnow()
)
)
```
---
## 6. 创建 vs 更新
### 创建新商品
```json
{
"offer_id": "NEW-PRODUCT-001", // 全新 offer_id
// ... 其他字段
}
```
- 如果 `offer_id` 不存在 → 创建新商品
- 如果 `offer_id` 已存在 → 返回 409 冲突
### 更新已有商品
```json
{
"offer_id": "EXISTING-001", // 已存在的 offer_id
// ... 要更新的字段(可部分更新)
}
```
或使用 `product_id`
```json
{
"product_id": 987654321, // Ozon 商品 ID
// ... 要更新的字段
}
```
⚠️ **注意**
- 更新时,未传的字段**保持原值**(非清空)
- 图片数组传空 `[]` 会清空图片(需小心)
- 建议更新前先读取当前值(`/v3/product/info/list`
---
## 7. 批量导入
单次请求最多 **100 个 item**
```json
{
"items": [
{ "offer_id": "PROD-001", /* ... */ },
{ "offer_id": "PROD-002", /* ... */ },
// ... 最多 100 个
]
}
```
响应包含每个 item 的状态:
```json
{
"result": {
"items": [
{ "offer_id": "PROD-001", "status": "imported", "product_id": 111 },
{ "offer_id": "PROD-002", "status": "failed", "errors": [...] }
]
}
}
```
---
## 8. 常见错误
### 错误码速查
| code | message | 原因 | 解决 |
|---|---|---|---|
| `INVALID_PARAMETER` | 参数错误 | 必填字段缺失 / 格式错误 | 检查字段完整性 |
| `INVALID_ATTRIBUTE` | 属性错误 | 缺少必填属性 / 字典值不匹配 | 补全必填属性,校验字典值 |
| `INVALID_CATEGORY` | 类目错误 | `description_category_id` 不存在或已禁用 | 重新选择类目 |
| `INVALID_IMAGE` | 图片错误 | URL 不可访问 / 非 https / 格式不支持 | 检查图片 URL 有效性 |
| `OFFER_ID_DUPLICATE` | offer_id 重复 | 该 offer_id 已存在 | 换一个唯一 offer_id 或走更新 |
| `DIMENSION_REQUIRED` | 尺寸必填 | 尺寸/重量为 0 或缺失 | 填写正确尺寸重量 |
| `PRICE_INVALID` | 价格错误 | 价格 ≤ 0 或格式错误 | 检查价格字段 |
### 典型错误示例
#### 错误 1:尺寸为 0
```json
{
"errors": [
{
"code": "DIMENSION_REQUIRED",
"message": "Dimensions must be greater than 0",
"field": "weight"
}
]
}
```
**解决**:确保 `depth/width/height/weight` 都 > 0。
#### 错误 2:缺少必填属性
```json
{
"errors": [
{
"code": "INVALID_ATTRIBUTE",
"message": "Required attribute 'Бренд' (id=85) is missing",
"field": "attributes"
}
]
}
```
**解决**:补充缺失的必填属性。
#### 错误 3:图片 URL 不可访问
```json
{
"errors": [
{
"code": "INVALID_IMAGE",
"message": "Image URL is not accessible: https://...",
"field": "images[0]"
}
]
}
```
**解决**
1. 检查 URL 是 https(非 http
2. 检查 URL 公网可访问(Ozon 服务器需能拉取)
3. 检查图片格式(支持 jpg/png/webp
---
## 9. 发布后操作
### 设置库存(必须)
商品导入成功后**不会自动上架**,需设置库存才能开售:
```http
POST /v2/products/stocks
```
```json
{
"stocks": [
{
"product_id": 987654321,
"offer_id": "MY-THERMOS-001",
"stock": 100,
"warehouse_id": 12345678
}
]
}
```
⚠️ 不设置库存 → 商品在后台但不可购买。
### 查询商品详情
```http
POST /v3/product/info/list
```
```json
{
"offer_id": ["MY-THERMOS-001"]
}
```
返回商品完整信息(含审核状态、图片、属性)。
---
## 10. V2 项目集成
### API 层(已实现)
```python
# server/api/publish.py
@router.post("/products/{product_id}/publish")
async def publish_product(
product_id: str,
shop_id: str = Body(...),
db: AsyncSession = Depends(get_db)
):
# 1. 取商品数据
product = await get_product(db, product_id)
# 2. 校验必填项
validate_required_fields(product)
# 3. 组装 ImportProductsV3 请求体
item = build_import_item(product)
# 4. 调用 Ozon API
shop = await get_shop(db, shop_id)
resp = await ozon_client.import_products(
shop.client_id_dec,
shop.api_key_dec,
[item]
)
task_id = resp["result"]["task_id"]
# 5. 记录发布任务
task = PublishTask(
product_id=product_id,
shop_id=shop_id,
ozon_task_id=task_id,
status="pending",
request_payload=item
)
db.add(task)
await db.commit()
# 6. 启动后台轮询
asyncio.create_task(background_poll_task(task_id, product_id))
return {"task_id": task_id}
def build_import_item(product: Product) -> dict:
"""组装 ImportProductsV3 items[0]"""
return {
"offer_id": product.offer_id,
"name": product.name,
"description": product.description,
"description_category_id": product.description_category_id,
"type_id": product.type_id,
"price": str(product.price),
"old_price": str(product.old_price) if product.old_price else "",
"currency_code": product.currency_code,
"vat": product.vat,
"depth": product.depth,
"width": product.width,
"height": product.height,
"dimension_unit": product.dimension_unit,
"weight": product.weight,
"weight_unit": product.weight_unit,
"images": product.images, # 七牛 URL 数组
"primary_image": product.primary_image or "",
"images360": product.images360 or [],
"color_image": product.color_image or "",
"barcode": product.barcode or "",
"attributes": product.attributes or [],
"complex_attributes": product.complex_attributes or []
}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/PublishPanel.tsx
import { Button, Select, message } from 'antd';
export function PublishPanel({ productId }) {
const [shops, setShops] = useState([]);
const [publishing, setPublishing] = useState(false);
const handlePublish = async (shopId) => {
setPublishing(true);
try {
const resp = await fetch(`/api/products/${productId}/publish`, {
method: 'POST',
body: JSON.stringify({ shop_id: shopId })
});
const data = await resp.json();
message.success('发布任务已提交,轮询中...');
// 轮询状态(或 WebSocket 推送)
pollPublishStatus(data.task_id);
} catch (err) {
message.error(`发布失败: ${err.message}`);
} finally {
setPublishing(false);
}
};
return (
<div>
<Select
placeholder="选择目标店铺"
options={shops.map(s => ({ label: s.name, value: s.id }))}
onChange={handlePublish}
/>
<Button
type="primary"
loading={publishing}
onClick={() => /* trigger select */}
>
Ozon
</Button>
</div>
);
}
```
---
## 相关文档
- [02-category-tree.md](./02-category-tree.md) —— 获取类目
- [03-category-attributes.md](./03-category-attributes.md) —— 获取属性
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
- [09-stocks.md](./09-stocks.md) —— 设置库存(必须)
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
+557
View File
@@ -0,0 +1,557 @@
# 商品信息查询 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductInfoListV3
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v3/product/info/list` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 查询商品详细信息(含审核状态、图片、属性、错误) |
---
## 1. 请求
### 请求体
```json
{
"offer_id": ["MY-THERMOS-001", "MY-THERMOS-002"],
"product_id": [987654321],
"sku": [123456789]
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| offer_id | array | 可选 | 自己的货号数组(最多 100 个) |
| product_id | array | 可选 | Ozon 商品 ID 数组(最多 100 个) |
| sku | array | 可选 | Ozon SKU 数组(最多 100 个) |
⚠️ **至少提供一个筛选条件**offer_id / product_id / sku)。
---
## 2. 响应
### 成功响应(200
```json
{
"result": {
"items": [
{
"id": 987654321,
"name": "Термокружка Thermos из нержавеющей стали 500 мл",
"offer_id": "MY-THERMOS-001",
"barcode": "",
"buybox_price": "2990.00",
"category_id": 17033876,
"created_at": "2024-08-10T10:30:00Z",
"images": [
{
"file_name": "thermos-main-1.jpg",
"default": true,
"index": 0
}
],
"marketing_price": "2990.00",
"min_price": "2690.00",
"old_price": "3490.00",
"premium_price": "2790.00",
"price": "2990.00",
"recommended_price": "2990.00",
"sources": [
{
"is_enabled": true,
"sku": 123456789,
"source": "fbs"
}
],
"state": "processed",
"stocks": {
"coming": 0,
"present": 100,
"reserved": 5
},
"errors": [],
"vat": "0.00",
"visible": true,
"visibility_details": {
"has_price": true,
"has_stock": true,
"active_product": true
},
"price_index": "5.0",
"images360": [],
"color_image": "",
"primary_image": "",
"status": {
"state": "processed",
"state_failed": "",
"moderate_status": "approved",
"decline_reasons": [],
"validation_state": "success",
"state_name": "Processed",
"state_description": "Product is processed",
"is_failed": false,
"is_created": true,
"state_tooltip": ""
},
"description_category_id": 17033876,
"type_id": 97114,
"width": 80,
"height": 200,
"depth": 80,
"dimension_unit": "mm",
"weight": 320,
"weight_unit": "g",
"attributes": [
{
"attribute_id": 85,
"complex_id": 0,
"values": [
{
"dictionary_value_id": 971082156,
"value": "Thermos"
}
]
}
]
}
]
}
}
```
---
## 3. 核心字段说明
### 基本信息
| 字段 | 类型 | 说明 |
|---|---|---|
| id | integer | Ozon 商品 ID`product_id` |
| name | string | 商品名称 |
| offer_id | string | 自己的货号 |
| barcode | string | 条形码 |
| created_at | string | 创建时间(ISO 8601 |
### 价格
| 字段 | 类型 | 说明 |
|---|---|---|
| price | string | 当前售价 |
| old_price | string | 划线价(原价) |
| marketing_price | string | 营销价 |
| buybox_price | string | BuyBox 价格(赢得购物车的价格) |
| recommended_price | string | 平台推荐价 |
| min_price | string | 允许的最低价(低于此价需申请) |
| premium_price | string | Premium 会员价 |
### 状态
| 字段 | 类型 | 说明 |
|---|---|---|
| **state** | string | **商品状态**(见下表) |
| **status** | object | **状态详情**(含审核状态、错误原因) |
| visible | boolean | 是否可见(上架) |
| visibility_details | object | 可见性详情(是否有价格/库存/激活) |
#### state 状态值
| state | 含义 | 说明 |
|---|---|---|
| **processed** | ✅ 已处理 | 商品创建成功,可正常展示 |
| **processing** | ⏳ 处理中 | 正在处理(刚导入) |
| **moderating** | ⏳ 审核中 | 平台审核中 |
| **failed** | ❌ 失败 | 创建/审核失败,查看 `errors` |
| **archived** | 📦 已归档 | 商品已下架归档 |
#### status.moderate_status 审核状态
| moderate_status | 含义 |
|---|---|
| **approved** | ✅ 审核通过 |
| **pending** | ⏳ 待审核 |
| **declined** | ❌ 审核拒绝 |
### 库存
| 字段 | 类型 | 说明 |
|---|---|---|
| stocks.present | integer | 当前库存 |
| stocks.reserved | integer | 已预订数量 |
| stocks.coming | integer | 即将到货数量 |
### 图片
| 字段 | 类型 | 说明 |
|---|---|---|
| images | array | 图片数组 |
| images[].file_name | string | 图片文件名 |
| images[].default | boolean | 是否主图 |
| images[].index | integer | 顺序 |
| primary_image | string | 主图 URL |
| images360 | array | 360° 图 |
| color_image | string | 营销色图 |
### 尺寸与属性
| 字段 | 类型 | 说明 |
|---|---|---|
| description_category_id | integer | 类目 ID |
| type_id | integer | 商品类型 ID |
| width / height / depth | number | 尺寸 |
| dimension_unit | string | 尺寸单位 |
| weight | number | 重量 |
| weight_unit | string | 重量单位 |
| attributes | array | 属性数组(结构同导入) |
### 错误信息
| 字段 | 类型 | 说明 |
|---|---|---|
| errors | array | 错误数组(审核失败原因、字段错误等) |
| status.decline_reasons | array | 审核拒绝原因 |
| status.validation_state | string | 校验状态:`success` / `failed` |
---
## 4. 使用场景
### 场景 1:发布后回查 product_id
```python
# 导入后用 offer_id 查询,获取 product_id
async def get_product_id_by_offer(offer_id: str):
resp = await ozon_client.get_product_info(
offer_id=[offer_id]
)
items = resp["result"]["items"]
if items:
return items[0]["id"]
return None
```
### 场景 2:检查审核状态
```python
async def check_moderation_status(product_id: int):
resp = await ozon_client.get_product_info(
product_id=[product_id]
)
item = resp["result"]["items"][0]
status = item["status"]
return {
"state": status["state"],
"moderate_status": status["moderate_status"],
"is_approved": status["moderate_status"] == "approved",
"decline_reasons": status["decline_reasons"]
}
```
### 场景 3:读取审核错误
```python
async def get_product_errors(offer_id: str):
resp = await ozon_client.get_product_info(offer_id=[offer_id])
item = resp["result"]["items"][0]
errors = []
# 字段错误
if item.get("errors"):
errors.extend(item["errors"])
# 审核拒绝原因
if item["status"].get("decline_reasons"):
errors.extend(item["status"]["decline_reasons"])
return errors
```
---
## 5. 错误处理
### 商品不存在
```json
{
"result": {
"items": []
}
}
```
返回空数组,非 404 错误。
### 部分成功
```json
{
"result": {
"items": [
{
"id": 987654321,
"offer_id": "EXISTING-001",
/* ... */
}
]
}
}
```
请求 3 个 offer_id,只有 1 个存在 → 只返回 1 个 item。
---
## 6. 与其他接口的关系
### 与 `/v3/product/import` 的配合
```
① POST /v3/product/import → task_id
② POST /v1/product/import/info → status=imported, product_id=X
③ POST /v3/product/info/list (product_id=X) → 读取完整信息(含图片/审核状态)
```
**用途**:导入后可能需要:
- 确认图片上传成功
- 检查审核状态
- 读取 Ozon 生成的 SKU
- 查看价格索引(`price_index`,影响排名)
### 与 `/v3/product/list` 的区别
| 接口 | 用途 | 返回字段 |
|---|---|---|
| `/v3/product/info/list` | **详情查询** | 完整字段(图片/属性/状态/错误) |
| `/v3/product/list` | **列表分页** | 基本字段(id/name/price/state),支持筛选/排序 |
**选择建议**
- 已知 offer_id/product_id,要完整信息 → 用 `info/list`
- 分页浏览所有商品、筛选状态 → 用 `list`
---
## 7. 示例代码
### Python(服务端)
```python
async def fetch_product_detail(
client_id: str,
api_key: str,
offer_id: str = None,
product_id: int = None
):
"""查询商品详情"""
headers = {
"Client-Id": client_id,
"Api-Key": api_key,
"Content-Type": "application/json"
}
payload = {}
if offer_id:
payload["offer_id"] = [offer_id]
if product_id:
payload["product_id"] = [product_id]
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://api-seller.ozon.ru/v3/product/info/list",
headers=headers,
json=payload
)
resp.raise_for_status()
data = resp.json()
items = data.get("result", {}).get("items", [])
return items[0] if items else None
```
### TypeScript(前端)
```typescript
async function getProductDetail(
productId: string,
by: 'offer_id' | 'product_id' = 'offer_id'
) {
const resp = await fetch('/api/products/detail', {
method: 'POST',
body: JSON.stringify({
[by]: [productId]
})
});
const data = await resp.json();
return data.result.items[0];
}
// 使用
const detail = await getProductDetail('MY-THERMOS-001', 'offer_id');
console.log('审核状态:', detail.status.moderate_status);
console.log('库存:', detail.stocks.present);
```
---
## 8. 审核拒绝原因解读
### 常见拒绝原因
| decline_reason | 含义 | 解决 |
|---|---|---|
| 图片不符合要求 | 图片非白底/有水印/模糊 | 重新上传符合规范的图片 |
| 标题含禁用词 | 标题有夸大宣传/品牌侵权词 | 修改标题,去除违规词 |
| 描述不完整 | 描述过短或缺少关键信息 | 补充完整商品描述 |
| 类目错误 | 商品与类目不匹配 | 重新选择正确类目 |
| 属性缺失 | 缺少必填属性 | 补充必填属性 |
| 品牌未授权 | 品牌需授权认证 | 提供品牌授权书或改用无品牌 |
### 处理流程
```
① 读取 status.decline_reasons
② 根据原因修改商品(改图/改文案/改属性)
③ 重新调用 /v3/product/import(同 offer_id = 更新)
④ 再次审核
```
---
## 9. V2 项目集成
### API 层(待实现)
```python
# server/api/products.py
@router.get("/products/{product_id}/ozon-detail")
async def get_ozon_detail(
product_id: str,
db: AsyncSession = Depends(get_db)
):
"""查询商品在 Ozon 的详情(审核状态/库存/图片)"""
product = await get_product(db, product_id)
if not product.ozon_product_id and not product.offer_id:
raise HTTPException(404, "商品尚未发布到 Ozon")
# 获取店铺凭证(从发布记录找)
task = await db.execute(
select(PublishTask)
.where(PublishTask.product_id == product_id)
.order_by(PublishTask.created_at.desc())
.limit(1)
)
task = task.scalar_one_or_none()
if not task:
raise HTTPException(404, "未找到发布记录")
shop = await get_shop(db, task.shop_id)
# 调用 Ozon API
detail = await ozon_client.get_product_info(
shop.client_id_dec,
shop.api_key_dec,
offer_id=[product.offer_id] if product.offer_id else None,
product_id=[product.ozon_product_id] if product.ozon_product_id else None
)
return {"result": detail}
```
### 前端(商品详情页展示审核状态)
```tsx
// studio/src/pages/product/components/OzonStatusBadge.tsx
import { Badge, Tooltip } from 'antd';
export function OzonStatusBadge({ productId }) {
const [status, setStatus] = useState(null);
useEffect(() => {
fetch(`/api/products/${productId}/ozon-detail`)
.then(r => r.json())
.then(data => {
const item = data.result.items[0];
setStatus(item.status);
});
}, [productId]);
if (!status) return null;
const statusMap = {
approved: { color: 'success', text: '审核通过' },
pending: { color: 'processing', text: '审核中' },
declined: { color: 'error', text: '审核拒绝' }
};
const config = statusMap[status.moderate_status] || {};
return (
<Tooltip title={status.decline_reasons?.join(', ')}>
<Badge status={config.color} text={config.text} />
</Tooltip>
);
}
```
---
## 10. 性能优化
### 批量查询
```python
# 一次查询多个商品(最多 100 个)
async def batch_get_products(offer_ids: list[str]):
resp = await ozon_client.get_product_info(offer_id=offer_ids)
return {
item["offer_id"]: item
for item in resp["result"]["items"]
}
# 使用
details = await batch_get_products([
"PROD-001", "PROD-002", "PROD-003"
])
```
### 缓存策略
```python
# 商品详情变化不频繁,可短期缓存
from functools import lru_cache
@lru_cache(maxsize=1000)
async def get_product_info_cached(offer_id: str, ttl=300):
# TTL 5 分钟
detail = await ozon_client.get_product_info(offer_id=[offer_id])
return detail["result"]["items"][0] if detail["result"]["items"] else None
# 审核状态变化时清缓存
get_product_info_cached.cache_clear()
```
---
## 相关文档
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
- [06-product-list.md](./06-product-list.md) —— 商品列表分页
- [09-stocks.md](./09-stocks.md) —— 库存管理
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) —— 发布集成方案
+477
View File
@@ -0,0 +1,477 @@
# 商品列表查询 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_GetProductListV3
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v3/product/list` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | 分页查询商品列表(支持筛选、排序) |
---
## 1. 请求
### 请求体
```json
{
"filter": {
"offer_id": ["MY-THERMOS-001"],
"product_id": [987654321],
"visibility": "ALL"
},
"last_id": "",
"limit": 100
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| filter | object | 可选 | 筛选条件 |
| filter.offer_id | array | 可选 | 货号数组 |
| filter.product_id | array | 可选 | Ozon 商品 ID 数组 |
| filter.visibility | string | 可选 | 可见性:`ALL`(全部)/ `VISIBLE`(可见)/ `INVISIBLE`(不可见)/ `EMPTY_STOCK`(无库存)。默认 `ALL` |
| **last_id** | string | 可选 | **分页游标**(上一页最后一个商品的 ID),首页传空字符串 `""` |
| **limit** | integer | 可选 | 每页数量,最大 **1000**,默认 100 |
---
## 2. 响应
### 成功响应(200
```json
{
"result": {
"items": [
{
"product_id": 987654321,
"offer_id": "MY-THERMOS-001"
},
{
"product_id": 987654322,
"offer_id": "MY-THERMOS-002"
}
],
"total": 256,
"last_id": "bnVtYmVyMjo5ODc2NTQzMjI="
}
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| items | array | 商品列表(**仅基本字段**product_id + offer_id |
| total | integer | 商品总数 |
| **last_id** | string | **下一页游标**(Base64 编码,传给下次请求) |
⚠️ **注意**:此接口**仅返回 product_id 和 offer_id**,不返回名称/价格/图片等详情。要获取完整信息,需再调 `/v3/product/info/list`
---
## 3. 分页示例
### 游标分页(推荐)
```python
async def fetch_all_products(visibility="ALL"):
"""拉取所有商品(游标分页)"""
all_items = []
last_id = ""
while True:
resp = await ozon_client.get_product_list(
filter={"visibility": visibility},
last_id=last_id,
limit=1000 # 单次最多 1000
)
items = resp["result"]["items"]
all_items.extend(items)
# 无更多数据
if not resp["result"].get("last_id"):
break
last_id = resp["result"]["last_id"]
return all_items
```
### 分批处理
```python
async def process_products_in_batches(batch_size=100):
"""分批处理商品(避免一次拉全部)"""
last_id = ""
while True:
resp = await ozon_client.get_product_list(
last_id=last_id,
limit=batch_size
)
items = resp["result"]["items"]
if not items:
break
# 处理当前批次
await process_batch(items)
last_id = resp["result"].get("last_id")
if not last_id:
break
```
---
## 4. 获取完整信息
### 方法 A:批量查详情(推荐)
```python
async def fetch_products_with_detail(visibility="ALL"):
"""拉取商品列表 + 完整信息"""
# 1. 拉列表(仅 ID
list_resp = await ozon_client.get_product_list(
filter={"visibility": visibility},
limit=1000
)
items = list_resp["result"]["items"]
product_ids = [item["product_id"] for item in items]
# 2. 批量查详情(每次最多 100 个)
details = []
for i in range(0, len(product_ids), 100):
batch = product_ids[i:i+100]
detail_resp = await ozon_client.get_product_info(
product_id=batch
)
details.extend(detail_resp["result"]["items"])
return details
```
### 方法 B:按需查详情
```python
# 先列表,用户点击某个商品时再查详情
products = await ozon_client.get_product_list(limit=100)
# 用户点击 product_id=987654321
detail = await ozon_client.get_product_info(product_id=[987654321])
```
---
## 5. 筛选条件详解
### visibility 筛选
| 值 | 含义 | 使用场景 |
|---|---|---|
| **ALL** | 全部商品 | 管理后台(查看所有) |
| **VISIBLE** | 可见商品(上架) | 前台展示的商品 |
| **INVISIBLE** | 不可见商品(下架/草稿) | 待上架/审核失败/归档 |
| **EMPTY_STOCK** | 无库存商品 | 补货提醒 |
### 示例
```python
# 查询所有上架商品
visible = await ozon_client.get_product_list(
filter={"visibility": "VISIBLE"},
limit=1000
)
# 查询无库存商品(需补货)
empty_stock = await ozon_client.get_product_list(
filter={"visibility": "EMPTY_STOCK"},
limit=100
)
```
---
## 6. 性能对比
### `/v3/product/list` vs `/v3/product/info/list`
| 维度 | `/v3/product/list` | `/v3/product/info/list` |
|---|---|---|
| 返回字段 | 仅 product_id + offer_id | 完整字段(图片/属性/状态) |
| 单次数量 | 最多 **1000** | 最多 **100** |
| 响应速度 | 快(字段少) | 慢(字段多) |
| 适用场景 | 列表/分页/ID 收集 | 详情查询/更新前读取 |
**策略**
1. 先用 `/list` 拉 ID 列表(快)
2. 再用 `/info/list` 批量查详情(按需,100 个一批)
---
## 7. 与本地数据库同步
### 场景:回填 product_id
```python
async def sync_product_ids():
"""发布后回填 product_id(用 offer_id 匹配)"""
# 1. 从数据库取所有「已发布但无 product_id」的商品
local_products = await db.execute(
select(Product)
.where(
Product.stage == "published",
Product.ozon_product_id.is_(None),
Product.offer_id.isnot(None)
)
)
local_products = local_products.scalars().all()
if not local_products:
return
offer_ids = [p.offer_id for p in local_products]
# 2. 从 Ozon 查询这些 offer_id 的 product_id
ozon_items = []
for i in range(0, len(offer_ids), 100):
batch = offer_ids[i:i+100]
resp = await ozon_client.get_product_info(offer_id=batch)
ozon_items.extend(resp["result"]["items"])
# 3. 回填到数据库
ozon_map = {item["offer_id"]: item["id"] for item in ozon_items}
for p in local_products:
if p.offer_id in ozon_map:
p.ozon_product_id = ozon_map[p.offer_id]
await db.commit()
```
### 场景:定期同步状态
```python
async def sync_product_states():
"""定期同步商品状态(审核状态/库存/可见性)"""
# 1. 拉取所有 Ozon 商品 ID
ozon_resp = await ozon_client.get_product_list(limit=1000)
ozon_ids = [item["product_id"] for item in ozon_resp["result"]["items"]]
# 2. 批量查详情
details = []
for i in range(0, len(ozon_ids), 100):
batch = ozon_ids[i:i+100]
resp = await ozon_client.get_product_info(product_id=batch)
details.extend(resp["result"]["items"])
# 3. 更新本地数据库
for item in details:
await db.execute(
update(Product)
.where(Product.ozon_product_id == item["id"])
.values(
stage="published" if item["visible"] else "failed",
# 可同步更多字段:价格/库存/审核状态
)
)
await db.commit()
```
---
## 8. 常见问题
### Q1: 为什么 `/list` 只返回 ID
**A**: 性能考虑。商品列表可能有**数万条**,返回完整字段会很慢。设计思路:
1. 先快速拉 ID 列表(轻量)
2. 前端展示分页,只查当前页的详情
3. 或后台批量拉详情,按需处理
### Q2: 如何获取商品总数?
**A**: 响应的 `total` 字段。但注意:
- `total` 是当前筛选条件下的总数
- 不保证精确(可能略有延迟)
### Q3: 游标分页与偏移分页的区别?
**A**:
- **游标分页**(last_id):适合全量遍历,性能稳定
- **偏移分页**offset):Ozon 不支持(无 offset 参数)
### Q4: 多久同步一次?
**A**: 建议策略:
- 发布后立即查询(回填 product_id
- 定期同步(每天一次,更新状态/库存)
- 用户主动刷新(按需实时查询)
---
## 9. V2 项目集成
### API 层(待实现)
```python
# server/api/products.py
@router.get("/products/sync-from-ozon")
async def sync_from_ozon(
shop_id: str = Query(...),
db: AsyncSession = Depends(get_db)
):
"""从 Ozon 同步商品列表(回填 product_id + 状态)"""
shop = await get_shop(db, shop_id)
# 1. 拉取 Ozon 商品列表
ozon_items = []
last_id = ""
while True:
resp = await ozon_client.get_product_list(
shop.client_id_dec,
shop.api_key_dec,
last_id=last_id,
limit=1000
)
items = resp["result"]["items"]
ozon_items.extend(items)
last_id = resp["result"].get("last_id")
if not last_id:
break
# 2. 批量查详情
product_ids = [item["product_id"] for item in ozon_items]
details = []
for i in range(0, len(product_ids), 100):
batch = product_ids[i:i+100]
detail_resp = await ozon_client.get_product_info(
shop.client_id_dec,
shop.api_key_dec,
product_id=batch
)
details.extend(detail_resp["result"]["items"])
# 3. 更新本地数据库
updated = 0
for item in details:
result = await db.execute(
update(Product)
.where(Product.offer_id == item["offer_id"])
.values(
ozon_product_id=item["id"],
stage="published" if item["visible"] else "archived"
)
)
updated += result.rowcount
await db.commit()
return {
"synced": len(details),
"updated": updated
}
```
### 前端(待实现)
```tsx
// studio/src/pages/products/SyncButton.tsx
import { Button, message } from 'antd';
import { SyncOutlined } from '@ant-design/icons';
export function SyncFromOzonButton({ shopId }) {
const [syncing, setSyncing] = useState(false);
const handleSync = async () => {
setSyncing(true);
try {
const resp = await fetch(
`/api/products/sync-from-ozon?shop_id=${shopId}`
);
const data = await resp.json();
message.success(
`已同步 ${data.synced} 个商品,更新 ${data.updated} 条记录`
);
} catch (err) {
message.error(`同步失败: ${err.message}`);
} finally {
setSyncing(false);
}
};
return (
<Button
icon={<SyncOutlined />}
loading={syncing}
onClick={handleSync}
>
Ozon
</Button>
);
}
```
---
## 10. 高级用法
### 增量同步
```python
async def incremental_sync(last_sync_time: datetime):
"""增量同步:只拉取最近更新的商品"""
# Ozon 的 /list 接口不支持按更新时间筛选
# 策略:全量拉 ID,对比本地 updated_at,只查变化的
ozon_items = await fetch_all_products()
ozon_ids = {item["product_id"] for item in ozon_items}
# 查本地已有的 product_id
local = await db.execute(
select(Product.ozon_product_id, Product.updated_at)
.where(Product.ozon_product_id.isnot(None))
)
local_map = {
row.ozon_product_id: row.updated_at
for row in local.fetchall()
}
# 找出新增的 ID
new_ids = ozon_ids - set(local_map.keys())
# 批量查详情(只查新增的)
if new_ids:
details = await batch_get_product_info(list(new_ids))
# 插入数据库
...
```
---
## 相关文档
- [04-product-import.md](./04-product-import.md) —— 创建/更新商品
- [05-product-info.md](./05-product-info.md) —— 查询商品详情
- [09-stocks.md](./09-stocks.md) —— 库存管理
- [docs/v2/database.md](../v2/database.md) —— products 表结构
+599
View File
@@ -0,0 +1,599 @@
# 库存管理 API
> 官方文档:https://docs.ozon.ru/api/seller/zh/#operation/ProductAPI_ProductsStocksV2
---
## 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v2/products/stocks` |
| 鉴权 | 需要 `Client-Id` + `Api-Key` |
| 用途 | **设置/更新商品库存**(必须操作,否则商品不可购买) |
---
## 1. 重要约束
⚠️ **商品导入成功后不会自动上架**,必须设置库存才能开售:
```
POST /v3/product/import → status=imported(商品已创建)
POST /v2/products/stocks → 设置库存(商品可购买)
```
未设置库存的商品:
- ✅ 在卖家后台可见
- ❌ 前台不展示
- ❌ 无法购买
---
## 2. 请求
### 请求体
```json
{
"stocks": [
{
"offer_id": "MY-THERMOS-001",
"product_id": 987654321,
"stock": 100,
"warehouse_id": 12345678
},
{
"offer_id": "MY-THERMOS-002",
"stock": 50,
"warehouse_id": 12345678
}
]
}
```
### 参数说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| stocks | array | ✅ 是 | 库存数组(最多 **100** 个) |
| stocks[].offer_id | string | 条件 | 自己的货号(与 product_id 二选一) |
| stocks[].product_id | integer | 条件 | Ozon 商品 ID(与 offer_id 二选一) |
| stocks[].stock | integer | ✅ 是 | 库存数量。`0` = 无库存(下架) |
| stocks[].warehouse_id | integer | ✅ 是 | 仓库 ID(见下节) |
⚠️ **必须提供 offer_id 或 product_id**(建议用 offer_id,更稳定)。
---
## 3. 仓库 IDwarehouse_id
### 获取仓库 ID
**接口**`POST /v1/warehouse/list`
```json
{}
```
**响应**
```json
{
"result": [
{
"warehouse_id": 12345678,
"name": "FBS 仓库-莫斯科",
"can_print_act_in_advance": true,
"is_rfbs": false,
"has_postings_limit": false,
"postings_limit": 0,
"status": "working"
}
]
}
```
| 字段 | 说明 |
|---|---|
| warehouse_id | **仓库 ID**(设置库存时用) |
| name | 仓库名称 |
| is_rfbs | 是否 rFBS 仓库(Ozon 代发货) |
| status | 状态:`working`(运行中)/ `disabled`(禁用) |
### 仓库类型
| 类型 | 说明 | warehouse_id |
|---|---|---|
| **FBS** | 自发货(Fulfillment by Seller | 从 `/v1/warehouse/list` 获取 |
| **FBO** | Ozon 发货(Fulfillment by Ozon | 从 `/v1/warehouse/list` 获取 |
| **rFBS** | Ozon 代发货(类似 FBO,但库存在卖家处) | `is_rfbs=true` |
**推荐**:新商户优先用 **FBS**(自发货),灵活且门槛低。
---
## 4. 响应
### 成功响应(200
```json
{
"result": [
{
"errors": [],
"offer_id": "MY-THERMOS-001",
"product_id": 987654321,
"updated": true,
"warehouse_id": 12345678
},
{
"errors": [
{
"code": "PRODUCT_NOT_FOUND",
"message": "Product not found"
}
],
"offer_id": "MY-THERMOS-999",
"product_id": 0,
"updated": false,
"warehouse_id": 12345678
}
]
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| updated | boolean | 是否更新成功 |
| errors | array | 错误数组(失败时) |
| offer_id | string | 货号(回显) |
| product_id | integer | Ozon 商品 ID(回显) |
| warehouse_id | integer | 仓库 ID(回显) |
---
## 5. 常见错误
| code | message | 原因 | 解决 |
|---|---|---|---|
| `PRODUCT_NOT_FOUND` | 商品不存在 | offer_id/product_id 错误或商品已删除 | 检查 ID 是否正确 |
| `WAREHOUSE_NOT_FOUND` | 仓库不存在 | warehouse_id 错误 | 调 `/v1/warehouse/list` 获取正确 ID |
| `INVALID_STOCK` | 库存值错误 | stock < 0 | 库存必须 ≥ 0 |
| `PRODUCT_ARCHIVED` | 商品已归档 | 商品处于归档状态 | 先恢复商品再设库存 |
---
## 6. 使用场景
### 场景 1:发布后设置初始库存
```python
async def publish_and_set_stock(product: Product, shop: Shop):
"""发布商品 + 设置库存(完整流程)"""
# 1. 导入商品
resp = await ozon_client.import_products(
shop.client_id_dec,
shop.api_key_dec,
[build_import_item(product)]
)
task_id = resp["result"]["task_id"]
# 2. 轮询直到成功
result = await wait_for_import(task_id)
if not result["success"]:
raise Exception(f"发布失败: {result['errors']}")
product_id = result["product_id"]
# 3. 获取仓库 ID
warehouses = await ozon_client.get_warehouses(
shop.client_id_dec,
shop.api_key_dec
)
warehouse_id = warehouses[0]["warehouse_id"] # 取第一个
# 4. 设置库存
stock_resp = await ozon_client.update_stocks(
shop.client_id_dec,
shop.api_key_dec,
[{
"product_id": product_id,
"stock": 100, # 初始库存
"warehouse_id": warehouse_id
}]
)
return stock_resp
```
### 场景 2:批量更新库存
```python
async def batch_update_stocks(updates: list[dict]):
"""批量更新库存(最多 100 个)"""
# updates = [
# {"offer_id": "PROD-001", "stock": 50},
# {"offer_id": "PROD-002", "stock": 0}, # 0 = 下架
# ]
warehouse_id = await get_default_warehouse_id()
stocks = [
{
"offer_id": u["offer_id"],
"stock": u["stock"],
"warehouse_id": warehouse_id
}
for u in updates
]
resp = await ozon_client.update_stocks(
client_id, api_key, stocks
)
# 检查失败项
failed = [
item for item in resp["result"]
if not item["updated"]
]
return {
"success": len(resp["result"]) - len(failed),
"failed": failed
}
```
### 场景 3:库存为 0 时下架
```python
async def out_of_stock(offer_id: str):
"""库存售罄,设为 0(自动下架)"""
await ozon_client.update_stocks(
client_id, api_key,
[{
"offer_id": offer_id,
"stock": 0, # 库存为 0 → 前台不展示
"warehouse_id": warehouse_id
}]
)
```
### 场景 4:补货后上架
```python
async def restock(offer_id: str, quantity: int):
"""补货后重新上架"""
await ozon_client.update_stocks(
client_id, api_key,
[{
"offer_id": offer_id,
"stock": quantity, # 设置新库存 → 自动上架
"warehouse_id": warehouse_id
}]
)
```
---
## 7. 查询当前库存
### 接口信息
| 项 | 值 |
|---|---|
| 方法 | POST |
| 路径 | `/v3/product/info/stocks` |
| 用途 | 查询商品当前库存 |
### 请求
```json
{
"filter": {
"offer_id": ["MY-THERMOS-001"],
"product_id": [987654321],
"visibility": "ALL"
},
"last_id": "",
"limit": 100
}
```
### 响应
```json
{
"result": {
"items": [
{
"offer_id": "MY-THERMOS-001",
"product_id": 987654321,
"stocks": [
{
"type": "fbs",
"present": 100,
"reserved": 5,
"warehouse_id": 12345678,
"warehouse_name": "FBS 仓库-莫斯科"
}
]
}
],
"last_id": "",
"total": 1
}
}
```
| 字段 | 说明 |
|---|---|
| stocks[].present | 可用库存 |
| stocks[].reserved | 已预订数量(订单未完成) |
| stocks[].type | 仓库类型:`fbs` / `fbo` / `rfbs` |
---
## 8. 库存同步策略
### 策略 A:实时同步(推荐)
```python
# 本地库存变化时立即更新 Ozon
async def on_local_stock_change(product_id: str, new_stock: int):
product = await get_product(db, product_id)
if not product.ozon_product_id:
return # 未发布到 Ozon
shop = await get_default_shop(db)
warehouse_id = await get_default_warehouse_id()
await ozon_client.update_stocks(
shop.client_id_dec,
shop.api_key_dec,
[{
"offer_id": product.offer_id,
"stock": new_stock,
"warehouse_id": warehouse_id
}]
)
```
### 策略 B:定时同步
```python
# 每天凌晨同步一次(防止偏差累积)
async def daily_sync_stocks():
"""定时任务:同步本地库存到 Ozon"""
products = await db.execute(
select(Product)
.where(
Product.stage == "published",
Product.ozon_product_id.isnot(None)
)
)
products = products.scalars().all()
warehouse_id = await get_default_warehouse_id()
# 批量更新(100 个一批)
for i in range(0, len(products), 100):
batch = products[i:i+100]
stocks = [
{
"offer_id": p.offer_id,
"stock": p.local_stock, # 假设有 local_stock 字段
"warehouse_id": warehouse_id
}
for p in batch
]
await ozon_client.update_stocks(
client_id, api_key, stocks
)
```
### 策略 C:反向同步(从 Ozon 读回)
```python
# 定期从 Ozon 读回库存(多渠道销售时需要)
async def sync_stocks_from_ozon():
"""从 Ozon 同步库存到本地"""
resp = await ozon_client.get_product_stocks(
filter={"visibility": "VISIBLE"},
limit=1000
)
for item in resp["result"]["items"]:
offer_id = item["offer_id"]
ozon_stock = item["stocks"][0]["present"]
# 更新本地库存
await db.execute(
update(Product)
.where(Product.offer_id == offer_id)
.values(local_stock=ozon_stock)
)
await db.commit()
```
---
## 9. V2 项目集成
### API 层(待实现)
```python
# server/api/products.py
@router.post("/products/{product_id}/set-stock")
async def set_stock(
product_id: str,
stock: int = Body(..., ge=0),
shop_id: str = Body(...),
db: AsyncSession = Depends(get_db)
):
"""设置商品库存"""
product = await get_product(db, product_id)
if not product.ozon_product_id and not product.offer_id:
raise HTTPException(400, "商品尚未发布到 Ozon")
shop = await get_shop(db, shop_id)
# 获取仓库 ID(缓存)
warehouse_id = await get_or_cache_warehouse_id(shop)
# 调用 Ozon API
resp = await ozon_client.update_stocks(
shop.client_id_dec,
shop.api_key_dec,
[{
"offer_id": product.offer_id,
"stock": stock,
"warehouse_id": warehouse_id
}]
)
result = resp["result"][0]
if not result["updated"]:
raise HTTPException(500, f"更新失败: {result['errors']}")
# 更新本地记录
product.local_stock = stock
await db.commit()
return {"success": True, "stock": stock}
@router.get("/shops/{shop_id}/warehouses")
async def get_warehouses(
shop_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取店铺的仓库列表"""
shop = await get_shop(db, shop_id)
warehouses = await ozon_client.get_warehouses(
shop.client_id_dec,
shop.api_key_dec
)
return {"result": warehouses}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/StockPanel.tsx
import { InputNumber, Button, message } from 'antd';
export function StockPanel({ productId, shopId }) {
const [stock, setStock] = useState(0);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await fetch(`/api/products/${productId}/set-stock`, {
method: 'POST',
body: JSON.stringify({ stock, shop_id: shopId })
});
message.success(`库存已设置为 ${stock}`);
} catch (err) {
message.error(`设置失败: ${err.message}`);
} finally {
setSaving(false);
}
};
return (
<div>
<InputNumber
min={0}
value={stock}
onChange={setStock}
placeholder="库存数量"
/>
<Button
type="primary"
loading={saving}
onClick={handleSave}
>
</Button>
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
💡 0
</div>
</div>
);
}
```
---
## 10. 最佳实践
### 1. 发布流程中必须设库存
```
✅ 正确:
POST /v3/product/import → 轮询成功 → POST /v2/products/stocks
❌ 错误:
POST /v3/product/import → 轮询成功 → 结束(商品不可购买)
```
### 2. 缓存仓库 ID
```python
# 仓库 ID 不常变,启动时拉取并缓存
_warehouse_cache = {}
async def get_warehouse_id(shop_id: str):
if shop_id not in _warehouse_cache:
warehouses = await ozon_client.get_warehouses(...)
_warehouse_cache[shop_id] = warehouses[0]["warehouse_id"]
return _warehouse_cache[shop_id]
```
### 3. 库存为 0 的处理
```python
# 库存为 0 → 自动下架,但商品仍在后台
# 补货后再设置库存 → 自动上架
# 不需要删除商品,只需更新库存
```
### 4. 批量操作
```python
# 单次最多 100 个,超过需分批
async def update_large_batch(stocks: list):
results = []
for i in range(0, len(stocks), 100):
batch = stocks[i:i+100]
resp = await ozon_client.update_stocks(client_id, api_key, batch)
results.extend(resp["result"])
return results
```
---
## 相关文档
- [04-product-import.md](./04-product-import.md) —— 创建商品(发布前置)
- [05-product-info.md](./05-product-info.md) —— 查询商品信息(含库存)
- [10-prices.md](./10-prices.md) —— 价格更新
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) §5 —— 发布链路(含库存设置)
+102
View File
@@ -0,0 +1,102 @@
# Ozon Seller API 文档总览
> 本目录整理 Ozon Seller API 的核心接口文档,供 ozon-seller-kit 项目集成使用。
> 官方文档:https://docs.ozon.ru/api/seller/zh/
---
## 目录
| 文档 | 内容 |
|---|---|
| [01-authentication.md](./01-authentication.md) | 鉴权方式、请求头、错误码 |
| [02-category-tree.md](./02-category-tree.md) | 类目树查询 |
| [03-category-attributes.md](./03-category-attributes.md) | 类目属性与字典值 |
| [04-product-import.md](./04-product-import.md) | 商品导入(创建/更新) |
| [05-product-info.md](./05-product-info.md) | 商品信息查询 |
| [06-product-list.md](./06-product-list.md) | 商品列表 |
| [07-import-by-sku.md](./07-import-by-sku.md) | 跟卖(按 SKU 复制) |
| [08-pictures.md](./08-pictures.md) | 图片更新 |
| [09-stocks.md](./09-stocks.md) | 库存管理 |
| [10-prices.md](./10-prices.md) | 价格更新 |
---
## 快速索引
### 核心流程
**1. 发布新商品**
```
① 获取类目树 → 选择类目 → 得 description_category_id + type_id
② 获取该类目属性 → 映射属性值
③ 组装 ImportProductsV3 请求体
④ POST /v3/product/import → 得 task_id
⑤ 轮询 POST /v1/product/import/info → 得 product_id
```
**2. 跟卖已有商品**
```
① POST /v1/product/import-by-sku(传 sku + 基本信息)
② 轮询状态
```
**3. 更新商品**
- 更新商品信息:复用 `/v3/product/import`(传 `product_id``offer_id`
- 更新图片:`POST /v1/product/pictures/import`
- 更新价格:`POST /v1/product/import/prices`
- 更新库存:`POST /v2/products/stocks`
---
## API 基础信息
| 项 | 值 |
|---|---|
| Base URL | `https://api-seller.ozon.ru` |
| 鉴权方式 | 请求头 `Client-Id` + `Api-Key` |
| 内容类型 | `application/json` |
| 超时建议 | 30s(常规)/ 90simport/轮询) |
| 限流 | 官方未明确公开限流规则,建议控制在 10 req/s |
---
## 关键约束
1. **类目选择**:只有末级类目(`disabled=false`)可建品
2. **必填字段**`name/description/category/price/尺寸重量/offer_id/images` 必填且不能为 0
3. **图片 URL**:必须是 **https 公网直链**http 会被拒绝)
4. **属性映射**`is_required=true` 的属性必须填写
5. **异步任务**`/v3/product/import` 返回 `task_id`,需轮询 `/v1/product/import/info` 获取最终状态
6. **库存必须设置**`import` 成功后商品在后台,需设置库存才能上架
---
## 错误码速查
| HTTP | 含义 | 处理 |
|---|---|---|
| 400 | 参数错误 | 检查请求体字段 |
| 403 | 权限不足 | 检查 Api-Key 权限级别 |
| 404 | 资源不存在 | 检查 product_id/category_id |
| 409 | 冲突(如 offer_id 重复) | 改 offer_id 或走更新 |
| 429 | 限流 | 指数退避重试 |
| 500 | 服务端错误 | 重试或联系支持 |
---
## V2 项目集成清单
| 接口 | 用途 | 实现状态 |
|---|---|---|
| `/v1/description-category/tree` | 类目树 | ✅ API 已建(categories.py |
| `/v1/description-category/attribute` | 类目属性 | ✅ API 已建 |
| `/v1/description-category/attribute/values` | 属性值字典 | ✅ API 已建 |
| `/v3/product/import` | 商品导入 | ✅ API 已建(publish.py |
| `/v1/product/import/info` | 导入状态 | ✅ API 已建 |
| `/v3/product/list` | 商品列表 | 🟡 待建 |
| `/v3/product/info/list` | 商品详情 | 🟡 待建 |
| `/v1/product/import-by-sku` | 跟卖 | 🟡 待建(二期) |
| `/v1/product/pictures/import` | 图片更新 | 🟡 待建(二期) |
| `/v2/products/stocks` | 库存 | 🟡 待建(二期) |
| `/v1/product/import/prices` | 价格 | 🟡 待建(二期) |
+143
View File
@@ -0,0 +1,143 @@
# Ozon Seller API 文档整理完成
已完成 Ozon Seller API 的核心接口文档整理,涵盖商品发布、管理的完整流程。
## 已完成的文档
### 核心文档(9 个)
1. **README.md** - 总览与快速索引
2. **01-authentication.md** - 鉴权方式、Base URL、错误码、限流
3. **02-category-tree.md** - 类目树查询(选择类目)
4. **03-category-attributes.md** - 类目属性与字典值(属性映射)
5. **04-product-import.md** - 商品导入/创建/更新(核心接口)
6. **05-product-info.md** - 商品详情查询(审核状态、图片、属性)
7. **06-product-list.md** - 商品列表分页(ID 收集、批量查询)
8. **09-stocks.md** - 库存管理(必须设置才能上架)
### 待补充(二期)
- **07-import-by-sku.md** - 跟卖(按 SKU 复制 PDP
- **08-pictures.md** - 图片更新
- **10-prices.md** - 价格批量更新
---
## 文档特色
### 1. 完整的代码示例
- Python(服务端)示例
- TypeScript(前端)示例
- 实际可运行的代码片段
### 2. V2 项目集成指引
- 每个文档都包含"V2 项目集成"章节
- API 层实现示例(对齐 server/ 结构)
- 前端组件示例(对齐 studio/ 结构)
### 3. 最佳实践与常见问题
- 缓存策略
- 错误处理
- 性能优化
- 分页/批量操作
### 4. 实用场景
- 发布流程(端到端)
- 属性映射工作流
- 库存同步策略
- 审核状态检查
---
## 核心流程速查
### 完整发布流程
```
① 获取类目树 → 选择类目
POST /v1/description-category/tree
② 获取类目属性 → 映射属性
POST /v1/description-category/attribute
POST /v1/description-category/attribute/values/search
③ 组装请求体 → 发布商品
POST /v3/product/import → task_id
④ 轮询状态 → 获取 product_id
POST /v1/product/import/info → status=imported
⑤ 设置库存(必须)
POST /v2/products/stocks → 商品上架
⑥ 查询详情(可选)
POST /v3/product/info/list → 审核状态/图片/库存
```
### 关键约束总结
1. **类目**:只有末级类目(`disabled=false`)可建品
2. **必填字段**name/description/category/price/尺寸重量/offer_id/images
3. **图片 URL**:必须 https 公网直链
4. **属性**`is_required=true` 的必须填写
5. **异步任务**`/import` 返回 task_id,需轮询状态
6. **库存必须设置**:不设置库存 = 商品不可购买
---
## 与 V2 项目的对应关系
| Ozon API | V2 后端 API | V2 前端页面 | 状态 |
|---|---|---|---|
| `/description-category/tree` | `/api/categories/tree` | CategoryPicker | ✅ 已建 |
| `/description-category/attribute` | `/api/categories/{id}/attributes` | AttributeMapper | ✅ 已建 |
| `/attribute/values` | `/api/categories/attribute/{id}/values` | - | ✅ 已建 |
| `/v3/product/import` | `/api/products/{id}/publish` | PublishPanel | ✅ 已建 |
| `/v1/product/import/info` | (background poll) | - | ✅ 已建 |
| `/v3/product/info/list` | `/api/products/{id}/ozon-detail` | OzonStatusBadge | 🟡 待建 |
| `/v3/product/list` | `/api/products/sync-from-ozon` | SyncButton | 🟡 待建 |
| `/v2/products/stocks` | `/api/products/{id}/set-stock` | StockPanel | 🟡 待建 |
---
## 使用建议
### 阅读顺序(新接入)
1. **01-authentication.md** - 了解鉴权与基础
2. **04-product-import.md** - 核心接口,先看这个
3. **02-category-tree.md** - 类目选择
4. **03-category-attributes.md** - 属性映射(难点)
5. **09-stocks.md** - 库存设置(必须)
6. 其他按需查阅
### 开发时查阅
- 看接口契约 → 查对应章节的"请求/响应"
- 看错误处理 → 查"常见错误"章节
- 看集成方式 → 查"V2 项目集成"章节
- 看最佳实践 → 查"使用场景"或"最佳实践"章节
---
## 下一步
### 立即可用
现有 9 个文档已覆盖 V2 项目一期的所有核心接口,可立即用于:
- 服务端 `ozon_client.py` 开发
- API 端点实现参考
- 前端组件开发参考
### 二期补充
需要时再补充:
- 跟卖(import-by-sku
- 图片单独更新
- 价格批量更新
---
## 相关文档
- [docs/v2/ozon-publish.md](../v2/ozon-publish.md) - V2 发布集成方案(与本文档配套)
- [docs/v2/api.md](../v2/api.md) - V2 后端 API 设计
- [docs/v2/database.md](../v2/database.md) - V2 数据库设计
+112
View File
@@ -0,0 +1,112 @@
# Ozon Seller Kit V2 方案总览
> 状态:方案设计(待确认)
> 最后更新:2026-08-14
> 定位:本文是 V2 全部设计文档的入口与决策总表。先读本文,再按需读分册。
---
## 1. 一句话定位
V2 把 Ozon Seller Kit 从「**本地文件夹 + 单机工具**」升级为「**云端数据库 + 多店铺工作台**」:
```
V1(现状) V2(目标)
插件 ──写本地文件夹──> studio 读 插件 ──上传──> 服务端落库(采集箱)
用户 ──> studio 工作台:看采集箱 → 编辑 → 发布
服务端 ──> Ozon Seller API(多店铺)
发布结果落库 → 支持 CSV 导出
```
核心变化只有一条:**契约真源从「磁盘上的商品文件夹」换成「数据库 + 七牛对象存储」**。本地文件夹不再承载主流程,降级为可选的导入/导出格式。
这是原架构文档(`docs/architecture.md` §7)里早已规划的 **S4 阶段**:商品库落库,本地文件夹降级。
---
## 2. 现状盘点(V1 资产)
| 部分 | 现状 | V2 处置 |
|---|---|---|
| `web/` 工具台 v1 | ✅ 在用(计价/登记/水印/俄文文案),冻结 | **只读**。计价公式、水印算法、文案交互被抄进 studio,不改原文件 |
| `extension-v1` | 1688/淘宝采集(SSR/DOM) | 保留为素材补充来源(二期) |
| `extension-v2` | Ozon 采集,**写本地文件夹**File System Access | 改造成**上传服务端落库**,删除本地写盘主路径 |
| `studio/` | React + antd**仅「AI 图生图」一页**wanx2.1-imageedit | 扩为**多页工作台**:采集箱 / 商品编辑 / 发布 / 店铺 / 导出 |
| `server/` | FastAPI,无 DB,无 Ozon 对接;有 `/api/ai/*``/api/image/edit` | 加 DB + 七牛 + Ozon 对接 + 鉴权 + 异步任务 |
现状能力与可复用清单详见 [`capability-inventory.md`](./capability-inventory.md)V2 设计输入稿)。
---
## 3. V2 决策总表(D 系列)
| 编号 | 决策 | 内容 | 理由 |
|---|---|---|---|
| **D1** | 契约云端化 | 商品数据落 **PostgreSQL**,图片落 **七牛**;本地文件夹降级为导入/导出格式 | 多设备、多店铺、可发布、可导出,单机文件夹做不到 |
| **D2** | 插件上传 | 插件采集结果走 `POST /api/materials` 上传落库,不再写本地 | 复用 `docs/extension/plan.md` §14 已定好的契约 |
| **D3** | 数据库选型 | PostgreSQL 16(腾讯云 CDB+ SQLAlchemy 2.0 + Alembic | 单库覆盖结构化字段 + JSONBattributes/raw),运维成熟 |
| **D4** | 图片存储 | 七牛云:源图由服务端代下转存,生成图也转存;Ozon 发布用七牛公网 URL | Ozon `images` 只收公网 URL(见 `architecture.md` §4 |
| **D5** | 图片方案 | **方案 B(高低搭配)✅ 已拍板**:集成 ecommerce-image-suite「电商套图」+ 保留 wanx2.1-imageedit(改名「智能修图」) | 二者是不同能力、共用 DASHSCOPE Key,互补不互斥;详见 [`image-strategy.md`](./image-strategy.md) |
| **D6** | 工作台化 | studio 从单页扩为:采集箱 → 商品编辑(计价+文案+图片+类目/属性)→ 发布 → 店铺 → 导出 | 对齐「采集 → 编辑 → 发布」主链路 |
| **D7** | 鉴权 | MVP 用长期 Bearer Token(单用户自用);预留 `users` 表升级多用户 | 自用阶段不做 OAuth,与插件 options 页一致 |
| **D8** | 部署 | 腾讯云:FastAPI + nginx + PostgreSQL + 七牛;studio 静态托管;插件/前端指向公网后端 | 用户明确要部署腾讯云 |
---
## 4. 数据流(端到端)
```
① 浏览 Ozon 竞品页(或 1688/淘宝补素材)
│ 点插件 → 侧边栏 → 采集 → 勾选
② 插件 POST /api/materials 上传(texts + images 的 URL + source
│ 服务端立即落库为「采集箱商品」,异步排队下载源图 → 转存七牛
③ studio「采集箱」列表:查看/筛选/删除商品
│ 进入商品编辑页
④ 编辑:offer_id / 计价 / 俄文文案 / 图片(水印·智能修图·套图)/ 类目选择 / 属性映射
│ 每一步落库(Draft),可随时回来继续
⑤ 发布:绑定店铺 → 服务端组装 ImportProductsV3 items[0] → POST /v3/product/import
│ 轮询 /v1/product/import/info 直到 imported / moderation / failed
⑥ 落库:ozon_product_id / product_id / 状态 / 发布任务记录
⑦ CSV 导出:采集箱 + 已发布商品的字段导出(含 product_id 回填)
```
---
## 5. 分册索引
| 文档 | 内容 | 什么时候读 |
|---|---|---|
| [`architecture.md`](./architecture.md) | V2 总体架构:组件、技术栈、目录、鉴权、部署拓扑 | 先读这个,建立全局 |
| [`database.md`](./database.md) | PostgreSQL 表结构(按 Ozon 字段 + 采集/编辑/发布/店铺维度) | 做数据层时读 |
| [`api.md`](./api.md) | 后端 REST 接口契约(采集入库 / 商品 / 类目 / 店铺 / 发布 / 导出 / 图片 / 汇率) | 前后端联调时读 |
| [`image-strategy.md`](./image-strategy.md) | 图片处理:方案 A/B 对比、推荐、七牛存储、套图集成方式 | 图片这块没想明白时读 |
| [`ozon-publish.md`](./ozon-publish.md) | Ozon Seller API 集成:鉴权、店铺绑定、类目/属性、发布、任务状态、CSV 导出字段 | 做发布链路时读 |
| [`migration.md`](./migration.md) | 分阶段落地计划、改动点清单、风险 | 开工前读 |
---
## 6. 与 V1 文档的关系
- `docs/architecture.md`(V1 总架构)仍有效,V2 是其 **S4 阶段**的具体化;S1–S3 的结论(后端收进 `server/`、契约对齐 ImportProductsV3、`_` 前缀剥离、图床公网 URL 硬约束)全部沿用。
- `docs/contracts/product-json.md` 的**字段结构**在 V2 成为 `products` 表 + `product_assets` 表的设计蓝本;「文件夹」语义换成「商品记录」。
- `docs/extension/plan.md` §9/§13/§14 的**消息层、`/api/materials` 契约、鉴权、重试队列**在 V2 原样采纳,只把「写文件夹」换成「上传落库」。
- 若存在分歧,以 `docs/v2/` 为准。
---
## 7. 关键风险(先立 flag,细节见 migration.md
| 风险 | 级别 | 对策 |
|---|---|---|
| Ozon 类目/属性字典大且需实时性 | 🟡 中 | 服务端缓存 + 按类目按需拉取,见 [`ozon-publish.md`](./ozon-publish.md) §3 |
| 采集属性 → Ozon 属性 id 的映射工作量大 | 🔴 高 | 自动匹配 + 人工确认 UI,见 `ozon-publish.md` §4 |
| ecommerce-image-suite 是「脚本+Skill」形态,非服务 | 🟡 中 | 把 generate.py 的 prompt 引擎抽成服务端能力,见 `image-strategy.md` §5 |
| 发布是异步(task_id 轮询),用户不能干等 | 🟡 中 | 任务表 + 轮询 + 状态回显,见 `ozon-publish.md` §5 |
| 密钥落库(店铺 Client-Id/Api-Key | 🔴 高 | 服务端 AES 加密存储,前端永不回显明文,见 `database.md` §2.6 |
+219
View File
@@ -0,0 +1,219 @@
# V2 后端 API 设计
> 状态:方案设计(待确认)
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md) · [数据库](./database.md)
统一约定:
- 前缀 `/api`;鉴权 `Authorization: Bearer <JWT>`(除 `/auth/login``/health` 外)。
- 错误:FastAPI 语义状态码;`detail` 为可读中文;校验错误 `422`
- 列表分页:`?page=&page_size=``?limit=&last_id=`(Ozon 风格,仅对代理 Ozon 的接口)。
- 时间一律 ISO 8601 UTC。
---
## 1. 鉴权 `/api/auth`
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/auth/login` | `{token}``{access_token, expires_at}`。MVPtoken 等于 `.env``APP_TOKEN`;升级后:用户名密码 |
---
## 2. 采集入库 `/api/materials`(插件 → 服务端)
沿用 `docs/extension/plan.md` §14 的契约,**把「文件夹」换成「商品」**。
### `POST /api/materials` ★ 主接口
```jsonc
{
"product": { // 对应「文件夹」;首次上传可空=新建
"id": "uuid-or-null" // 传了=追加到已有商品(跨平台补素材)
},
"source": {
"platform": "ozon", // ozon | 1688 | taobao
"itemId": "123456789",
"url": "https://www.ozon.ru/product/…",
"collectedAt": 1710000000000
},
"texts": [
{ "kind": "title", "content": "…" },
{ "kind": "params", "content": "…", "pairs": [{ "key": "…", "value": "…" }] }
],
"images": [
{ "groupKey": "main", "groupName": "主图", "variantName": null,
"url": "https://…原图…", "index": 0, "type": "img", "dedupeKey": "…" }
],
"refererOrigin": "https://www.ozon.ru" // 该站图片下载需带的 Referer
}
```
响应:
```jsonc
{ "product": { "id": "…", "stage": "collected" }, "assetsQueued": 12 }
```
**语义**:服务端立即落库(product + texts 进 raw + assets 置 `pending`),**响应不等图片下载完成**;后台协程按 `refererOrigin` 下载源图 → 转存七牛 → 更新 asset 状态。插件无需等待。
### 其余
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/materials/bytes` | 字节兜底:`multipart/form-data``product_id` + `meta`(JSON) + `file`),供源图带登录态、插件在页面上下文抓字节上传 |
| GET | `/products/:id/fingerprints` | 跨页去重:返回已采集 `dedupeKey` 列表 |
| GET | `/collected?platform=&itemId=` | 状态回显:`{collected, count}`(页面徽标用) |
---
## 3. 商品 `/api/products`
### 列表(采集箱 / 发布列表)
`GET /api/products?stage=&q=&page=&page_size=`
```jsonc
{
"total": 128,
"items": [
{
"id": "…", "stage": "collected",
"name": "…", "offer_id": "", "price": null,
"source_platform": "ozon", "source_url": "…",
"asset_count": { "main": 6, "sku": 4, "detail": 9 },
"ozon_product_id": null,
"created_at": "…", "updated_at": "…"
}
]
}
```
### 详情 / 编辑 / 删除
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/products/:id` | 完整商品(含 raw / pricing / copy / attributes / assets |
| PATCH | `/products/:id` | 部分更新(编辑页 autosave)。可改字段见 database.md §2.3 |
| POST | `/products` | 手动新建商品(不经过插件) |
| DELETE | `/products/:id` | 软删(stage→archived)或硬删(采集箱未发布项) |
| POST | `/products/:id/stage` | `{stage}` 流转(editing→ready 前校验必填项) |
> 编辑页的计价、文案、图片、类目/属性都是「编辑 `products` 的某几列」,统一走 `PATCH /products/:id` 或细分子资源(见 §6–§8),不新增独立存储。
---
## 4. 汇率 `/api/fx`
`GET /api/fx``{ "rate": 11.84, "source": "cbr", "updated_at": "…" }`
服务端抓取(FloatRates → 俄央行 → er-api 三级降级,沿用 v1 数据源但**移到服务端**),缓存 + 脏数据过滤(5~25 区间)。前端计价用它,也可在计价时快照进 `products.fx_rate`
---
## 5. AI 文案 `/api/ai`(沿用现有,零改动)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/ai/models` | 模型列表(models.yaml |
| POST | `/ai/copy` | 中文采买信息 → 俄文标题/描述/标签 + 中文对照 |
V2 编辑页的「文案」面板直接消费这两个接口,生成结果写入 `products.copy` + `products.name/description`(用户确认后)。
---
## 6. 图片 `/api/image`
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/image/edit` | **智能修图**wanx2.1-imageedit,改名,沿用现有)——单图换背景/去水印/局部重绘/加文字 |
| POST | `/image/suite` | **电商套图**(集成 ecommerce-image-suite,新增)——原图 + 卖点 → 套图 |
| POST | `/image/upload-token` | 获取七牛直传 token(若走前端直传;一期可省,走服务端中转) |
`/image/edit``/image/suite` 的结果图由服务端**下载 → 转存七牛 → 返回七牛 URL**(改造现状 image_edit.py 已预留的扩展点)。
详见 [`image-strategy.md`](./image-strategy.md)。
---
## 7. 店铺 `/api/shops`
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/shops` | 列表(脱敏:不返回 key) |
| POST | `/shops` | `{name, client_id, api_key, currency_code}` 绑定(加密落库) |
| PATCH | `/shops/:id` | 更新(可只更新 name/currency,或换 key |
| DELETE | `/shops/:id` | 删除(级联校验是否有进行中发布) |
| POST | `/shops/:id/test` | **连通性校验**:用该店铺凭证调 Ozon `/v1/roles`,成功→`{ok:true, roles:[…]}`,失败→`status=invalid` 并返回原因 |
> `test` 用 `/v1/roles`(返回该 key 的角色与方法权限),既验凭证又验权限范围,成本为零。
---
## 8. 类目与属性 `/api/categories`(服务端代理 Ozon
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/categories/tree?lang=RU` | 类目树(代理 `/v1/description-category/tree`,服务端缓存) |
| GET | `/categories/:category_id/attributes?type_id=` | 属性列表(代理 `/v1/description-category/attribute` |
| GET | `/categories/attribute/:attribute_id/values?category_id=&type_id=&q=` | 属性值字典(代理 `/values``/values/search` 按需) |
> 这些接口需要店铺凭证(Client-Id/Api-Key)。请求体里带 `shop_id`,服务端用对应店铺凭证调 Ozon。类目树可全局缓存(与店铺无关);属性/值按类目缓存。详见 `ozon-publish.md` §3。
---
## 9. 发布 `/api/publish`
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/products/:id/publish` | `{shop_id}` 发布:组装 items[0] → `/v3/product/import` → 建 `publish_tasks` → 返回 `{task_id}` |
| GET | `/publish/:taskId` | 查询发布任务状态(服务端已轮询回写,直接读库) |
| GET | `/products/:id/publish-history` | 该商品历史发布记录 |
**发布请求体组装**(服务端职责,见 `ozon-publish.md` §5):
1.`products` 平铺字段 + `attributes`/`complex_attributes`
2. 剥离 `_` 前缀扩展字段(DB 里已天然分层,无需剥离);
3. 校验必填(name/description/category/尺寸重量/offer_id/images);
4. `POST /v3/product/import`(头 `Client-Id`/`Api-Key`)→ 得 `task_id`
5. 后台轮询 `POST /v1/product/import/info` → 回写 `products.stage` + `ozon_product_id`
---
## 10. CSV 导出 `/api/export`
`GET /api/export/products.csv?stage=&ids=` → 流式返回带 BOM 的 UTF-8 CSV。
字段(默认全量,`fields=` 可指定子集):
```
offer_id, product_id, name, description_category_id, price, old_price,
currency_code, vat, weight, weight_unit, depth, width, height, dimension_unit,
barcode, primary_image, images(join "|"), source_platform, source_item_id, source_url,
stage, published_at, created_at
```
已发布商品含 `product_id`;未发布留空。支持按 `stage`collected/published/全部)与 `ids`(勾选导出)筛选。CSV 字段明细与公式见 [`ozon-publish.md`](./ozon-publish.md) §6。
---
## 11. 插件运维接口(沿用 plan.md §14)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/ext/profiles` | 远程采集配置下发(选择器热更) |
| POST | `/ext/logs` | 埋点批量上报(抗改版看板) |
---
## 12. 接口 → 现有代码映射(改造量)
| V2 接口 | 现状 | 改造 |
|---|---|---|
| `/auth/*` | 无 | 新建 |
| `/materials` 系列 | 无(plan 里有设计) | 新建 |
| `/products` 系列 | 无 | 新建 |
| `/fx` | 无(v1 前端直连第三方) | 新建(搬 v1 数据源到服务端) |
| `/ai/*` | ✅ 有 | 复用 |
| `/image/edit` | ✅ 有 | 复用 + 加七牛转存 |
| `/image/suite` | 无 | 新建(集成 ecommerce-image-suite |
| `/shops``/categories``/publish``/export` | 无(`api/ozon.py` 占位) | 新建 |
| `/ext/*` | 无 | 新建 |
+182
View File
@@ -0,0 +1,182 @@
# V2 总体架构
> 状态:方案设计(待确认)
> 上游:[V2 总览](./README.md) · V1 [`architecture.md`](../architecture.md)
> 前置阅读:建议先读 [`capability-inventory.md`](./capability-inventory.md) 了解现状资产。
---
## 1. 组件与职责
```
┌───────────────┐ Bearer Token ┌──────────────────────────────────────────────┐
│ Chrome 插件 │ ────────────────▶ │ 服务端 server/FastAPI
│ extension-v2 │ POST /api/materials│ │
Ozon/1688 采集)│ │ ├─ api/ 采集/商品/类目/店铺/发布/图片/汇率 │
└───────────────┘ │ ├─ services/ Ozon / DeepSeek / 套图 / 七牛 │
▲ │ ├─ models/ SQLAlchemy ORM + Alembic │
│ 远程配置 / 埋点 │ └─ jobs/ 异步:图下载转存 / 发布轮询 │
┌───────┴───────┐ │ │
│ 采集配置文件 │ └──────┬───────────────┬───────────────┬────────┘
│(可热更) │ │ │ │
└───────────────┘ ┌──────────▼──┐ ┌───────▼────────┐ ┌─────▼─────────┐
│ PostgreSQL │ │ 七牛对象存储 │ │ Ozon Seller API│
│ 商品/店铺/任务│ │ 源图/生成图/水印 │ │ Client-Id+Api-Key│
└─────────────┘ └────────────────┘ └───────────────┘
│ REST /api/*
┌─────────────┴──────────────┐
│ studio/React + Vite + antd)│
│ 采集箱 / 商品编辑 / 发布 / 店铺 / 导出 │
└────────────────────────────┘
```
四个部分与 V1 一致(插件 / studio / server / 采集配置),但 **studio 的职责显著扩大**(从单页图生图 → 完整工作台),**server 从无状态代理 → 有状态业务中枢**。
---
## 2. 核心边界(延续 V1,补充 V2)
1. **密钥只放服务端**DeepSeek / DASHSCOPE / 七牛 / Ozon 店铺 Client-Id+Api-Key 全部只在 server 侧;插件和 studio 只持有一个长期 Bearer Token。
2. **插件仍做纯采集**:不调 LLM、不做图片处理、不碰 Ozon API;只是把「写本地文件夹」换成「上传落库」。
3. **服务端是唯一出网口(对 Ozon/云厂商)**:插件 background 与服务端通信,studio 与服务端通信;谁都不直连 Ozon。
4. **商品数据单点真源 = `products` 表**`_` 前缀的本地扩展字段(`_raw`/`_pricing`/`_images`)仍保留在 JSONB 里,提交 Ozon 前按 V1 契约剥离。
5. **图片一律七牛公网 URL**:数据库里存七牛 URL,不存本地路径、不存源站 URL(源站 URL 仅存 `product_assets.source_url` 做溯源)。
---
## 3. 技术栈
| 部分 | 技术栈 | 说明 |
|---|---|---|
| server | FastAPI + SQLAlchemy 2.0async+ Alembic + httpx | 沿用现状 FastAPIORM 用 SQLAlchemy 2.0 async |
| DB | PostgreSQL 16(腾讯云 CDB | JSONB 存 attributes/raw/pricing |
| 任务 | 轻量:先 DB 轮询 + asyncio 后台任务;量大再上 Redis/Celery | 图下载转存、发布轮询都是 IO 密集 |
| 对象存储 | 七牛云 Kodo | 源图转存 + 生成图 + 水印结果 |
| 缓存 | 类目/属性字典 → PostgreSQL 表 + 内存 LRU;可选 Redis | 见 `ozon-publish.md` §3 |
| studio | React 19 + Vite 7 + antd 6 + react-router 7 | 沿用现状;加 react-query 或 zustand 管状态 |
| extension | WXT + React + TS | 沿用;改造 export → upload |
| 鉴权 | JWT(短期)+ Bearer TokenMVP 单用户,预留 `users` 表 | 见 §6 |
**与 V1 的差异**:唯一新增重依赖是 **SQLAlchemy + Alembic****七牛 SDKqiniu**。任务队列一期不引入 Redis/Celery,用「DB 状态机 + 后台协程」即可(单人自用规模)。
---
## 4. 目录结构(目标)
```
ozon-seller-kit/
├── server/
│ ├── main.py # 应用入口,挂载路由 + studio 静态
│ ├── api/ # 按域拆:collection / products / categories /
│ │ │ # shops / publish / export / image / ai / fx / auth
│ ├── services/ # ozon_client / deepseek / image_suite / qiniu / pricing
│ ├── models/ # SQLAlchemy 模型(见 database.md
│ ├── schemas/ # Pydantic(接口契约真源)
│ ├── jobs/ # 后台协程:下载转存 / 发布轮询
│ ├── migrations/ # Alembic
│ └── config/ # settings.py + models.yaml(沿用)
├── studio/ # 工作台(多页)
│ └── src/
│ ├── pages/
│ │ ├── collection/ # 采集箱列表
│ │ ├── product/ # 商品编辑(核心)
│ │ │ └── components/ # PricingPanel / CopyPanel / ImagePanel /
│ │ │ # CategoryPicker / AttributeMapper / PublishPanel
│ │ ├── publish/ # 发布任务 / 状态
│ │ ├── shops/ # 店铺管理(Client-Id / Api-Key
│ │ ├── export/ # CSV 导出
│ │ └── ai-image/ # 保留:智能修图(wanx2.1-imageedit,改名)
│ ├── services/ # 与 /api/* 对齐的客户端
│ ├── stores/ # zustand:商品编辑态 / 采集箱筛选
│ └── pricing/ # 从 v1 抄来的计价纯函数(不改原文件)
├── extension-v2/ # 采集插件(改造:上传落库)
│ └── src/
│ ├── messaging/ # 消息层(plan.md §9
│ ├── api/ # 后端客户端(仅 background
│ └── collector/ profiles/ # 沿用采集引擎
├── web/ # v1 工具台,冻结
├── docs/
│ ├── v2/ # ★ 本文档集
│ └── ...V1 文档)
└── .env / .env.example
```
---
## 5. 状态机:商品生命周期
V1 是 `collected → edited → published`。V2 因为「落库 + 异步发布」,扩展为:
```
collected ──(进入编辑)──> editing ──(填写完整)──> ready ──(点发布)──> publishing
┌───────────────────────────────────────┤
▼ ▼
imported(成功) failed(失败,可改后重发)
│ ▲
└────── published ──(可归档)──> archived ─┘
collected 插件刚上传,只有素材与原文
editing 用户正在编辑(计价/文案/图片/类目)
ready 必填项齐全,可发布
publishing 已提交 ImportProductsV3,拿到 task_id,等待轮询
published 轮询 imported 成功,回填 product_id
failed 轮询返回 errors / 校验失败;可回到 editing 修复后重发
archived 手动归档(软删)
```
- 每步都落库,刷新/换设备不丢。
- `publishing` 由发布任务表(`publish_tasks`)驱动,服务端轮询 `/v1/product/import/info` 更新状态。
- 状态定义详见 [`database.md`](./database.md) §2.2。
---
## 6. 鉴权与多租户
**MVP(单人自用)**`.env` 里配一个 `APP_TOKEN`,插件 options 页和 studio 登录页填同一个值,请求头 `Authorization: Bearer <APP_TOKEN>`。服务端校验后签发短期 JWT,后续请求用 JWT。
**预留升级路径(不影响 MVP**`users` 表 + `shops.user_id` 已留好外键,未来要做多用户 SaaS 只需补注册/登录 + 按 `user_id` 过滤查询,schema 不用改。
| 层 | MVP | 升级 |
|---|---|---|
| 身份 | 单个 `APP_TOKEN` | `users` 表 + 密码哈希 |
| 会话 | 短期 JWT`Authorization: Bearer` | 同左,加刷新令牌 |
| 店铺归属 | 全部归当前用户 | 按 `user_id` 隔离 |
| 密钥保护 | 店铺 Api-Key 服务端 AES-GCM 加密落库 | 同左 |
---
## 7. 部署拓扑(腾讯云)
```
┌────────────── nginx (443) ──────────────┐
│ /api/* → uvicorn (127.0.0.1:8800) │
浏览器/插件 ─────▶ │ / → studio 静态资源(构建产物)│
│ /ozonSeller.html → web/v1,可选保留) │
└─────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
PostgreSQLCDB 七牛 Kodo(对象存储) 外部 APIOzon/DeepSeek/DashScope
```
- **单进程部署**FastAPI 同源托管 studio 构建产物(与 V1 托管 web/ 同思路),`/api` 走 nginx 反代到 uvicorn。
- **环境变量**`.env` 在服务器上维护(不入 git),新增 `APP_TOKEN` / `DATABASE_URL` / `QINIU_*` / `APP_BASE_URL`
- **CORS**:同源托管时 `CORS_ORIGINS` 留空;开发期 studio 跑 8900 时用 Vite 代理 `/api`,无需 CORS。
- 详见 [`migration.md`](./migration.md) §5。
---
## 8. 与 V1 的差异小结
| 维度 | V1 | V2 |
|---|---|---|
| 契约真源 | 磁盘「商品文件夹」 | `products` 表 + 七牛 |
| 插件出口 | File System Access 写盘 | `POST /api/materials` 落库 |
| studio | 单页图生图 | 多页工作台 |
| server | 无状态代理(ai/image | 有状态业务中枢(DB/七牛/Ozon/任务) |
| 发布 | 预留 `/api/ozon/*` 占位 | 完整发布链路 + 任务轮询 |
| 图片 | 图生图(wanx2.1)+ 前端水印 | 智能修图 + 电商套图 + 七牛托管 |
| 数据导出 | v1 登记表 CSV(前端) | 服务端统一 CSV 导出 |
| 部署 | 本机 127.0.0.1 | 腾讯云公网 |
+218
View File
@@ -0,0 +1,218 @@
# Ozon Seller Kit 现有能力清单(V2 方案设计输入)
> 依据代码逐文件核对生成(server/ 与 studio/,忽略 node_modules/.venv/__pycache__/.output)。
> 数据来源文件:`ozon-seller-kit/server/**` 与 `ozon-seller-kit/studio/src/**`,全部行号以当前工作区为准。
---
## 一、后端 API 清单
FastAPI 应用入口 `server/main.py`
- 应用名 `Ozon Seller Kit` v0.1.0main.py:13)。
- CORS 中间件:仅当 `cors_origin_list` 非空时启用,`allow_credentials=True`,方法/请求头全放行(main.py:16-23)。
- 挂载三个路由:`api/ai``api/image``api/ozon`main.py:25-27)。
- `GET /api/health``{"status":"ok"}`main.py:30-32)。
- 若仓库根 `web/`v1 工具台,冻结)存在,则 `app.mount("/", StaticFiles(html=True))` 静态托管(main.py:11, 35-36)——即生产形态下 FastAPI 同源托管前端。
| 方法 | 路径 | 功能 | 关键入参 | 关键出参 |
|---|---|---|---|---|
| GET | `/api/health` | 健康检查(main.py:30-32 | 无 | `{status: "ok"}` |
| GET | `/api/ai/models` | 列出可用模型(ai.py:10-12 → models_catalog.list_model_options | 无 | `{default: str, models: [{id, label}]}` |
| POST | `/api/ai/copy` | 生成 Ozon 俄文商品文案(ai.py:15-17 → deepseek.generate_copy | `CopyRequest``source_text`(≥10字符)、`product_name``model_code``model`(可选) | `CopyResponse``titles_ru/zh[2]``description_ru/zh``tags_ru/zh``model``usage{prompt_tokens,completion_tokens}` |
| POST | `/api/image/edit` | AI 图生图/图像编辑(image.py:9-11 → image_edit.edit_image | `ImageEditRequest``base_image`(dataURL/公网URL)、`prompt``model`(白名单)、`mask_image?``function?``n`(1-4)、`size?``seed?``style?``prompt_extend``strength?` | `ImageEditResponse``task_id``results[{url(24h), image_base64(dataURL)}]``image_count``request_id` |
| — | `/api/ozon/*` | **空占位**ozon.py:3-5):仅定义前缀与注释 `# Phase 3: Ozon Seller API product upload`,无任何端点 | — | — |
前端调用面:`studio/src/services/image.ts` 只调 `/api/image/edit``/api/ai/*` 目前只有 v1 工具台 `web/js/ai-copy.js`233 行 models、299 行 copy,用原生 fetch)在消费。
---
## 二、配置与模型目录机制
### 2.1 环境变量(server/config/settings.py + 仓库根 `.env`
- `load_dotenv` 与 pydantic-settings 均指向仓库根 `.env`settings.py:8-9, 16-17`parents[2]` 上跳两级)。
- 字段(settings.py:21-29):
- `deepseek_api_key` / `openai_api_key` / `dashscope_api_key`
- `dashscope_base_http_api_url`(华北2北京业务空间专用,普通 API Key 留空)
- `host=127.0.0.1``port=8800`uvicorn 启动参数)
- `cors_origins`(逗号分隔)→ 属性 `cors_origin_list`settings.py:31-35
- `get_settings()``@lru_cache`settings.py:38-40)。
- `.env.example` 给出全部变量名:`DEEPSEEK_API_KEY``DASHSCOPE_API_KEY``DASHSCOPE_BASE_HTTP_API_URL``HOST``PORT``CORS_ORIGINS`
### 2.2 模型目录(server/config/models.yaml + services/models_catalog.py
- YAML 结构:`default`(默认模型 id+ `models[]`,每项含 `id/label/provider/api_model/base_url/api_key_env/max_tokens/params`
- 密钥**不写入 yaml**,只引用环境变量名(`api_key_env: DEEPSEEK_API_KEY`)。
- 当前仅两个 deepseek 模型(deepseek-v4-flash 默认、deepseek-v4-pro),均 `base_url=https://api.deepseek.com``max_tokens=4000``params.thinking.type=disabled`(关闭思维链,防止推理耗尽 token 正文为空)。
- `models_catalog.py`
- `ModelSpec` Pydantic 模型(16-25 行),`params` 为任意 dict、直接并入请求体。
- `load_models_file()``@lru_cache`43-54 行),校验 default 在列表中、列表非空;文件缺失抛 RuntimeError。
- `list_model_options()`57-62 行)→ 前端下拉用 `{id,label}`
- `get_model_spec(model_id)`65-74 行):空值回落 default,未知 id 抛 400。
- `resolve_api_key(spec)`77-83 行):按 `api_key_env` 读环境变量,缺失抛 500。
- 设计要点:**模型即配置**——新增模型只需改 yaml + 加环境变量,代码零改动(deepseek 类);这是 V2 可直接继承的机制。
### 2.3 依赖(server/requirements.txt
`fastapi>=0.115``uvicorn[standard]>=0.32``httpx>=0.27``pydantic-settings>=2.6``python-dotenv``PyYAML``dashscope>=1.23.8`
---
## 三、AI 文案服务细节(services/deepseek.py + prompts/copy_ru.py
### 3.1 调用链
`POST /api/ai/copy``generate_copy(req)`deepseek.py:144-181):
1. `get_model_spec(req.model)` 取模型规格,未传用默认。
2. messages = system(`SYSTEM_PROMPT`) + user(`build_user_prompt(...)`)system 完整文本见 copy_ru.py:1-65。
3. `_chat_once(spec, messages)`deepseek.py:93-141):
- URL = `base_url + /chat/completions`Bearer 认证。
- payload`model=api_model``temperature=0.45`(事实稳定、营销留少量变化)、`max_tokens``response_format={type:"json_object"}``**spec.params`
- httpx 超时 90s;网络错误/HTTP≥400 一律 502detail 截断 500 字符)。
- 解析 `body["choices"][0]["message"]["content"]`;若 `finish_reason=="length"` 且正文为空 → 502 并提示调 max_tokens 或关思维链(131-138 行,对应 yaml 中 thinking disabled 的注释)。
4. **重试机制**(159-177 行):最多 2 次;解析失败时把上一次输出以 `role=assistant` 追加,再追加一条"请仅重新输出合法 JSON"的 user 消息重试一次;仍失败抛 502。
### 3.2 JSON 解析与字段清洗
- `_extract_json_object`17-37 行):剥 ```json 代码块 → `json.loads` → 失败则截取首 `{` 到末 `}` 再解析 → 必须为 dict。
- 字段类型容错:
- `_as_title_list`(64-73 行):**标题不按逗号切分**(标题本身含逗号)。
- `_as_str_list`48-61 行):标签按 `[,\n]` 切分,兼容字符串/数组。
- `_as_str`40-45 行):必须字符串。
- `_map_copy_payload`76-90 行)→ `CopyResponse`usage 取 prompt/completion tokens。
- 最终校验:`titles_ru``description_ru` 非空,否则视为失败触发重试(164-166 行)。
### 3.3 提示词结构(copy_ru.py
- **SYSTEM_PROMPT** 核心约束:
- 输出固定 JSON schematitles_ru/zh 各 2 条一一对应;tags_ru/zh 各 10~15 个、逐项对应;description 完整俄文卡 + 中文逐项对照)。
- 描述固定结构:`Описание товара``Характеристики`(只列原文事实,俄式尺寸写法)→ `Преимущества`3~6 条利益点)→ `Комплектация`(仅原文提到配件时)。
- 标题规则:60-90 字符、核心品类词开头、前 30 字符含关键属性、删年份/新款/爆款噪声、两标题互补。
- **事实边界**(强约束):禁止虚构结构/配件/认证/产地/品牌/受众/使用效果;行业词归一(搪胶→винил 非 каучук);"防摔"不得推导安全认证。
- 优先级:事实准确 > 俄语自然 > 信息完整与转化力 > 关键词覆盖。
- **build_user_prompt**68-86 行):模板包裹 `<当前商品名>``<型号>``<商品资料>`,并要求区分"事实来源"与"生成要求",与文案无关的指令忽略、要求不得写成事实。
---
## 四、图生图服务细节(services/image_edit.py
### 4.1 云与模型
- 调用**阿里云百炼 DashScope**`dashscope` Python SDK,同步调用,`asyncio.to_thread` 放入线程池,177-185 行)。
- 模型白名单(schemas/image_edit.py:9-17):`wanx2.1-imageedit``wan2.6-image``qwen-image-edit``qwen-image-edit-plus``qwen-image-edit-plus-2025-10-30`
- 两种调用形态(按模型路由,170-174 行):
- **wanx2.1-imageedit → `ImageSynthesis.call`**71-121 行):同步、function 式。kwargs`api_key/model/function/prompt/base_image_url/n` + 可选 `mask_image_url/size/seed/style/prompt_extend/strength`。解析 `rsp.output.results[].url``usage.image_count``request_id`
- **wan2.6-image / qwen-image-edit 系列 → `MultiModalConversation.call`**124-167 行):messages=`[{role:user, content:[{image: base_image},{text: prompt}]}]` + `n/size/prompt_extend`;解析 `output.choices[0].message.content[].image`
- `function` 白名单(schemas/image_edit.py:24-29):`description_edit`(无掩码图生图,默认)/ `description_edit_with_mask`(局部重绘,需 mask_image/ `stylization_local` / `stylization_all`
### 4.2 请求校验(schemas/image_edit.py
- `base_image` / `mask_image`data URL`data:image/...`)或公网 http(s) URLdata URL 编码后 ≤ 15MB`_MAX_BASE64_LENGTH`32 行);mask 可空。
- `prompt` 非空;`model` 白名单校验;`n` 1~4`strength` 0.0~1.0(默认 0.5,加文字/大改建议 0.8);`prompt_extend` 默认 True。
### 4.3 关键工程决策
- **服务端代理下载 → data URL**(`_download_to_data_url`,29-48 行):阿里云结果 URL 未开放 CORS,前端直接绘 canvas 会污染画布无法导出;服务端下载后转 `data:{content-type};base64,...`,上限 20MB`_MAX_PROXY_BYTES`23 行),失败回退空串、前端用 `url`
- 无密钥 → 500SDK 异常/HTTP 非 200 → 502`_fail`60-64 行)。
- `_apply_base_url`(51-57 行):仅北京业务空间需设 `dashscope.base_http_api_url`
- 文件头注释明确:**当前不落盘、不存储图片**,将来接七牛云可在返回 URL 后加"下载并转存"步骤,api 层与前端契约不动(image_edit.py:1-9)——这是刻意的扩展点。
---
## 五、studio 前端页面/组件/功能清单与交互流程
### 5.1 技术栈(studio/package.json + vite.config.ts
- React 19.2、react-router 7createBrowserRouter)、antd 6.1zhCN)、axios、Vite 7SWC 插件)、TS 5.9`@` 别名 → `src`
- dev server:端口 8900strictPort、open),`/api` 代理到 `http://127.0.0.1:8800`vite.config.ts:17-22)。
### 5.2 页面与路由
| 文件 | 内容 |
|---|---|
| src/main.tsx | createRoot 挂载 |
| src/App.tsx | ConfigProviderzhCN、主色 #8b5cf6、圆角 8+ AntdApp + RouterProvider |
| src/router/index.tsx | `/``/ai-image` → AiImagePageMainLayout 内);`*` → Navigate `/` |
| src/layouts/MainLayout.tsx | 固定 Sider(240, 深色, 可折叠) + Header(页面标题/副标题) + Content(Outlet)<768px 切 Drawer 移动菜单 |
| src/layouts/SidebarMenu.tsx | antd Menu"主要功能"分组,点击 navigate |
| src/layouts/menuConfig.tsx | **仅一个菜单项**`/ai-image`「AI 图生图」(subtitle:上传图片、加水印、用万相模型进行图生图编辑);`getPageInfo` 路径→标题映射 |
### 5.3 AiImagePagepages/ai-image/AiImagePage.tsx)——主页面
状态:图片列表 `ImageItem[]`id/fileName/image/dataUrl/state)、全局水印设置(type=image|text、text 默认 'Panda Store'、opacity 默认 30、水印图 `/imgs/watermark.jpg` 预加载为 HTMLImageElement)、编辑弹窗(modalOpen/editing)。
区块与流程:
1. **水印设置面板**:类型 Radio(图片/文字)→ 文字输入 或 水印图预览(圆形贴图);透明度 Slider(0-100)。
2. **上传**`Upload.Dragger` 多图、`accept=image/*``beforeUpload` 返回 false(阻止自动上传,纯前端读文件)→ `fileToImage`FileReader → dataURL → Image)→ 追加进列表,按图片尺寸算默认水印位置(右下角)。
3. **预览网格**:每张图一个 `WatermarkCanvas`(可拖拽水印,坐标相对原图、`clampPos` 限制不越界);卡片操作:加水印/清除水印(`toggleWatermark`)、**AI 生图**`openEdit``renderFullRes` 全分辨率合成水印图 → dataURL → 打开弹窗)、导出 PNG(`downloadCanvas`,文件名加 `_watermark` 后缀)、删除;顶部"全部加水印"(`applyToAll` 重建所有 state)与"清空所有"。
4. **AI 生图弹窗** = ImageEditModal(见下)。
### 5.4 ImageEditModalpages/ai-image/components/ImageEditModal.tsx)——编辑工作台 Drawer
右抽屉(`min(1240px, 96vw)`),每次打开重置状态;三段式布局 + 底部 AI 指令条:
- **左:底图切换 Thumb 列表**——「原图」+ 每次 AI 生成的结果图(`AiResult{id,image,dataUrl,url}`),点击切换当前底图(标注可叠加在 AI 结果上继续编辑)。
- **中:AnnotationCanvas**(预览最大宽 900)——标注画布,Pointer 交互:拖拽移动、旋转手柄旋转、四角手柄缩放(文字改 fontSize、标尺等比改 length/tSize/lineWidth/labelFontSize);命中检测 `hitTest`(旋转手柄 → 四角 → body);`onBeginInteraction` 每次交互开始时压撤销快照。
- **右:属性编辑**——Tabs(文字/标尺);添加文字/标尺按钮;选中元素属性面板 ElementPropsPanel**样式预设**:选中元素可"保存预设"localStorage key `ozon_annotation_presets_v1`,只存样式不含内容/位置),新建元素/选中元素可套用预设;撤销/清空/导出 PNG。
- **底部 AI 条**TextArea 指令(Enter 快捷生成)+ 模型 Select(5 个模型带中文说明)+「AI 生成」按钮。
- **runAi**195-228 行):`editImage({base_image: currentDataUrl, prompt, model, n:1, strength:0.8, prompt_extend:true})` → 取 `results[0]`,优先 `image_base64` 否则 `url` → 解码为 Image 追加为结果底图并自动切换。出错用 `apiErrorMessage` 提示。
- **exportImage**230-240 行):新 canvas 合成底图 + 全部标注元素 → PNG 下载。
### 5.5 组件与工具细节
| 文件 | 能力 |
|---|---|
| components/WatermarkCanvas.tsx | 单图水印预览(PREVIEW_MAX_WIDTH=360),命中水印区域拖拽,坐标映射回原图,`clampPos` 防越界 |
| components/AnnotationCanvas.tsx | 标注画布(预览最大宽 900):绘制底图+元素+选中框;Pointer 交互(move/rotate/resize);交互开始回调存撤销 |
| components/ElementPropsPanel.tsx | 文字:内容/字体(9 种)/字号/加粗/文字色/描边色/描边宽;标尺:长度/线色/线宽/T字大小/标签文字/标签色/标签字号;公共:透明度/旋转 |
| utils/watermark.ts | `WATERMARK_SCALE=0.15`(图片水印直径比)、`WATERMARK_MARGIN=10``WATERMARK_TEXT_SCALE=0.051``getWatermarkSize/clampPos/defaultPos``drawWatermarked`:图片水印圆形裁剪+全局透明度,文字水印先绘离屏层再合成(避免描边透出);`renderFullRes` 全分辨率合成 |
| utils/annotation.ts | `FONT_FAMILIES``HANDLE_RADIUS=7``ROTATE_GAP=26``measureText/elementBox/toLocal/hitTest``drawElement/drawText/drawRuler/drawSelectionHandles``createTextElement/createRulerElement` 工厂(默认值随图宽缩放) |
| utils/image.ts | `fileToImage``canvasToDataUrl``downloadCanvas` |
| types/image.ts | `WatermarkType/WatermarkState/ImageItem/ImageEditRequest/ImageEditResponse`(与后端 schema 字段一致) |
| types/annotation.ts | `TextElement/RulerElement`(中心点/旋转/透明度 + 各自样式字段) |
| services/api.ts | axios 实例:baseURL=`envConfig.apiBaseUrl`(默认 `/api`)、**timeout 120s**`api.get/post` 解包 `res.data``apiErrorMessage` 提取 FastAPI `detail` |
| services/image.ts | `editImage(payload)``POST /image/edit` |
| config/env.ts | `VITE_API_BASE_URL`(默认 `/api`)、`VITE_APP_NAME``debug=import.meta.env.DEV` |
### 5.6 前后端衔接方式
- 前端**只调 `/api/image/edit`**services/image.ts);`/api/ai/*` 暂无 studio 页面(v1 `web/js/ai-copy.js` 在消费,v1 已冻结)。
- 图片以 data URL 在请求体内传输(15MB 上限),响应以服务端代理的 `image_base64` 为主、`url` 兜底——专为规避阿里云结果 URL 无 CORS 的画布污染问题。
---
## 六、可直接复用到 V2 vs 需要重写的部分
### 6.1 可直接复用(成熟、结构清晰、低耦合)
| 资产 | 理由 |
|---|---|
| 模型目录机制(models.yaml + models_catalog.py | "模型即配置":换/加 LLM 只改 yaml+env,代码零改动;`resolve_api_key` 按 env 名取密钥,安全;V2 加多厂商直接扩展 |
| DeepSeek 调用骨架(deepseek.py 的 `_chat_once` + `_extract_json_object` + 重试纠错循环) | 通用性强:JSON 输出强制、代码块剥离、容错截取、失败追加修正消息重试一次;可抽象为通用 "JSON 任务 LLM 调用器" 供 V2 任意生成任务复用 |
| 文案 schema 与提示词(schemas/copy.py + prompts/copy_ru.py | 领域逻辑已打磨(事实边界、俄语标题/描述结构、中俄对照);V2 若保留文案生成,整套平移 |
| 图生图服务(services/image_edit.py + schemas/image_edit.py | 多模型路由(ImageSynthesis vs MultiModalConversation)、服务端代理下载规避 CORS、线程池隔离同步 SDK、响应契约(task_id/request_id 已预留异步字段);注释已规划"返回后接七牛转存"扩展点,与 V2 存储需求天然衔接 |
| 前端 API 层(services/api.ts + services/image.ts + config/env.ts | axios 封装(120s 超时、detail 提取)与端点封装可原样复用;新增端点照 image.ts 模式加即可 |
| 水印/图片/标注工具集(utils/watermark.ts、utils/image.ts、utils/annotation.ts | 纯 canvas 数学、框架无关、按原图坐标系建模(预览缩放/全分辨率导出分离),可直接搬入 V2 |
| 标注数据模型(types/annotation.tsTextElement/RulerElement | 字段设计合理(中心点+旋转+透明度+样式),可直接作为 V2 标注/叠加元素的数据契约种子,甚至与后端共享 schema |
| 布局壳(MainLayout + SidebarMenu + menuConfig | 菜单配置驱动、移动端适配完整;V2 加页面只改 menuConfig + router |
| 水印批处理主流程(AiImagePage 上传→全局水印→批量套用→导出) | 完整闭环,可整体作为 V2 的一个功能模块迁入 |
### 6.2 需要重写/新建(当前缺失或形态不适配 V2)
| 资产 | 现状与重写理由 |
|---|---|
| Ozon 对接(api/ozon.py | 纯占位,V2 若做"发布到 Ozon"需从零实现 Seller API(token 管理、类目树、商品上传、图片上传等) |
| 前端 AI 文案页 | studio 完全没有文案 UI;后端 `/api/ai/copy` 就绪,V2 需新建页面(可参考 v1 web/js/ai-copy.js 的表单交互) |
| 服务端文件/任务持久化 | 现状:图不落盘、无上传端点、无 DB、无任务队列;图生图同步等待(前端 120s 超时)。V2 若引入"商品文件夹"契约(README 所述四部分衔接点)与异步任务,需新增上传/资产/任务/持久化体系 |
| ImageEditModal 状态管理 | 编辑+AI 生成+撤销+预设全在组件本地 useState;V2 若需跨步骤工作流/历史/多页共享,需抽出 store(如 zustand/redux)与后端任务状态同步 |
| AiImagePage 批量状态 | 纯内存 useState,刷新即失;V2 按商品文件夹组织时需要持久化/恢复会话 |
| 鉴权与错误治理 | 无任何鉴权、无统一错误码规范(全部 HTTPException detail 字符串);V2 多用户/上线需补齐 |
| 水印可配置性 | 水印图硬编码 `/imgs/watermark.jpg`、比例/字号为常量;V2 应支持自定义水印资源与配置 |
| 路由/菜单 | 仅 1 页;V2 多页面需按功能域重组(当前结构太薄,扩展时建议直接重构而非继续堆叠) |
| 测试与文档链 | 无自动化测试;契约靠 README/docs 维护。V2 建议引入 schema 共享(README 中 `packages/schema` 待建项)避免前后端字段漂移 |
### 6.3 一句话结论
**后端资产(模型目录、LLM 调用骨架、图生图多模型服务、schema)质量高且刻意留了扩展点(异步字段、七牛转存注释),可大幅平移;前端可平移的是纯 canvas 工具层与布局壳,而所有"产品化"能力——Ozon 对接、文案 UI、持久化/资产、异步任务、鉴权、状态管理——目前为空或雏形,是 V2 的主要建设量。**
+278
View File
@@ -0,0 +1,278 @@
# V2 数据库设计
> 状态:方案设计(待确认)
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md)
> 字段来源:Ozon `ProductAPI_ImportProductsV3` + V1 `docs/contracts/product-json.md` + 采集/发布/店铺维度
---
## 1. 设计原则
1. **商品主表对齐 Ozon 字段**`products` 表按 `ImportProductsV3` 的字段平铺(`name/description/price/offer_id/...`),JSONB 存三类结构:`attributes` / `complex_attributes`Ozon 动态属性)、`raw`(采集原文)、`pricing`(计价结果)。
2. **素材与发布字段分离**:采集来的源图(分组/变体/源站 URL/七牛 URL)放 `product_assets` 表;`products.images` 只存「将提交给 Ozon 的有序公网 URL 数组」。
3. **店铺密钥加密落库**`shops.client_id_enc` / `api_key_enc` 用服务端密钥 AES-GCM 加密,前端永不回显明文。
4. **发布异步化**:发布请求与结果存 `publish_tasks`,商品状态由轮询结果回写。
5. **类目字典可重建**`category_*` 三张表是 Ozon 字典的本地缓存,可随时清空重拉,不作为业务真源。
数据库:**PostgreSQL 16**。ID 统一 `UUID``gen_random_uuid()`)或 Ozon 原生 `BIGINT`(类目/属性 id 用 BIGINT 保持与 Ozon 一致)。时间统一 `timestamptz`
---
## 2. 表结构
### 2.1 `users` —— 用户(预留,MVP 单用户可空置)
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| username | varchar(64) UNIQUE | 登录名 |
| password_hash | varchar(255) | Argon2/bcrypt |
| created_at | timestamptz | |
MVP 用 `APP_TOKEN` 时此表可留空;升级多用户时启用。
### 2.2 `shops` —— Ozon 店铺
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| user_id | UUID FK → users | 归属(MVP 可为空) |
| name | varchar(128) | 店铺显示名 |
| client_id_enc | text | Client-Id 密文 |
| api_key_enc | text | Api-Key 密文 |
| currency_code | varchar(3) DEFAULT 'RUB' | 店铺结算币种(RUB/CNY |
| status | enum('active','invalid','disabled') DEFAULT 'active' | invalid=连通性校验失败 |
| last_checked_at | timestamptz | 最近一次校验时间 |
| created_at / updated_at | timestamptz | |
> **密钥安全**`client_id` / `api_key` 用服务端 `SECRET_KEY` 做 AES-GCM 加密后存 `*_enc`。列表接口只返回 `id/name/currency/status/last_checked_at` 与**打码**的 client_id 后四位,永不返回明文 key。
### 2.3 `products` —— 商品(采集箱 + 编辑 + 发布一体化)
| 列 | 类型 | 说明 | 对应 Ozon 字段 |
|---|---|---|---|
| id | UUID PK | 内部主键 | — |
| user_id | UUID FK → users | 归属(MVP 可空) | — |
| stage | enum | `collected/editing/ready/publishing/published/failed/archived` | — |
| source_platform | varchar(16) | `ozon/1688/taobao` | — |
| source_item_id | varchar(64) | 源平台商品 ID(去重) | — |
| source_url | text | 采集来源 URL | — |
| offer_id | varchar(255) | **自己的货号**(采集恒空,编辑必填) | offer_id |
| ozon_product_id | bigint | 发布成功后回填 | — |
| ozon_sku | bigint | 跟卖(import-by-sku)用,可空 | — |
| name | text | 商品名(俄文,最终) | name |
| description | text | 商品描述(俄文,最终) | description |
| description_category_id | bigint | 类目 | description_category_id |
| type_id | bigint | 商品类型 | type_id |
| price | numeric(20,2) | 销售价 | price |
| old_price | numeric(20,2) | 划线价 | old_price |
| currency_code | varchar(3) DEFAULT 'RUB' | | currency_code |
| vat | varchar(8) DEFAULT '0' | 0 / 0.1 / 0.2 | vat |
| depth / width / height | numeric(12,3) | 尺寸 | depth/width/height |
| dimension_unit | varchar(4) DEFAULT 'mm' | mm / cm | dimension_unit |
| weight | numeric(12,3) | 重量 | weight |
| weight_unit | varchar(4) DEFAULT 'g' | g / kg | weight_unit |
| barcode | varchar(64) | 条码 | barcode |
| images | jsonb | 有序公网 URL(七牛)数组,≤15 | images |
| primary_image | text | 主图 URL | primary_image |
| images360 | jsonb | 360 图 URL 数组 | images360 |
| color_image | text | 营销色图 URL | color_image |
| pdf_list | jsonb | | pdf_list |
| attributes | jsonb | `[{complex_id,id,values:[{dictionary_value_id,value}]}]` | attributes |
| complex_attributes | jsonb | 视频/尺码表等 | complex_attributes |
| promotions | jsonb | | promotions |
| raw | jsonb | 采集原文:`{title,price,params[],desc,sellingPoints,brand,texts[]}` | —(`_raw` |
| pricing | jsonb | 计价结果(见 §3) | —(`_pricing` |
| copy | jsonb | AI 文案结果:`{titles_ru/zh,description_ru/zh,tags_ru/zh,model}` | — |
| fx_rate | numeric(12,4) | 计价时快照的汇率 | — |
| published_at | timestamptz | 发布成功时间 | — |
| created_at / updated_at | timestamptz | | |
索引:
- `(user_id, stage)` —— 采集箱/发布列表主查询
- `(source_platform, source_item_id)` UNIQUE(可空)—— 采集去重
- `offer_id` —— 货号查重
- `ozon_product_id`
### 2.4 `product_assets` —— 采集素材(图片/视频)
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| product_id | UUID FK → products ON DELETE CASCADE | |
| group_key | varchar(16) | `main/sku/detail/video/param` |
| variant_name | varchar(128) | SKU 规格名(俄文原样) |
| sort_order | int | 组内顺序(1 起,对应命名 `main-001` |
| type | varchar(8) | `img/video` |
| source_url | text | 源站原图 URL(溯源) |
| qiniu_url | text | 七牛公网 URL(转存成功后) |
| status | enum('pending','downloading','uploaded','failed') | 转存状态 |
| dedupe_key | varchar(512) | URL 归一化指纹(去重) |
| width / height | int | |
| error | text | 失败原因 |
| created_at | timestamptz | |
索引:`(product_id, group_key, sort_order)`
> **与 `products.images` 的关系**`product_assets` 是「素材库」(编辑期勾选、分组、去重);用户从素材库选出 ≤15 张主图后,按顺序写 `products.images`(七牛 URL)。这两层解耦,跟卖换主图不改素材库。
### 2.5 `product_texts` —— 采集文本(可选,也可并进 raw)
> 一期建议**并进 `products.raw`**JSONB),不必单开表。若后续要按「卖点/参数」检索,再拆此表:
| 列 | 类型 | 说明 |
|---|---|---|
| product_id | UUID FK | |
| kind | varchar(16) | `title/params/selling_point/desc/price/brand` |
| content | text | 文本 |
| pairs | jsonb | `table` 模式的 kv |
### 2.6 `publish_tasks` —— 发布任务
| 列 | 类型 | 说明 |
|---|---|---|
| id | UUID PK | |
| product_id | UUID FK → products | |
| shop_id | UUID FK → shops | 发布到哪个店铺 |
| ozon_task_id | bigint | `/v3/product/import` 返回的 task_id |
| status | enum('pending','processing','moderation','imported','failed') | 轮询结果 |
| request_payload | jsonb | 实际发给 Ozon 的 items[0](脱敏后) |
| response | jsonb | `/v1/product/import/info` 原始结果 |
| errors | jsonb | 失败原因数组 |
| created_at / completed_at | timestamptz | |
索引:`(product_id, created_at DESC)``ozon_task_id`
### 2.7 类目字典缓存(三张,可重建)
#### `category_tree`
| 列 | 类型 | 说明 |
|---|---|---|
| description_category_id | bigint PK | 类目 ID |
| parent_id | bigint | 父类目 |
| category_name | varchar(255) | |
| type_id | bigint | 商品类型 ID |
| type_name | varchar(255) | |
| disabled | boolean | 不可建品 |
| level | int | 层级 |
| lang | varchar(8) | DEFAULT/RU/EN/ZH_HANS |
| updated_at | timestamptz | 缓存时间 |
#### `category_attributes`
主键 `(description_category_id, type_id, attribute_id)`
| 列 | 类型 | 说明 |
|---|---|---|
| description_category_id / type_id / attribute_id | bigint | 复合主键 |
| name | varchar(255) | 属性名 |
| description | text | |
| type | varchar(32) | 属性值类型 |
| dictionary_id | bigint | 0=无字典 |
| group_id / group_name | bigint / varchar | 属性分组 |
| is_required | boolean | 必填 |
| is_aspect | boolean | 变体属性(颜色/尺码) |
| is_collection | boolean | 多值 |
| max_value_count | int | |
| attribute_complex_id | bigint | 复杂属性 |
| complex_is_collection | boolean | |
| category_dependent | boolean | 字典值是否依赖类目 |
| lang | varchar(8) | |
| updated_at | timestamptz | |
#### `attribute_values`
| 列 | 类型 | 说明 |
|---|---|---|
| id | bigint | 字典值 ID |
| attribute_id | bigint | |
| description_category_id / type_id | bigint | |
| value | varchar(512) | 字典值文本 |
| picture | text | 值配图 |
| info | text | |
| lang | varchar(8) | |
| updated_at | timestamptz | |
> 字典值可能很大(一个类目数万条),**按需拉取**:用户选了类目+属性后才拉该属性字典,且只缓存用过的属性(见 `ozon-publish.md` §3)。
---
## 3. JSONB 结构约定
### 3.1 `products.raw`(采集原文,对齐 V1 `_raw` + texts
```jsonc
{
"title": "Термокружка детская 316",
"price": "1 290 ₽",
"params": [{ "key": "Материал", "value": "Нержавеющая сталь" }],
"desc": "…",
"sellingPoints": "…",
"brand": "…",
"texts": [ // 插件 texts[] 原样
{ "kind": "params", "content": "…", "pairs": [{ "key": "…", "value": "…" }] }
],
"images": { "main": [...], "sku": [...], "detail": [...], "video": [...] } // 采集快照(可选)
}
```
### 3.2 `products.pricing`(对齐 V1 `_pricing`
```jsonc
{
"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": "…"
}
```
> 计价公式与字段沿用 v1`web/js/app.js`),**只抄不改**,见 `migration.md` §3。`products.price` 最终取 `sellingPriceRub`(预留折扣后售价)。
### 3.3 `products.attributes`(对齐 Ozon
```jsonc
[ { "complex_id": 0, "id": 5076, "values": [ { "dictionary_value_id": 971082156, "value": "Speaker stand" } ] } ]
```
---
## 4. 关系图
```
users 1─n shops 1─n publish_tasks n─1 products
1─n product_assets
1─n product_texts(可选)
products n─1 category_tree(弱关联,仅存 id
```
---
## 5. 迁移(Alembic)约定
- 首个迁移建全部表;后续 schema 变更走 Alembic revision。
- JSONB 字段的 schema 演进靠应用层版本号(`raw.schemaVersion` / `pricing.schemaVersion`)而非 DB 迁移,避免频繁 ALTER。
- `shops.client_id_enc/api_key_enc` 的加密密钥 `SECRET_KEY``.env`,**换环境(本地/腾讯云)需保证一致或做好密文重写**。
---
## 6. 规模预估(单人自用 → 小团队)
| 表 | 量级 | 说明 |
|---|---|---|
| products | 万级 | 每商品数十素材,主表轻 |
| product_assets | 十万级 | 每商品 10~30 图 |
| publish_tasks | 万级 | 每发布一次一条 |
| category_* | 类目数万 / 属性数百万 / 值可能上亿(按需缓存) | 只缓存用过的 |
该量级单机 PostgreSQL 绰绰有余,无需分库分表;`product_assets` 后续可考虑按 product_id 分区或归档。
+148
View File
@@ -0,0 +1,148 @@
# V2 图片处理方案
> 状态:方案设计(**图片方案 B 已拍板** 2026-08-15;套图落地节奏待定)
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md)
> 相关:V1 [`docs/studio/image-edit.md`](../studio/image-edit.md) · `ecommerce-image-suite/` 源码与 `SKILL.md`
---
## 1. 先厘清:这是「两种不同的能力」,不是二选一的替代品
用户提出的两个方案,本质是把两种**不同粒度**的图片能力放在一起比了:
| | 现有 studio「AI 生图」 | ecommerce-image-suite「电商套图」 |
|---|---|---|
| 模型 | 万相 `wanx2.1-imageedit`+ qwen-image-edit 系列) | `wan2.7-image-pro` / 豆包 `doubao-seedream` / GPT-image 等 |
| 形态 | **单图编辑**:换背景/去水印/局部重绘/加文字/风格化 | **套图生成**:原图 → 8~9 张营销图 |
| 输入 | 一张底图 + 一句指令 | 商品原图(1~3 张)+ 卖点文案 |
| 输出 | 1 张改好的图 | 白底主图/核心卖点图/卖点图/材质图/场景图/模特图/多场景拼图/详情图/三角度 |
| 适用 | 「修一张图」:白底、去水印、补字 | 「产出一整套」:营销素材、详情页 |
| 成本 | 单次编辑(百炼按张计) | 一套 8~9 张 × 单张价 + 1 次视觉分析 |
**结论先行**:这两个是**互补**的,不是谁替代谁。所以不存在真正的「方案一 vs 方案二」,而是「只保留一个」还是「两个都要」。
---
## 2. 对 Ozon 跟卖场景,真正刚需是什么
跟卖(从竞品页采集 → 自己上品)的图片痛点很具体:
1. **白底主图**:Ozon 主图要求纯白底、无文字、无水印。竞品图往往带背景/水印/营销字。
2. **去水印/去字**:采集来的图常带竞品水印。
3. **补充营销图**:主图之外,详情页要卖点图、场景图、模特图(服饰类)。
| 痛点 | 最合适的工具 | 成本 |
|---|---|---|
| 白底 / 去水印 / 去字 / 换背景 | **`wanx2.1-imageedit`(单图编辑)** | 单张,便宜,可控 |
| 整套营销图 / 模特图 / 场景图 / 详情图 | **ecommerce-image-suite(套图)** | 一套多张,按需 |
**单图编辑是高频刚需**(几乎每个商品都要做白底),**套图是选配**(服饰/需要营销图的类目才用,且部分类目 Ozon 主图够用)。
---
## 3. 方案对比
### 方案 A:只集成 ecommerce-image-suite,去掉 wanx2.1-imageedit
- ✅ 简单:图片能力一个入口,前端一套 UI。
-**贵且不划算**:白底/去水印这种单图需求,也要走整套生成(8~9 张),大量浪费。
-**产出不完全对口**:套图里只有「白底主图」一张符合 Ozon 主图规范;卖点图/场景图**带营销文字**,不能当 Ozon 主图(Ozon 主图禁文字水印),只能进详情/补充。
- ❌ 丢掉了「改一张图」的精细控制(局部重绘、加字、改背景强度),这些 wanx2.1-imageedit 已经做好且便宜。
### 方案 B:套图 + 保留 wanx2.1-imageedit(改名「智能修图」),高低搭配 ✅ **已选(2026-08-15**
- 高频单图需求(白底/去水印/换背景/加字)→ **智能修图**wanx2.1-imageedit,已有代码,复用改名)。
- 低频整套需求(卖点图/场景图/模特图/详情图)→ **电商套图**ecommerce-image-suite)。
- ✅ 两者共用 `DASHSCOPE_API_KEY`,无额外对接成本。
- ✅ 现有 `server/api/image.py` + `services/image_edit.py` + studio `AiImagePage` 整套**原样保留**,只是改个名字和菜单。
- ✅ 成本可控:默认用便宜的修图,需要时再整套。
**成本佐证**(来自 ecommerce-image-suite `references/providers.md`):
| 供应商/模型 | 单价 | 参考图 | 国内直连 |
|---|---|---|---|
| 千问 `wan2.7-image-pro` | ¥0.14/张 | ✅ | ✅ |
| 豆包 `doubao-seedream-4-5` | ¥0.12/张 | ✅ | ✅ |
| Gemini 3.1-flash-image | $0.03/张 | ✅ | 需代理 |
| GPT-image-1.5 | $0.04~0.2/张 | ✅ | 需代理 |
| Stability core | $0.03/张 | ❌ | 需代理 |
一套 8 张 ≈ ¥0.96~1.12(国内直连),加一次视觉分析(qwen-vl-max)。单图编辑是「按需 1 张」,成本远低于整套。
> **纠正一个直觉**:套图「贵」不在单张价,而在「一次要生成一整套」。单张价其实比很多平台便宜。所以「方案 B 更贵」不成立——方案 B 反而因为默认走单图编辑而更省。
---
## 4. 命名与入口(方案 B 落定后的 UI)
studio 编辑页「图片」面板里,每张图/每个插槽提供两类操作:
| 菜单 | 能力 | 底层 |
|---|---|---|
| **智能修图** | 白底、去水印、换背景、局部重绘、加文字、风格化 | `wanx2.1-imageedit``/api/image/edit` |
| **电商套图** | 从商品原图生成整套营销图(可勾选图型) | ecommerce-image-suite`/api/image/suite` |
现有的「AI 图生图」独立页保留,改名「智能修图」,作为单图精修工作台;「电商套图」作为编辑页图片面板里的一个按钮/抽屉。
---
## 5. 电商套图的集成方式(ecommerce-image-suite 是「脚本+Skill」,不是服务)
`ecommerce-image-suite` 目前是给 Agent/人用的 **脚本 + Skill** 形态(`analyze.py` + `generate.py`Apache-2.0),不是现成 API。要集成进 studio,有三档:
| 档 | 做法 | 代价 | 建议 |
|---|---|---|---|
| L1 快速 | 服务端 subprocess 调 `analyze.py`/`generate.py` | 依赖 Python 环境、脚本路径、退出码解析;无并发控制 | 验证期可用 |
| L2 正式 ✅ | 把 `generate.py`**prompt 引擎 + 供应商调用**抽成 `services/image_suite.py`(纯 Python 模块,直接在 FastAPI 里调 DashScope/豆包) | 移植 prompt 模板与参数(图型 9 种、6 套视觉模板、平台规范),约 1~2 天 | **推荐** |
| L3 独立服务 | 套图做成独立微服务,HTTP 调用 | 重,单人项目不值 | 不建议 |
**L2 的关键取舍**ecommerce-image-suite 里有大量「Agent 交互」逻辑(模特选择、模板推荐、场景推荐、确认步骤),这些在 studio 里**不该照搬**。V2 只取它的**生成引擎**(图型 Prompt 模板 + 供应商 API 调用),把交互简化成 studio 表单:
- 输入:选 1~3 张商品原图(素材库已有)+ 卖点文案(可自动从 `products.raw`/`copy` 取,可手改)+ 勾选图型(白底主图/卖点图/场景图/…)+ 目标语言(俄文)。
- 输出:生成结果逐张进素材库(`product_assets`group_key 标 `generated`),用户挑图再进 `products.images`
- 视觉分析(analyze 那步):一期跳过(直接让用户填卖点),二期可调 qwen-vl-max 自动提炼卖点。
> 注意:ecommerce-image-suite 的图型 Prompt 模板当前是为**国内平台/Amazon**写的(中文/英文文案、平台字体规范)。Ozon 是俄文市场,**俄文文案渲染需要新增一套俄文 Prompt 约束**(或先出英文/无文字图,俄文文案靠前端叠加,见 §7)。这是集成里唯一需要新做的实质工作。
### 5.1 已知坑(源码级核对,服务端集成必须处理)
| 坑 | 说明 | 对策 |
|---|---|---|
| `generate.py` 全局禁用 SSL 校验 | 脚本为方便本地跑图关闭了 TLS 验证 | 服务端集成(L1/L2)**必须移除**,恢复正常 TLS,否则是安全漏洞 |
| 退出码恒 0,单张失败不中断 | 成败不反映在退出码上 | 以 `generate_result.json` 为**唯一真源**逐张判成败;失败图重试或标记 |
| 文档与代码不一致 | Gemini 端点、豆包/视频模型版本、README 称"无 LICENSE"但实为 Apache-2.0 等 | 以 `generate.py` 实际调用为准,逐供应商核对后再落地 |
| 输出固定中文文件名 | `白底主图.jpg` 等中文命名 | 转存七牛时改用英文/序号命名,避免 Ozon 与跨平台文件名问题 |
### 5.2 用 SKILL.md 当 studio 向导蓝图(可选)
`SKILL.md` 里那段「上传原图 → 分析卖点 → 选平台/图型/模板/模特 → 生成 → 确认」的对话流,本身是经过打磨的**交互蓝图**。做 studio「电商套图」抽屉时可直接参照它,把多步向导固化成表单步骤(原图选择 → 卖点确认 → 图型勾选 → 模板/模特 → 生成),省去重新设计交互的成本。
---
## 6. 七牛存储(贯穿所有图片路径)
| 来源 | 处理 |
|---|---|
| 采集源图 | 插件传 URL → 服务端下载(带 Referer)→ 转存七牛 → `product_assets.qiniu_url` |
| 智能修图结果 | DashScope 返回 URL(24h)→ 服务端下载 → 转存七牛 → 返回七牛 URL |
| 套图结果 | 同上 |
| 前端水印合成 | studio canvas 合成 → 上传七牛(服务端中转或直传 token) |
**为什么必须转存**Ozon `images` 只收公网可访问 URL(Ozon 服务器主动拉取);阿里云结果 URL 24h 失效且无 CORS;源站 URL 可能防盗链/失效。七牛是稳定公网源。
一期建议**服务端中转上传**(改动小、无前端直传的 token 复杂度);量大后再切前端直传 + 上传 token。
---
## 7. 推荐落地顺序(务实版)
1. **一期只做「智能修图」**(已有代码):白底/去水印/换背景。这是最高频、最省、复用度最高的部分。`/api/image/edit` 加七牛转存即可。
2. **二期集成「电商套图」**:按 §5 L2 抽 `services/image_suite.py`,先支持 `white_bg / key_features / selling_pt / material / lifestyle / model / multi_scene` 几个高频图型,俄文文案先出英文/无字版本。
3. **三期**:俄文文案渲染(新增俄文 Prompt 约束或前端叠字)、视觉分析自动提炼卖点、模特库接入(45 位内置模特)。
---
## 8. 决策记录
-**图片方案:已选 B(高低搭配)**2026-08-15):集成 ecommerce-image-suite「电商套图」+ 保留 wanx2.1-imageedit(改名「智能修图」)。
- ⏳ 待定:**电商套图一期就做,还是先只交付「智能修图」跑通闭环、套图二期再加**(见 [`migration.md`](./migration.md) §7)。
+139
View File
@@ -0,0 +1,139 @@
# V2 落地计划与改动清单
> 状态:方案设计(待确认)
> 上游:[V2 总览](./README.md) · 其余各分册
---
## 1. 分阶段里程碑(建议顺序)
每个里程碑都可独立验收,且不破坏 V1 正在用的部分。
| # | 里程碑 | 内容 | 产出/验收 | 估时 |
|---|---|---|---|---|
| **M0** | 数据层与骨架 | 建 DB + SQLAlchemy 模型 + Alembic 首迁移;`/api/health` 接 DB`.env``DATABASE_URL`/`APP_TOKEN`/`SECRET_KEY`/`QINIU_*` | 服务能连库、能 `alembic upgrade` | 1d |
| **M1** | 插件上传落库 | extension-v2 加消息层 + api client + options`POST /api/materials` + 素材下载转存七牛(后台协程) | 插件点「上传」,采集箱能看到商品与图 | 2d |
| **M2** | 采集箱列表 + 商品编辑骨架 | studio 加「采集箱」页 + 「商品编辑」页(表单 + autosave 落库);计价面板(抄 v1 公式);文案面板(接 `/api/ai/copy`) | 能看采集箱、编辑保存、算价、生成文案 | 3d |
| **M3** | 图片:智能修图 + 七牛 | `/api/image/edit` 加七牛转存;编辑页图片面板(水印沿用 canvas + 智能修图入口) | 图片能转存七牛、能白底/去水印 | 1d |
| **M4** | 店铺 + 类目 + 属性 | `shops` CRUD + 连通校验;`/api/categories/*` 代理 + 缓存;属性映射 UI | 能绑店铺、选类目、映射属性 | 3d |
| **M5** | 发布链路 | `POST /products/:id/publish` + 组装 items[0] + 轮询回填 + 发布结果页 | 商品成功进 Ozon 后台,product_id 回填 | 2d |
| **M6** | CSV 导出 + 打磨 | `/api/export/products.csv` + 导出页;错误处理/限流/日志 | 能导出 CSV | 1d |
| **M7** | 部署腾讯云 | nginx + systemd + PostgreSQL + 七牛配置;插件/studio 指向公网 | 公网可访问,闭环 | 1d |
| **M8** | (二期)电商套图 | 集成 ecommerce-image-suite`/api/image/suite`);俄文 Prompt | 按方案 B 决策而定 | 2~3d |
> M2/M4 是最大的两块(编辑页 + 属性映射),也是最值得先用静态样例打磨 UI 的部分。
---
## 2. 各端改动清单
### 2.1 extension-v2(采集插件)
1.`src/messaging/``MessageMap` + client)与 `src/api/client.ts`Bearer 鉴权,仅 background)。
2. `background.ts` 从「单个 fetchImage 分支」改为 handler 表路由;新增 `collect`/`product-*`/`health` 等消息。
3. options 页:后端地址 + Token + 「测试连接」。
4. sidepanel「导出到本地」旁边加「上传到服务端」:复用 `buildProduct()` 产物 → `POST /api/materials`
5. 删除/降级 File System Access 写盘主路径(保留为可选本地备份)。
6. `SH_PENDING_QUEUE` 重试队列 + `GET /products/:id/fingerprints` 跨页去重接线(v2 已写 builder 但未读回比对)。
7. manifest 加后端域名 `host_permissions`
> 详细契约沿用 `docs/extension/plan.md` §9/§13/§14,已在 `api.md` §2 落地。
### 2.2 server(服务端)
1. 依赖加:`sqlalchemy[asyncio]``asyncpg``alembic``qiniu``python-jose`(或 pyjwt)、`cryptography`
2. 新增 `models/``migrations/``jobs/`(下载转存协程、发布轮询协程)。
3. 新增 api 文件:`collection/products/categories/shops/publish/export/fx/auth`
4. 复用不改:`ai.py`/`image.py`/`deepseek.py`/`image_edit.py`/`models_catalog.py``image_edit.py` 加七牛转存一步)。
5. 新增 `services/ozon_client.py`Ozon 通用调用)、`services/qiniu.py`(上传/下载转存)、`services/pricing.py`(把 v1 公式实现为服务端校验/计算,供「ready 校验」与 CSV)。
6. 鉴权中间件:JWT 校验 + `APP_TOKEN` 换发。
### 2.3 studio(工作台)
1. 菜单从单页扩为多页:`采集箱 / 商品编辑 / 发布 / 店铺 / 导出 / 智能修图(原 AI 图生图)`
2. 新建 `pages/product/` 及子组件(PricingPanel/CopyPanel/ImagePanel/CategoryPicker/AttributeMapper/PublishPanel)。
3. 计价纯函数从 `web/js/app.js` **抄**进 `src/pricing/`(不改原文件),补单测锁定 v1 数值。
4. 文案面板复用 `/api/ai/copy`(参考 `web/js/ai-copy.js` 交互)。
5. 状态管理引入 zustand(编辑页跨面板共享);axios 客户端对齐 `/api/*`
6. 复用不动:`utils/watermark.ts``annotation.ts``image.ts`、布局壳、`AiImagePage`(改名「智能修图」)。
### 2.4 webv1 工具台)
**冻结,零改动**。只被读(抄公式、抄文案交互)。
---
## 3. 计价公式迁移(v1 → 服务端 + studio
来源:`web/js/app.js``calculateLogisticsFee` / `calculateAndDisplay` / `validateDimensions` / `validateLogisticsLevel` / `validatePriceRange` / `updateDerivedPrices`)。
迁移方式:
- **studio 侧**:抽成 TS 纯函数(输入字段 + 汇率 + 预留% → 输出全部结果字段),展示在编辑页计价面板。
- **server 侧**:抽成 `services/pricing.py`(同样公式的 Python 版),用于「ready 校验」、`products.price` 最终写入、CSV 导出的一致性。
> **为什么两端各一份**:计价是高频纯前端交互(实时算),不需要每次走后端;但发布前校验和导出需要服务端有权威结果。约定:**以服务端 `services/pricing.py` 为真源**,前端 TS 版照抄并对齐,用同一组 fixture 测两端一致性(沿用 V1 契约测试思路)。
关键常量(照抄不改):
| 项 | 值 |
|---|---|
| 物流费 | low/high/high2 三档 × 两重量段(公式见 app.js:959 |
| 净到手比例 netRate | low=0.845high/high2=0.785 |
| 完全抽成 | 15.5%low/ 21.5%(其他),含约 3.5% 其它费 |
| 汇率源 | FloatRates → 俄央行 → er-api5~25 区间过滤,兜底 11.5 |
| 预留折扣 | 默认 50%0~95 |
---
## 4. 配置(`.env` 新增项)
```env
# 现有
DEEPSEEK_API_KEY=
DASHSCOPE_API_KEY=
# V2 新增
APP_TOKEN=# 单用户登录 tokenMVP
SECRET_KEY=# 店铺凭证 AES-GCM 加密密钥
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/ozon_seller
# 七牛
QINIU_ACCESS_KEY=
QINIU_SECRET_KEY=
QINIU_BUCKET=
QINIU_DOMAIN=https://cdn.example.com # 七牛绑定域名(Ozon 拉取用)
APP_BASE_URL=https://api.example.com # 插件/studio 回写、生成图回调用
```
`.env.example` 同步补占位并注释。
---
## 5. 部署(腾讯云)
1. **资源**:轻量应用服务器 / CVM + CDB PostgreSQL + 七牛(域名需备案,Ozon 拉取的是公网 URL,务必用已备案域名)。
2. **应用**`uvicorn main:app --app-dir server --host 127.0.0.1 --port 8800 --workers 2`systemd 守护;nginx 反代 `/api`,托管 studio 构建产物。
3. **数据**`alembic upgrade head``.env` 放服务器(不入 git);`SECRET_KEY` 换环境时注意密文一致性(见 database.md §5)。
4. **七牛**:配置 bucket + 绑定 CDN 域名 + 证书;Ozon 服务器需能公网访问该域名。
5. **健康检查**`/api/health`(含 DB ping)给运维探活。
---
## 6. 风险与对策
| 风险 | 级别 | 对策 |
|---|---|---|
| 属性映射工作量大、体验差 | 🔴 高 | 自动匹配 + 人工确认;先做基础版,迭代智能匹配 |
| Ozon 改版/限额/风控 | 🟡 中 | 采集端已有四路径 + 埋点热更;发布端错误透传 + 退避 |
| ecommerce-image-suite 集成是脚本非服务 | 🟡 中 | 抽 prompt 引擎为服务模块(image-strategy §5 L2 |
| 店铺密钥泄露 | 🔴 高 | AES-GCM 加密落库 + 前端打码 + 永不回显明文 + 日志脱敏 |
| 任务异步(下载/发布)状态不可见 | 🟡 中 | 素材/发布都有状态表 + 前端轮询回显 |
| 本地 → 云上环境不一致 | 🟡 中 | 十二要素:配置全走 `.env`Alembic 管 schema |
---
## 7. 待确认项(开工前拍板)
1. ~~图片方案 A / B~~**已定 B(高低搭配)**2026-08-15,见 [`image-strategy.md`](./image-strategy.md) §8)。
2. **电商套图是否进一期**:建议一期先只交付「智能修图」跑通闭环,套图二期。
3. **单用户还是预留多用户**:schema 已按多用户预留,MVP 用 `APP_TOKEN` 即可。
4. **库存是否自动设置**:一期发布到「已创建/审核」,库存去后台补(或二期接 `/v2/products/stocks`)。
5. **数据库选型**:已定 PostgreSQL;若想更省事可换 SQLite(本地)→ 但 JSONB/并发/腾讯云部署建议直接用 PostgreSQL。
+138
View File
@@ -0,0 +1,138 @@
# V2 多 SKU(多变体)配置方案调研
> 状态:调研结论(待实现)
> 来源:Ozon Seller API 官方文档 + `reference/maozi-plugin-3.2.3`(毛子ERP 逆向)+ `reference/AI编辑 - 毛子ERP.html`
---
## 1. 结论一句话
Ozon 的多变体商品**不是"一个商品带多个 SKU 子结构"**,而是 **N 个独立商品(各自 `offer_id` / `product_id`),通过同一个「型号名称」属性(attribute id = `9048`)自动合并成一张卡**。变体之间的差异只能体现在「aspect 属性」(颜色/尺码等)上。
---
## 2. Ozon 官方机制
### 2.1 合并规则(官方文档原文)
`/v3/product/import` 文档明确写:
> To merge two product description pages, pass `9048` in the `attributes` array for each product. **All attributes except size or color must match** in these description pages.
即:
- 每个变体 = `items[]` 里的一个独立 item(独立 `offer_id`)。
- 每个 item 的 `attributes` 里都带上 `id=9048`(型号名称 / Название модели),且**值相同**。
- 除「尺寸/颜色」这类 aspect 属性外,其它属性必须完全一致。
- Ozon 会把同型号、仅 aspect 不同的商品**自动合并成一张带变体选择器的卡片**。
### 2.2 aspect 属性(`is_aspect`
`/v1/description-category/attribute` 返回的属性里,`is_aspect=true` 表示该属性是「区分同型号商品的变体维度」(官方定义:颜色、尺码这类)。这就是多变体的"轴":
- 变体轴 = 类目下 `is_aspect=true` 的属性(通常 `颜色``尺码`/`尺寸`)。
- 其它属性(品牌、材料、型号名称…)在各变体间必须一致。
### 2.3 每个变体的图片
- 主图 `images`:每个变体传自己的主图(通常是该 SKU 的图)。
- 采集端已支持"SKU 图带规格名"`sku-001-синий.jpg`),正好一一对应。
---
## 3. 采集侧(毛子ERP 的 SKU 组合逻辑)
毛子ERP1688 采集,`content-scripts/content.js` 里的 `生成SKU组合` 函数)的做法:
```
规格选项(颜色×尺寸…) × SKU详情 → 笛卡尔积 → 变体列表
红色, 蓝色 M, L 红色-M, 红色-L, 蓝色-M, 蓝色-L
```
关键点:
1.`webAspects` 拿到规格维度(aspect)+ 每个维度的可选值(含 SKU 图)。
2. 若有独立 SKU 详情(价格/图),做笛卡尔积;否则用默认价格 + 规格图。
3. 每个变体:`{ name: "红色-M", price, primary_image, sku }`
对我们的映射:`webAspects` 采集回来的 `skuVariants`(已有 `variantName` + `image`)就是变体轴的数据源。
---
## 4. 发布侧(Ozon 多 SKU 实现方案)
### 4.1 数据模型
在现有 `products` 表基础上,多 SKU 用**一张卡对应多个 product 记录**来表示:
- 每个变体是一条 `products` 记录,`offer_id` 唯一。
- 变体间共享 `型号名称`(存 `raw.model_name`,映射到 attribute `9048`)。
- 变体差异在 `attributes`aspect 属性填不同字典值)。
### 4.2 发布流程
```
① 主商品:确定类目 → 拉属性 → 找 is_aspect=true 的属性(如 颜色/尺码)
② 变体配置 UI
颜色 ▾ [红/蓝/绿](字典值)
尺码 ▾ [M/L/XL](字典值)
→ 笛卡尔积生成变体列表(可编辑每个变体的 offer_id / 价格 / 主图)
③ 对每个变体,组装 items[i]:
offer_id = 变体自己的货号(如 SKU-001-RED-M
attributes = 同型号名称(9048) + 变体自己的颜色/尺码字典值 + 其它共同属性
images = 变体自己的主图
④ 一次 /v3/product/import 提交所有变体(≤100 个 item)
⑤ 轮询回填每个变体的 product_id,Ozon 自动合并成一张卡
```
### 4.3 关键字段映射
| 概念 | 来源 | 落点 |
|---|---|---|
| 型号名称 | 编辑页「型号名称」输入 | `attributes[{id:9048, values:[{value}]}]` |
| 变体轴 | 类目 `is_aspect=true` 属性 | `attributes`(不同变体填不同 `dictionary_value_id` |
| 变体货号 | 用户/自动 | 每个变体 `offer_id` |
| 变体图 | 采集 `skuVariants[].image` | 每个变体 `images[0]` |
| 共同属性 | 属性映射结果 | 每个变体相同 |
---
## 5. 待实现的 UIstudio 新增「SKU 配置」面板)
```
[型号名称] 儿童保温杯 316
[变体维度] 颜色(字典下拉,多选) [红][蓝][绿]
尺码(字典下拉,多选) [M][L][XL]
[生成变体] ← 笛卡尔积
┌──────────────────────────────────────────────┐
│ 颜色 尺码 货号 价格 主图 │
│ 红 M SKU-001-RED-M [ ] [图] │
│ 红 L SKU-001-RED-L [ ] [图] │
│ 蓝 M SKU-001-BLU-M [ ] [图] │
│ … │
└──────────────────────────────────────────────┘
[保存变体] → 生成 N 条 products 记录(或发布时展开)
```
实现上两种选择:
- **A. 落库展开**:保存时直接生成 N 条 `products`(每个变体一条),发布时各自提交。
- **B. 发布时展开**:主商品存一份变体配置 JSON,发布时动态展开成 N 个 item 提交。
建议 **A**(落库展开)——与现有"采集箱 → 单商品编辑 → 发布"链路一致,每个变体可独立查看/编辑/重发。
---
## 6. 注意点
1. **变体必须同型号**`9048` 值必须完全一致,否则 Ozon 不合并,会生成 N 张独立卡片。
2. **非 aspect 属性必须一致**:品牌/材料/描述等若不同,Ozon 拒绝合并。
3. **变体图数量**:每个变体各自 ≤15 张主图;`skuVariants` 采集的规格图作为变体主图候选。
4. **`complex_attributes` 不是用来做 SKU 的**:它用于视频/尺码表等富内容,别混用。
5. **单次 ≤100 item**:变体数超过 100 需分批提交。
---
## 7. 结论
- 多 SKU = **同型号名称(9048) + aspect 属性区分 + 多 product 记录**,靠 Ozon 自动合并,无需额外的"组合商品" API。
- 采集端已具备变体轴数据(`webAspects``skuVariants`),发布端只需一个「变体配置 + 展开提交」的 UI 即可落地。
- 推荐实现路径:studio 增加「SKU 配置」面板 → 落库展开为多条 product → 复用现有发布链路。
+175
View File
@@ -0,0 +1,175 @@
# V2 Ozon 发布集成
> 状态:方案设计(待确认)
> 上游:[V2 总览](./README.md) · [V2 架构](./architecture.md) · [数据库](./database.md) · [API](./api.md)
> 官方文档:[Ozon Seller API(中文)](https://docs.ozon.ru/api/seller/zh/)
---
## 1. 鉴权与店铺凭证
Ozon Seller API 用 **两个请求头** 鉴权(不是 OAuth):
```
Client-Id: <你的 Client ID>
Api-Key: <你的 API Key>
```
- 获取:Ozon 卖家后台 → 设置 → Seller API → 生成 Key(可选权限级别)。
- **凭证归属店铺**:V2 里每店铺一条 `shops` 记录,`client_id`/`api_key` 加密落库,调用时解密拼头。
- **连通性校验**`POST /api/shops/:id/test` 调 [`/v1/roles`](https://docs.ozon.ru/api/seller/zh/#operation/AccessAPI_RolesByToken)(返回该 key 的角色与可用方法),既验证凭证又看权限范围,零成本。
服务端封装 `services/ozon_client.py`:统一 base URL`https://api-seller.ozon.ru`)、拼头、超时、错误映射(400/403/409/500 → 语义化 detail)、限流退避。
---
## 2. 核心接口(本项目用到)
| 用途 | 方法 | 说明 |
|---|---|---|
| 类目树 | `POST /v1/description-category/tree` | 返回 `description_category_id / type_id / category_name / type_name / disabled / children`**只有末级类目可建品** |
| 类目属性 | `POST /v1/description-category/attribute` | 入参 `description_category_id + type_id`;返回属性含 `is_required / is_aspect / is_collection / dictionary_id / type / max_value_count` |
| 属性值字典 | `POST /v1/description-category/attribute/values` | 入参 `attribute_id + category_id + type_id + limit(≤2000) + last_value_id`(分页) |
| 属性值搜索 | `POST /v1/description-category/attribute/values/search` | 按 `value` 模糊匹配参考值(≥2 字符,limit≤100) |
| **发布/更新商品** | `POST /v3/product/import` | 一次 ≤100 个 item;返回 `task_id` |
| **发布状态** | `POST /v1/product/import/info` | 入参 `task_id`;返回 `items[{offer_id, product_id, status, errors[]}]` |
| 商品列表/回填 | `POST /v3/product/list` | 用 `offer_id/product_id` 过滤取 `product_id`,或分页拉全部 |
| 跟卖复制 PDP | `POST /v1/product/import-by-sku` | 入参 `sku + name + offer_id + price...`;返回 `task_id + unmatched_sku_list` |
| 图片更新 | `POST /v1/product/pictures/import` | 按 `product_id` 覆盖 `images/images360/color_image` |
| 商品详情(含图片/审核错误) | `POST /v3/product/info/list` | 回读已发布商品的图片/状态/错误 |
---
## 3. 类目与属性字典(采集属性 → Ozon 属性的关键)
### 3.1 数据流
```
类目树(全局缓存)
└─ 用户选类目 → 得 description_category_id + type_id
└─ 拉该类目属性(按 category+type 缓存)
└─ 对每个「有字典」的属性,按需拉值(/values 或 /values/search
```
- **类目树全局缓存**:与店铺无关(虽然接口要凭证),服务端拉一次存 `category_tree` 表 + 内存 LRUTTL 24h。
- **属性按类目缓存**`category_attributes` 表,按 `(category_id, type_id)` 缓存。
- **属性值按需拉取**:值目录可能非常大,只在用户映射到某个属性时才拉,且用 `/values/search`(按关键词搜)而非全量拉。
### 3.2 属性映射(采集的 `raw.params` → Ozon `attributes[]`
这是发布链路**最重的工作**。流程:
```
采集 raw.params: [{key:"Материал", value:"Нержавеющая сталь"}, …]
│ ① 自动匹配:key 与属性 name 模糊匹配(归一化 + 词干)
│ ② 有字典的属性:value 去 /values/search 找 dictionary_value_id
属性映射 UI:自动匹配结果 + 人工确认未匹配项 + 必填项高亮
products.attributes = [{complex_id:0, id, values:[{dictionary_value_id, value}]}]
```
**必填项校验**:服务端在「ready 校验」和「发布前」两次校验:`category_attributes``is_required=true` 的属性必须已映射,否则阻断发布并指出缺哪些。
---
## 4. 发布请求体组装(对齐 ImportProductsV3
`items[0]` 字段(已核对官方示例):
| 字段 | 来源 | 说明 |
|---|---|---|
| offer_id | `products.offer_id` | **自己的货号**,跟卖不能用竞品的 |
| name / description | `products.name/description`(俄文) | |
| description_category_id / type_id | `products.*` | 从类目树选 |
| price / old_price / currency_code / vat | `products.*` | currency 须与店铺设置一致(默认 RUB) |
| depth/width/height/dimension_unit/weight/weight_unit | `products.*` | **必填且不能为 0**(官方硬约束) |
| barcode | `products.barcode` | 可选 |
| images | `products.images`(七牛 URL,≤15) | 顺序即展示顺序;首张为主图。**必须 https 直链**(实测 Ozon 不接受 http,见 §7 |
| primary_image | `products.primary_image` | 用 primary_image 则 images ≤14 |
| images360 / color_image | `products.*` | 可选 |
| attributes | `products.attributes` | 映射结果 |
| complex_attributes | `products.complex_attributes` | 视频/尺码表等 |
| pdf_list / promotions | 可选 | 一般留空 |
**跟卖场景可选优化**:若竞品允许复制 PDP,走 `/v1/product/import-by-sku`(只需 sku + 基本信息),更快且继承竞品详情——但受「卖家是否允许复制」限制,且不能更新,故作为**可选快捷路径**,主路径仍是 `import`
---
## 5. 发布状态机与轮询
`/v3/product/import` 是异步的,返回 `task_id`。流程:
```
POST /v3/product/import → { task_id }
│ 建 publish_tasks(status=pending, ozon_task_id)
后台协程轮询 POST /v1/product/import/info { task_id }
│ items[0].status ∈ imported | moderation | failed(+errors[])
imported → products.stage=published, ozon_product_id=items[0].product_id
moderation → products.stage=publishing(继续轮询,通常 <1 天)
failed → products.stage=failed, publish_tasks.errors=items[0].errors
```
- 轮询间隔:先 5s,退避到 30s`moderation` 状态降低频率到分钟级。
-`/v3/product/list`filter by offer_id)回读 `product_id` 兜底(轮询遗漏时)。
- **上架还需设置库存**`import` 成功后商品进入后台但不自动上架(`architecture.md` 与官方文档均明确「只有设置库存后才开售」)。V2 一期发布到「已创建/审核」即可,库存设置(`/v2/products/stocks`)作为二期可选,或提示用户去后台补库存。
---
## 6. CSV 导出字段
服务端 `GET /api/export/products.csv`,带 BOM 的 UTF-8,Excel 直接打开不乱码。字段:
```
offer_id, product_id, name, description_category_id, type_id,
price, old_price, currency_code, vat,
weight, weight_unit, depth, width, height, dimension_unit,
barcode, primary_image, images, source_platform, source_item_id, source_url,
stage, published_at, created_at, updated_at
```
- `images``|` 拼接七牛 URL。
- 未发布商品 `product_id` 为空。
- 支持筛选 `stage`collected/ready/published/failed/全部)与 `ids`(勾选导出)。
> 对齐 V1:v1 登记表导出的 `sku + 卢布预留价` 组合码,V2 里 `offer_id + price`(卢布)即等价物;若要完全兼容 v1 组合码,可加一列 `combo``offer_id + 卢布预留价`)。
---
## 7. 错误处理与限流
| Ozon 错误 | 含义 | 处理 |
|---|---|---|
| 400 Invalid parameter | 参数错误 | 把 detail 透传前端,定位字段 |
| 403 Access denied | 权限不足 | 提示检查 Api-Key 权限级别 |
| 409 Request conflict | 冲突(如 offer_id 重复) | 提示改 offer_id 或走更新 |
| 429 / 限流 | 频率超限 | 指数退避重试 |
| `item_limit_exceeded` | 超过当日建/更新商品限额 | 提示限额,可查 `/v4/product/info/limit` |
`publish_tasks.errors` 完整保存 Ozon 返回的 errors 数组,前端发布结果页展示中文解读。
---
## 8. 店铺绑定交互
1. 店铺管理页「新增店铺」:填 `名称 + Client ID + API Key + 结算币种`
2. 点「测试连接」→ `/api/shops/:id/test` → 调 `/v1/roles` → 显示 `ok` 与角色列表,或失败原因(凭证错/权限不足/网络)。
3. 保存后 `client_id` 只显示尾号打码(如 `…1234`),key 永不回显。
4. 发布时从店铺下拉选择目标店铺。
---
## 9. 一期范围 vs 二期
| 能力 | 一期 | 二期 |
|---|---|---|
| 店铺绑定 + 连通性校验 | ✅ | |
| 类目树 + 属性 + 值字典(缓存) | ✅ | |
| 属性映射 UI(自动 + 人工) | ✅ 基础版 | 智能匹配优化 |
| `/v3/product/import` 发布 + 轮询回填 | ✅ | |
| `/v1/product/import-by-sku` 跟卖复制 | 🟡 可选 | |
| 库存设置(上架) | ❌(提示去后台) | `/v2/products/stocks` |
| 价格/库存批量更新 | ❌ | `/v1/product/import/prices``/v2/products/stocks` |
| 图片更新(换图) | ❌ | `/v1/product/pictures/import` |
+124
View File
@@ -0,0 +1,124 @@
# Ozon Seller Kit - Ozon 采集插件(extension-v2
采集 Ozon 商品页信息(标题 / 价格 / 参数 / 卖点 / 描述 / 图片 / 视频)到本地商品文件夹。
参考实现:`reference/maozi-plugin-3.2.3`(毛子ERP,Ozon 跟卖插件)。本项目按其采集思路,
落成本仓库 `docs/` 已经定下的「纯采集器 + 本地商品文件夹」架构(见 `docs/contracts/product-json.md`)。
## 与毛子ERP 的对应关系
| 毛子ERP 做法 | 本插件实现 |
|---|---|
| 请求 Ozon 内部页 JSON 接口 `entrypoint-api.bx/page/json/v2` | ✅ `src/collector/ozon-api.ts`(只收画廊 widget 的图)|
| 解析 `application/ld+json` 结构化数据 | ✅ `src/collector/jsonld.ts` |
| DOM `data-widget` 区块选择器 | ✅ `src/profiles/ozon.ts`(兜底 + 详情图补充)|
| —(Ozon SSR 页面自带 widget data-state | ✅ `src/collector/ozon-state.ts`(★ 主路径,白名单)|
| 上传到毛子云后端 | ❌ 改为写本地商品文件夹(架构决策 D3/R1)|
| 登录 / AI / 跟卖工作流 | ❌ 不实现,插件只做采集 |
## 快速开始
```bash
cd extension-v2
pnpm install
pnpm dev # 或 pnpm build 出 .output/chrome-mv3
```
然后:
1. 打开 `chrome://extensions/`
2. 开启「开发者模式」
3. 「加载已解压的扩展程序」→ 选择 `extension-v2/.output/chrome-mv3`
## 使用
1. 打开任意 Ozon 商品详情页(`ozon.ru` / `ozon.kz` / `ozon.by`
2. 滚动到页面底部,让详情图完成懒加载
3. 点扩展图标 → 侧边栏打开
4. 「选择保存目录」(仅首次,之后自动记住)
5. 「开始采集当前页」→ 核对结果、勾选图片
6. 「导出到本地」→ 生成商品文件夹
## 采集路径(四路径降级)
```
① SSR widget stateDOM data-state 属性)── 主路径,同步、白名单、无需网络
webGallery / webPrice / webProductHeading / webShortCharacteristics / webAspects …
│ 缺失
② JSON-LDschema.org/Product)── 标准化,稳定(标题/品牌/价格/评分)
│ 缺失
③ Ozon 页 JSON APIentrypoint-api.bx)── 补充完整参数表与富文本描述
│ 缺失
④ DOM 选择器(data-widget 区块)── 兜底 + 详情图补充
```
**「为您推荐 / 一起购买」等其它商品 carousel 的图片不会被采集**
- ① 只读白名单 widgetwebGallery / webAspects 等)的 data-state,绝不遍历全页;
- ③ 只从画廊类 widget 收图片,不递归整个 widgetStates(旧版 bug 所在);
- ④ DOM 选择器按 data-widget 区块作用域限定。
## 目录结构
```
extension-v2/
├── entrypoints/
│ ├── background.ts # 图片代理 fetch(绕 CORS / 防盗链)
│ ├── sidepanel/ # 采集控制 UI
│ └── content/index.ts # 注入商品页,暴露采集入口
├── src/
│ ├── profiles/ # ozon.tsDOM 选择器 + URL/CDN 规则)
│ ├── collector/ # 采集引擎
│ │ ├── ozon-state.ts # ★ SSR data-state 提取(主路径,白名单)
│ │ ├── jsonld.ts # JSON-LD 提取
│ │ ├── ozon-api.ts # Ozon 页 JSON API(只收画廊 widget 的图)
│ │ ├── scan.ts # scanCurrentPage() 入口(四路径编排)
│ │ ├── image.ts / text.ts / dom.ts / url.ts
│ ├── export/ # builder / filesystem / idb
│ └── schema/product.ts # product.json 契约(TS 侧)
├── scripts/verify-pages.ts # 用 reference/ozon*.html 验证采集逻辑
├── wxt.config.ts
└── package.json
```
## Console 调试
在 Ozon 商品详情页的 Console 里执行:
```js
const r = await window.__SellerHelperOzon.scan();
console.table(r.texts);
console.table(r.images);
console.log('stats:', r.stats, 'warnings:', r.warnings, 'source:', r.source);
```
## 验证
`scripts/verify-pages.ts` 用真实保存的页面(`reference/ozon1.html``ozon2.html`
跑采集逻辑,覆盖:标题 / 价格 / 评分 / 画廊原图 / SKU 变体(图+名)/ 参数表 /
URL 还原(/wc\d+/、/c\d+/ 尺寸标记 → 原图)/ 缩略图生成。
```bash
# 从 extension-v2 目录
node_modules/.pnpm/esbuild@0.25.12/node_modules/esbuild/bin/esbuild \
scripts/verify-pages.ts --bundle --platform=node --format=esm --outfile=/tmp/verify.mjs
node /tmp/verify.mjs
```
## 当前状态与已知限制
- ✅ 四路径采集引擎(SSR state / JSON-LD / API / DOM),选择器已在真实页面核实
- ✅ 「为您推荐 / 一起购买」等其它商品图片已排除(白名单 + 画廊 widget 限定)
- ✅ Side Panel UI(分组预览、勾选、文件夹名、写盘进度)
- ✅ File System Access 写本地商品文件夹(product.json + sources.json + images/
- ✅ 图片 CDN 域名(ir.ozone.ru / io.ozone.ru / v-1.ozone.ru / cdn1.ozonusercontent.com)已加入 host_permissions
- ⚠️ 详情图(webDescription 区)在静态快照里没有,需滚动到底部后由 DOM 补充
- ⚠️ 完整参数表(>5 项)走 API 补充,若 API 被风控则只有前 5 项(webShortCharacteristics
## 相关文档
- [总体架构](../../docs/architecture.md)
- [商品文件夹契约](../../docs/contracts/product-json.md)
- [插件方案(含毛子ERP 逆向分析)](../../docs/extension/plan.md)
- [插件方案修正(Ozon 优先)](../../docs/extension/plan-revision.md)
+44
View File
@@ -0,0 +1,44 @@
import { uploadMaterials } from '../src/api/client';
// Background Service Worker —— 唯一出网口(代理图片 fetch / 上传服务端,绕 CORS / 防盗链)
export default defineBackground(() => {
console.log('[套娃采集助手] background started');
// 点击扩展图标 → 打开 Side Panel
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'fetchImage') {
fetchImageAsDataUrl(msg.url)
.then((dataUrl) => sendResponse({ ok: true, dataUrl }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true; // 保持异步通道
}
if (msg?.action === 'uploadMaterials') {
uploadMaterials(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
return false;
});
});
/** 代理取图,返回 base64 data URL(结构化克隆可安全跨消息传递) */
async function fetchImageAsDataUrl(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const type = res.headers.get('content-type') || 'image/jpeg';
const buf = await res.arrayBuffer();
const bytes = new Uint8Array(buf);
// 分块转二进制串,避免超长参数列表
let bin = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return `data:${type};base64,${btoa(bin)}`;
}
+20
View File
@@ -0,0 +1,20 @@
// Content Script —— 注入 Ozon 商品页,暴露采集入口
import { scanCurrentPage } from '../../src/collector/scan';
export default defineContentScript({
matches: [
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*'
],
main() {
console.log('[Ozon Seller Kit] Content script loaded');
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
(window as any).__SellerHelperOzon = {
scan: scanCurrentPage,
};
console.log('[Ozon Seller Kit] 就绪。Console 可测: await window.__SellerHelperOzon.scan()');
},
});
+710
View File
@@ -0,0 +1,710 @@
import { createRoot } from 'react-dom/client';
import { useEffect, useMemo, useState } from 'react';
import {
App as AntdApp,
Alert,
Button,
Card,
Collapse,
ConfigProvider,
Form,
Input,
Row,
Col,
Space,
Tag,
Typography,
theme,
} from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
CloudUploadOutlined,
DownloadOutlined,
ScanOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import { cleanFilename } from '../../src/collector/url';
import { buildProduct, type TextEdits } from '../../src/export/builder';
import { chooseRootDir, writeProductFolder, type ExportResult } from '../../src/export/filesystem';
import { loadRootDir } from '../../src/export/idb';
import { buildMaterialsPayload } from '../../src/api/client';
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
const { Title, Text } = Typography;
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
function defaultSelection(result: ScanResult): Set<string> {
const sel = new Set<string>();
let detailCount = 0;
for (const img of result.images) {
if (img.groupKey === 'detail') {
if (detailCount < 3) sel.add(img.key);
detailCount++;
} else {
sel.add(img.key);
}
}
return sel;
}
function norm(s: string): string {
return s.toLowerCase().trim().replace(/[,:()()]/g, '');
}
/** 从参数表里抽出「包装重量 + 包装尺寸(长宽高)」,其余参数保留 */
function extractWeightAndDims(pairs: Array<{ key: string; value: string }>): {
weight: string;
dims: { l: string; w: string; h: string };
dimsUnit: 'mm' | 'cm';
remaining: Array<{ key: string; value: string }>;
} {
// 包装重量:优先「包装重量」,其次「重量 / вес」
const weightP =
pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装重量') || k.includes('вес упаковки');
}) ??
pairs.find((p) => {
const k = norm(p.key);
return k.includes('重量') || k.includes('вес');
});
// 分开的长/宽/高(用「长度/宽度/高度」而非「长/宽/高」,避免误匹配「长X宽x高」这种合并键)
const lenP = pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装长度') || k.includes('长度') || k.includes('длина');
});
const widP = pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装宽度') || k.includes('宽度') || k.includes('ширина');
});
const heiP = pairs.find((p) => {
const k = norm(p.key);
return k.includes('包装高度') || k.includes('高度') || k.includes('высота');
});
let l = lenP?.value ?? '';
let w = widP?.value ?? '';
let h = heiP?.value ?? '';
let dimsUnit: 'mm' | 'cm' = 'cm';
// 合并的「包装尺寸(长X宽x高),厘米 = 48*18*25」→ 拆分
let dimP: { key: string; value: string } | undefined;
if (!l && !w && !h) {
dimP = pairs.find((p) => norm(p.key).includes('包装尺寸'));
if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('размер') || norm(p.key).includes('габарит'));
if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('尺寸'));
if (dimP) {
const isMm = /(мм|mm|毫米)/.test(`${dimP.key} ${dimP.value}`.toLowerCase());
dimsUnit = isMm ? 'mm' : 'cm';
const nums = dimP.value.match(/\d+(?:[.,]\d+)?/g) ?? [];
if (nums.length >= 3) {
l = nums[0] ?? '';
w = nums[1] ?? '';
h = nums[2] ?? '';
}
}
} else {
const combined = `${lenP?.key ?? ''} ${lenP?.value ?? ''} ${widP?.value ?? ''} ${heiP?.value ?? ''}`.toLowerCase();
dimsUnit = /(мм|mm|毫米)/.test(combined) ? 'mm' : 'cm';
}
// 其余参数保留(去掉已抽走的重量/尺寸项)
const used = new Set([weightP, lenP, widP, heiP, dimP].filter(Boolean));
const remaining = pairs.filter((p) => !used.has(p));
return { weight: weightP?.value ?? '', dims: { l, w, h }, dimsUnit, remaining };
}
/** 调用页面里的采集入口,返回 ScanResult 或 null(未加载/不支持) */
async function scanTab(tabId: number): Promise<ScanResult | null> {
try {
const [r] = await chrome.scripting.executeScript({
target: { tabId },
func: () => (window as any).__SellerHelperOzon?.scan?.(),
});
return (r?.result as ScanResult) ?? null;
} catch {
return null;
}
}
/** 判断当前 tab 是否 Ozon 商品详情页 */
async function isProductPage(tabId: number): Promise<boolean> {
try {
const [r] = await chrome.scripting.executeScript({
target: { tabId },
func: () =>
/\/product\/[^/]+-\d+\/?/.test(location.pathname) ||
/\/context\/detail\/id\/\d+/.test(location.pathname),
});
return !!r?.result;
} catch {
return false;
}
}
function Panel() {
const { message } = AntdApp.useApp();
const { token } = theme.useToken();
const [form] = Form.useForm();
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const [result, setResult] = useState<ScanResult | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [params, setParams] = useState<Array<{ key: string; value: string }>>([]);
const [dimsUnit, setDimsUnit] = useState<'mm' | 'cm'>('cm');
const [folderName, setFolderName] = useState('');
const [rootLabel, setRootLabel] = useState('');
const [exporting, setExporting] = useState(false);
const [uploading, setUploading] = useState(false);
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
const [uploadResult, setUploadResult] = useState<{ product_id: string; stage: string; assets_queued: number } | null>(null);
// 服务端设置(上传用)
const [settings, setSettings] = useState<BackendSettings | null>(null);
const [baseUrl, setBaseUrl] = useState('http://127.0.0.1:8800');
const [appToken, setAppToken] = useState('');
useEffect(() => {
loadRootDir().then((h) => setRootLabel(h ? h.name : ''));
loadSettings().then((s) => {
setSettings(s);
setBaseUrl(s.baseUrl);
setAppToken(s.token);
});
}, []);
const groups = useMemo(() => {
if (!result) return [];
return GROUP_ORDER.map((g) => ({
...g,
items: result.images.filter((img) => img.groupKey === g.key),
})).filter((g) => g.items.length > 0);
}, [result]);
const selectedCount = useMemo(() => {
if (!result) return 0;
return result.images.filter((img) => selected.has(img.key)).length;
}, [result, selected]);
const onSaveSettings = async () => {
const s: BackendSettings = { baseUrl: baseUrl.trim(), token: appToken.trim() };
await saveSettings(s);
setSettings(s);
message.success('服务端设置已保存');
};
const handleScan = async () => {
setError('');
setResult(null);
setExportResult(null);
setUploadResult(null);
setStatus('采集中');
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
setError('无法获取当前标签页');
setStatus('');
return;
}
let data = await scanTab(tab.id);
// 内容脚本没加载(页面在插件安装/重载前就打开了)→ 手动注入后重试
if (!data) {
try {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content-scripts/content.js'],
});
await new Promise((r) => setTimeout(r, 300));
data = await scanTab(tab.id);
} catch {
/* 注入失败忽略,走下方错误提示 */
}
}
if (!data) {
const isProduct = await isProductPage(tab.id);
setError(
isProduct
? '采集失败:内容脚本未生效,请刷新商品页后重试'
: '当前页面不是 Ozon 商品详情页,请打开一个商品页后再采集',
);
setStatus('');
return;
}
setResult(data);
setSelected(defaultSelection(data));
setFolderName(cleanFilename(data.texts.find((t) => t.kind === 'title')?.content ?? '') || '');
const rawParams = data.texts.find((t) => t.kind === 'params')?.pairs ?? [];
const { weight, dims, dimsUnit: du, remaining } = extractWeightAndDims(rawParams);
setParams(remaining);
setDimsUnit(du);
// 填表单
form.setFieldsValue({
title: data.texts.find((t) => t.kind === 'title')?.content ?? '',
price: data.texts.find((t) => t.kind === 'price')?.content ?? '',
brand: data.texts.find((t) => t.kind === 'brand')?.content || '无品牌',
sellingPoints: data.texts.find((t) => t.kind === 'selling_point')?.content ?? '',
desc: data.texts.find((t) => t.kind === 'desc')?.content ?? '',
packWeight: weight,
packLen: dims.l,
packWidth: dims.w,
packHeight: dims.h,
});
setStatus('采集完成');
} catch (err) {
setError(`采集失败: ${err instanceof Error ? err.message : String(err)}`);
setStatus('');
}
};
const handlePickDir = async () => {
setError('');
try {
// 始终弹选择器(选择或更换目录)
const handle = await chooseRootDir();
setRootLabel(handle.name);
message.success(`已选择目录「${handle.name}`);
} catch (err) {
// 用户取消选择(AbortError)不算错误,静默处理
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
setError(`选择目录失败: ${err instanceof Error ? err.message : String(err)}`);
}
};
const toggleOne = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleGroup = (items: ImageMaterial[]) => {
setSelected((prev) => {
const next = new Set(prev);
const allOn = items.every((i) => next.has(i.key));
for (const i of items) {
if (allOn) next.delete(i.key);
else next.add(i.key);
}
return next;
});
};
const getEdits = (): TextEdits => {
const v = form.getFieldsValue();
return {
title: v.title,
price: v.price,
brand: v.brand,
sellingPoints: v.sellingPoints,
desc: v.desc,
params,
weight: v.packWeight,
dims: { l: v.packLen, w: v.packWidth, h: v.packHeight },
dimsUnit,
};
};
const handleExport = async () => {
if (!result) return;
if (selectedCount === 0) {
setError('请至少勾选一张图片');
return;
}
setExporting(true);
setError('');
setExportResult(null);
try {
const name = folderName.trim() || `ozon-${result.itemId ?? 'product'}`;
const built = buildProduct(result, selected, getEdits());
const res = await writeProductFolder(name, built.product, built.sources, built.files);
setExportResult(res);
message.success('已导出到本地');
} catch (err) {
setError(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setExporting(false);
}
};
const handleUpload = async () => {
if (!result) return;
if (selectedCount === 0) {
setError('请至少勾选一张图片');
return;
}
setUploading(true);
setError('');
setUploadResult(null);
try {
const payload = buildMaterialsPayload(result, selected, getEdits());
const resp = await chrome.runtime.sendMessage({
action: 'uploadMaterials',
baseUrl: settings?.baseUrl ?? 'http://127.0.0.1:8800',
token: settings?.token ?? '',
payload,
});
if (!resp?.ok) throw new Error(resp?.error ?? '上传失败');
setUploadResult(resp.data);
message.success('已上传服务端,进入采集箱');
} catch (err) {
setError(`上传失败: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setUploading(false);
}
};
return (
<div style={{ minHeight: '100vh' }}>
{/* 顶部固定:标题 + 采集按钮 */}
<div style={{ position: 'sticky', top: 0, zIndex: 20, background: '#f5f5f5', padding: '12px 12px 8px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ marginBottom: 8 }}>
<Title level={4} style={{ margin: 0 }}>
🪆
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
Ozon ·
</Text>
</div>
{/* 采集按钮 */}
<Button
type="primary"
block
size="large"
icon={<ScanOutlined />}
loading={status === '采集中'}
onClick={handleScan}
>
{status === '采集中' ? '采集中…' : '开始采集当前页'}
</Button>
</div>
<div style={{ padding: '0 12px 12px' }}>
{/* 服务端设置 */}
<Collapse
ghost
size="small"
style={{ marginTop: 8 }}
items={[
{
key: 'settings',
label: (
<Space size={4}>
<SettingOutlined />
<span style={{ fontSize: 12 }}></span>
</Space>
),
children: (
<div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}></Text>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://127.0.0.1:8800"
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>访 Token</Text>
<Input.Password
value={appToken}
onChange={(e) => setAppToken(e.target.value)}
placeholder="后续加账户体系时再填"
/>
</div>
<Button size="small" onClick={onSaveSettings}>
</Button>
</div>
),
},
]}
/>
{error && <Alert type="error" showIcon message={error} style={{ marginTop: 8 }} />}
{result && (
<>
<Card size="small" style={{ marginTop: 12 }} title="采集信息(可修改)">
<div style={{ marginBottom: 8 }}>
<Space wrap size={4}>
<Tag color="purple">{result.platform.toUpperCase()}</Tag>
{result.itemId && <Tag>{result.itemId}</Tag>}
<Tag color="blue">:{result.source}</Tag>
</Space>
</div>
<Form form={form} layout="vertical" size="small">
<Form.Item label="标题" name="title">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="价格" name="price">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="品牌" name="brand">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item label="包装重量" name="packWeight">
<Input placeholder="如 3.5 кг" />
</Form.Item>
<Form.Item label="包装尺寸(长 × 宽 × 高)">
<Space.Compact block>
<Form.Item name="packLen" noStyle>
<Input placeholder="长" />
</Form.Item>
<Form.Item name="packWidth" noStyle>
<Input placeholder="宽" />
</Form.Item>
<Form.Item name="packHeight" noStyle>
<Input placeholder="高" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item label="卖点" name="sellingPoints">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item label="描述" name="desc">
<Input.TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
{/* 参数表(左侧参数名只读,右侧参数值可编辑) */}
{params.length > 0 && (
<Collapse
ghost
size="small"
items={[
{
key: 'params',
label: <Text style={{ fontSize: 12 }}>{params.length} </Text>,
children: (
<div>
{params.map((p, i) => (
<Row key={i} gutter={8} align="middle" style={{ marginBottom: 6 }}>
<Col span={10}>
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all', display: 'block' }}>
{p.key || '—'}
</Text>
</Col>
<Col span={14}>
<Input
size="small"
value={p.value}
onChange={(e) => {
const next = [...params];
next[i] = { ...next[i], value: e.target.value };
setParams(next);
}}
/>
</Col>
</Row>
))}
</div>
),
},
]}
/>
)}
</Form>
</Card>
{/* 图片分组 */}
<Card size="small" style={{ marginTop: 12 }} title={`图片素材(已选 ${selectedCount} 张)`}>
{groups.map((g) => {
const allOn = g.items.every((i) => selected.has(i.key));
return (
<div key={g.key} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<Text strong style={{ fontSize: 12 }}>
{g.name} ({g.items.length})
</Text>
<a style={{ fontSize: 12 }} onClick={() => toggleGroup(g.items)}>
{allOn ? '取消全选' : '全选'}
</a>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{g.items.map((img) => {
const on = selected.has(img.key);
return (
<div
key={img.key}
onClick={() => toggleOne(img.key)}
style={{
position: 'relative',
width: 64,
height: 64,
border: on ? `2px solid ${token.colorPrimary}` : '1px solid #e0e0e0',
borderRadius: 8,
overflow: 'hidden',
cursor: 'pointer',
background: '#f5f5f5',
}}
>
{img.type === 'video' ? (
<div style={{ position: 'relative', width: '100%', height: '100%', background: '#eee' }}>
{img.thumbUrl && !/\.(mp4|webm|m3u8|mov|avi)(\?|$)/i.test(img.thumbUrl) ? (
<img src={img.thumbUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
) : null}
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.25)' }}>
<span style={{ color: '#fff', fontSize: 20, lineHeight: 1 }}></span>
</div>
</div>
) : (
<img src={img.thumbUrl} alt={img.variantName ?? g.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
)}
{on && (
<div style={{ position: 'absolute', inset: 0, background: `rgba(139,92,246,0.15)` }}>
<span style={{ position: 'absolute', top: 2, right: 5, color: token.colorPrimary, fontSize: 14 }}></span>
</div>
)}
{img.variantName && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0,0,0,0.5)', color: '#fff', fontSize: 9, padding: '1px 2px', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{img.variantName}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</Card>
{/* 警告 */}
{result.warnings.length > 0 && (
<div style={{ marginTop: 12 }}>
{result.warnings.map((w, i) => (
<Alert key={i} type="warning" showIcon message={w} style={{ marginBottom: 4 }} />
))}
</div>
)}
<div style={{ position: 'sticky', bottom: 0, background: '#f5f5f5', padding: '12px 0', zIndex: 10, marginTop: 12, borderTop: '1px solid #f0f0f0' }}>
{/* 本地文件夹名 + 导出/上传(固定在底部) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}></Text>
<Input
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
placeholder="留空用商品标题"
size="small"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontSize: 11, color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rootLabel ? `保存到:${rootLabel}` : '未选择保存目录'}
</div>
<Button size="small" onClick={handlePickDir}>
{rootLabel ? '更换目录' : '选择目录'}
</Button>
</div>
<Row gutter={8}>
<Col span={12}>
<Button
block
icon={<DownloadOutlined />}
onClick={handleExport}
loading={exporting}
disabled={selectedCount === 0}
>
</Button>
</Col>
<Col span={12}>
<Button
block
type="primary"
icon={<CloudUploadOutlined />}
onClick={handleUpload}
loading={uploading}
disabled={selectedCount === 0}
>
</Button>
</Col>
</Row>
{exportResult && (
<Alert
type={exportResult.failed.length ? 'warning' : 'success'}
showIcon
style={{ marginTop: 8 }}
message={`已写入 ${exportResult.written} 张图片到「${exportResult.folderName}${exportResult.failed.length ? `${exportResult.failed.length} 张失败` : ''}`}
/>
)}
{uploadResult && (
<Alert
type="success"
showIcon
style={{ marginTop: 8 }}
message={`已上传服务端,商品已进入采集箱(素材 ${uploadResult.assets_queued} 张,后台转存中)`}
/>
)}
</div>
</>
)}
{!result && (
<div style={{ marginTop: 16, padding: 12, background: '#fafafa', borderRadius: 8, fontSize: 12, color: '#888' }}>
<div>💡 使</div>
<ol style={{ margin: '4px 0 0 18px', padding: 0 }}>
<li> Ozon ru/kz/by</li>
<li></li>
<li> / </li>
</ol>
</div>
)}
</div>
</div>
);
}
function Root() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#8b5cf6',
borderRadius: 8,
},
}}
>
<AntdApp>
<Panel />
</AntdApp>
</ConfigProvider>
);
}
const root = createRoot(document.getElementById('root')!);
root.render(<Root />);
export default Root;
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>套娃采集助手</title>
<style>
/* 放大侧边栏宽度(Chrome side panel 受 min-width 约束) */
html, body, #root {
min-width: 460px;
margin: 0;
padding: 0;
}
body {
background: #f5f5f5;
}
/* 缩小表单各项上下间距 */
.ant-form-item {
margin-bottom: 6px;
}
.ant-form-item .ant-form-item-label {
padding-bottom: 2px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "taowa-collector",
"version": "0.2.0",
"type": "module",
"private": true,
"scripts": {
"dev": "wxt",
"build": "wxt build",
"zip": "wxt zip"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"antd": "^6.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/chrome": "^0.0.268",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/wicg-file-system-access": "^2023.10.7",
"typescript": "^5.5.3",
"wxt": "^0.19.0"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}
+4419
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
/**
* 用真实保存的 Ozon 页面验证采集逻辑(不依赖浏览器)
* 用法:先 esbuild 打包再 node 运行(见 README / 命令注释)
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { extractOzonState } from '../src/collector/ozon-state';
import { extractJsonLd } from '../src/collector/jsonld';
import { toOriginalUrl, toThumbUrl, pickBestFromSrcset } from '../src/collector/url';
class FakeEl {
id: string;
attrs: Record<string, string>;
constructor(id: string, attrs: Record<string, string>) {
this.id = id;
this.attrs = attrs;
}
getAttribute(name: string): string | null {
return this.attrs[name] ?? null;
}
get textContent(): string {
return this.attrs['text'] ?? '';
}
}
function buildFakeDoc(html: string) {
const stateEls: FakeEl[] = [];
const re = /<div\s+id="state-([^"]+)"\s+data-state='(.*?)'\s*>/gs;
let m: RegExpExecArray | null;
while ((m = re.exec(html))) {
stateEls.push(new FakeEl('state-' + m[1], { 'data-state': m[2] }));
}
const ldEls: FakeEl[] = [];
const re2 = /<script[^>]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/gs;
while ((m = re2.exec(html))) {
ldEls.push(new FakeEl('', { text: m[1] }));
}
return {
querySelectorAll(sel: string): FakeEl[] {
if (sel === 'div[id^="state-"]') return stateEls;
if (sel === 'script[type="application/ld+json"]') return ldEls;
return [];
},
};
}
function check(name: string, actual: unknown, expected: unknown): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
const ok = a === e;
console.log(`${ok ? '✅' : '❌'} ${name}`);
if (!ok) {
console.log(' expected:', e);
console.log(' actual :', a);
}
}
for (const fn of ['../reference/ozon1.html', '../reference/ozon2.html']) {
console.log(`\n========== ${fn} ==========`);
const html = readFileSync(join(process.cwd(), fn), 'utf8');
(globalThis as any).document = buildFakeDoc(html);
const state = extractOzonState();
console.log('state:', JSON.stringify({
title: state.title,
price: state.price,
originalPrice: state.originalPrice,
rating: state.rating,
reviewCount: state.reviewCount,
gallery: state.galleryImages.length,
videos: state.videos,
variants: state.skuVariants,
characteristics: state.characteristics,
}, null, 1));
const ld = extractJsonLd();
console.log('jsonld:', JSON.stringify(ld));
}
// ── URL 工具验证 ──
console.log('\n========== url tools ==========');
const rules = [
{ match: /\/wc\d+\//, replace: '/' },
{ match: /\/c\d+\//, replace: '/' },
{ match: /(?<!:)\/{2,}/g, replace: '/' },
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
];
check('wc1000 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/wc1000/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
check('wc140 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-q/wc140/9290076002.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-q/9290076002.jpg');
check('c50 → 原图', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/c50/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
check('原图不变', toOriginalUrl('https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg', rules), 'https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg');
check('带 query 尺寸', toOriginalUrl('https://cdn.x.com/a.jpg?width=200&h=300', rules), 'https://cdn.x.com/a.jpg');
check('缩略图', toThumbUrl('https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg'), 'https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg');
check('已带标记不再缩略', toThumbUrl('https://ir.ozone.ru/s3/multimedia-1-5/wc50/9290076089.jpg'), 'https://ir.ozone.ru/s3/multimedia-1-5/wc50/9290076089.jpg');
check('srcset 取最大', pickBestFromSrcset('https://ir.ozone.ru/s3/a/wc50/1.jpg 1x, https://ir.ozone.ru/s3/a/wc100/1.jpg 2x'), 'https://ir.ozone.ru/s3/a/wc100/1.jpg');
console.log('\n全部验证结束');
+104
View File
@@ -0,0 +1,104 @@
/**
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
* 契约对齐 server 端 /api/materials(见 docs/v2/api.md)。
*/
import type { ScanResult } from '../collector/scan';
import type { TextEdits } from '../export/builder';
export interface MaterialsPayload {
product_id: string | null;
source: {
platform: string;
itemId: string | null;
url: string;
collectedAt: number;
};
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
images: Array<{
groupKey: string;
groupName: string;
variantName?: string | null;
url: string;
index: number;
type: string;
dedupeKey?: string | null;
}>;
refererOrigin?: string;
}
/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */
export function buildMaterialsPayload(
result: ScanResult,
selectedKeys: Set<string>,
edits?: TextEdits,
): MaterialsPayload {
const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? '';
const title = edits?.title ?? text('title');
const price = edits?.price ?? text('price');
const brand = edits?.brand ?? text('brand');
const desc = edits?.desc ?? text('desc');
const sellingPoints = edits?.sellingPoints ?? text('selling_point');
const params: Array<{ key: string; value: string }> = [
...(edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs ?? []),
];
// 包装重量 / 包装尺寸合并进参数(后端存到 raw.paramsstudio 里再映射为 Ozon 字段)
if (edits?.weight) params.push({ key: '包装重量', value: edits.weight });
const dimSuffix = edits?.dimsUnit === 'mm' ? ' mm' : ' cm';
if (edits?.dims?.l) params.push({ key: '包装长度', value: `${edits.dims.l}${dimSuffix}` });
if (edits?.dims?.w) params.push({ key: '包装宽度', value: `${edits.dims.w}${dimSuffix}` });
if (edits?.dims?.h) params.push({ key: '包装高度', value: `${edits.dims.h}${dimSuffix}` });
const texts: MaterialsPayload['texts'] = [];
if (title) texts.push({ kind: 'title', content: title });
if (price) texts.push({ kind: 'price', content: price });
if (brand) texts.push({ kind: 'brand', content: brand });
if (params.length) texts.push({ kind: 'params', content: '', pairs: params });
if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints });
if (desc) texts.push({ kind: 'desc', content: desc });
const images = result.images
.filter((img) => selectedKeys.has(img.key))
.map((img) => ({
groupKey: img.groupKey,
groupName: img.groupName,
variantName: img.variantName ?? null,
url: img.url,
index: img.index,
type: img.type,
dedupeKey: img.url,
}));
return {
product_id: null,
source: {
platform: result.platform,
itemId: result.itemId,
url: result.url,
collectedAt: result.scannedAt,
},
texts,
images,
refererOrigin: 'https://www.ozon.ru',
};
}
export async function uploadMaterials(
baseUrl: string,
token: string,
payload: MaterialsPayload,
): Promise<{ product_id: string; stage: string; assets_queued: number }> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`; // 单用户宽松模式:token 可空
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
}
return data;
}
+54
View File
@@ -0,0 +1,54 @@
/**
* DOM 工具 - 等待元素、Shadow DOM 穿透
* 从 extension-v1 移植
*/
/** 等待任一选择器出现(MutationObserver + 超时) */
export function waitForAny(
selectors: string[],
timeoutMs = 10_000
): Promise<Element | null> {
const hit = () => selectors.map((s) => document.querySelector(s)).find(Boolean) ?? null;
const found = hit();
if (found) return Promise.resolve(found);
return new Promise((resolve) => {
const timer = setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeoutMs);
const observer = new MutationObserver(() => {
const el = hit();
if (el) {
clearTimeout(timer);
observer.disconnect();
resolve(el);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
});
}
/** 穿透 Shadow DOM 查询元素(Ozon 部分组件用了 Web Components */
export function queryAllDeep(selectors: string[]): Element[] {
const out: Element[] = [];
for (const sel of selectors) {
let nodes: NodeListOf<Element>;
try {
nodes = document.querySelectorAll(sel);
} catch {
continue; // 选择器写错不能拖垮整个扫描
}
nodes.forEach((el) => {
if (el.shadowRoot) {
out.push(...Array.from(el.shadowRoot.querySelectorAll('img, video, source')));
} else {
out.push(el);
}
});
}
return out;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 图片提取 - 主图、SKU、详情图、视频
* 从 extension-v1 移植,新增:
* - srcset 处理(Ozon 画廊是 <img srcset> / <picture><source>
* - toOriginalUrl 传平台规则(Ozon /wc\d+/
*/
import {
toAbsoluteUrl,
toOriginalUrl,
urlInBrackets,
looksLikeImageUrl,
dedupeKey,
pickBestFromSrcset,
} from './url';
import { queryAllDeep } from './dom';
import type { ImageGroupKey, SiteProfile, SrcProp } from '../profiles/types';
export interface ImageMaterial {
key: string; // 'main-001'
groupKey: ImageGroupKey; // 'main'
groupName: string; // '主图'
variantName?: string; // SKU 规格名(仅 sku 组)
url: string; // 已还原为原图
thumbUrl: string; // 页面上的原始小图地址
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
/** 从元素上读出图片地址与名称,按 srcProps 顺序降级 */
function readImageSource(
el: Element,
srcProps: SrcProp[],
nameSelectors?: string[]
): { url: string; name: string; imgEl: HTMLImageElement | null } {
let url = '';
let name = '';
let imgEl: HTMLImageElement | null = el instanceof HTMLImageElement ? el : null;
for (const prop of srcProps) {
if (url) break;
if (prop === 'backgroundImage') {
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
url = img.currentSrc || img.src || '';
name = img.alt || '';
} else {
const bg = getComputedStyle(el).backgroundImage || '';
const cand = (urlInBrackets(bg) || bg).replace(/['"]/g, '');
if (looksLikeImageUrl(cand)) url = cand;
}
continue;
}
if (prop === 'srcset') {
// <img srcset> 或 <source srcset>
const raw = el.getAttribute('srcset') || (el as any).srcset || '';
if (raw) url = pickBestFromSrcset(raw);
continue;
}
const raw = (el as any)[prop] || el.getAttribute(prop);
if (raw) {
// srcset 场景下 currentSrc 才是实际加载的那张
url = prop === 'src' ? ((el as HTMLImageElement).currentSrc || (el as HTMLImageElement).src || '') : raw;
}
}
// 选择器命中的是容器、图在子节点上
if (!url && el.tagName !== 'IMG') {
const inner = el.querySelector('img, source');
if (inner) {
const srcset = inner.getAttribute('srcset');
url = srcset
? pickBestFromSrcset(srcset)
: inner.getAttribute('data-src') || (inner as HTMLImageElement).currentSrc || (inner as HTMLImageElement).src || '';
if (inner instanceof HTMLImageElement) imgEl = inner;
if (!name && inner instanceof HTMLImageElement) name = inner.alt || '';
}
}
// 名称统一取(SKU 规格名)
if (!name && nameSelectors?.length) {
for (const sel of nameSelectors) {
const t = el.querySelector(sel)?.textContent?.trim();
if (t) {
name = t;
break;
}
}
}
return { url: url ? toAbsoluteUrl(url) : '', name, imgEl };
}
export function collectImages(profile: SiteProfile): ImageMaterial[] {
const result: ImageMaterial[] = [];
for (const group of profile.imageGroups) {
const srcProps = group.srcProps ?? profile.defaultSrcProps;
// 去重按组独立:一张图同时是主图和 SKU 图是正常的
const seen = new Set<string>();
const activeSet = new Set(group.activeSelectors ? queryAllDeep(group.activeSelectors) : []);
for (const el of queryAllDeep(group.selectors)) {
if (activeSet.has(el)) continue;
if (group.excludeWithin?.some((sel) => el.closest(sel))) continue;
const { url: rawUrl, name, imgEl } = readImageSource(el, srcProps, group.nameSelectors);
if (!rawUrl) continue;
if (group.type === 'video' && !/\.(mp4|avi|mov|wmv|m3u8|webm)(\?|$)/i.test(rawUrl) && !/^blob:/i.test(rawUrl)) {
continue;
}
const url = group.type === 'img' ? toOriginalUrl(rawUrl, profile.originalUrlRules) : rawUrl;
// 尺寸过滤
if (group.type === 'img' && (group.minWidth || group.minHeight)) {
const measured = imgEl ?? (el as HTMLElement);
const w = (measured as HTMLImageElement).naturalWidth || (measured as HTMLElement).offsetWidth || 0;
const h = (measured as HTMLImageElement).naturalHeight || (measured as HTMLElement).offsetHeight || 0;
if (w > 0 && h > 0 && (w < (group.minWidth ?? 0) || h < (group.minHeight ?? 0))) continue;
}
const k = group.key === 'sku' ? `${dedupeKey(url, profile.originalUrlRules)}::${name}` : dedupeKey(url, profile.originalUrlRules);
if (seen.has(k)) continue;
seen.add(k);
result.push({
key: `${group.key}-${String(result.filter((r) => r.groupKey === group.key).length + 1).padStart(3, '0')}`,
groupKey: group.key,
groupName: group.name,
variantName: group.key === 'sku' ? name || undefined : undefined,
url,
thumbUrl: rawUrl,
index: result.length,
type: group.type,
});
}
}
return result;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* JSON-LD 提取器(schema.org/Product
*
* Ozon 是 SSR 站点,商品页 HTML 里带 application/ld+json
* 是 DOM 之外最稳定的结构化来源(比哈希类名稳定一个数量级)。
*
* 参考实现(毛子ERP)也解析 application/ld+json 取 description / offers.url。
*/
export interface JsonLdProduct {
title?: string;
description?: string;
brand?: string;
sku?: string;
price?: string;
currency?: string;
images: string[];
rating?: string;
reviewCount?: string;
}
function asString(v: unknown): string | undefined {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
return undefined;
}
function findProduct(node: unknown): any | null {
if (Array.isArray(node)) {
for (const item of node) {
const r = findProduct(item);
if (r) return r;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const obj = node as Record<string, unknown>;
const type = obj['@type'];
const types = Array.isArray(type) ? type : [type];
if (types.some((t) => t === 'Product')) return obj;
// @graph 包裹
if (Array.isArray(obj['@graph'])) {
for (const g of obj['@graph']) {
const r = findProduct(g);
if (r) return r;
}
}
return null;
}
function collectImages(node: unknown, out: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (/^(https?:)?\/\/.+/i.test(node) && !out.includes(node)) out.push(node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectImages(n, out));
return;
}
if (typeof node === 'object') {
for (const v of Object.values(node as Record<string, unknown>)) {
collectImages(v, out);
}
}
}
export function extractJsonLd(): JsonLdProduct | null {
try {
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of Array.from(scripts)) {
const text = script.textContent?.trim();
if (!text) continue;
let data: unknown;
try {
data = JSON.parse(text);
} catch {
continue;
}
const product = findProduct(data);
if (!product) continue;
const offers = Array.isArray(product.offers) ? product.offers[0] : product.offers;
const brandName = product.brand?.name ?? (typeof product.brand === 'string' ? product.brand : undefined);
const images: string[] = [];
if (product.image) collectImages(product.image, images);
return {
title: asString(product.name),
description: asString(product.description),
brand: asString(brandName),
sku: asString(product.sku),
price: asString(offers?.price),
currency: asString(offers?.priceCurrency),
images,
rating: asString(product.aggregateRating?.ratingValue),
reviewCount: asString(product.aggregateRating?.reviewCount),
};
}
} catch (err) {
console.warn('[JSON-LD] 提取失败:', err);
}
return null;
}
+283
View File
@@ -0,0 +1,283 @@
/**
* Ozon 内部页 JSON API 提取器(补充路径)
*
* 参考实现(毛子ERP)的采集核心是直接请求 Ozon 自己的页数据接口:
*
* GET {origin}/api/entrypoint-api.bx/page/json/v2?url=/product/{id}/
* → { widgetStates: { "webCharacteristics-…": "...", "webGallery-…": "...", ... } }
*
* ★ 关键点(毛子ERP 的做法,也是本文件修复点):
* - 默认页 `/product/{id}/` 里带 **webCharacteristics(全量「特征」)**
* SSR 里的 webShortCharacteristics 只给前 5 项(limit:5)。
* - 描述页 `/product/{id}/?layout_container=pdpPage2column&layout_page_index=2`
* 里带 webDescription(富文本描述)。
* 所以要两个 URL 都请求、合并,才能拿到完整参数表 + 描述。
*
* ★ 图片只从画廊类 widget 收(白名单),绝不递归全部 widgetStates
* 避免「为您推荐 / 一起购买」等 carousel 图混入。
*/
export interface OzonPageData {
title?: string;
price?: string;
oldPrice?: string;
description?: string;
/** 主图画廊(仅来自画廊 widget) */
images: string[];
videos: string[];
/** 参数表(kv */
characteristics: Array<{ key: string; value: string }>;
}
const IMG_EXT = /\.(jpg|jpeg|png|webp|gif|avif)(\?|$)/i;
const VID_EXT = /\.(mp4|m3u8|webm|mov)(\?|$)/i;
function parseWidgetState(v: unknown): unknown {
if (typeof v !== 'string') return v;
try {
return JSON.parse(v);
} catch {
return v;
}
}
function parseWidgetStates(widgetStates: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (!widgetStates || typeof widgetStates !== 'object') return out;
for (const [k, v] of Object.entries(widgetStates as Record<string, unknown>)) {
out[k] = parseWidgetState(v);
}
return out;
}
function pushUnique(arr: string[], v: string): void {
if (v && !arr.includes(v)) arr.push(v);
}
/** 递归收集画廊 widget 内的图片/视频 URL(只在这个 widget 内走) */
function collectMedia(node: unknown, images: string[], videos: string[]): void {
if (!node) return;
if (typeof node === 'string') {
if (IMG_EXT.test(node)) pushUnique(images, node);
else if (VID_EXT.test(node)) pushUnique(videos, node);
return;
}
if (Array.isArray(node)) {
node.forEach((n) => collectMedia(n, images, videos));
return;
}
if (typeof node !== 'object') return;
for (const v of Object.values(node as Record<string, unknown>)) {
collectMedia(v, images, videos);
}
}
/** 从 characteristic 类 widget 里收参数表 */
function collectCharacteristics(node: unknown, out: Array<{ key: string; value: string }>): void {
if (!node || typeof node !== 'object') return;
const walk = (n: unknown): void => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
const obj = n as Record<string, unknown>;
for (const [k, v] of Object.entries(obj)) {
if (/characteristic|aspect/i.test(k) && Array.isArray(v)) {
for (const row of v) {
if (!row || typeof row !== 'object') continue;
const r = row as Record<string, unknown>;
// { title: {textRs:[{content}]}, values:[{text}] }Ozon 实测结构)
const key = readText(r.title);
if (key && Array.isArray(r.values)) {
const vals = r.values
.map((x) => (x && typeof x === 'object' ? readText((x as Record<string, unknown>).text) : ''))
.filter(Boolean);
if (vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// { key/value } / { name/value } / { title/text }
const k2 = (r.key ?? r.name ?? r.title) as string | undefined;
const v2 = (r.value ?? r.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
} else if (/characteristic|aspect/i.test(k) && typeof v === 'object') {
walk(v);
}
}
};
walk(node);
}
function readText(node: unknown): string {
if (!node) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
// { textRs: [{ type, content }] } / { content } / { text }
const obj = node as Record<string, unknown>;
if (Array.isArray(obj.textRs)) {
return obj.textRs
.map((t) => (t && typeof t === 'object' ? (t as Record<string, unknown>).content ?? '' : ''))
.join('')
.trim();
}
if (typeof obj.content === 'string') return obj.content.trim();
if (typeof obj.text === 'string') return obj.text.trim();
return '';
}
/** 从描述类 widget 里收富文本描述 */
function collectDescription(node: unknown, out: { description?: string }): void {
if (!node || typeof node !== 'object') return;
const obj = node as Record<string, unknown>;
if (typeof obj.richAnnotationJson === 'string') {
try {
const rich = JSON.parse(obj.richAnnotationJson);
out.description = richToString(rich);
} catch {
out.description = obj.richAnnotationJson;
}
return;
}
if (typeof obj.description === 'string') {
out.description = obj.description;
return;
}
}
/** richAnnotationJson(富文本块数组)→ 纯文本 */
function richToString(rich: unknown): string {
if (!rich) return '';
if (typeof rich === 'string') return rich;
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'text' && typeof v === 'string') texts.push(v);
else if (k !== 'type') walk(v);
}
}
};
walk(rich);
return texts.join('\n').trim();
}
/** 解析单个 widgetStates → 部分 OzonPageData */
function parsePage(widgets: Record<string, unknown>): OzonPageData {
const images: string[] = [];
const videos: string[] = [];
const characteristics: Array<{ key: string; value: string }> = [];
const desc: { description?: string } = {};
let title: string | undefined;
let price: string | undefined;
let oldPrice: string | undefined;
for (const [wkey, wval] of Object.entries(widgets)) {
const key = wkey.toLowerCase();
// 图片/视频:只收主画廊 widgetwebGallery),
// 不能按 "gallery" 子串匹配 —— webReviewGallery 是「买家照片和视频」,会混入
if (key.startsWith('webgallery')) {
collectMedia(wval, images, videos);
}
// 参数表(含全量 webCharacteristics
if (/(characteristic|aspect)/.test(key)) {
collectCharacteristics(wval, characteristics);
}
// 描述
if (/(description|richcontent)/.test(key)) {
collectDescription(wval, desc);
}
// 标题 / 价格(各自的 widget)
if (/heading|title/.test(key) && !title) {
const v = (wval as Record<string, unknown>)?.title ?? (wval as Record<string, unknown>)?.name;
if (typeof v === 'string' && v && !/^https?:/i.test(v)) title = v;
}
if (/webprice/.test(key) && !price) {
const p = (wval as Record<string, unknown>)?.price;
if (typeof p === 'string') price = p;
const op = (wval as Record<string, unknown>)?.originalPrice;
if (typeof op === 'string') oldPrice = op;
}
}
return {
title,
price,
oldPrice,
description: desc.description,
images,
videos,
characteristics: dedupePairs(characteristics),
};
}
async function fetchPage(url: string): Promise<Record<string, unknown> | null> {
try {
const res = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const json = (await res.json()) as { widgetStates?: unknown };
return parseWidgetStates(json.widgetStates);
} catch (err) {
console.warn('[Ozon API] 请求失败:', url, err);
return null;
}
}
export async function fetchOzonPageData(itemId: string): Promise<OzonPageData | null> {
// 默认页(标题/价格/画廊 + 全量特征 webCharacteristics+ 描述页(富文本描述)
const urls = [
`/product/${itemId}/`,
`/product/${itemId}/?layout_container=pdpPage2column&layout_page_index=2`,
];
const merged: OzonPageData = { images: [], videos: [], characteristics: [] };
let gotAny = false;
for (const target of urls) {
const widgets = await fetchPage(
`${location.origin}/api/entrypoint-api.bx/page/json/v2?url=${encodeURIComponent(target)}`,
);
if (!widgets) continue;
const p = parsePage(widgets);
gotAny = true;
merged.title = merged.title || p.title;
merged.price = merged.price || p.price;
merged.oldPrice = merged.oldPrice || p.oldPrice;
merged.description = merged.description || p.description;
for (const img of p.images) if (!merged.images.includes(img)) merged.images.push(img);
for (const v of p.videos) if (!merged.videos.includes(v)) merged.videos.push(v);
for (const c of p.characteristics) merged.characteristics.push(c);
}
merged.characteristics = dedupePairs(merged.characteristics);
return gotAny &&
(merged.images.length || merged.title || merged.price || merged.characteristics.length || merged.description)
? merged
: null;
}
function dedupePairs(pairs: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> {
const seen = new Set<string>();
const out: Array<{ key: string; value: string }> = [];
for (const p of pairs) {
const k = `${p.key}::${p.value}`;
if (seen.has(k)) continue;
seen.add(k);
out.push(p);
}
return out;
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Ozon SSR widget state 提取器(主路径)
*
* Ozon 页面把每个 widget 的 JSON state 内嵌在 DOM 里:
* <div id="state-webGallery-3311626-default-1" data-state='{...}'>
* content script 直接读 data-state 即可,无需访问页面 JSmain world)。
*
* 结构已在真实页面实测(reference/ozon1.html、ozon2.html):
* - webGallery: coverImage / images[{src,alt}](原图)/ videos[{url,coverUrl}]
* - webPrice: price / originalPrice / cardPrice(如 "108,26 ¥"
* - webProductHeading: title
* - webShortCharacteristics / webDetailedCharacteristics: characteristics[]
* - webAspects: aspects[].variants[].data.{searchableText, coverImage}SKU 变体)
* - webReviewProductScore: totalScore / reviewsCount
*
* ★ 白名单机制:只读上面这几个 widget 的 state。
* 绝不遍历全页 —— "为您推荐 / 一起购买" 等其它商品 carousel 的 state
* webRecommendedProducts / webCarousel / 类似 widget)根本不会被读到。
*/
import { toAbsoluteUrl } from './url';
export interface OzonVariant {
name: string;
image?: string; // 可能为 undefined(纯文字规格,如尺码)
}
export interface BreadcrumbItem {
name: string; // 类目名称(如"扑满"、"儿童房"
href: string; // 原始链接(/category/kopilki-15056/ 或 ?category=7041
searchCategoryId?: number; // Ozon 搜索类目 ID(从 ?category=xxx 解析)
slug?: string; // URL slug(从 /category/xxx-123/ 解析,含数字 ID
}
export interface OzonStateData {
title?: string;
price?: string;
originalPrice?: string;
rating?: string;
reviewCount?: string;
galleryImages: string[]; // 原图(无尺寸标记)
videos: string[];
videoCovers: string[];
skuVariants: OzonVariant[];
characteristics: Array<{ key: string; value: string }>;
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径
}
/** 允许读取的 widget 前缀白名单 */
const ALLOWED_WIDGETS = [
'webGallery-',
'webPrice-',
'webProductHeading-',
'webShortCharacteristics-',
'webDetailedCharacteristics-',
'webCharacteristics-',
'webAspects-',
'webReviewProductScore-',
'breadCrumbs-', // 面包屑类目路径
];
function pushUnique(arr: string[], v: string): void {
const abs = toAbsoluteUrl(v);
if (abs && !arr.includes(abs)) arr.push(abs);
}
function readTextRs(node: unknown): string {
// 提取 textRs / descriptionRs 里的展示文本。
// 规则:content/text 字段的值收进文本;递归进入数组/对象找嵌套的 content/text
// 跳过 type/font/color/id/href 等样式与元数据字段(type=newLine 除外)。
if (node == null) return '';
if (typeof node === 'string') return node.trim();
if (typeof node !== 'object') return '';
const texts: string[] = [];
const walk = (n: unknown): void => {
if (!n) return;
if (typeof n === 'string') {
texts.push(n);
return;
}
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (typeof n === 'object') {
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
if (k === 'type' && (v === 'newLine' || v === 'lineBreak')) {
texts.push('\n');
} else if (k === 'content' || k === 'text') {
walk(v);
} else if (v && typeof v === 'object') {
walk(v);
}
// 其它原始值(font/color/id/type='text' 等)直接跳过
}
}
};
walk(node);
return texts.join('').trim();
}
function parseCharacteristics(chars: unknown): Array<{ key: string; value: string }> {
if (!Array.isArray(chars)) return [];
const out: Array<{ key: string; value: string }> = [];
for (const c of chars) {
if (!c || typeof c !== 'object') continue;
const row = c as Record<string, unknown>;
// 结构 A{ title: { textRs: [...] }, values: [{ text: ... }] }(实测)
const key = readTextRs(row.title);
if (Array.isArray(row.values)) {
const vals = row.values
.map((v) => (v && typeof v === 'object' ? readTextRs((v as Record<string, unknown>).text) : ''))
.map((t) => t.replace(/,\s*$/, '')) // 源数据值自带尾逗号(如 "音乐, "
.filter(Boolean);
if (key && vals.length) out.push({ key, value: vals.join(', ') });
continue;
}
// 结构 B{ key, value } / { name, value } / { title, text }
const k2 = (row.key ?? row.name ?? row.title) as string | undefined;
const v2 = (row.value ?? row.text) as string | undefined;
if (typeof k2 === 'string' && k2 && typeof v2 === 'string' && v2) {
out.push({ key: k2, value: v2 });
}
}
return out;
}
export function extractOzonState(): OzonStateData {
const data: OzonStateData = {
galleryImages: [],
videos: [],
videoCovers: [],
skuVariants: [],
characteristics: [],
breadcrumbs: [],
};
const seenChars = new Set<string>();
const els = document.querySelectorAll('div[id^="state-"]');
for (const el of Array.from(els)) {
const id = el.id.slice('state-'.length);
if (!ALLOWED_WIDGETS.some((p) => id.startsWith(p))) continue;
const raw = el.getAttribute('data-state');
if (!raw) continue;
let state: unknown;
try {
state = JSON.parse(raw);
} catch {
continue;
}
if (!state || typeof state !== 'object') continue;
const s = state as Record<string, unknown>;
if (id.startsWith('webGallery-')) {
if (typeof s.coverImage === 'string') pushUnique(data.galleryImages, s.coverImage);
if (Array.isArray(s.images)) {
for (const img of s.images) {
const src = img && typeof (img as Record<string, unknown>).src === 'string'
? (img as Record<string, unknown>).src as string
: undefined;
if (src) pushUnique(data.galleryImages, src);
}
}
if (Array.isArray(s.videos)) {
for (const v of s.videos) {
const rec = v as Record<string, unknown>;
if (typeof rec.url === 'string') pushUnique(data.videos, rec.url);
if (typeof rec.coverUrl === 'string') pushUnique(data.videoCovers, rec.coverUrl);
}
}
} else if (id.startsWith('webPrice-')) {
if (typeof s.price === 'string') data.price = s.price;
if (typeof s.originalPrice === 'string') data.originalPrice = s.originalPrice;
if (!data.price && typeof s.cardPrice === 'string') data.price = s.cardPrice;
} else if (id.startsWith('webProductHeading-')) {
if (typeof s.title === 'string') data.title = s.title;
} else if (
id.startsWith('webShortCharacteristics-') ||
id.startsWith('webDetailedCharacteristics-') ||
id.startsWith('webCharacteristics-')
) {
for (const c of parseCharacteristics(s.characteristics)) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
data.characteristics.push(c);
}
}
} else if (id.startsWith('webAspects-')) {
if (Array.isArray(s.aspects)) {
for (const aspect of s.aspects) {
const a = aspect as Record<string, unknown>;
if (!Array.isArray(a.variants)) continue;
for (const v of a.variants) {
const rec = v as Record<string, unknown>;
const d = rec.data as Record<string, unknown> | undefined;
const name = typeof d?.searchableText === 'string' ? d.searchableText
: typeof d?.title === 'string' ? d.title : '';
const image = typeof d?.coverImage === 'string' ? d.coverImage : undefined;
if (name) data.skuVariants.push({ name, image });
}
}
}
} else if (id.startsWith('webReviewProductScore-')) {
if (typeof s.totalScore === 'number') data.rating = String(s.totalScore);
if (typeof s.reviewsCount === 'number') data.reviewCount = String(s.reviewsCount);
} else if (id.startsWith('breadCrumbs-')) {
// breadCrumbs widget state: { breadcrumbs: [{text, link, crumbType}] }
if (Array.isArray(s.breadcrumbs) && data.breadcrumbs.length === 0) {
for (const crumb of s.breadcrumbs) {
const c = crumb as Record<string, unknown>;
const name = typeof c.text === 'string' ? c.text.trim() : '';
const href = typeof c.link === 'string' ? c.link : '';
if (!name || !href) continue;
// 解析 ?category=7041highlight 样式链接)
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
// 解析 /category/kopilki-15056/(末尾带数字 ID 的 slug
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
}
// 如果 widget state 没有面包屑(旧版页面),尝试读 DOM 渲染的 ol
if (data.breadcrumbs.length === 0) {
const ol = document.querySelector('[class*="breadCrumbs"] ol, nav ol, ol[class*="breadcrumb"]');
if (ol) {
for (const a of Array.from(ol.querySelectorAll('a[href]'))) {
const href = a.getAttribute('href') ?? '';
const name = a.textContent?.trim() ?? '';
if (!name) continue;
const catMatch = href.match(/[?&]category=(\d+)/);
const searchCategoryId = catMatch ? Number(catMatch[1]) : undefined;
const slugMatch = href.match(/\/category\/([^/?]+)/);
const slug = slugMatch ? slugMatch[1] : undefined;
data.breadcrumbs.push({ name, href, searchCategoryId, slug });
}
}
}
return data;
}
+278
View File
@@ -0,0 +1,278 @@
/**
* 采集引擎入口 - 扫描当前页
*
* Ozon 四路径(优先级从高到低):
* ① SSR widget stateDOM data-state 属性,同步、白名单、无需网络)★ 主路径
* ② JSON-LDschema.org/Product
* ③ Ozon 内部页 JSON APIentrypoint-api.bx,只收画廊 widget 的图)
* ④ DOM 选择器(data-widget 区块)—— 兜底 + 详情图补充
*
* ① 白名单保证不会读到「为您推荐 / 一起购买」等其它商品 carousel 的图片。
*/
import { matchProfile } from '../profiles';
import { waitForAny } from './dom';
import { collectImages, type ImageMaterial } from './image';
import { collectTexts, mergeTexts, type TextMaterial } from './text';
import { extractJsonLd } from './jsonld';
import { fetchOzonPageData, type OzonPageData } from './ozon-api';
import { extractOzonState, type OzonStateData } from './ozon-state';
import { dedupeKey, toOriginalUrl, toThumbUrl } from './url';
import type { SiteProfile } from '../profiles/types';
export type { ImageMaterial, TextMaterial };
import type { BreadcrumbItem } from './ozon-state';
export interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
breadcrumbs: BreadcrumbItem[]; // 面包屑类目路径(用于 studio 类目推荐)
scannedAt: number;
stats: Record<string, number>; // 分组统计
warnings: string[]; // 警告(如详情图为 0
source: 'state' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径
}
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图片' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
/** 合并后的结构化素材 */
interface StructuredBundle {
title?: string;
price?: string;
brand?: string;
description?: string;
characteristics: Array<{ key: string; value: string }>;
galleryImages: string[];
videos: string[];
videoCovers: string[];
skuVariants: Array<{ name: string; image?: string }>;
}
/** 合并 state + JSON-LD + API,靠前来源优先,靠后来源填空缺 */
function mergeStructured(
state: OzonStateData,
jsonld: ReturnType<typeof extractJsonLd>,
api: OzonPageData | null
): StructuredBundle {
const bundle: StructuredBundle = {
title: state.title || jsonld?.title || api?.title,
price: state.price || jsonld?.price || api?.price,
brand: jsonld?.brand,
description: api?.description || jsonld?.description,
characteristics: [...state.characteristics],
galleryImages: [...state.galleryImages],
videos: [...state.videos],
videoCovers: [...state.videoCovers],
skuVariants: [...state.skuVariants],
};
// API 补充:画廊图片、视频、参数(state 没有才补)
for (const u of api?.images ?? []) {
if (!bundle.galleryImages.includes(u)) bundle.galleryImages.push(u);
}
for (const u of api?.videos ?? []) {
if (!bundle.videos.includes(u)) bundle.videos.push(u);
}
const seenChars = new Set(bundle.characteristics.map((c) => `${c.key}::${c.value}`));
for (const c of api?.characteristics ?? []) {
const k = `${c.key}::${c.value}`;
if (!seenChars.has(k)) {
seenChars.add(k);
bundle.characteristics.push(c);
}
}
return bundle;
}
/** 从合并后的结构化素材构建文本与图片 */
function buildFromBundle(profile: SiteProfile, bundle: StructuredBundle): {
texts: TextMaterial[];
images: ImageMaterial[];
} {
const texts: TextMaterial[] = [];
const images: ImageMaterial[] = [];
if (bundle.title) texts.push({ kind: 'title', content: bundle.title });
if (bundle.price) texts.push({ kind: 'price', content: bundle.price });
if (bundle.brand) texts.push({ kind: 'brand', content: bundle.brand });
if (bundle.characteristics.length) {
texts.push({
kind: 'params',
content: bundle.characteristics.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs: bundle.characteristics,
});
}
if (bundle.description) texts.push({ kind: 'desc', content: bundle.description });
let idx = 0;
bundle.galleryImages.forEach((u, i) => {
const orig = toOriginalUrl(u, profile.originalUrlRules);
images.push({
key: `main-${String(i + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: orig,
thumbUrl: toThumbUrl(orig),
index: idx++,
type: 'img',
});
});
bundle.skuVariants.forEach((s, i) => {
if (!s.image) return;
const orig = toOriginalUrl(s.image, profile.originalUrlRules);
images.push({
key: `sku-${String(i + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: s.name || undefined,
url: orig,
thumbUrl: toThumbUrl(orig),
index: idx++,
type: 'img',
});
});
bundle.videos.forEach((u, i) => {
images.push({
key: `video-${String(i + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: u,
// 用视频封面图做缩略图(首帧),拿不到再留空走 ▶ 占位
thumbUrl: bundle.videoCovers[i] ?? '',
index: idx++,
type: 'video',
});
});
return { texts, images };
}
/** 按组分组合并:结构化优先,DOM 填缺,按 dedupeKey 去重后重排 index */
function mergeImages(
structured: ImageMaterial[],
dom: ImageMaterial[],
profile: SiteProfile
): ImageMaterial[] {
const byGroup = new Map<string, ImageMaterial[]>();
const seen = new Set<string>();
let counter = 0;
const push = (m: ImageMaterial) => {
const k = m.groupKey === 'sku'
? `${dedupeKey(m.url, profile.originalUrlRules)}::${m.variantName ?? ''}`
: dedupeKey(m.url, profile.originalUrlRules);
if (seen.has(k)) return;
seen.add(k);
const arr = byGroup.get(m.groupKey) ?? [];
arr.push({ ...m, index: counter++ });
byGroup.set(m.groupKey, arr);
};
for (const m of structured) push(m);
for (const m of dom) push(m);
const out: ImageMaterial[] = [];
for (const g of GROUP_ORDER) {
const arr = byGroup.get(g.key);
if (!arr) continue;
// 组内重排 keymain-001 …)
arr.forEach((m, i) => {
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
m.groupName = g.name;
});
out.push(...arr);
}
return out;
}
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
if (!profile) {
console.warn('[Ozon Seller Kit] 当前页面不支持采集:', location.href);
return null;
}
const itemId = profile.extractItemId(location.href);
console.log('[Ozon Seller Kit] 开始采集:', profile.name, itemId, location.href);
// ── 路径①:SSR widget state(同步、白名单)──
const state = extractOzonState();
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
// ── 路径②:JSON-LD ──
const jsonld = extractJsonLd();
// ── 路径③:Ozon 页 JSON API(异步,失败不阻塞)──
let api: OzonPageData | null = null;
if (profile.id === 'ozon' && itemId) {
try {
api = await fetchOzonPageData(itemId);
} catch (err) {
console.warn('[Ozon Seller Kit] API 提取异常:', err);
}
}
const bundle = mergeStructured(state, jsonld, api);
const structured = buildFromBundle(profile, bundle);
const usedStructured = structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0;
if (usedStructured && source === 'dom') source = 'mixed';
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) {
console.warn('[Ozon Seller Kit] 等待页面就绪超时(继续尝试 DOM 采集)');
}
const domTexts = collectTexts(profile).materials;
const domImages = collectImages(profile);
// ── 合并 ──
const texts = mergeTexts(structured.texts, domTexts);
const images = mergeImages(structured.images, domImages, profile);
const stats: Record<string, number> = {};
for (const img of images) stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
const warnings: string[] = [];
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题(所有路径均失败)');
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
console.log('[Ozon Seller Kit] 采集完成:', {
texts: texts.map((t) => t.kind),
images: images.length,
stats,
warnings,
source,
});
return {
platform: profile.id,
itemId,
url: location.href,
texts,
images,
breadcrumbs: state.breadcrumbs,
scannedAt: Date.now(),
stats,
warnings,
source,
};
}
// 暴露到全局供 side panel / console 调用
if (typeof window !== 'undefined') {
(window as any).__SellerHelperOzon = {
scan: scanCurrentPage,
};
}
+110
View File
@@ -0,0 +1,110 @@
/**
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
* 从 extension-v1 移植(DOM 兜底路径)
*/
import type { SiteProfile, TextRule } from '../profiles/types';
export interface TextMaterial {
kind: TextRule['kind'];
content: string;
pairs?: Array<{ key: string; value: string }>; // table 模式的结构化结果
}
function clean(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
function extractOne(rule: TextRule): TextMaterial | null {
for (const sel of rule.selectors) {
let nodes: NodeListOf<Element>;
try {
nodes = document.querySelectorAll(sel);
} catch {
continue;
}
if (!nodes.length) continue;
// table 模式:参数表
if (rule.extract === 'table') {
const pairs: Array<{ key: string; value: string }> = [];
nodes.forEach((row) => {
const k = clean(row.querySelector(rule.tableKeySelector ?? '')?.textContent ?? '');
const v = clean(row.querySelector(rule.tableValueSelector ?? '')?.textContent ?? '');
if (k && v) pairs.push({ key: k.replace(/[:]$/, ''), value: v });
});
if (pairs.length) {
return {
kind: rule.kind,
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs,
};
}
continue;
}
// join 模式:标题被拆成多个 span
if (rule.extract === 'join') {
let text = '';
nodes.forEach((n) => {
text += n.textContent ?? '';
});
text = clean(text);
if (text) return { kind: rule.kind, content: text };
continue;
}
// first 模式:只取第一个
const first = clean(nodes[0].textContent ?? '');
if (first) return { kind: rule.kind, content: first };
}
return null;
}
export function collectTexts(profile: SiteProfile): {
materials: TextMaterial[];
missingRequired: string[];
} {
const materials: TextMaterial[] = [];
const missingRequired: string[] = [];
for (const rule of profile.textRules) {
const m = extractOne(rule);
if (m) materials.push(m);
else if (rule.required) missingRequired.push(rule.kind);
}
return { materials, missingRequired };
}
/** 合并去重:以 kind 为键,结构化来源优先,DOM 来源兜底。
* 参数表(params)特殊处理:两边的 pairs 做并集合并(按 key 去重),
* 因为「关于商品」只给前几项,完整「特征」在 DOM 里,需要合并才能拿全。
*/
export function mergeTexts(
primary: TextMaterial[],
fallback: TextMaterial[]
): TextMaterial[] {
const map = new Map<string, TextMaterial>();
for (const m of [...primary, ...fallback]) {
if (m.kind === 'params') {
const existing = map.get('params');
if (!existing) {
map.set('params', { ...m, pairs: [...(m.pairs ?? [])] });
} else {
const merged = [...(existing.pairs ?? [])];
const seen = new Set(merged.map((p) => p.key));
for (const p of m.pairs ?? []) {
if (!seen.has(p.key)) {
merged.push(p);
seen.add(p.key);
}
}
existing.pairs = merged;
existing.content = merged.map((p) => `${p.key}: ${p.value}`).join('\n');
}
continue;
}
if (!map.has(m.kind)) map.set(m.kind, m);
}
return Array.from(map.values());
}
+132
View File
@@ -0,0 +1,132 @@
/**
* URL 工具链
* 从 extension-v1 移植,新增:
* - toOriginalUrl 支持平台自定义规则(Ozon 的 /wc\d+/ 路径段尺寸标记)
* - pickBestFromSrcset:从 srcset 里挑最大尺寸候选
*/
const IMG_EXT = /\.(jpg|jpeg|png|gif|bmp|heic|webp|avif)$/i;
export interface UrlRule {
match: RegExp;
replace: string;
}
/**
* 缩略图 URL → 原图 URL
* 先走平台规则(Ozon 的 /wc\d+/ → /wc1200/),
* 再走阿里系通用规则:xxx.jpg_400x400.jpg → xxx.jpg
*/
export function toOriginalUrl(url: string, rules?: UrlRule[]): string {
let out = url;
for (const r of rules ?? []) {
// 带 g 标志的正则(query 清洗)要反复 replace,不带 g 的只替换一次
if (r.match.global) {
out = out.replace(r.match, r.replace);
} else if (r.match.test(out)) {
out = out.replace(r.match, r.replace);
}
}
const m = out.match(/^(.+?\.(jpg|jpeg|png|gif|bmp|heic|webp|avif))_/i);
return m ? m[1] : out;
}
/** url("https://...") → https://... */
export function urlInBrackets(s: string): string {
if (!s?.trim()) return '';
return s.match(/\((.*?)\)/)?.[1]?.replace(/['"]/g, '') ?? '';
}
export function isDataUrl(u: string): boolean {
return /^data:image/.test(u);
}
/** 协议相对 // / 根相对 / / 相对路径 → 绝对 URL */
export function toAbsoluteUrl(u: string): string {
if (!u) return u;
if (isDataUrl(u) || u.startsWith('blob:')) return u;
const proto = u.startsWith('http:') ? 'http' : 'https';
if (/^\/\//.test(u)) return `${proto}:${u}`;
if (/^\//.test(u)) return `${location.origin}${u}`;
if (!/^(.*):/.test(u)) return `${location.origin}/${u}`;
return u;
}
/** 去重用的归一化 key:还原原图 + 剥 query/hash */
export function dedupeKey(url: string, rules?: UrlRule[]): string {
const base = toOriginalUrl(url, rules);
try {
const u = new URL(base);
u.search = '';
u.hash = '';
return u.toString();
} catch {
return base;
}
}
export function looksLikeImageUrl(u: string): boolean {
if (isDataUrl(u)) return true;
try {
return IMG_EXT.test(new URL(u).pathname);
} catch {
return IMG_EXT.test(u);
}
}
/**
* 从 srcset 里挑最大尺寸候选。
* 支持两种语法:
* "a.jpg 100w, b.jpg 200w, c.jpg 300w" → c.jpg
* "a.jpg 1x, b.jpg 2x" → 最后一个
* "a.jpg 400w, b.jpg 800w, c.jpg 1200w, d.jpg" → 最后一个(无描述符 = 兜底最大)
*/
export function pickBestFromSrcset(srcset: string): string {
if (!srcset) return '';
const parts = srcset.split(',').map((p) => p.trim()).filter(Boolean);
if (!parts.length) return '';
let best = '';
let bestSize = -1;
for (const part of parts) {
const seg = part.split(/\s+/);
const url = seg[0];
const desc = seg[1] ?? '';
let size = -1;
const w = desc.match(/^(\d+)w$/);
const x = desc.match(/^(\d+(?:\.\d+)?)x$/);
if (w) size = Number(w[1]);
else if (x) size = Math.round(Number(x[1]) * 1000);
else size = 0; // 无描述符,通常是最小的兜底,但也可能是唯一候选
if (size >= bestSize) {
bestSize = size;
best = url;
}
}
return best;
}
/**
* Ozon CDN 原图 → wc200 缩略图(侧边栏预览用,省流量)
* 实测结构(reference/ozon1.html):
* https://ir.ozone.ru/s3/multimedia-1-5/9290076089.jpg
* → https://ir.ozone.ru/s3/multimedia-1-5/wc200/9290076089.jpg
* 已带尺寸标记(/wc\d+/、/c\d+/)或非 multimedia 路径的 URL 原样返回。
*/
export function toThumbUrl(url: string): string {
const m = url.match(/^(https?:\/\/[^/]+\/s3\/[^/]+\/)([^/]+)$/);
if (m && !/\/wc\d+\//.test(url) && !/\/c\d+\//.test(url)) {
return `${m[1]}wc200/${m[2]}`;
}
return url;
}
/** 清洗文件名非法字符(Windows 兼容) */
export function cleanFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/\s+/g, ' ')
.replace(/\s+/g, '_')
.substring(0, 80);
}
+167
View File
@@ -0,0 +1,167 @@
/**
* 商品文件夹构建器 —— ScanResult → product.json / sources.json / 待写图片清单
* 契约见 docs/contracts/product-json.md
*/
import type { ScanResult, ImageMaterial } from '../collector/scan';
import type { ProductJson, SourcesJson } from '../schema/product';
import { cleanFilename } from '../collector/url';
export interface BuiltProduct {
folderName: string;
product: ProductJson;
sources: SourcesJson;
/** 待写图片:相对路径 + 源 URL */
files: Array<{ relativePath: string; url: string }>;
}
/** 用户在表单里二次修改后的文本(覆盖采集原文) */
export interface TextEdits {
title?: string;
price?: string;
brand?: string;
desc?: string;
sellingPoints?: string;
params?: Array<{ key: string; value: string }>;
/** 包装重量(原样字符串,如 "3.5 кг" */
weight?: string;
/** 包装尺寸(长/宽/高) */
dims?: { l: string; w: string; h: string };
/** 包装尺寸单位(上传时保留单位,避免 mm/cm 混淆) */
dimsUnit?: 'mm' | 'cm';
}
function extractExt(img: ImageMaterial): string {
try {
const m = new URL(img.url).pathname.match(/\.(jpg|jpeg|png|webp|gif|avif|mp4|mov|m3u8|webm)$/i);
if (m) return m[1].toLowerCase();
} catch {
/* ignore */
}
return img.type === 'video' ? 'mp4' : 'jpg';
}
/** "1 290 ₽" → "1290" */
function extractNumericPrice(text: string): string {
const m = text.replace(/\s/g, '').replace(',', '.').match(/(\d+(?:\.\d+)?)/);
return m ? m[1] : '';
}
/** "3.5 кг" → 3.5 */
function parseNumber(text: string): number | null {
const m = (text ?? '').replace(',', '.').match(/(\d+(?:\.\d+)?)/);
return m ? parseFloat(m[1]) : null;
}
/** 解析包装重量,返回 { weight, unit },单位自动识别 kg/g */
function parseWeight(text?: string): { weight: number | null; unit: 'g' | 'kg' } {
const s = (text ?? '').toLowerCase();
const num = parseNumber(s);
if (num == null) return { weight: null, unit: 'g' };
if (s.includes('кг') || s.includes('kg')) return { weight: num, unit: 'kg' };
if (s.includes('г') || s.includes('g')) return { weight: num, unit: 'g' };
return { weight: num, unit: 'g' };
}
/** 解析包装尺寸,单位自动识别 cm/mm */
function parseDimUnit(text?: string): 'cm' | 'mm' {
const s = (text ?? '').toLowerCase();
if (s.includes('мм') || s.includes('mm')) return 'mm';
return 'cm';
}
export function buildProduct(
result: ScanResult,
selectedKeys: Set<string>,
edits?: TextEdits
): BuiltProduct {
const text = (kind: string) => result.texts.find((t) => t.kind === kind)?.content ?? '';
const title = edits?.title ?? text('title');
const priceText = edits?.price ?? text('price');
const brand = edits?.brand ?? text('brand');
const desc = edits?.desc ?? text('desc');
const sellingPoints = edits?.sellingPoints ?? text('selling_point');
const params =
edits?.params ?? result.texts.find((t) => t.kind === 'params')?.pairs;
const folderName = cleanFilename(title || `ozon-${result.itemId ?? 'product'}`) || 'ozon-product';
const now = new Date().toISOString();
const selected = result.images.filter((img) => selectedKeys.has(img.key));
const images: ProductJson['_images'] = { main: [], sku: [], detail: [], video: [] };
const files: BuiltProduct['files'] = [];
const dedupeKeys: string[] = [];
const counts: Record<string, number> = {};
for (const img of selected) {
counts[img.groupKey] = (counts[img.groupKey] ?? 0) + 1;
const ext = extractExt(img);
const base = `${img.groupKey}-${String(counts[img.groupKey]).padStart(3, '0')}`;
const variantSuffix = img.groupKey === 'sku' && img.variantName ? `-${cleanFilename(img.variantName)}` : '';
const filename = `${base}${variantSuffix}.${ext}`;
const relativePath = `images/${img.groupKey}/${filename}`;
images[img.groupKey].push({
file: relativePath,
sourceUrl: img.url,
variantName: img.variantName,
w: img.width,
h: img.height,
});
files.push({ relativePath, url: img.url });
dedupeKeys.push(img.url);
}
const weightInfo = parseWeight(edits?.weight);
const dimUnit = parseDimUnit(edits?.dims?.l || edits?.dims?.w || edits?.dims?.h);
const product: ProductJson = {
_meta: { schemaVersion: 1, stage: 'collected', createdAt: now, updatedAt: now },
offer_id: '',
name: title,
description: desc,
description_category_id: null,
type_id: null,
price: extractNumericPrice(priceText),
old_price: '',
currency_code: 'RUB',
vat: '0',
depth: parseNumber(edits?.dims?.l ?? ''),
width: parseNumber(edits?.dims?.w ?? ''),
height: parseNumber(edits?.dims?.h ?? ''),
dimension_unit: dimUnit,
weight: weightInfo.weight,
weight_unit: weightInfo.unit,
images: [],
primary_image: '',
images360: [],
color_image: '',
attributes: [],
complex_attributes: [],
_images: images,
_raw: {
title,
price: priceText,
params,
desc,
sellingPoints,
brand,
},
};
const sources: SourcesJson = {
sources: [
{
platform: result.platform as 'ozon',
itemId: result.itemId,
url: result.url,
collectedAt: now,
counts: { ...result.stats },
},
],
dedupeKeys,
};
return { folderName, product, sources, files };
}
+103
View File
@@ -0,0 +1,103 @@
/**
* File System Access 写盘 —— 生成完整商品文件夹
*
* 目录结构(契约见 docs/contracts/product-json.md):
* <商品名>/
* ├── product.json
* ├── sources.json
* └── images/{main,sku,detail,video}/main-001.jpg …
*
* 图片字节统一走 background 代理 fetch(绕 CORS / 防盗链)。
*/
import { loadRootDir, saveRootDir } from './idb';
import type { ProductJson, SourcesJson } from '../schema/product';
export interface ExportResult {
folderName: string;
written: number;
failed: Array<{ file: string; error: string }>;
}
/** 取(或让用户选)根目录,并确保读写权限 */
export async function ensureRootDir(): Promise<FileSystemDirectoryHandle> {
let handle = await loadRootDir();
if (!handle) {
handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await saveRootDir(handle);
return handle;
}
let perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm !== 'granted') {
perm = await handle.requestPermission({ mode: 'readwrite' });
}
if (perm !== 'granted') throw new Error('目录读写权限被拒绝');
return handle;
}
/** 重新选择根目录(忽略已保存的,强制弹出选择器) */
export async function chooseRootDir(): Promise<FileSystemDirectoryHandle> {
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await saveRootDir(handle);
return handle;
}
/** 通过 background 代理取图,返回 Blob */
async function fetchImageBlob(url: string): Promise<Blob> {
const resp = await chrome.runtime.sendMessage({ action: 'fetchImage', url });
if (!resp?.ok) throw new Error(resp?.error ?? '图片下载失败');
const res = await fetch(resp.dataUrl);
if (!res.ok) throw new Error(`解码失败 HTTP ${res.status}`);
return res.blob();
}
export async function writeProductFolder(
folderName: string,
product: ProductJson,
sources: SourcesJson,
files: Array<{ relativePath: string; url: string }>,
onProgress?: (done: number, total: number) => void
): Promise<ExportResult> {
const root = await ensureRootDir();
const productDir = await root.getDirectoryHandle(folderName, { create: true });
// product.json
const pj = await productDir.getFileHandle('product.json', { create: true });
const w1 = await pj.createWritable();
await w1.write(JSON.stringify(product, null, 2));
await w1.close();
// sources.json
const sj = await productDir.getFileHandle('sources.json', { create: true });
const w2 = await sj.createWritable();
await w2.write(JSON.stringify(sources, null, 2));
await w2.close();
// images/
const imagesDir = await productDir.getDirectoryHandle('images', { create: true });
const total = files.length;
let written = 0;
const failed: Array<{ file: string; error: string }> = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
const parts = f.relativePath.split('/'); // "images/main/main-001.jpg"
const group = parts[1];
const filename = parts[2];
try {
const blob = await fetchImageBlob(f.url);
const groupDir = await imagesDir.getDirectoryHandle(group, { create: true });
const fh = await groupDir.getFileHandle(filename, { create: true });
const w = await fh.createWritable();
await w.write(blob);
await w.close();
written++;
} catch (err) {
failed.push({ file: f.relativePath, error: err instanceof Error ? err.message : String(err) });
}
onProgress?.(i + 1, total);
}
return { folderName, written, failed };
}
+63
View File
@@ -0,0 +1,63 @@
/**
* IndexedDB 封装 —— 持久化 FileSystemDirectoryHandle
*
* chrome.storage 存不了 FileSystemDirectoryHandle(它不是 JSON 可序列化类型),
* 必须用 IndexedDBstructured clone 支持)。存一次后跨会话免重复授权。
*/
const DB_NAME = 'ozon-seller-kit';
const STORE = 'handles';
const ROOT_KEY = 'SH_ROOT_DIR';
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(STORE)) {
req.result.createObjectStore(STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function idbSet(key: string, value: unknown): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(value, key);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
export async function idbGet<T>(key: string): Promise<T | null> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => {
db.close();
resolve((req.result as T) ?? null);
};
req.onerror = () => {
db.close();
reject(req.error);
};
});
}
export async function saveRootDir(handle: FileSystemDirectoryHandle): Promise<void> {
await idbSet(ROOT_KEY, handle);
}
export async function loadRootDir(): Promise<FileSystemDirectoryHandle | null> {
return idbGet<FileSystemDirectoryHandle>(ROOT_KEY);
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Profile 路由 - 根据 URL 匹配平台
*/
import type { SiteProfile } from './types';
import { profileOzon } from './ozon';
const PROFILES: SiteProfile[] = [profileOzon];
export function matchProfile(url: string): SiteProfile | null {
for (const p of PROFILES) {
if (p.urlPatterns.some((re) => re.test(url))) {
return p;
}
}
return null;
}
export { profileOzon };
export type { SiteProfile };
+162
View File
@@ -0,0 +1,162 @@
/**
* Ozon 商品页采集配置
*
* 选择器已在真实页面实测(reference/ozon1.html、ozon2.html2026-08-15):
* - webProductHeading → <h1> 标题
* - webGallery → 主图(<img srcset>wc50/wc100 缩略图)
* - webAspects → SKU 变体(颜色/尺码选择器)
* - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区)
* - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state
*
* ★ 主采集路径是 structuredozon-state.ts 读 SSR data-state + JSON-LD + API),
* 本文件的 DOM 选择器只是兜底 + 详情图补充。
*/
import type { SiteProfile } from './types';
export const profileOzon: SiteProfile = {
id: 'ozon',
name: 'Ozon',
urlPatterns: [
// 新版: https://www.ozon.ru/product/slug-123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/product\/[^/]+-\d+\/?/,
// 旧版: https://www.ozon.ru/context/detail/id/123456789/
/^https:\/\/[^/]+\.ozon\.(ru|kz|by)\/context\/detail\/id\/\d+/,
],
extractItemId: (url) => {
const m = url.match(/\/product\/[^/]+-(\d+)\/?/);
if (m?.[1]) return m[1];
const m2 = url.match(/\/context\/detail\/id\/(\d+)/);
return m2?.[1] ?? null;
},
readySelectors: [
'[data-widget="webProductHeading"]',
'[data-widget="webGallery"]',
'h1',
],
readyTimeoutMs: 8_000,
// Ozon 画廊图片是 <img srcset>,懒加载真实地址在 srcset / currentSrc / src
defaultSrcProps: ['srcset', 'currentSrc', 'src', 'data-src'],
refererOrigin: 'https://www.ozon.ru',
textRules: [
{
kind: 'title',
selectors: [
'[data-widget="webProductHeading"] h1',
'h1[itemprop="name"]',
'h1',
],
extract: 'first',
required: true,
},
{
kind: 'price',
selectors: [
'[data-widget="webPrice"] span',
'span[itemprop="price"]',
'[data-widget="webPrice"]',
],
extract: 'first',
},
{
kind: 'params',
selectors: [
'[data-widget="webDetailedCharacteristics"] dl',
'[data-widget="webCharacteristics"] dl',
'[data-widget="webShortCharacteristics"] dl',
'[data-widget="webAspects"] dl',
'#section-characteristics dl',
],
extract: 'table',
tableKeySelector: 'dt, [class*="key"], [class*="Key"], [class*="label"]',
tableValueSelector: 'dd, [class*="value"], [class*="Value"]',
},
{
kind: 'selling_point',
selectors: [
'[data-widget="webShortCharacteristics"]',
'[data-widget="webFeatures"]',
'[data-widget="webAO"]',
],
extract: 'join',
},
{
kind: 'desc',
selectors: [
'[data-widget="webDescription"]',
'[data-widget="webRichContent"]',
'#section-description',
],
extract: 'join',
},
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
selectors: [
'[data-widget="webGallery"] img',
'[data-widget="webGallery"] source',
'[data-widget="webPhotoGallery"] img',
],
// 不设 minWidth:画廊缩略图 naturalWidth 可能很小,原图靠 toOriginalUrl 还原
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
selectors: [
// 实测:变体选择器在 webAspectswebDetailSKU 其实是"复制 SKU"按钮,没有图)
'[data-widget="webAspects"] img',
'[data-widget="webVariants"] img',
],
nameSelectors: [
'span[class*="Value"]',
'span[class*="Text"]',
'span',
],
minWidth: 16,
minHeight: 16,
},
{
key: 'detail',
name: '详情图',
type: 'img',
selectors: [
'[data-widget="webDescription"] img',
'[data-widget="webRichContent"] img',
'[data-widget="webFeatures"] img',
'#section-description img',
],
minWidth: 300,
minHeight: 100,
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: [
'[data-widget="webGallery"] video',
'[data-widget="webVideo"] video',
],
},
],
// 实测 CDNir.ozone.ru):尺寸标记是路径段 /wc\d+/wc50…wc1000)和 /c\d+/c50/c600
// 去掉标记即为原图(页面本身就有无标记的原始 URL)。
originalUrlRules: [
{ match: /\/wc\d+\//, replace: '/' },
{ match: /\/c\d+\//, replace: '/' },
// 去掉尺寸段后路径里会有双斜杠(不动 https:// 的 //
{ match: /(?<!:)\/{2,}/g, replace: '/' },
// 兼容 query 参数形式的尺寸(?width=200&h=300 逐个剥掉)
{ match: /[?&](width|height|size|quality|w|h)=[^&]+/g, replace: '' },
],
};
+69
View File
@@ -0,0 +1,69 @@
/**
* Site Profile - 平台采集配置(声明式)
*
* 与 extension-v1 同一套抽象,新增 Ozon 需要的文本类型:
* selling_point(卖点 / About this item)、brand(品牌)。
*
* 采集引擎(collector/)完全通用,加一个新平台只需新增一个 profile。
*/
export type TextKind =
| 'title'
| 'price'
| 'params'
| 'selling_point'
| 'desc'
| 'brand';
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video';
export type SrcProp =
| 'data-lazyload-src'
| 'data-src'
| 'srcset'
| 'currentSrc'
| 'src'
| 'backgroundImage';
export interface TextRule {
kind: TextKind;
/** 多套选择器,逐个尝试直到命中 */
selectors: string[];
extract: 'join' | 'first' | 'table';
/** table 模式的 key/value 子选择器 */
tableKeySelector?: string;
tableValueSelector?: string;
required?: boolean;
}
export interface ImageGroupRule {
key: ImageGroupKey;
name: string;
type: 'img' | 'video';
selectors: string[];
/** 覆盖 defaultSrcProps */
srcProps?: SrcProp[];
/** SKU 规格名来源 */
nameSelectors?: string[];
/** 画廊"当前高亮"元素(排除) */
activeSelectors?: string[];
/** 位于这些容器内的图片一律跳过(el.closest 判断) */
excludeWithin?: string[];
minWidth?: number;
minHeight?: number;
}
export interface SiteProfile {
id: string;
name: string;
urlPatterns: RegExp[];
extractItemId: (url: string) => string | null;
readySelectors: string[];
readyTimeoutMs?: number;
defaultSrcProps: SrcProp[];
textRules: TextRule[];
imageGroups: ImageGroupRule[];
/** 图片 URL 还原原图规则(缺省用通用 CDN 后缀规则) */
originalUrlRules?: Array<{ match: RegExp; replace: string }>;
refererOrigin?: string;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Product JSON - 商品文件夹契约(TS 侧)
* 对应 server/schemas/product.pyPydantic 为真源)
* 详见 docs/contracts/product-json.md
*/
export type Stage = 'collected' | 'edited' | 'published';
export interface ProductJson {
_meta: {
schemaVersion: 1;
stage: Stage;
createdAt: string; // ISO 8601
updatedAt: string;
};
// Ozon 字段(对齐 ImportProductsV3
offer_id: string; // 自己的货号,采集阶段恒空
name: string;
description: string;
description_category_id: number | null;
type_id: number | null;
price: string; // 采到的竞品价,仅参考
old_price: string;
currency_code: 'RUB' | 'CNY';
vat: string;
depth: number | null;
width: number | null;
height: number | null;
dimension_unit: 'mm' | 'cm';
weight: number | null;
weight_unit: 'g' | 'kg';
images: string[]; // 发布时才填公网 URL
primary_image: string;
images360: string[];
color_image: string;
attributes: any[]; // 工作台映射后才填
complex_attributes: any[];
// 本地扩展字段(下划线前缀,提交 Ozon 前剥离)
_images: {
main: ImageMeta[];
sku: ImageMeta[];
detail: ImageMeta[];
video: ImageMeta[];
};
_raw: {
title: string;
price: string;
params?: Array<{ key: string; value: string }>;
desc?: string;
sellingPoints?: string;
brand?: string;
};
_pricing?: any; // 工作台计价结果
}
export interface ImageMeta {
file: string; // 相对路径:images/main/main-001.jpg
sourceUrl: string; // 源站 URL(可能失效)
variantName?: string; // SKU 规格名
w?: number;
h?: number;
}
export interface SourcesJson {
sources: Array<{
platform: 'ozon' | '1688' | 'taobao';
itemId: string | null;
url: string;
collectedAt: string; // ISO 8601
counts: Record<string, number>;
}>;
dedupeKeys: string[]; // URL 去重指纹
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 服务端设置(上传用):后端地址 + Bearer Token,持久化到 chrome.storage.local。
*/
export interface BackendSettings {
baseUrl: string;
token: string;
}
const KEY = 'taowa_backend_settings';
const DEFAULT: BackendSettings = {
baseUrl: 'http://127.0.0.1:8800',
token: '',
};
export async function loadSettings(): Promise<BackendSettings> {
const r = await chrome.storage.local.get(KEY);
return { ...DEFAULT, ...(r[KEY] ?? {}) };
}
export async function saveSettings(s: BackendSettings): Promise<void> {
await chrome.storage.local.set({ [KEY]: s });
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"exclude": ["node_modules", ".output"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: '套娃采集助手',
description: '套娃(Matryoshka)· Ozon 商品页采集,支持导出到本地或上传服务端',
permissions: [
'storage',
'sidePanel',
'activeTab',
'scripting' // 执行 content script 函数需要
],
host_permissions: [
// 商品页 + 图片/视频 CDN(实测:ir.ozone.ru / io.ozone.ru / v-1.ozone.ru / cdn1.ozonusercontent.com
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
'https://*.ozone.ru/*',
'https://*.ozonusercontent.com/*',
// 本机后端(上传用);生产换成你的公网域名
'http://127.0.0.1:8800/*',
'http://localhost:8800/*'
],
action: {
default_title: '套娃采集'
}
},
modules: ['react']
});
+23
View File
@@ -0,0 +1,23 @@
"""鉴权路由。"""
from __future__ import annotations
import secrets
from fastapi import APIRouter, HTTPException
from config import get_settings
from core.security import create_access_token
from schemas.auth import LoginRequest, LoginResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest) -> LoginResponse:
settings = get_settings()
if not settings.app_token:
raise HTTPException(status_code=500, detail="服务端未配置 APP_TOKEN")
if not secrets.compare_digest(body.token, settings.app_token):
raise HTTPException(status_code=401, detail="Token 不正确")
token, expires_at = create_access_token("app")
return LoginResponse(access_token=token, expires_at=expires_at)
+121
View File
@@ -0,0 +1,121 @@
"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。"""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret
from db import get_db
from deps import get_current_user
from models import Shop
from services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/categories", tags=["categories"])
class ShopRef(BaseModel):
shop_id: str
lang: str = "ZH_HANS" # 中文类目
async def _client(shop_id: str, db: AsyncSession) -> OzonClient:
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
def _unwrap(result: dict) -> dict:
return result.get("result", result)
@router.post("/tree")
async def category_tree(
body: ShopRef,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
result = await client.post("/v1/description-category/tree", {"language": body.lang})
return _unwrap(result)
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
class AttributeQuery(BaseModel):
shop_id: str
type_id: int
lang: str = "ZH_HANS"
@router.post("/{category_id}/attributes")
async def category_attributes(
category_id: int,
body: AttributeQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
result = await client.post(
"/v1/description-category/attribute",
{
"description_category_id": category_id,
"type_id": body.type_id,
"language": body.lang,
},
)
return _unwrap(result)
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
class ValueQuery(BaseModel):
shop_id: str
category_id: int
type_id: int
q: str | None = None
limit: int = 100
last_value_id: int | None = None
lang: str = "ZH_HANS"
@router.post("/attribute/{attribute_id}/values")
async def attribute_values(
attribute_id: int,
body: ValueQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
if body.q and len(body.q) >= 2:
result = await client.post(
"/v1/description-category/attribute/values/search",
{
"attribute_id": attribute_id,
"description_category_id": body.category_id,
"type_id": body.type_id,
"limit": body.limit,
"value": body.q,
},
)
else:
result = await client.post(
"/v1/description-category/attribute/values",
{
"attribute_id": attribute_id,
"description_category_id": body.category_id,
"type_id": body.type_id,
"limit": body.limit,
"last_value_id": body.last_value_id or 0,
"language": body.lang,
},
)
return result # values 返回 {result, has_next}
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
+272
View File
@@ -0,0 +1,272 @@
"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。"""
from __future__ import annotations
import re
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db import get_db, get_session_factory
from deps import get_current_user
from models import Product, ProductAsset
from models.enums import AssetStatus, Stage
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
router = APIRouter(prefix="/api", tags=["collection"])
def _parse_number(text: str | None) -> float | None:
"""'1 290 ₽' / '3.5 кг' / '48*18*25' → 1290.0 / 3.5 / 48"""
if not text:
return None
m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", "."))
return float(m.group(1)) if m else None
def _find_param(pairs: list[dict] | None, keys: list[str]) -> str | None:
for p in pairs or []:
k = (p.get("key") or "").lower()
if any(kw in k for kw in keys):
return p.get("value")
return None
def _apply_texts(product: Product, texts: list[TextMaterial]) -> None:
raw = dict(product.raw or {})
raw_texts: list[dict] = list(raw.get("texts") or [])
for t in texts:
raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs})
if t.kind == "title" and t.content and not product.name:
product.name = t.content
raw["title"] = t.content
elif t.kind == "price":
raw["price"] = t.content
num = _parse_number(t.content)
if num is not None and (product.price is None or product.price == 0):
product.price = num
elif t.kind == "params":
raw["params"] = t.pairs
_apply_weight_dims(product, t.pairs)
elif t.kind == "selling_point":
raw["sellingPoints"] = t.content
elif t.kind == "desc":
raw["desc"] = t.content
if not product.description:
product.description = t.content
elif t.kind == "brand":
raw["brand"] = t.content
raw["texts"] = raw_texts
product.raw = raw
def _apply_weight_dims(product: Product, pairs: list[dict] | None) -> None:
"""从参数表里解析「包装重量 / 包装尺寸(长宽高)」,统一换算成克 / 毫米回填。"""
weight = _find_param(pairs, ["包装重量", "重量", "вес"])
if weight is not None:
num = _parse_number(weight)
if num is not None:
is_kg = any(u in weight.lower() for u in ("кг", "kg"))
product.weight = num * 1000 if is_kg else num # 统一为克
product.weight_unit = "g"
l = _find_param(pairs, ["包装长度", "长度", "длина"])
w = _find_param(pairs, ["包装宽度", "宽度", "ширина"])
h = _find_param(pairs, ["包装高度", "高度", "высота"])
if l or w or h:
combined = (l or "") + (w or "") + (h or "")
factor = 1 if any(u in combined.lower() for u in ("мм", "mm")) else 10 # 厘米→毫米
product.depth = (_parse_number(l) or 0) * factor if l else None
product.width = (_parse_number(w) or 0) * factor if w else None
product.height = (_parse_number(h) or 0) * factor if h else None
product.dimension_unit = "mm"
else:
dim = _find_param(pairs, ["包装尺寸", "размер", "габарит", "尺寸"])
if dim is not None:
nums = re.findall(r"\d+(?:[.,]\d+)?", dim.replace(",", "."))
if len(nums) >= 3:
factor = 1 if any(u in dim.lower() for u in ("мм", "mm")) else 10
product.depth = float(nums[0]) * factor
product.width = float(nums[1]) * factor
product.height = float(nums[2]) * factor
product.dimension_unit = "mm"
async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product:
if req.product_id:
product = await db.get(Product, UUID(req.product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
return product
product = Product(
stage=Stage.collected,
source_platform=req.source.platform,
source_item_id=req.source.itemId,
source_url=req.source.url,
)
db.add(product)
await db.flush()
return product
@router.post("/materials", response_model=MaterialsResponse)
async def create_materials(
req: MaterialsRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
) -> MaterialsResponse:
product = await _get_or_create_product(db, req)
_apply_texts(product, req.texts)
# 采集溯源(追加来源)
if not product.source_url:
product.source_url = req.source.url
if not product.source_platform:
product.source_platform = req.source.platform
# 去重 + 建素材
existing = set()
if req.images:
rows = (await db.execute(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == product.id,
ProductAsset.dedupe_key.isnot(None),
)
)).scalars().all()
existing = {k for k in rows if k}
queued, skipped = 0, 0
for img in req.images:
if img.dedupeKey and img.dedupeKey in existing:
skipped += 1
continue
db.add(ProductAsset(
product_id=product.id,
group_key=img.groupKey,
variant_name=img.variantName,
sort_order=img.index,
type=img.type,
source_url=img.url,
status=AssetStatus.pending,
dedupe_key=img.dedupeKey,
))
if img.dedupeKey:
existing.add(img.dedupeKey)
queued += 1
# 更新分组计数
counts: dict = {}
for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)):
counts[a.group_key] = counts.get(a.group_key, 0) + 1
product.asset_counts = counts
product.stage = Stage.collected if product.stage == Stage.collected else product.stage
await db.commit()
await db.refresh(product)
if queued:
background.add_task(process_product_assets, str(product.id))
return MaterialsResponse(
product_id=str(product.id),
stage=product.stage.value,
assets_queued=queued,
assets_skipped=skipped,
)
async def process_product_assets(product_id: str) -> None:
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
from services.storage import get_storage
storage = get_storage()
async with get_session_factory()() as db:
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.status == AssetStatus.pending,
)
)).all()
for a in assets:
a.status = AssetStatus.downloading
await db.commit()
try:
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
a.stored_url = stored
a.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
a.status = AssetStatus.failed
a.error = str(exc)[:500]
await db.commit()
@router.post("/materials/bytes")
async def upload_material_bytes(
background: BackgroundTasks,
product_id: str = Form(...),
group_key: str = Form("main"),
variant_name: str | None = Form(None),
sort_order: int = Form(0),
type: str = Form("img"),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
data = await file.read()
asset = ProductAsset(
product_id=product.id,
group_key=group_key,
variant_name=variant_name,
sort_order=sort_order,
type=type,
source_url="",
status=AssetStatus.pending,
)
db.add(asset)
await db.flush()
# 直接转存字节
from services.storage import get_storage
storage = get_storage()
try:
asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "")
asset.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
asset.status = AssetStatus.failed
asset.error = str(exc)[:500]
await db.commit()
return {"asset_id": str(asset.id), "status": asset.status.value}
@router.get("/products/{product_id}/fingerprints")
async def product_fingerprints(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.dedupe_key.isnot(None),
)
)).all()
return {"dedupe_keys": list(rows)}
@router.get("/collected")
async def is_collected(
platform: str,
itemId: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.execute(
select(Product).where(
Product.source_platform == platform,
Product.source_item_id == itemId,
)
)).scalars().all()
return {"collected": len(rows) > 0, "count": len(rows)}
+14
View File
@@ -0,0 +1,14 @@
"""汇率路由。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from deps import get_current_user
from services.fx import get_fx_rate
router = APIRouter(prefix="/api/fx", tags=["fx"])
@router.get("")
async def fx(_user: dict = Depends(get_current_user)):
return await get_fx_rate()
+186
View File
@@ -0,0 +1,186 @@
"""商品 CRUD(采集箱 / 编辑 / 删除)。"""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from db import get_db
from deps import get_current_user
from models import Product, ProductAsset
from models.enums import Stage
from schemas.product import ProductDetail, ProductListItem, ProductUpdate
router = APIRouter(prefix="/api/products", tags=["products"])
@router.get("")
async def list_products(
stage: str | None = None,
q: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
stmt = select(Product)
if stage:
stmt = stmt.where(Product.stage == stage)
if q:
stmt = stmt.where(Product.name.ilike(f"%{q}%") | Product.offer_id.ilike(f"%{q}%"))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (await db.execute(
stmt.order_by(Product.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
items = [ProductListItem.model_validate(r) for r in rows]
return {"total": total, "items": items}
@router.get("/{product_id}", response_model=ProductDetail)
async def get_product(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
return ProductDetail.model_validate(product)
@router.post("", response_model=ProductDetail)
async def create_product(
body: ProductUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = Product(stage=Stage.collected)
_apply_update(product, body)
db.add(product)
await db.commit()
await db.refresh(product)
return ProductDetail.model_validate(product)
@router.post("/{product_id}/copy", response_model=ProductDetail)
async def copy_product(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
"""复制商品为新变体:继承标题/描述/属性/型号名称/计价,重置货号与图片。"""
src = await db.get(Product, UUID(product_id))
if src is None:
raise HTTPException(status_code=404, detail="商品不存在")
clone = Product(
stage=Stage.collected,
shop_id=src.shop_id,
source_platform=src.source_platform,
source_item_id=None, # 新变体,不沿用源 itemId(避免去重冲突)
source_url=src.source_url,
offer_id="", # 重置货号
name=src.name,
description=src.description,
description_category_id=src.description_category_id,
type_id=src.type_id,
price=src.price,
old_price=src.old_price,
currency_code=src.currency_code,
vat=src.vat,
depth=src.depth,
width=src.width,
height=src.height,
dimension_unit=src.dimension_unit,
weight=src.weight,
weight_unit=src.weight_unit,
barcode=src.barcode,
images=None, # 重置图片
primary_image=None,
images360=None,
color_image=None,
attributes=src.attributes,
complex_attributes=src.complex_attributes,
raw=src.raw, # 含 model_name(型号名称)
pricing=src.pricing,
copy=src.copy,
fx_rate=src.fx_rate,
)
db.add(clone)
await db.commit()
await db.refresh(clone)
return ProductDetail.model_validate(clone)
@router.patch("/{product_id}", response_model=ProductDetail)
async def update_product(
product_id: str,
body: ProductUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
_apply_update(product, body)
await db.commit()
await db.refresh(product)
return ProductDetail.model_validate(product)
@router.delete("/{product_id}")
async def delete_product(
product_id: str,
hard: bool = False,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
if hard:
await db.delete(product)
else:
product.stage = Stage.archived
await db.commit()
return {"deleted": True}
@router.get("/{product_id}/assets")
async def list_assets(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(
select(ProductAsset)
.where(ProductAsset.product_id == UUID(product_id))
.order_by(ProductAsset.group_key, ProductAsset.sort_order)
)).all()
return [
{
"id": str(a.id),
"group_key": a.group_key,
"variant_name": a.variant_name,
"sort_order": a.sort_order,
"type": a.type,
"source_url": a.source_url,
"stored_url": a.stored_url,
"status": a.status.value,
"width": a.width,
"height": a.height,
"error": a.error,
}
for a in rows
]
def _apply_update(product: Product, body: ProductUpdate) -> None:
data = body.model_dump(exclude_unset=True)
if "stage" in data and data["stage"]:
data["stage"] = Stage(data["stage"])
for key, value in data.items():
if value is not None or key in ("raw", "pricing", "copy", "images", "attributes", "complex_attributes", "shop_id"):
setattr(product, key, value)
+183
View File
@@ -0,0 +1,183 @@
"""发布端点:提交 ImportProductsV3 + 后台轮询回填。"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret
from db import get_db, get_session_factory
from deps import get_current_user
from models import Product, PublishTask, Shop
from models.enums import PublishStatus, Stage
from services.ozon_client import OzonClient, OzonAPIError
from services.publish import build_import_item, validate_ready
router = APIRouter(prefix="/api", tags=["publish"])
class PublishRequest(BaseModel):
shop_id: str
def _client(shop: Shop) -> OzonClient:
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
@router.post("/products/{product_id}/publish")
async def publish_product(
product_id: str,
body: PublishRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
shop = await db.get(Shop, UUID(body.shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
missing = validate_ready(product)
if missing:
raise HTTPException(status_code=422, detail=f"缺少必填项:{''.join(missing)}")
item = build_import_item(product)
client = _client(shop)
try:
result = await client.post("/v3/product/import", {"items": [item]})
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
task_id = (result.get("result") or {}).get("task_id")
if not task_id:
raise HTTPException(status_code=502, detail=f"Ozon 未返回 task_id{result}")
task = PublishTask(
product_id=product.id,
shop_id=shop.id,
ozon_task_id=int(task_id),
status=PublishStatus.pending,
request_payload=item,
)
db.add(task)
product.stage = Stage.publishing
await db.commit()
await db.refresh(task)
background.add_task(_poll, str(task.id))
return {"task_id": str(task.id), "ozon_task_id": task.ozon_task_id}
async def _poll(task_id: str) -> None:
"""后台轮询 import/info,直到 imported / failed 或超时(约 40s)。"""
async with get_session_factory()() as db:
task = await db.get(PublishTask, UUID(task_id))
if task is None:
return
shop = await db.get(Shop, task.shop_id)
product = await db.get(Product, task.product_id)
if shop is None or product is None:
return
client = _client(shop)
for attempt in range(8):
try:
result = await client.post("/v1/product/import/info", {"task_id": task.ozon_task_id})
except OzonAPIError as exc:
task.status = PublishStatus.failed
task.errors = [{"error": exc.detail}]
task.completed_at = datetime.now(timezone.utc)
product.stage = Stage.failed
await db.commit()
return
items = (result.get("result") or {}).get("items") or []
item = items[0] if items else {}
status = item.get("status", "")
product_id = item.get("product_id")
errors = item.get("errors") or []
if status == "imported":
task.status = PublishStatus.imported
task.response = item
task.completed_at = datetime.now(timezone.utc)
if product_id:
product.ozon_product_id = int(product_id)
product.stage = Stage.published
product.published_at = datetime.now(timezone.utc)
await db.commit()
return
if status == "failed":
task.status = PublishStatus.failed
task.errors = errors
task.response = item
task.completed_at = datetime.now(timezone.utc)
product.stage = Stage.failed
await db.commit()
return
# pending / moderation → 继续等
task.status = PublishStatus.moderation if status in ("moderating", "moderation") else PublishStatus.processing
if product_id:
product.ozon_product_id = int(product_id)
await db.commit()
await asyncio.sleep(5 * (attempt + 1))
# 超时未定:保留 processing,前端可刷新
task.status = PublishStatus.moderation
task.response = item
await db.commit()
@router.get("/publish/{task_id}")
async def get_publish_task(
task_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
task = await db.get(PublishTask, UUID(task_id))
if task is None:
raise HTTPException(status_code=404, detail="发布任务不存在")
return {
"id": str(task.id),
"product_id": str(task.product_id),
"shop_id": str(task.shop_id),
"ozon_task_id": task.ozon_task_id,
"status": task.status.value,
"errors": task.errors,
"response": task.response,
"created_at": task.created_at,
"completed_at": task.completed_at,
}
@router.get("/products/{product_id}/publish-history")
async def publish_history(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(
select(PublishTask)
.where(PublishTask.product_id == UUID(product_id))
.order_by(PublishTask.created_at.desc())
)).all()
return [
{
"id": str(t.id),
"ozon_task_id": t.ozon_task_id,
"status": t.status.value,
"errors": t.errors,
"created_at": t.created_at,
"completed_at": t.completed_at,
}
for t in rows
]
+124
View File
@@ -0,0 +1,124 @@
"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret, encrypt_secret
from db import get_db
from deps import get_current_user
from models import Shop
from models.enums import ShopStatus
from schemas.shop import ShopCreate, ShopListItem, ShopUpdate
from services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/shops", tags=["shops"])
def _mask(client_id: str) -> str:
return f"{client_id[-4:]}" if len(client_id) > 4 else ""
@router.get("", response_model=list[ShopListItem])
async def list_shops(
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(select(Shop).order_by(Shop.created_at))).all()
items = []
for s in rows:
item = ShopListItem.model_validate(s)
try:
item.client_id_masked = _mask(decrypt_secret(s.client_id_enc))
except Exception: # noqa: BLE001
item.client_id_masked = ""
items.append(item)
return items
@router.post("", response_model=ShopListItem)
async def create_shop(
body: ShopCreate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = Shop(
name=body.name,
client_id_enc=encrypt_secret(body.client_id),
api_key_enc=encrypt_secret(body.api_key),
currency_code=body.currency_code or "RUB",
status=ShopStatus.active,
)
db.add(shop)
await db.commit()
await db.refresh(shop)
item = ShopListItem.model_validate(shop)
item.client_id_masked = _mask(body.client_id)
return item
@router.patch("/{shop_id}", response_model=ShopListItem)
async def update_shop(
shop_id: str,
body: ShopUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
if body.name is not None:
shop.name = body.name
if body.currency_code is not None:
shop.currency_code = body.currency_code
if body.client_id:
shop.client_id_enc = encrypt_secret(body.client_id)
if body.api_key:
shop.api_key_enc = encrypt_secret(body.api_key)
await db.commit()
await db.refresh(shop)
item = ShopListItem.model_validate(shop)
item.client_id_masked = _mask(decrypt_secret(shop.client_id_enc))
return item
@router.delete("/{shop_id}")
async def delete_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
await db.delete(shop)
await db.commit()
return {"deleted": True}
@router.post("/{shop_id}/test")
async def test_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
client = OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
try:
result = await client.test_credentials()
except OzonAPIError as exc:
shop.status = ShopStatus.invalid
await db.commit()
return {"ok": False, "error": exc.detail, "roles": []}
shop.status = ShopStatus.active
shop.last_checked_at = datetime.now(timezone.utc)
await db.commit()
roles = [r.get("name") for r in result.get("roles", [])]
return {"ok": True, "roles": roles}
+29 -1
View File
@@ -18,22 +18,50 @@ class Settings(BaseSettings):
extra="ignore",
)
# ── AI 密钥 ──
deepseek_api_key: str = ""
openai_api_key: str = ""
dashscope_api_key: str = ""
# 华北2(北京)业务空间时需填:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1
# 普通 API Key 调用留空即可。
dashscope_base_http_api_url: str = ""
# ── 运行 ──
host: str = "127.0.0.1"
port: int = 8800
cors_origins: str = ""
# ── V2:数据层 ──
# 本地过渡用 SQLite;上线切 PostgreSQLpostgresql+asyncpg://user:pass@host:5432/ozon_seller
database_url: str = "sqlite+aiosqlite:///./data/app.db"
# ── V2:鉴权 ──
app_token: str = "" # MVP 单用户登录 token(换发 JWT 用)
secret_key: str = "" # 店铺凭证 AES-GCM 加密密钥 + JWT 签名密钥
jwt_expire_minutes: int = 60 * 24 * 7 # JWT 有效期(默认 7 天)
# ── V2:七牛(图片存储)──
qiniu_access_key: str = ""
qiniu_secret_key: str = ""
qiniu_bucket: str = ""
qiniu_domain: str = "" # 绑定域名,如 https://cdn.example.com
# 为空时用本地文件系统兜底(开发期),不为空时走七牛
storage_backend: str = "local" # local | qiniu
# ── V2:对外地址(插件/前端回写、生成图回调)──
app_base_url: str = "http://127.0.0.1:8800"
@property
def cors_origin_list(self) -> list[str]:
if not self.cors_origins.strip():
return []
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def use_qiniu(self) -> bool:
return self.storage_backend == "qiniu" and bool(
self.qiniu_access_key and self.qiniu_secret_key and self.qiniu_bucket
)
@lru_cache
def get_settings() -> Settings:
View File
+56
View File
@@ -0,0 +1,56 @@
"""JWT 鉴权 + 店铺凭证 AES-GCM 加解密。"""
from __future__ import annotations
import base64
import hashlib
import os
from datetime import datetime, timedelta, timezone
import jwt
from config import get_settings
# ── JWT ──
def create_access_token(subject: str = "app") -> tuple[str, int]:
"""签发 JWT。返回 (token, 过期 epoch 秒)。"""
settings = get_settings()
expires = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "exp": expires}
token = jwt.encode(payload, settings.secret_key, algorithm="HS256")
return token, int(expires.timestamp())
def decode_token(token: str) -> dict:
"""校验并解析 JWT;失败抛 jwt.PyJWTError。"""
settings = get_settings()
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
# ── AES-GCM 店铺凭证加密 ──
def _derive_key() -> bytes:
settings = get_settings()
return hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
def encrypt_secret(plaintext: str) -> str:
"""AES-GCM 加密,返回 base64(nonce + ciphertext + tag)。"""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = _derive_key()
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(nonce + ct).decode("ascii")
def decrypt_secret(ciphertext: str) -> str:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = _derive_key()
raw = base64.b64decode(ciphertext.encode("ascii"))
nonce, ct = raw[:12], raw[12:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
+50
View File
@@ -0,0 +1,50 @@
"""数据库引擎与会话工厂(SQLite 本地过渡 / PostgreSQL 生产)。"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from config import get_settings
class Base(DeclarativeBase):
pass
_engine = None
_session_factory = None
def get_engine():
global _engine
if _engine is None:
settings = get_settings()
connect_args: dict = {}
# SQLite 需允许多线程/多协程访问同一文件
if settings.database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
_engine = create_async_engine(
settings.database_url,
echo=False,
future=True,
connect_args=connect_args,
)
return _engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
global _session_factory
if _session_factory is None:
_session_factory = async_sessionmaker(
get_engine(),
class_=AsyncSession,
expire_on_commit=False,
)
return _session_factory
async def get_db():
"""FastAPI 依赖:请求级 AsyncSession。"""
factory = get_session_factory()
async with factory() as session:
yield session
+26
View File
@@ -0,0 +1,26 @@
"""FastAPI 依赖:数据库会话 + 鉴权。"""
from __future__ import annotations
import jwt as pyjwt
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from core.security import decode_token
_bearer = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> dict:
"""校验 Bearer JWT,返回 payload。
MVP:单用户宽松模式 —— 未带 / 失效 token 也放行(返回匿名身份),
后续加账户体系时再收紧为强制校验。
"""
if credentials is None or not credentials.credentials:
return {"sub": "app", "anonymous": True}
try:
return decode_token(credentials.credentials)
except pyjwt.PyJWTError:
return {"sub": "app", "anonymous": True}
+38 -5
View File
@@ -3,14 +3,18 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from api import ai, image, ozon
from api import ai, auth, categories, collection, fx, image, ozon, products, publish, shops
from config import get_settings
from db import get_engine
# web/ 是 v1 工具台,留在仓库根,故上跳一级
WEB_DIR = Path(__file__).resolve().parents[1] / "web"
# 本地存储(开发兜底)媒体目录
MEDIA_DIR = Path(__file__).resolve().parents[1] / "data" / "media"
app = FastAPI(title="Ozon Seller Kit", version="0.1.0")
app = FastAPI(title="Ozon Seller Kit", version="0.2.0")
settings = get_settings()
if settings.cors_origin_list:
@@ -22,15 +26,44 @@ if settings.cors_origin_list:
allow_headers=["*"],
)
# 业务路由
app.include_router(auth.router)
app.include_router(collection.router)
app.include_router(products.router)
app.include_router(shops.router)
app.include_router(categories.router)
app.include_router(publish.router)
app.include_router(fx.router)
app.include_router(ai.router)
app.include_router(image.router)
app.include_router(ozon.router)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.on_event("startup")
async def on_startup() -> None:
# 开发便利:确保表存在(生产以 Alembic 迁移为准,create_all 幂等不删表)
from db import Base
import models # noqa: F401
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@app.get("/api/health")
async def health() -> dict:
db_ok = True
try:
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
except Exception: # noqa: BLE001
db_ok = False
return {"status": "ok" if db_ok else "degraded", "db": db_ok}
# 本地媒体(开发兜底存储)
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/media", StaticFiles(directory=str(MEDIA_DIR)), name="media")
if WEB_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
+53
View File
@@ -0,0 +1,53 @@
"""Alembic 迁移环境。URL 从 server/config/settings.py 读取,支持 autogenerate。"""
from __future__ import annotations
import sys
from pathlib import Path
from alembic import context
from sqlalchemy import create_engine, pool
# 让 `from config import ...` / `from db import ...` / `import models` 可解析
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from config import get_settings # noqa: E402
from db import Base # noqa: E402
import models # noqa: E402,F401 确保所有模型注册到 Base.metadata
config = context.config
target_metadata = Base.metadata
def _sync_url(url: str) -> str:
"""异步 URL → 同步 URL(迁移用同步引擎跑更稳)。"""
return url.replace("+aiosqlite", "").replace("+asyncpg", "")
def run_migrations_offline() -> None:
context.configure(
url=_sync_url(get_settings().database_url),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = create_engine(
_sync_url(get_settings().database_url),
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
@@ -0,0 +1,203 @@
"""initial v2 schema
Revision ID: 51715d16e5c3
Revises:
Create Date: 2026-08-15 10:06:57.793304
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '51715d16e5c3'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('attribute_values',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('attribute_id', sa.BigInteger(), nullable=False),
sa.Column('description_category_id', sa.BigInteger(), nullable=False),
sa.Column('type_id', sa.BigInteger(), nullable=False),
sa.Column('value', sa.String(length=512), nullable=False),
sa.Column('picture', sa.Text(), nullable=False),
sa.Column('info', sa.Text(), nullable=False),
sa.Column('lang', sa.String(length=8), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id', 'attribute_id', 'description_category_id', 'type_id')
)
op.create_table('category_attributes',
sa.Column('description_category_id', sa.BigInteger(), nullable=False),
sa.Column('type_id', sa.BigInteger(), nullable=False),
sa.Column('attribute_id', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('type', sa.String(length=32), nullable=False),
sa.Column('dictionary_id', sa.BigInteger(), nullable=False),
sa.Column('group_id', sa.BigInteger(), nullable=True),
sa.Column('group_name', sa.String(length=255), nullable=False),
sa.Column('is_required', sa.Boolean(), nullable=False),
sa.Column('is_aspect', sa.Boolean(), nullable=False),
sa.Column('is_collection', sa.Boolean(), nullable=False),
sa.Column('max_value_count', sa.Integer(), nullable=False),
sa.Column('attribute_complex_id', sa.BigInteger(), nullable=True),
sa.Column('complex_is_collection', sa.Boolean(), nullable=False),
sa.Column('category_dependent', sa.Boolean(), nullable=False),
sa.Column('lang', sa.String(length=8), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('description_category_id', 'type_id', 'attribute_id')
)
op.create_table('category_tree',
sa.Column('description_category_id', sa.BigInteger(), nullable=False),
sa.Column('parent_id', sa.BigInteger(), nullable=True),
sa.Column('category_name', sa.String(length=255), nullable=False),
sa.Column('type_id', sa.BigInteger(), nullable=True),
sa.Column('type_name', sa.String(length=255), nullable=False),
sa.Column('disabled', sa.Boolean(), nullable=False),
sa.Column('level', sa.Integer(), nullable=False),
sa.Column('lang', sa.String(length=8), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('description_category_id')
)
op.create_index(op.f('ix_category_tree_parent_id'), 'category_tree', ['parent_id'], unique=False)
op.create_table('products',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('stage', sa.Enum('collected', 'editing', 'ready', 'publishing', 'published', 'failed', 'archived', name='stage', native_enum=False, length=16), nullable=False),
sa.Column('source_platform', sa.String(length=16), nullable=True),
sa.Column('source_item_id', sa.String(length=64), nullable=True),
sa.Column('source_url', sa.Text(), nullable=True),
sa.Column('offer_id', sa.String(length=255), nullable=False),
sa.Column('ozon_product_id', sa.BigInteger(), nullable=True),
sa.Column('ozon_sku', sa.BigInteger(), nullable=True),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('description_category_id', sa.BigInteger(), nullable=True),
sa.Column('type_id', sa.BigInteger(), nullable=True),
sa.Column('price', sa.Float(), nullable=True),
sa.Column('old_price', sa.Float(), nullable=True),
sa.Column('currency_code', sa.String(length=3), server_default='RUB', nullable=False),
sa.Column('vat', sa.String(length=8), server_default='0', nullable=False),
sa.Column('depth', sa.Float(), nullable=True),
sa.Column('width', sa.Float(), nullable=True),
sa.Column('height', sa.Float(), nullable=True),
sa.Column('dimension_unit', sa.String(length=4), server_default='mm', nullable=False),
sa.Column('weight', sa.Float(), nullable=True),
sa.Column('weight_unit', sa.String(length=4), server_default='g', nullable=False),
sa.Column('barcode', sa.String(length=64), nullable=True),
sa.Column('images', sa.JSON(), nullable=True),
sa.Column('primary_image', sa.Text(), nullable=True),
sa.Column('images360', sa.JSON(), nullable=True),
sa.Column('color_image', sa.Text(), nullable=True),
sa.Column('pdf_list', sa.JSON(), nullable=True),
sa.Column('promotions', sa.JSON(), nullable=True),
sa.Column('attributes', sa.JSON(), nullable=True),
sa.Column('complex_attributes', sa.JSON(), nullable=True),
sa.Column('raw', sa.JSON(), nullable=True),
sa.Column('pricing', sa.JSON(), nullable=True),
sa.Column('copy', sa.JSON(), nullable=True),
sa.Column('fx_rate', sa.Float(), nullable=True),
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('asset_counts', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_offer_id'), 'products', ['offer_id'], unique=False)
op.create_index(op.f('ix_products_ozon_product_id'), 'products', ['ozon_product_id'], unique=False)
op.create_index(op.f('ix_products_source_item_id'), 'products', ['source_item_id'], unique=False)
op.create_index(op.f('ix_products_stage'), 'products', ['stage'], unique=False)
op.create_index(op.f('ix_products_updated_at'), 'products', ['updated_at'], unique=False)
op.create_table('shops',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=True),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('client_id_enc', sa.String(length=1024), nullable=False),
sa.Column('api_key_enc', sa.String(length=1024), nullable=False),
sa.Column('currency_code', sa.String(length=3), server_default='RUB', nullable=False),
sa.Column('status', sa.Enum('active', 'invalid', 'disabled', name='shopstatus', native_enum=False, length=16), nullable=False),
sa.Column('last_checked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('product_assets',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('product_id', sa.Uuid(), nullable=False),
sa.Column('group_key', sa.String(length=16), nullable=False),
sa.Column('variant_name', sa.String(length=128), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('type', sa.String(length=8), nullable=False),
sa.Column('source_url', sa.Text(), nullable=False),
sa.Column('stored_url', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('pending', 'downloading', 'uploaded', 'failed', name='assetstatus', native_enum=False, length=16), nullable=False),
sa.Column('dedupe_key', sa.String(length=512), nullable=True),
sa.Column('width', sa.Integer(), nullable=True),
sa.Column('height', sa.Integer(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_product_assets_dedupe_key'), 'product_assets', ['dedupe_key'], unique=False)
op.create_index(op.f('ix_product_assets_product_id'), 'product_assets', ['product_id'], unique=False)
op.create_index(op.f('ix_product_assets_status'), 'product_assets', ['status'], unique=False)
op.create_table('publish_tasks',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('product_id', sa.Uuid(), nullable=False),
sa.Column('shop_id', sa.Uuid(), nullable=False),
sa.Column('ozon_task_id', sa.BigInteger(), nullable=True),
sa.Column('status', sa.Enum('pending', 'processing', 'moderation', 'imported', 'failed', name='publishstatus', native_enum=False, length=16), nullable=False),
sa.Column('request_payload', sa.JSON(), nullable=True),
sa.Column('response', sa.JSON(), nullable=True),
sa.Column('errors', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_publish_tasks_ozon_task_id'), 'publish_tasks', ['ozon_task_id'], unique=False)
op.create_index(op.f('ix_publish_tasks_product_id'), 'publish_tasks', ['product_id'], unique=False)
op.create_index(op.f('ix_publish_tasks_shop_id'), 'publish_tasks', ['shop_id'], unique=False)
op.create_index(op.f('ix_publish_tasks_status'), 'publish_tasks', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_publish_tasks_status'), table_name='publish_tasks')
op.drop_index(op.f('ix_publish_tasks_shop_id'), table_name='publish_tasks')
op.drop_index(op.f('ix_publish_tasks_product_id'), table_name='publish_tasks')
op.drop_index(op.f('ix_publish_tasks_ozon_task_id'), table_name='publish_tasks')
op.drop_table('publish_tasks')
op.drop_index(op.f('ix_product_assets_status'), table_name='product_assets')
op.drop_index(op.f('ix_product_assets_product_id'), table_name='product_assets')
op.drop_index(op.f('ix_product_assets_dedupe_key'), table_name='product_assets')
op.drop_table('product_assets')
op.drop_table('users')
op.drop_table('shops')
op.drop_index(op.f('ix_products_updated_at'), table_name='products')
op.drop_index(op.f('ix_products_stage'), table_name='products')
op.drop_index(op.f('ix_products_source_item_id'), table_name='products')
op.drop_index(op.f('ix_products_ozon_product_id'), table_name='products')
op.drop_index(op.f('ix_products_offer_id'), table_name='products')
op.drop_table('products')
op.drop_index(op.f('ix_category_tree_parent_id'), table_name='category_tree')
op.drop_table('category_tree')
op.drop_table('category_attributes')
op.drop_table('attribute_values')
# ### end Alembic commands ###
@@ -0,0 +1,29 @@
"""add shop_id to products
Revision ID: 658b0503f71c
Revises: 51715d16e5c3
Create Date: 2026-08-15 14:30:34.342589
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '658b0503f71c'
down_revision = '51715d16e5c3'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('products', sa.Column('shop_id', sa.Uuid(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('products', 'shop_id')
# ### end Alembic commands ###
+18
View File
@@ -0,0 +1,18 @@
"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。"""
from models.asset import ProductAsset
from models.category import AttributeValue, CategoryAttribute, CategoryTree
from models.product import Product
from models.publish_task import PublishTask
from models.shop import Shop
from models.user import User
__all__ = [
"User",
"Shop",
"Product",
"ProductAsset",
"PublishTask",
"CategoryTree",
"CategoryAttribute",
"AttributeValue",
]
+34
View File
@@ -0,0 +1,34 @@
"""采集素材(图片/视频):分组、源站 URL、转存 URL、状态。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import AssetStatus
class ProductAsset(Base):
__tablename__ = "product_assets"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
product_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True
)
group_key: Mapped[str] = mapped_column(String(16), default="main") # main/sku/detail/video/param/generated
variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # SKU 规格名
sort_order: Mapped[int] = mapped_column(Integer, default=0)
type: Mapped[str] = mapped_column(String(8), default="img") # img / video
source_url: Mapped[str] = mapped_column(Text, default="")
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地路径或七牛公网 URL
status: Mapped[AssetStatus] = mapped_column(
Enum(AssetStatus, native_enum=False, length=16), default=AssetStatus.pending, index=True
)
dedupe_key: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+60
View File
@@ -0,0 +1,60 @@
"""Ozon 类目字典缓存(可重建,不作为业务真源)。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
class CategoryTree(Base):
__tablename__ = "category_tree"
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
parent_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
category_name: Mapped[str] = mapped_column(String(255), default="")
type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
type_name: Mapped[str] = mapped_column(String(255), default="")
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
level: Mapped[int] = mapped_column(Integer, default=0)
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CategoryAttribute(Base):
__tablename__ = "category_attributes"
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
name: Mapped[str] = mapped_column(String(255), default="")
description: Mapped[str] = mapped_column(Text, default="")
type: Mapped[str] = mapped_column(String(32), default="")
dictionary_id: Mapped[int] = mapped_column(BigInteger, default=0)
group_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
group_name: Mapped[str] = mapped_column(String(255), default="")
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
is_aspect: Mapped[bool] = mapped_column(Boolean, default=False)
is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
max_value_count: Mapped[int] = mapped_column(Integer, default=0)
attribute_complex_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
complex_is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
category_dependent: Mapped[bool] = mapped_column(Boolean, default=False)
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AttributeValue(Base):
__tablename__ = "attribute_values"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
value: Mapped[str] = mapped_column(String(512), default="")
picture: Mapped[str] = mapped_column(Text, default="")
info: Mapped[str] = mapped_column(Text, default="")
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+39
View File
@@ -0,0 +1,39 @@
"""业务枚举。值写入数据库字符串列(native_enum=False,跨 SQLite/PG 一致)。"""
from __future__ import annotations
import enum
class Stage(str, enum.Enum):
collected = "collected" # 插件刚上传,只有素材与原文
editing = "editing" # 用户正在编辑
ready = "ready" # 必填项齐全,可发布
publishing = "publishing" # 已提交 ImportProductsV3,等待轮询
published = "published" # 轮询 imported 成功
failed = "failed" # 轮询返回 errors / 校验失败
archived = "archived" # 手动归档(软删)
class AssetStatus(str, enum.Enum):
pending = "pending" # 已入库,等待下载
downloading = "downloading" # 正在下载源图
uploaded = "uploaded" # 已转存(本地/七牛)
failed = "failed" # 下载或转存失败
class PublishStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
moderation = "moderation"
imported = "imported"
failed = "failed"
class ShopStatus(str, enum.Enum):
active = "active"
invalid = "invalid" # 连通性校验失败
disabled = "disabled"
# 图片分组(对齐契约 _images)
IMAGE_GROUPS = ("main", "sku", "detail", "video", "param", "generated")
+76
View File
@@ -0,0 +1,76 @@
"""商品主表:对齐 Ozon ImportProductsV3 字段 + 本地扩展(raw/pricing/copy)。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, Float, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import Stage
from models.types import JSONType
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True)
shop_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 上架店铺
stage: Mapped[Stage] = mapped_column(
Enum(Stage, native_enum=False, length=16), default=Stage.collected, index=True
)
# 采集溯源
source_platform: Mapped[str | None] = mapped_column(String(16), nullable=True)
source_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
# ── Ozon 字段(对齐 ImportProductsV3)──
offer_id: Mapped[str] = mapped_column(String(255), default="", index=True)
ozon_product_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
ozon_sku: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
name: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str] = mapped_column(Text, default="")
description_category_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
price: Mapped[float | None] = mapped_column(Float, nullable=True)
old_price: Mapped[float | None] = mapped_column(Float, nullable=True)
# 币种固定人民币(跨境卖家 CNY 计价);vat 恒为 0(简化税制,无 НДС),前端不再展示
currency_code: Mapped[str] = mapped_column(String(3), default="CNY", server_default="CNY")
vat: Mapped[str] = mapped_column(String(8), default="0", server_default="0")
depth: Mapped[float | None] = mapped_column(Float, nullable=True)
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
dimension_unit: Mapped[str] = mapped_column(String(4), default="mm", server_default="mm")
weight: Mapped[float | None] = mapped_column(Float, nullable=True)
weight_unit: Mapped[str] = mapped_column(String(4), default="g", server_default="g")
barcode: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 图片(有序公网 URL,≤15
images: Mapped[list | None] = mapped_column(JSONType, nullable=True)
primary_image: Mapped[str | None] = mapped_column(Text, nullable=True)
images360: Mapped[list | None] = mapped_column(JSONType, nullable=True)
color_image: Mapped[str | None] = mapped_column(Text, nullable=True)
pdf_list: Mapped[list | None] = mapped_column(JSONType, nullable=True)
promotions: Mapped[list | None] = mapped_column(JSONType, nullable=True)
# 动态属性(工作台映射后填)
attributes: Mapped[list | None] = mapped_column(JSONType, nullable=True)
complex_attributes: Mapped[list | None] = mapped_column(JSONType, nullable=True)
# ── 本地扩展(提交 Ozon 前剥离)──
raw: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 采集原文 + texts
pricing: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 计价结果
copy: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # AI 文案结果
fx_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
)
# 采集素材数量(冗余,供列表快速展示;由 service 维护)
asset_counts: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
+33
View File
@@ -0,0 +1,33 @@
"""发布任务:一次 ImportProductsV3 请求与轮询结果。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import PublishStatus
from models.types import JSONType
class PublishTask(Base):
__tablename__ = "publish_tasks"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
product_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True
)
shop_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("shops.id", ondelete="CASCADE"), index=True
)
ozon_task_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
status: Mapped[PublishStatus] = mapped_column(
Enum(PublishStatus, native_enum=False, length=16), default=PublishStatus.pending, index=True
)
request_payload: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 脱敏后的 items[0]
response: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # import/info 原始结果
errors: Mapped[list | None] = mapped_column(JSONType, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+30
View File
@@ -0,0 +1,30 @@
"""Ozon 店铺(Client-Id / Api-Key 加密落库)。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import ShopStatus
class Shop(Base):
__tablename__ = "shops"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 预留多用户
name: Mapped[str] = mapped_column(String(128), nullable=False)
client_id_enc: Mapped[str] = mapped_column(String(1024), nullable=False) # AES-GCM 密文
api_key_enc: Mapped[str] = mapped_column(String(1024), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
status: Mapped[ShopStatus] = mapped_column(
Enum(ShopStatus, native_enum=False, length=16), default=ShopStatus.active
)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
+8
View File
@@ -0,0 +1,8 @@
"""共享列类型:JSONSQLite 存 TEXTPostgreSQL 存 JSON;跨库一致)。"""
from __future__ import annotations
from sqlalchemy import JSON
# 统一用 generic JSONSQLite/PostgreSQL 均可,避免 autogenerate 对 JSONB 变体渲染异常。
# 生产若需 JSONB 的索引能力,可再按需迁移,量级上差异可忽略。
JSONType = JSON
+19
View File
@@ -0,0 +1,19 @@
"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+9
View File
@@ -3,5 +3,14 @@ uvicorn[standard]>=0.32.0
httpx>=0.27.0
pydantic-settings>=2.6.0
python-dotenv>=1.0.0
python-multipart>=0.0.9
PyYAML>=6.0.0
dashscope>=1.23.8
# V2:数据层 / 鉴权 / 对象存储
sqlalchemy[asyncio]>=2.0.0
aiosqlite>=0.20.0
alembic>=1.13.0
PyJWT>=2.8.0
cryptography>=42.0.0
qiniu>=7.13.0
+14
View File
@@ -0,0 +1,14 @@
"""鉴权请求/响应模型。"""
from __future__ import annotations
from pydantic import BaseModel
class LoginRequest(BaseModel):
token: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_at: int
+42
View File
@@ -0,0 +1,42 @@
"""采集上传(插件 → 服务端)请求/响应模型,对齐 docs/extension/plan.md §14。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class SourceInfo(BaseModel):
platform: str = Field(..., description="ozon | 1688 | taobao")
itemId: str | None = None
url: str = ""
collectedAt: int | None = None # epoch 毫秒
class TextMaterial(BaseModel):
kind: str = Field(..., description="title | params | selling_point | desc | price | brand")
content: str = ""
pairs: list[dict] | None = None # table 模式 kv[{key, value}]
class ImageMaterial(BaseModel):
groupKey: str = Field(..., description="main | sku | detail | video | param")
groupName: str = ""
variantName: str | None = None # SKU 规格名
url: str = Field(..., description="源站原图 URL")
index: int = 0
type: str = "img" # img | video
dedupeKey: str | None = None
class MaterialsRequest(BaseModel):
product_id: str | None = Field(default=None, description="传了=追加到已有商品(跨平台补素材)")
source: SourceInfo
texts: list[TextMaterial] = Field(default_factory=list)
images: list[ImageMaterial] = Field(default_factory=list)
refererOrigin: str | None = None # 下载源图时需带的 Referer
class MaterialsResponse(BaseModel):
product_id: str
stage: str
assets_queued: int
assets_skipped: int = 0 # 因 dedupeKey 重复而跳过
+102
View File
@@ -0,0 +1,102 @@
"""商品 Pydantic 模型(列表 / 详情 / 部分更新)。"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class ProductListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
stage: str
name: str = ""
offer_id: str = ""
price: float | None = None
currency_code: str = "RUB"
source_platform: str | None = None
source_url: str | None = None
asset_counts: dict | None = None
ozon_product_id: int | None = None
created_at: datetime
updated_at: datetime
class ProductDetail(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
shop_id: UUID | None = None
stage: str
source_platform: str | None = None
source_item_id: str | None = None
source_url: str | None = None
offer_id: str = ""
ozon_product_id: int | None = None
name: str = ""
description: str = ""
description_category_id: int | None = None
type_id: int | None = None
price: float | None = None
old_price: float | None = None
currency_code: str = "RUB"
vat: str = "0"
depth: float | None = None
width: float | None = None
height: float | None = None
dimension_unit: str = "mm"
weight: float | None = None
weight_unit: str = "g"
barcode: str | None = None
images: list | None = None
primary_image: str | None = None
images360: list | None = None
color_image: str | None = None
attributes: list | None = None
complex_attributes: list | None = None
raw: dict | None = None
pricing: dict | None = None
copy: dict | None = None
fx_rate: float | None = None
asset_counts: dict | None = None
published_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ProductUpdate(BaseModel):
"""编辑页 autosave 的部分更新。仅允许业务字段,id/时间由服务端维护。"""
shop_id: UUID | None = None
stage: str | None = None
offer_id: str | None = None
name: str | None = None
description: str | None = None
description_category_id: int | None = None
type_id: int | None = None
price: float | None = None
old_price: float | None = None
currency_code: str | None = None
vat: str | None = None
depth: float | None = None
width: float | None = None
height: float | None = None
dimension_unit: str | None = None
weight: float | None = None
weight_unit: str | None = None
barcode: str | None = None
images: list | None = None
primary_image: str | None = None
attributes: list | None = None
complex_attributes: list | None = None
raw: dict | None = None
pricing: dict | None = None
copy: dict | None = None
fx_rate: float | None = None
source_url: str | None = None

Some files were not shown because too many files have changed in this diff Show More