15 Commits

Author SHA1 Message Date
joey5628 897e5585c0 feat: 试用过程中的细节优化 2026-09-13 22:19:16 +08:00
R524809 dbb2bb80bd feat: 生图增加商品尺寸填报 2026-09-11 17:28:06 +08:00
R524809 27c5daed72 feat: 更新 2.2 文档 2026-09-02 17:52:05 +08:00
joey5628 d7247ca2da docs: 多规格商品试算编辑 2026-08-31 22:38:59 +08:00
joey5628 8b54f7ed1e fix: 修改一下生图的 bug 2026-08-31 21:56:48 +08:00
R524809 2a667b4f3b Refine seller store implementation 2026-08-31 14:37:28 +08:00
joey5628 957289f717 feat: 优化图片资源删除管理,生图任务列表管理 2026-08-30 14:50:33 +08:00
Joey 4ee667142b feat: AI 生图、图片下载优化 2026-08-29 17:13:06 +08:00
R524809 a77ec26a02 refactor(server): 移除已放弃的 Ozon API 直传模块,新增素材删除接口
- 删除 shops/publish/categories/ozon 相关路由、模型、schema 与客户端服务(V2.1 决策放弃 API 直传,改人工上传)
- materials 新增 DELETE /assets/{id}:删除素材记录与本地文件,并同步修正 asset_counts
- 试算页支持单张素材删除,生图弹窗交互微调
- 新增 docs/v2.1/HANDOFF.md 工作交接说明,README 补充索引
2026-08-28 17:47:57 +08:00
R524809 dc6d38c128 refactor(server): 移除鉴权并归档遗留路由至 legacy/
- 删除 auth.py 与 deps.py,各路由去除 get_current_user 依赖
- collection.py 更名为 materials.py,冻结链路(ozon/publish/shops/categories)移入 legacy/
- 扩展默认生图服务端口并入 8800 并自动迁移旧配置,水印默认文案改为 Panda Store
- 新增 docs/v2.1/backend-structure.md 后端结构盘点文档
2026-08-28 15:09:22 +08:00
Joey 2835914fd8 feat: 迁移 ISS 服务端 2026-08-27 22:23:01 +08:00
R524809 efe3474deb feat: 采集插件整合进OSK 2026-08-27 17:18:50 +08:00
R524809 bb8f235e34 feat: 商品试算页面前端部分开发完成 2026-08-27 16:04:27 +08:00
R524809 7e1192f2ef feat: 价格试算模块优化 2026-08-27 10:35:07 +08:00
R524809 ac4ab22ac2 feat: 商品试算页 2026-08-26 17:38:44 +08:00
158 changed files with 23336 additions and 456 deletions
+36
View File
@@ -34,3 +34,39 @@ STORAGE_BACKEND=local
# ── V2:对外地址(插件/前端回写、生成图回调)──
APP_BASE_URL=http://127.0.0.1:8800
# ── 图片上传、下载与导出资源上限(单位:字节)──
# 默认:单张 20MB;一次采集 100 张;一次导出 50 张;ZIP 源文件/成品最多 200MB。
# ZIP 超过 2MB 自动使用临时磁盘,不继续占用服务进程内存。
# IMAGE_UPLOAD_MAX_BYTES=20971520
# IMAGE_DOWNLOAD_MAX_BYTES=20971520
# 图片最大解码像素,默认 40,000,000;按真实宽 × 高限制,防止超大图耗尽内存。
# IMAGE_MAX_PIXELS=40000000
# 远程图片只允许公网 HTTP(S) 地址;每次跳转都会再次校验,禁止 localhost/内网地址。
# 连接超时、单次读写超时、整次下载总超时(秒),以及最多重定向次数。
# IMAGE_DOWNLOAD_CONNECT_TIMEOUT=10
# IMAGE_DOWNLOAD_READ_TIMEOUT=30
# IMAGE_DOWNLOAD_TOTAL_TIMEOUT=60
# IMAGE_DOWNLOAD_MAX_REDIRECTS=3
# COLLECTION_MAX_IMAGES=100
# EXPORT_MAX_IMAGES=50
# EXPORT_ZIP_MAX_BYTES=209715200
# EXPORT_ZIP_MEMORY_THRESHOLD_BYTES=2097152
# ── 商品轮播视频(FFmpeg)──
# Ubuntu: sudo apt install -y ffmpeg。默认输出为 WB 兼容的 810×1080、30fps、无音轨 H.264 MP4。
# VIDEO_FFMPEG_PATH=ffmpeg
# VIDEO_FFPROBE_PATH=ffprobe
# VIDEO_MAX_BYTES=47185920
# VIDEO_RENDER_TIMEOUT_SECONDS=120
# ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)──
# 豆包 Seedream(火山方舟)
ARK_API_KEY=
# RightCode 中转 rightapi.ai(gpt-image-2 / gpt-image-2-vip / nano-banana 系列)
RIGHTAPI_API_KEY=
# Pcore 中转 pcore.ai(模型下拉里选「gpt-image-2(Pcore)」时使用)
PCORE_API_KEY=
# 可选覆盖:默认生图 provider(doubao | tongyi | rightapi | pcore)、水印徽章图路径
# IMAGE_PROVIDER=doubao
# WATERMARK_IMAGE_PATH=server/assets/watermark.jpg
@@ -0,0 +1,51 @@
## 采集功能对接计划(方案三:扩展并入主仓 + 上报开关 + 自动打开试算页)
**架构决策**(已确认):
- ISS 扩展整体并入主仓 **`extensions/collector/`**(复数容器目录容纳未来多个 Chrome 插件,子目录用功能名;extension-v2 原地冻结、docs 标注废弃)
- 整个项目作为一个应用部署(server 8800 + studio 8900 + 扩展独立构建)
- 「ISS 独立产品化」远期通过构建/配置裁剪实现(扩展天然独立分发;server 的 suite API 模块不依赖 products),**本次不实现配置化,只保证模块边界不阻碍**
- 本地阶段不做 Token/鉴权:`/api/materials` 用 settings 开关放开鉴权(`app_auth_enabled: bool = False`,远期可开回)
---
### Step 1:扩展并入主仓
1. 清空主仓 `extension/`(现为空壳,只有 .output/.wxt 残留)
2. 将 `image-suite-studio/extension/` 整体复制到主仓 **`extensions/collector/`**(src/collector、src/profiles、src/bridge、src/api、src/storage、entrypoints/{background,content,bridge.content,panel.content,sidepanel}、wxt.config.ts、package.json)
3. 构建方式:`pnpm -C extensions/collector build`(wxt 独立构建 → .output/chrome-mv3),不进 start.command(扩展是手动加载 unpacked)
4. wxt.config.ts / package.json 的 name 改为 `ozon-collector-extension`(或同语境命名)
### Step 2:上报功能改造(并入版扩展内,~150 行)
1. **`src/storage/settings.ts`**:BackendSettings 增加 `reportEnabled: boolean`(默认 true)、`reportBaseUrl: string`(默认 `http://127.0.0.1:8800`)、`studioBaseUrl: string`(默认 `http://localhost:8900`);loadSettings 合并逻辑补新字段默认值
2. **`src/api/report.ts`(新文件)**:`reportProduct(reportBaseUrl, payload)` → `POST {reportBaseUrl}/api/materials`,返回 `{ product_id, created }`。payload 组装:`{ platform, item_id, url, texts, images: [{url, group_key, variant_name, type, width, height}] }`——texts 用现成 `editedTexts()`,images 用 `buildGeneratePayload` 同款映射(ScanResult.images + uploadedImages 按 selectedKeys 过滤);实施时先读主仓 `server/schemas/collection.py` 对齐字段名
3. **`entrypoints/background.ts`**:照 `planSuite` 分支模式新增 action `reportProduct`(L17-56 监听器内加一个分支)
4. **`entrypoints/sidepanel/App.tsx`**:
- settingsPopup(L615-670):新增「上报商品」开关 + 「OSK 后台地址」+ 「试算页地址」三个设置项
- 01 商品信息 Section 末尾(L784 警告框后):加「上报商品」按钮——reportEnabled 且已采集时可用;点击 → `send('reportProduct', ...)` → 成功后 `chrome.tabs.create({ url: \`${studioBaseUrl}/trial/${product_id}\` })` + 成功提示;失败弹错误
5. 面板内生图功能保持不变(本期不动)
### Step 3:主仓 server 小改(免鉴权开关)
1. `server/config/settings.py`:加 `app_auth_enabled: bool = False`
2. `server/deps.py`:`get_current_user` 在 `app_auth_enabled=False` 时直接返回占位用户(跳过 JWT 校验);True 时走现有 JWT 逻辑——远期开鉴权只改配置
3. 主仓 `/api/materials` 契约已兼容 ISS ScanResult(响应已含 `product_id`),无需改动;重复采集同一商品返回同一 product_id(已实现),自动打开即回到原试算页
### Step 4:文档
1. `docs/v2.1/collect.md`:更新为方案三(扩展并入 `extensions/collector/` + 上报开关 + 配置化独立预留),标注 D2 决策修订、extension-v2 冻结
2. `docs/v2.1/README.md`:Phase C 状态与实施方式更新
3. 主仓 `README.md`:扩展部分改为指向 `extensions/collector/`
### Step 5:验证
1. `pnpm -C extensions/collector build` → Chrome 开发者模式加载 `.output/chrome-mv3`
2. Ozon / 1688 / 淘宝商详页:快速采集 → 面板出现数据 → 点「上报商品」→ 新标签自动打开 `http://localhost:8900/trial/{id}`,采集箱出现该商品
3. 同一商品重复采集上报 → 打开同一试算页(不重复建商品)
4. 后端未启动时上报 → 面板显示错误提示
5. 主仓 `pnpm build` + 类型检查通过
### 后续阶段(不在本次范围)
- **Phase B**:从 ISS server 平移 planner/generator/prompts/watermark/tasks 到主仓 server(/api/suite/* 等),打通试算页一键生成与单张 AI 生图——试算页前端契约已按此写好
- 远期「ISS-only 产品模式」:studio 构建变量裁剪路由 + server 鉴权范围配置
+37 -40
View File
@@ -1,70 +1,67 @@
# Ozon Seller Kit
Ozon 跨境上品工具链:**采集 → 编辑 → 发布**。
Ozon 跨境上品工具链:**采集 → 商品试算 → 登记导出**(V2.1 起主链路放弃 Ozon API 直传,人工上品,见 [`docs/v2.1/`](docs/v2.1/README.md))。
## 四个组成部分
## 组成部分
| 目录 | 部分 | 状态 |
|---|---|---|
| `web/` | ① 工具台 v1:计价、登记、水印、俄文文案 | ✅ 在用(冻结) |
| `extension/` | ② Chrome 采集插件:Ozon / 1688 商品页采集 | 🔨 待开发 |
| `studio/` | ③ 发布工作台:AI 图生图(上传/水印/万相图生图) | 🔨 开发中 |
| `server/` | ④ FastAPI:AI 文案、图生图(wanx2.1-imageedit)、Ozon API | 🔨 部分就绪 |
| `extensions/collector/` | Chrome 插件:三平台采集(Ozon/1688/淘宝/天猫)+ 套图生图面板 + **商品上报** | ✅ 在用(并入自 image-suite-studio) |
| `studio/` | 商品试算工作台:计价、俄文文案、AI 生图、登记导出 | ✅ 开发中 |
| `server/` | FastAPI:采集入库、商品库、AI 文案、图生图、汇率 | ✅ 部分就绪 |
| `web/` | 工具台 v1(计价/登记/水印/俄文文案) | 🧊 冻结(能力已迁入 studio) |
| `extension-v1/` `extension-v2/` | 旧版采集插件 | 🧊 冻结(被 collector 取代) |
| `docs/` | 全部文档 | — |
四者通过磁盘上的「[商品文件夹](docs/contracts/product-json.md)」契约衔接,不直接耦合代码。
主链路:**collector 采集 → 「上报商品」入库(`/api/materials`)→ 自动打开试算页 `/trial/{id}` → 计价/文案/生图 → 录入登记表 → CSV/组合码 → 人工上 Ozon 后台**。
## 目录
```
ozon-seller-kit/
├── server/ # ④ FastAPI(main.py / api / services / schemas / config)
├── web/ # ① 工具台 v1,冻结
├── extension/ # ② 采集插件(待建)
├── studio/ # ③ 发布工作台(React + Vite + antd,AI 图生图已上线)
├── packages/schema/ # 跨端共享契约(待建)
├── docs/ # 全部文档
├── server/ # FastAPI(main.py / api / services / schemas / config)
├── extensions/collector/ # Chrome 插件(WXT + React,pnpm -C extensions/collector build)
├── studio/ # 试算工作台(React 19 + Vite + antd)
├── web/ # 工具台 v1,冻结
├── extension-v1/ v2/ # 旧采集插件,冻结
├── docs/ # 全部文档(现行方案在 docs/v2.1/)
├── start.command
└── .env.example
```
## 快速开始
**双击 `start.command`**,或:
**1. 启动后端 + 前端**(双击 `start.command`,或手动):
```bash
./start.command
./start.command # server: http://127.0.0.1:8800 + studio: http://localhost:8900
```
打开 http://127.0.0.1:8800/ozonSeller.html
`start.command` 以单实例方式运行;再次启动时会先停止此前由它管理的同项目进程,再启动新实例。
**2. 构建/加载采集插件**:
```bash
pnpm -C extensions/collector install
pnpm -C extensions/collector build # 产物 .output/chrome-mv3
```
Chrome → `chrome://extensions` → 开发者模式 → 加载已解压的扩展程序 → 选 `extensions/collector/.output/chrome-mv3`。
打开 Ozon / 1688 / 淘宝 / 天猫商详页,点扩展图标(或页内悬浮「套」按钮)采集;
在面板「01 商品信息」下方点**「上报商品」**→ 自动入库并在新标签打开商品试算页。
- 密钥写在根目录 `.env`(参考 `.env.example`)
- 可选模型写在 `server/config/models.yaml`
### 发布工作台(studio/,AI 图生图)
先启动后端(同上 `./start.command`,或确保 `uvicorn` 跑在 8800),再启动前端:
```bash
cd studio
pnpm install
pnpm dev # http://localhost:8900(启动后自动打开浏览器)
```
前端通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8800`。图生图依赖阿里云百炼的
**wanx2.1-imageedit**(华北2/北京),需在 `.env` 配置:
```bash
DASHSCOPE_API_KEY=sk-xxxx
```
- 扩展面板「服务端设置」:套图服务端(默认 3300)、商品上报开关 + OSK 后台地址(默认 8800)+ 试算页地址(默认 8900)
## 文档
| 文档 | 内容 |
|---|---|
| [`docs/architecture.md`](docs/architecture.md) | **总体架构**,先读这个 |
| [`docs/contracts/product-json.md`](docs/contracts/product-json.md) | 商品文件夹契约(四部分的衔接点)|
| [`docs/extension/plan.md`](docs/extension/plan.md) | 插件方案(含 1688 插件逆向分析)|
| [`docs/extension/plan-revision.md`](docs/extension/plan-revision.md) | 插件方案修正(针对 Ozon 优先)|
| [`docs/deployment.md`](docs/deployment.md) | 部署与启动 |
| [`docs/ai-copy-backend-plan.md`](docs/ai-copy-backend-plan.md) | 俄文文案后端方案 |
| [`docs/studio/image-edit.md`](docs/studio/image-edit.md) | AI 图生图(studio 页面 + /api/image/edit) |
| [`docs/v2.1/README.md`](docs/v2.1/README.md) | **现行方案总览**(V2.1 转向:试算工作流) |
| [`docs/v2.1/collect.md`](docs/v2.1/collect.md) | 采集方案:扩展并入 + 上报开关 |
| [`docs/v2.1/trial-page.md`](docs/v2.1/trial-page.md) | 商品试算页设计 |
| [`docs/v2.1/image-suite.md`](docs/v2.1/image-suite.md) | 图片生成(套图 + 单张 AI 生图) |
| [`docs/v2.1/api.md`](docs/v2.1/api.md) | 前后端 API 契约 |
| [`docs/architecture.md`](docs/architecture.md) | V1 总体架构(历史参考) |
| [`docs/ozon-seller-api/`](docs/ozon-seller-api/) | Ozon Seller API 整理(远期重启直传时用) |
+153
View File
@@ -0,0 +1,153 @@
# 工作交接说明(HANDOFF)
> 更新:2026-08-31
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`multi-sku-pricing.md`](./multi-sku-pricing.md)(多 SKU 试算与导出方案)、[`multi-sku-measurements.md`](./multi-sku-measurements.md)(**实物规格与包装物流规格的双口径设计**)、[`image-suite.md`](./image-suite.md)(生图)、[`background-jobs.md`](./background-jobs.md)(**后台任务中心方案、实施状态与路线图**)、[`image-asset-lifecycle.md`](./image-asset-lifecycle.md)(图片与水印存储规则)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
---
## 1. 项目一句话
Ozon 跨境上品工具链。当前主链路(V2.1,已放弃 Ozon API 直传):
```
extensions/collector 扩展采集(Ozon/1688/淘宝/天猫)
→ 「上报商品」POST /api/materials 入库 + 自动打开试算页
→ studio /trial/{id} 试算页:计价 → 俄文文案 → AI 生图(套图+单张)→ 登记
→ 导出 CSV / 组合码 → 人工上 Ozon 卖家后台
```
技术栈:FastAPI(server,8800)+ React19/Vite/antd6(studio,8900)+ WXT Chrome 扩展(extensions/collector)。SQLite(`data/app.db`),无鉴权(V2.1 决策:先功能后鉴权)。
## 2. 启动与构建
```bash
# 一键启动(推荐):自动装依赖 → 构建采集插件 → 起插件改动监听 → 起 studio → 起 server
./start.command
# server 单独起(等价;日志建议重定向到 /tmp/osk-server.log)
.venv/bin/uvicorn main:app --app-dir server --reload --reload-include "*.yaml" --host 127.0.0.1 --port 8800
# 扩展改动后(start.command 的监听会自动构建,仍需去 chrome://extensions 手动点刷新)
pnpm -C extensions/collector build # 产物 .output/chrome-mv3
```
- **start.command 新增**:启动时会先执行 `alembic upgrade head`,再自动构建采集插件并起后台监听(3s 轮询 `src/**` + `wxt.config.ts`,改动自动重建;BSD find 语法,无新依赖)。Ctrl+C 会连同监听进程一起清理。
- **后台任务 Worker**:`start.command` 会额外启动 `python server/worker.py`。单独启动 server 时也要另开终端运行该命令,否则生图任务只会停在“排队中”。
- **运行态提醒**:交接时 server 是上个会话用 nohup 起的后台进程(日志 `/tmp/osk-server.log`),原 start.command 终端已被停掉。新会话建议:`kill $(lsof -ti :8800)` 后用 `./start.command` 正常接管。
- **`uvicorn --reload` 不监控 `.env`**:改 `.env` 后必须手动重启 server(Kill 8800 端口进程再起)。
- 密钥在根目录 `.env`:`DEEPSEEK_API_KEY`(规划+文案)、`DASHSCOPE_API_KEY`(通义,可用 ✅)、`RIGHTAPI_API_KEY`(RightCode 中转,**通道当前故障** ❌)、`PCORE_API_KEY`(pcore.ai,**可用 ✅**)、`ARK_API_KEY` 未配(豆包未用)。`.env.example` 有全部键位说明。
## 3. 生图供应商现状(2026-08-29,重要)
| 供应商 | 域名 | 模型(下拉显示) | 状态 |
|---|---|---|---|
| 通义/阿里 | dashscope.aliyuncs.com | `qwen-image-3.0-pro` 等(阿里巴巴) | ✅ 可用,实测 10s 出图 |
| RightCode | rightapi.ai | `gpt-image-2`、`gpt-image-2-vip`、`nano-banana` 系列(RightCode) | ❌ 通道故障:任务能提交但生成阶段 503/「No available channel」,纯文生图也失败;中转侧问题,待其恢复 |
| Pcore | pcore.ai | `gpt-image-2@pcore`(Pcore) | ✅ 可用(用户已在 pcore 开通 image2 分组后端到端跑通,全链路约 1 分钟/张) |
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits` 且 images 必须是 **`[{image_url: dataURI}]` 对象数组**(纯字符串报错);均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 是枚举(1024x1536/1024x1024),不接受自定义尺寸。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。
## 4. AI 规划张数策略(`planner.py`)
- **不设硬性数量**:prompt 让 AI 按商品复杂度自主决定,「建议 8 张左右」;规划规则 = 先保证每个 SKU 1 张主图,其余类型自行取舍。
- **用户可通过「生图要求」指定张数**:写了「N 张」AI 必须严格执行;服务端 `_total_budget()` 用正则提取,安全上限:默认 15、指定时按 N 最大 24(防跑飞,超限从尾部截断,SKU 主图优先保留)。
- **规划器曾频繁报「规划器未返回有效方案项」**:根因是 deepseek-v4 是思维链模型而规划器裸调用没禁 thinking,token 被推理烧光、正文为空。已修复:请求体加 `"thinking": {"type": "disabled"}` + 解析失败/空方案自动重试一次 + 失败时记录原始输出 + 缺 title 的方案项从 detail/kind 兜底合成不再丢弃。30 SKU 压测 3/3 通过。
## 5. 当前进度
### ✅ 已完成(含 2026-08-29 一轮,**全部未提交**,见 §6)
| 模块 | 内容 |
|---|---|
| 试算页 | 五区块页面、计价、俄文文案、采集图片/出图方案/生成结果、登记导出 |
| 采集对接 | ISS 扩展并入 `extensions/collector/`,「上报商品」入库 + 自动打开试算页 |
| 生图服务端 | planner/generator/prompts/watermark/jobs + 独立 Worker,套图 + 单张 AI 生图 + 水印 + ZIP 导出 |
| **本轮:Pcore 接入** | 新 provider 全链路(生成/编辑/轮询/重试/错误处理),`@pcore` 路由约定,下拉带供应商名,默认模型切 Pcore |
| **本轮:规划器修复** | thinking 禁用 + 重试 + 张数策略(AI 自主,建议 8 张左右,生图要求可指定) |
| **本轮:采集箱** | 勾选列 + 全选/取消全选 + 批量删除(硬删、Popconfirm 二次确认、删空页自动回退) |
| **本轮:采集图片区改版** | 8 列网格;AI 生图按钮半透明悬浮图片右下角;图名显示在图片下方(取 URL 末段);SKU 名并入图名行(不再悬浮遮挡勾选钮);勾选钮在右上角;默认全部不勾选 |
| **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 POST /api/export/images,ZIP 内分「套图结果/单张生图」文件夹);「单张 AI 生图」小节改名「生图记录」(落库历史,刷新后保留) |
| **本轮:图片生命周期** | 采集、上传与 AI 图片统一保存为 `original + current`;只保留当前水印版;重加/移除水印自动回收旧对象;删除单图/硬删商品会清理本地或七牛对象;具体规则见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) |
| **本轮:图片资源上限** | 单张上传/下载默认 20 MB;采集最多 100 张;导出最多 50 张、总量 200 MB;ZIP 超过 2 MB 自动落临时磁盘并分块返回,配置见 `.env.example` |
| **本轮:远程图片 SSRF 防护** | 代理、采集转存、AI 参考图、远程导出仅允许公网 HTTP(S) 地址;每跳重定向复验,限制连接/读写/总耗时与跳转次数;本项目 `/media` 素材继续本地直读,正常防盗链代理不受影响 |
| **本轮:图片格式与元数据校验** | 上传、采集转存、生成图统一按真实字节验证 JPEG/PNG/WebP/GIF/BMP,默认限制 4,000 万像素;静态图纠正 EXIF 方向并落库实际宽高。历史 39 条本地图像已回填尺寸;`server/scripts/backfill_asset_dimensions.py` 可用于其他旧库 |
| **本轮:试算页图片组件拆分与按页加载** | `TrialSuitePanel.tsx` 拆出采集/结果/失败图片网格(`TrialImageGrids.tsx`)、水印设置(`TrialWatermarkSettings.tsx`)与共享类型;采集分组、套图结果、历史结果按每页 24 张渲染,缩略图启用原生懒加载。套图/水印轮询仍由父组件集中管理。 |
| **本轮:商品轮播视频** | 试算页独立的「07 视频制作」卡片:AI 图优先选择、2–5 图按选择顺序合成为 810×1080/30fps/H.264 无音轨 MP4;进行中卡片为 3:4 灰底加载态,成片可预览、下载或删除。任务复用 `background_jobs`,刷新恢复进行中/失败/完成状态;取消与删除都会立即移除记录及视频文件。Worker 依赖系统 `ffmpeg`/`ffprobe`(Ubuntu 安装 `apt install -y ffmpeg`)。 |
| **本轮:生图任务与轮询** | 单张 AI 编辑改为提交统一异步任务并立即返回 `suite_id`;套图与单张弹窗均采用无重叠递归轮询、取消在途请求与失败退避;套图任务 ID 可在刷新页面后从浏览器会话恢复 |
| **本轮:持久化图片任务** | 套图、单张编辑与批量添加水印已迁入数据库任务表和独立 Worker;Pcore/RightAPI/通义万相保存第三方任务 ID 并可在重启后恢复轮询;水印逐图进度可刷新恢复、取消并按失败项重试,详见 [`background-jobs.md`](./background-jobs.md) |
| **本轮:关键 bug 修复** | ① axios 全局 Content-Type 导致 FormData 被 JSON 化 → 上传 422(`api.ts` 移除全局头,**今后勿设**);② TrialPage `load()` 置 loading 导致整页闪 Spin、卸载面板(杀生图轮询/丢勾选)→ 改静默刷新;③ web v1 页面因 cdn.tailwindcss.com 被墙整页无样式 → tailwind 运行时固化到 `web/vendor/tailwind.js` |
| **本轮:杂项** | 采买地址「打开地址」改图标按钮(与复制图标同款);start.command 自动构建+监听插件 |
### 验证口径(本轮均实测通过)
- 上传/生图/导出/批量删除/规划,全部真实接口+浏览器操作验证;Pcore 端到端 56s 出图并回写素材。
- 每个 UI 改动后 `pnpm -C studio exec tsc -b` 零错误;插件构建通过;`/api/health` 200。
## 6. ⚠️ 未提交改动清单(当前最大事项)
分支 `feat-2.1.0`,工作区仍有多项未提交改动,**建议按模块分 4-5 个 commit**:
1. **Pcore 生图接入 + 规划器修复 + 张数策略**
`server/services/{generator,planner,suite_service,prompts/__init__}.py`、`server/{api/suite.py,schemas/suite.py,config/settings.py}`、`studio/src/services/suite.ts`、`.env.example`、`docs/v2.1/{image-suite,api}.md`
2. **试算页 UI 迭代 + 采集箱批量操作 + 请求层修复**
`studio/src/pages/trial/{TrialSuitePanel,TrialImageGrids,TrialWatermarkSettings,trialImageTypes,TrialInfoPanel,TrialPage}.tsx`、`studio/src/pages/collection/CollectionPage.tsx`、`studio/src/services/api.ts`
3. **web v1 样式修复(CDN 固化)+ start.command 插件自动构建**
`web/ozonSeller.html`、`web/vendor/tailwind.js`(新目录)、`start.command`
4. **图片生命周期与水印回收**:`server/services/image_assets.py`、`server/services/storage.py`、`server/{api/materials,api/products,models/asset}.py`、迁移 `a41e8c7f4b20_add_asset_image_lifecycle.py`、试算页水印入口
5. **交接文档**:`docs/v2.1/{HANDOFF,image-asset-lifecycle,optimization-audit}.md`
提交前:`git checkout -- studio/tsconfig.app.tsbuildinfo`(构建产物,勿提交)。
## 7. 下一步(按优先级)
1. **提交**:按 §6 分组提交。
2. **手动全流程回归**(浏览器 + Chrome 真机扩展):采集 → 上报 → 试算页五区块 → 生成(Pcore)/编辑/删除 → 登记表录入/导出 CSV/组合码 → 清空。
3. **运营观察**:pcore 余额($5 起步)与 image2 分组日限(1.2K 张/天);RightCode 通道恢复情况(恢复后 nano-banana 系列可回归)。
4. **Phase D 待办**:批量 CSV 服务端化(`GET /api/export/trial-csv`);登记表数据存 localStorage(跨设备需落库)。
5. **远期**:鉴权/账户体系重做(从 `deps.py` 层重引);ISS-only 产品模式构建;`extension-v1/v2`、`web/`、`server/legacy/` 清理时机。
## 8. 已知坑(新会话必读)
| # | 坑 | 对策 |
|---|---|---|
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 server |
| 2 | 生图任务依赖独立 Worker | `start.command` 会启动;若手动运行 uvicorn,必须另开 `python server/worker.py`,否则任务会一直排队 |
| 3 | SQLite 仅适合单机 Worker | 当前通过租约恢复异常退出的任务;未来多实例前先迁移 PostgreSQL,再做多 Worker 竞争领取 |
| 4 | 外部模型任务可恢复范围不同 | Pcore/RightAPI/通义万相保存 provider task id 后可继续轮询;豆包/同步千问没有外部任务 id,进程恰在请求中断时只能重试 |
| 5 | **axios 实例勿设全局 Content-Type** | axios 1.x 遇「默认 JSON 头 + FormData」会把表单转 JSON 发送(`formDataToJSON`),multipart 接口全 422。已从 `api.ts` 移除;对象请求体 axios 会自动补 JSON 头 |
| 6 | `web/` 样式依赖公网 CDN | tailwind 已固化 `web/vendor/tailwind.js`;font-awesome/jsdelivr 若也断,同样方式下载到 `web/vendor/` 改引用 |
| 7 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
| 8 | `studio/tsconfig.app.tsbuildinfo` 是构建产物 | 提交前 `git checkout -- studio/tsconfig.app.tsbuildinfo` |
| 9 | 冻结代码不要投入 | `server/legacy/`、`extension-v1/`、`extension-v2/`、`web/`(本轮 CDN 固化属修复例外)、studio 商品编辑页的属性映射/发布区块 |
| 10 | AI 规划/生图花钱 | pcore 按 $ 扣费、RightCode 按张计费;联调时 plan 张数设 1-2、优先低成本模型 |
| 11 | TrialPage `load()` 勿再置 loading | 置 loading = 整页 Spin + 卸载面板 = 杀生图轮询/丢状态(已修,保持静默刷新) |
| 12 | 浏览器自动化调试怪癖(如 ZCode IAB) | 截图偶发超时(重试一次多半仍失败,改用几何测量/计算样式验证);文件上传需用 DataTransfer+input change 事件模拟;改代码后 React 事件偶发失灵 → reload 页面即恢复 |
| 13 | 图片上传/导出存在大小限制 | 默认单张 20 MB、采集 100 张、导出 50 张、ZIP 200 MB;超限接口返回 413。需要调整时改 `.env` 的 `IMAGE_*` / `EXPORT_*` 配置并重启服务 |
## 9. 关键文件速查
| 功能 | 前端(studio/src/) | 后端(server/) |
|---|---|---|
| 试算页骨架 | `pages/trial/TrialPage.tsx`(load() 是静默刷新,见坑 11) | — |
| 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` |
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
| 03 俄文文案 | `pages/product/CopyPanel.tsx`(共用) | `api/ai.py` + `services/deepseek.py` + `prompts/copy_ru.py` |
| 04 图片与生图(7 列网格/悬浮按钮/图名) | `pages/trial/TrialSuitePanel.tsx`(状态/轮询)+ `TrialImageGrids.tsx`(素材/结果卡片与分页)+ `AiImageGenModal.tsx` | `api/suite.py` + `services/{jobs,job_worker,suite_service,generator,planner,prompts/,watermark,image_assets}.py` |
| 图片原图/水印/删除回收 | `TrialSuitePanel.tsx`(状态与调用)+ `TrialWatermarkSettings.tsx`(设置与任务状态) | `api/materials.py`、`api/products.py`、`services/{image_assets,storage}.py` |
| 生图供应商路由 | `services/suite.ts`(IMAGE_MODEL_OPTIONS 带供应商名;DEFAULT_IMAGE_MODEL) | `schemas/suite.py`(MODEL_PROVIDERS/`@pcore` 约定)+ `services/generator.py`(generate_pcore 等) |
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — |
| 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` |
| 采集箱(全选/批量删除) | `pages/collection/CollectionPage.tsx` | `api/products.py`(DELETE hard=true) |
| 前端服务层 | `services/{suite,product,ai,fx,api}.ts` | `schemas/suite.py`(契约对齐) |
| 扩展上报 | `extensions/collector/src/api/report.ts` + `entrypoints/{background,sidepanel/App}.tsx` + `storage/settings.ts` | `api/materials.py` |
## 10. 验收标准(改完怎么算完成)
1. `pnpm -C studio exec tsc -b` 零错误、`pnpm -C studio build` 通过(提交前还原 `tsconfig.app.tsbuildinfo`)
2. 改扩展:`pnpm -C extensions/collector build` 通过
3. 改 server:`/api/health` 200、涉及接口 curl/页面冒烟通过
4. UI 改动:浏览器实测 + 必要时截图确认
+153
View File
@@ -0,0 +1,153 @@
# Ozon Seller Kit V2.1 方案总览(转向:放弃 API 直传,聚焦试算工作流)
> 状态:主链路已落地,持续打磨;后台任务中心第一、二阶段已完成
> 最后更新:2026-08-30
> 定位:本文是 V2.1 全部设计文档的入口与决策总表。先读本文,再按需读分册。
> 前置:`docs/v2/`(V2 方案)。V2.1 是对 V2 主链路的一次「降本转向」,不推翻 V2 的基础设施决策(数据库/存储/鉴权/工作台化)。
---
## 1. 背景与转向
V2 原定主链路是「采集 → 编辑 → **Ozon Seller API 直传发布**」。实施到发布环节发现:
ImportProductsV3 直传要求类目字典对齐、属性 id 映射(含 `is_aspect` 变体属性)、description_category_id/type_id 解析、
多变体合并规则、错误码逐条排查等大量细节字段对齐工作。对个人开发者而言实现成本过高、性价比不高。
V2.1 退而求其次,把主链路改为:
```
V2(原计划) V2.1(现在)
采集 → 采集箱 → 编辑 → API 直传发布 采集 → 采集箱 → 商品试算页 → CSV 导出
↑ ↑
大量 Ozon 字段对齐工作 人工在 Ozon 后台上品(粘贴/上传)
```
核心变化只有一条:**「上品」这一步从 API 自动化退回人工,把省下来的精力放在试算页的效率工具上**
(价格试算、俄文文案、AI 图片生成、采购地址维护、数据入库、CSV 导出)。
采集、采集箱、商品数据落库等 V2 已实现的基础全部保留。
---
## 2. 端到端数据流
```
① 浏览 Ozon / 1688 / 淘宝商详页
│ 插件页内悬浮面板 → 快速采集(采集方案以 image-suite-studio 为主要参考)
▼
② 插件 POST /api/materials 上传(texts + images,已实现)
│ 服务端落库为采集箱商品(stage=collected),异步下载转存图片
▼
③ 双动作(插件内完成):
├─ a. 同步到采集箱(已在 ② 落库)
└─ b. 自动打开商品试算页 {studio}/trial/{product_id}(新标签页)
▼
④ 商品试算页(studio 新页面,操作流水线对齐 web/ 工具台 v1):
├─ 01 商品信息核对/补录(标题、重量、尺寸、描述、采买地址、货号)
├─ 02 价格试算(进货价/净利率/贴单费/物流等级/预留折扣/汇率 → 销售价 ¥/₽、利润、成本)
├─ 03 俄文文案生成(AI 标题/简介/标签,一键回填)
├─ 04 图片与 AI 生图(采集图勾选 → 套图规划 → 一键生成;单张 AI 图生图;导出)
└─ 05 入库与导出(每步自动落库 product.pricing/copy;CSV / 组合码导出)
▼
⑤ 上品:人工在 Ozon 卖家后台录入,用 CSV / 组合码批量回填价格与货号
```
---
## 3. V2.1 决策总表(D 系列)
| 编号 | 决策 | 内容 | 理由 |
|---|---|---|---|
| **D1** | 放弃 Ozon API 直传 | `server/api/publish.py`、`services/publish.py`、店铺/类目代理等发布链路代码**保留但冻结**,不作为主链路;上品方式 = CSV 导出 + 人工后台录入 | ImportProductsV3 字段对齐工作量对个人不可承受;人工上品每品只花几分钟 |
| **D2** | 采集以 image-suite-studio 为主要参考 | **(方案三,已实施)ISS 扩展整体并入主仓 `extensions/collector/`,成为唯一采集工具;扩展新增「上报商品」(POST /api/materials + 自动打开 /trial/{id});extension-v2 原地冻结** | ISS 已跑通三平台采集;单仓维护避免引擎分叉;ISS 独立产品化远期靠构建/配置裁剪,不在代码层分叉 |
| **D3** | 采集完成双动作 | 上传 `/api/materials` 入库(已实现)+ 插件自动打开 `{studio}/trial/{product_id}` | 采集即进入试算流水线,减少手工跳转 |
| **D4** | 商品试算页 studio 化 | 新增 `/trial/:id` 页面,功能与操作流水线对齐 `web/ozonSeller.html`(计价/俄文文案/图片水印/采购地址/登记/CSV/组合码),数据从 localStorage 升级为 products 表落库 | web/ v1 已验证好用,冻结只读;studio 是其 React+antd 版本 |
| **D5** | 图片生成对齐 image-suite-studio | 套图规划(DeepSeek)→ 一键生成(多 provider 模型路由:豆包/通义/RightAPI)→ 服务端水印 → 导出 ZIP;生成图回写 `product_assets(generated)` | 套图能力已在 image-suite-studio 验证;服务端代码整体平移复用 |
| **D6** | 单张 AI 图生图 | 采集图与生成图的每个图片单元格都有「AI 生图」按钮:弹窗输入要求 + 选模型 → 单张生成 | 套图批量生成之外补充精修单图的能力 |
| **D7** | 前端先行 | 先开发试算页前端:服务层按本文 [`api.md`](./api.md) 契约编写;未实现的服务端接口以明确报错提示(不阻断页面其它功能),后再开发服务端 | 先把交互/流水线定型,服务端按前端契约补齐,避免返工 |
| **D8** | 存储沿用 V2 基础设施 | 商品/素材落 PostgreSQL(本地开发 SQLite),图片走 `services/storage.py` 抽象(本地 `data/media/` 兜底,七牛可选) | 不为转向重做存储;D1 冻结后七牛不再是硬依赖 |
---
## 4. 现状盘点(2026-08-26)
### 4.1 已实现、V2.1 直接复用
| 能力 | 位置 | 说明 |
|---|---|---|
| Ozon 采集上传落库 | `extension-v2/` + `server/api/collection.py` | `POST /api/materials`、`/api/materials/bytes`、去重、异步转存 |
| 采集箱 | `studio/src/pages/collection/CollectionPage.tsx` | 列表/筛选/删除/复制 |
| 商品 CRUD | `server/api/products.py` + `studio/src/services/product.ts` | 详情/PATCH 自动保存(raw/pricing/copy JSON 均可存) |
| 计价纯函数 | `studio/src/pricing/pricing.ts` | 从 web/app.js 抄录的核心公式(V2.1 需补尺寸校验等,见 trial-page.md) |
| 俄文文案 | `server/api/ai.py` + `studio/src/pages/product/CopyPanel.tsx` | `/api/ai/models`、`/api/ai/copy`,生成+回填交互完整 |
| 智能修图(万相) | `server/api/image.py` + `studio/src/pages/ai-image/` | `/api/image/edit` 保留,独立页面不动 |
| 汇率 | `server/api/fx.py` + `studio/src/services/fx.ts` | `GET /api/fx` |
| 素材上传(字节) | `server/api/collection.py:204` | `POST /api/materials/bytes`,试算页「上传图片」直接用 |
| 登录鉴权 | `server/api/auth.py` + studio RequireAuth | APP_TOKEN 换 JWT |
### 4.2 V2.1 待开发
| 能力 | 端 | 分册 |
|---|---|---|
| 商品试算页(5 区块) | studio 前端(**Phase A,本次**) | [`trial-page.md`](./trial-page.md) |
| 套图规划/一键生成/单张 AI 生图 前端 | studio 前端(**Phase A,本次**) | [`image-suite.md`](./image-suite.md) |
| suite 系列服务端接口(plan/generate/suites/image-edit/export) | server(Phase B,已完成);生成和水印已接入持久化后台任务 | [`api.md`](./api.md)、[`background-jobs.md`](./background-jobs.md) |
| 采集对齐 image-suite-studio + 自动打开试算页 | extension-v2(Phase C) | [`collect.md`](./collect.md) |
| 批量 CSV 服务端导出(可选优化) | server(Phase D) | [`api.md`](./api.md) §7 |
### 4.3 冻结(不删代码,不再投入)
- Ozon 发布链路:`server/api/publish.py`、`server/services/publish.py`、`server/api/categories.py`、`server/services/ozon_client.py`
- studio 商品编辑页的「属性映射」「发布」区块(页面保留,作为长期能力储备)
- `docs/v2/ozon-publish.md`、`docs/v2/multi-sku.md` 的直传方案(远期若重启再参考)
- `web/` v1 工具台(继续冻结只读,直到试算页功能对齐后废弃)
---
## 5. 分册索引
| 文档 | 内容 | 什么时候读 |
|---|---|---|
| [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 |
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
| [`multi-sku-pricing.md`](./multi-sku-pricing.md) | 多 SKU:采集契约、父商品/SKU 数据模型、试算页明细、CSV 导出与分阶段迁移方案 | 扩展规格级计价、导出或采集时读 |
| [`multi-sku-measurements.md`](./multi-sku-measurements.md) | **多 SKU 双规格**:实物规格与包装物流规格的字段、采集、试算、尺寸图、导出和迁移设计 | 改 SKU、尺寸、重量、试算或尺寸图前必读 |
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
| [`background-jobs.md`](./background-jobs.md) | **后台任务中心**:统一任务模型、Worker、状态、恢复、已完成阶段和后续路线图 | 改生图、水印或任何耗时任务前先读 |
| [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) | 图片原图、水印当前版、对象路径与删除回收流程 | 改图片、水印或存储前先读 |
| [`api.md`](./api.md) | 前后端 REST 契约(前端服务层已按此开发) | 前后端联调时读 |
| [`backend-structure.md`](./backend-structure.md) | 后端代码结构盘点:文件/接口/服务树形图、数据流、状态标注(在用/冻结/残留) | 改后端前先读 |
---
## 6. 实施阶段
| 阶段 | 内容 | 状态 |
|---|---|---|
| **Phase A** | docs/v2.1 方案文档 + 试算页前端(路由/入口/5 区块/服务层契约) | ✅ 完成 |
| **Phase C** | 采集对接:ISS 扩展并入 `extensions/collector/` + 「上报商品」开关 + 自动打开试算页(方案三,见 collect.md) | ✅ 完成 |
| **Phase B** | 服务端:planner/generator/prompts/watermark、套图/单图接口、图片回写与导出;已切为持久化任务 + 独立 Worker | ✅ 完成 |
| **Phase D** | 打磨:批量 CSV 服务端化、图片生命周期、水印增强、ISS-only 产品模式构建配置 | 🚧 部分完成 |
| **Phase E** | 后台任务中心第三阶段:当前商品任务抽屉已完成;全局面板、任务事件、调度公平性与幂等性增强待开发 | 🚧 进行中,见 [`background-jobs.md`](./background-jobs.md) |
每个阶段结束都可独立运行:Phase A 结束时,试算页的计价/文案/素材上传/入库立即可用(对接已有接口),套图相关按钮点击会提示「服务端接口未实现」。
---
## 7. 与 V2 文档的关系
- V2 的基础设施决策(D1 契约云端化、D3 PostgreSQL、D6 工作台化、D7 鉴权)**全部沿用**。
- V2 的 D 图片方案 B(套图 + 智能修图)**升级落地**:套图引擎不再自研,直接平移 image-suite-studio 的服务端实现。
- V2 的发布链路(`ozon-publish.md`)**冻结**;`api.md` 中的 `/api/publish`、`/api/categories` 不再是主链路。
- 若存在分歧,以 `docs/v2.1/` 为准。
---
## 8. 关键风险
| 风险 | 级别 | 对策 |
|---|---|---|
| 模型生成耗时长、可能跨服务重启 | 🟡 中 | 本仓已用持久化父/子任务 + 独立 Worker;Pcore/RightAPI/通义万相保存外部 task id 后可继续轮询,详见 `background-jobs.md` |
| 采集图防盗链(Ozon/阿里 CDN)在 studio 页面直接展示可能失败 | 🟡 中 | 素材展示优先 `stored_url`(服务端已转存);Phase B 补 `/api/proxy-image` 兜底 |
| 前端先行的契约与服务端实现不一致导致返工 | 🟡 中 | api.md 即契约真源,服务端按文档实现;字段命名对齐 image-suite-studio 已验证的结构 |
| CSV 人工上品仍是手工活 | 🟢 低 | 组合码 + CSV 已是 web/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 |
+132
View File
@@ -0,0 +1,132 @@
# V2.1 API 契约(新增 + 复用)
> 状态:前端服务层已按本文开发(Phase A);服务端 Phase B 按 本文实现。
> 通用:鉴权 `Authorization: Bearer <JWT>`(studio 走 axios 拦截器自动附带);错误统一 FastAPI `detail`。
---
## 1. 复用的现有接口(Phase A 即可用)
| 接口 | 用途(试算页) |
|---|---|
| `GET /api/products/{id}`、`PATCH /api/products/{id}` | 商品详情 / 试算页自动保存(raw/pricing/copy/price/weight/... 全字段) |
| `GET /api/products/{id}/assets` | 素材列表(分组/规格/状态/current `stored_url`、`original_url`、水印状态) |
| `GET /api/products?stage&q&page&page_size` | 批量 CSV / 组合码导出的数据源 |
| `POST /api/materials/bytes` | 试算页「上传图片」(FormData:`product_id, group_key=upload, file`)→ `{asset_id, status}` |
| `GET /api/fx` | 汇率 `{rate, source, ts}` |
| `GET /api/ai/models`、`POST /api/ai/copy` | 俄文文案(模型白名单 / 生成) |
| `POST /api/image/edit` | 智能修图页保留,不在试算页主链路 |
## 2. 套图规划 `POST /api/suite/plan`(Phase B)
**Req**
```jsonc
{
"product_id": "uuid", // 可选:便于日志与上下文缓存
"texts": [ { "kind": "title|price|params|selling_point|desc|brand|sales|shop",
"content": "…", "pairs": [ { "key": "…", "value": "…" } ] } ],
"sku_variants": ["粉色", "蓝色"],
"image_stats": { "main": 8, "sku": 6, "detail": 12 },
"platform": "ozon",
"requirements": "…|null" // 用户强制要求,最高优先级
}
```
**Res** `{ "summary": "一句话方案说明", "items": [ { "kind": "white_bg", "title": "白底主图", "detail": "…", "prompt_hint": "…", "count": 2, "variant_name": "粉色|null" } ] }`
实现要点:DeepSeek 单行紧凑 JSON;kind 白名单清洗;count 钳 0-3;幻觉 variant 丢弃绑定;总张数 AI 自主(建议 8 张左右;安全上限:默认 15,生图要求指定「N 张」时按 N、最大 24)。同步接口(数秒级)。
## 3. 一键生成 `POST /api/suite/generate`(Phase B)
**Req**
```jsonc
{
"product_id": "uuid",
"texts": [ …同上… ],
"images": [ { "url": "…", "group_key": "main|sku|detail|upload", "variant_name": "粉色|null" } ], // 勾选参考底图
"style_set": 1,
"style_prompt": "…|null", // 用户改写的风格提示词,覆盖后端模板
"requirements": "…|null",
"plan": [ …PlanItem(count>0)… ],
"platform": "ozon",
"model": "gpt-image-2-vip",
"watermark": { "enabled": true, "type": "image|text", "text": "xiongmaoyx", "opacity": 30 } // 关闭时缺省
}
```
**Res** `{ "suite_id": "…" }`
实现要点:方案按 count 展开为持久化逐图子任务;独立 Worker 领取子项并在完成后回写进度。Pcore、RightAPI 与通义万相的第三方任务 ID 会先写入数据库,重启后继续轮询同一个模型任务;完成后再水印、落 storage、追加 `product_assets(group_key='generated')`。
## 4. 任务查询 `GET /api/suites/{id}`(Phase B)
**Res**
```jsonc
{
"id": "…", "status": "queued|running|done|partial|failed|cancelled",
"style_set": 1, "platform": "ozon", "lang": "俄文", "ratio": "3:4", "provider": "rightapi",
"total": 8, "completed": 3, "failed": 1,
"images": [ { "type_id": "white_bg", "name": "白底主图-01.png", "url": "/media/suites/…png",
"status": "queued|waiting_provider|running|done|failed|cancelled", "error": null } ],
"error": null
}
```
前端采用“本次请求结束后再延迟下一次”的递归轮询(正常 3s、请求失败指数退避),并在切换任务、组件卸载时取消在途请求;`done/partial/failed/cancelled` 为终态。每条 `images` 项另含 `item_id`,用于按图重试。浏览器会暂存当前任务 ID,并从服务端查询商品的活动任务;Web 服务或 Worker 重启后仍可恢复任务状态。
辅助接口:`GET /api/products/{product_id}/suites?active_only=true` 恢复活动任务;`POST /api/suites/{id}/cancel` 取消任务;`POST /api/suites/{id}/retry` 重试全部失败子项;`POST /api/suites/{id}/items/{item_id}/retry` 只重试指定失败项。
## 5. 结果导出 `GET /api/suites/{id}/zip`(Phase B)
→ `application/zip` blob(按方案标题命名内部文件)。404/失败返回 `detail` JSON。
## 6. 单张 AI 生图 `POST /api/suite/image-edit`(Phase B)
**Req**
```jsonc
{
"product_id": "uuid",
"image_url": "…", // stored_url 或 source_url(服务端代下绕防盗链)
"prompt": "把背景换成纯白色摄影棚", // 必填
"model": "nano-banana-2",
"append": true // true:结果追加 product_assets(generated)
}
```
**Res** `{ "suite_id": "…" }`
接口仅创建并提交统一图片任务,立即返回;前端通过 `GET /api/suites/{suite_id}` 查询状态和 `images[0].url`。完成后若 `append=true`,结果会回写为 `product_assets(group_key='generated')`。单张编辑优先级高于套图,提交模型后同样持久化第三方任务 ID;参考图 = 传入单图;Ozon 规格(俄文 3:4)。
## 7. 素材水印 `POST /api/assets/watermark` / `POST /api/assets/watermark/remove`
`POST /api/assets/watermark` 的请求体为 `{ "asset_ids": ["uuid"], "watermark": { "enabled": true, "type": "text|image", "text": "…", "opacity": 30 } }`,立即返回 `{ "job_id": "uuid", "total": n }`。Worker 对选中的采集图或生成图逐张应用水印,前端经 `GET /api/suites/{job_id}` 查看逐图进度和错误;`GET /api/products/{product_id}/watermark-jobs?active_only=true` 用于页面刷新后恢复。
`POST /api/assets/watermark/remove` 的请求体为 `{ "asset_ids": ["uuid"] }`,将选中图片恢复为无水印原图。
`POST /api/assets/watermark/remove` 当前仍为短时同步操作,返回 `{ "updated_ids": [], "failed": [] }`。两接口均只保留原图和当前展示版本:重新加水印会在数据库提交后删除旧水印文件;前端展示、单张下载与导出均使用 `stored_url`。
## 8. 商品轮播视频
- `POST /api/products/{product_id}/video-jobs`:请求 `{ "asset_ids": ["uuid", "uuid"] }`,按传入顺序选择当前商品的 2–5 张已转存图片,立即返回 `{ "job_id": "uuid" }`。服务端只接受素材 ID,不接受任意图片 URL。
- `GET /api/products/{product_id}/video-jobs`、`GET /api/video-jobs/{job_id}`:读取任务和成片状态;`POST /api/video-jobs/{job_id}/cancel` 会取消并立即清理任务记录,`POST /api/video-jobs/{job_id}/retry` 重试失败任务,`DELETE /api/video-jobs/{job_id}` 删除成片及记录,`GET /api/video-jobs/{job_id}/download` 下载 MP4。
- Worker 使用 FFmpeg 合成无音轨轮播视频:AI 生成图在前端选择器中优先展示;支持 2–5 张,按 4.5 / 3.5 / 3.0 / 2.8 秒单图停留并使用 0.35 秒淡入淡出。默认输出为 `810×1080`、`30 fps`、`H.264`、`yuv420p`、最大 45 MB,结果存入 `products/{product_id}/videos/{job_id}/result.mp4`。
- 需要系统安装 `ffmpeg` 与 `ffprobe`(Ubuntu:`apt install -y ffmpeg`);路径和大小/超时限制可通过 `VIDEO_*` 环境变量覆盖。任务复用 `background_jobs`,无需新增视频业务表;进行中、失败和已完成成片可在刷新后继续查看,取消会立即移除任务及临时文件,不保留记录。
## 9. 导出与代理(Phase B / D)
| 接口 | 方法 | Req | Res |
|---|---|---|---|
| `/api/export/images` | POST | `{ "title": "…", "images": [{ "url", "groupName", "variantName", "key" }] }` | zip blob(分组建文件夹) |
| `/api/proxy-image?url=` | GET | 源站图片 URL | 图片字节(带站点 Referer 代下,绕防盗链) |
| `/api/export/trial-csv` | GET | query:`stage?` | csv blob(16 列,见 trial-page.md §7;Phase D 服务端化,替代前端拼装) |
图片资源上限:默认单张上传/远程下载 20 MB;一次导出最多 50 张,ZIP 内源图片总量与成品 ZIP 均最多 200 MB。超过限制返回 HTTP `413` 及可读错误提示;ZIP 超过 2 MB 自动写入临时磁盘,并以分块响应下载。
图片校验与元数据:图片上传、采集转存和 AI 生成图写入素材库时,服务端以实际字节识别 JPEG、PNG、WebP、GIF 或 BMP,不信任 URL 后缀和上传声明的 MIME。解码失败或不支持的格式返回/记录为失败;默认限制为 40,000,000 像素(`IMAGE_MAX_PIXELS`),并记录 `product_assets.width`、`height`。静态图片若带 EXIF 方向,会在落库前纠正并移除该方向标记;动画图不重编码,以避免丢帧。`POST /api/materials/bytes` 成功响应会额外返回 `width`、`height`,不合规图片返回 `422`。
远程图片安全规则:`/api/proxy-image`、采集图转存、AI 参考图及远程导出统一只请求公网 `http(s)` 地址;`localhost`、回环、内网、链路本地、多播和其他保留 IP 会返回 `400`。重定向不会自动跟随:服务端最多跟随 3 跳,并在每一跳重新解析和校验目标地址。已转存的本项目 `/media/...` 素材直接读取本地磁盘,不经过 HTTP 回环请求。下载仍受 `IMAGE_DOWNLOAD_MAX_BYTES` 限制,连接/读写/总超时与重定向次数可通过 `IMAGE_DOWNLOAD_CONNECT_TIMEOUT`、`IMAGE_DOWNLOAD_READ_TIMEOUT`、`IMAGE_DOWNLOAD_TOTAL_TIMEOUT`、`IMAGE_DOWNLOAD_MAX_REDIRECTS` 配置;下载超时返回 `504`。
## 10. 采集相关(Phase C 使用,均已实现)
- `POST /api/materials`:插件上传采集结果;**响应含 `product_id`**(插件用于打开 `/trial/{id}`)。
- `GET /api/collected?platform=&itemId=`:采集前查重。
## 11. 冻结接口(不投入,保留代码)
`/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。
+161
View File
@@ -0,0 +1,161 @@
# V2.1 后端代码结构盘点(server/)
> 状态:现状盘点(2026-08-28,鉴权移除后)
> 用途:一眼看清后端有哪些代码、各自处理什么逻辑、哪些在用哪些冻结。做后端改动前先读本文。
> 契约细节另见 [`api.md`](./api.md)。
---
## 1. 总览
共 5270 行 Python。分层:`api/`(HTTP 路由)→ `services/`(业务逻辑)→ `models/`(ORM 表);`schemas/`(Pydantic 契约)、`core/`(AES 加解密)、`config/`(配置)、`migrations/`(Alembic)。
状态标注:✅ 在用 · 🧊 冻结(保留不维护)· 🗑 残留(可删)。
```
server/
├── main.py 【72·入口】
│ ├── FastAPI app + CORS + startup create_all(幂等建表,生产以 Alembic 为准)
│ ├── 挂载 12 个路由器(auth 已移除)
│ ├── /media 静态托管 data/media/(本地图片)
│ └── / 挂载 web/(v1 工具台,冻结)
│
├── api/ ──────────────────────────── 路由层(HTTP 接口,冻结链路已移至 legacy/)
│ ├── materials.py 【267 ✅核心】(原 collection.py,与 /api/materials 路径对齐)
│ │ ├── POST /api/materials 插件采集上报入库(建/复用商品 + 文本解析 + 图片队列下载转存)
│ │ ├── POST /api/materials/bytes 直接上传图片字节(试算页「上传图片」)
│ │ ├── GET /api/products/{id}/fingerprints 素材去重指纹
│ │ └── GET /api/collected 按 platform+itemId 查重(扩展防重复上报)
│ ├── products.py 【178 ✅核心】
│ │ ├── GET·POST /api/products 商品列表 / 新建
│ │ ├── GET·PATCH·DELETE /api/products/{id} 详情 / 自动保存 / 删除(软删 archived | 硬删)
│ │ └── POST /api/products/{id}/copy 复制为新变体(重置货号/图片防冲突)
│ ├── suite.py 【✅核心·Phase B】
│ │ ├── POST /api/suite/plan DeepSeek 出图方案规划(同步,数秒级)
│ │ ├── POST /api/suite/generate 一键生成:方案展开→持久化子任务→Worker 生图→回写 generated 素材
│ │ ├── GET /api/suites/{id} 任务状态轮询(前端 3s 一次)/ cancel / retry
│ │ ├── GET /api/products/{id}/suites 恢复商品活动任务
│ │ ├── GET /api/suites/{id}/zip 生成结果打包下载(中文文件名)
│ │ └── POST /api/suite/image-edit 单张 AI 生图(试算页每图按钮,append=true 回写素材)
│ ├── export.py 【121 ✅】POST /api/export/images 采集图片打包 ZIP(标题/分组/SKU规格 文件夹结构)
│ ├── proxy.py 【54 ✅】 GET /api/proxy-image?url= 图片防盗链代理(带站点 Referer 代下)
│ ├── fx.py 【13 ✅】 GET /api/fx 汇率(FloatRates→俄央行→er-api 三级回退 + 区间校验)
│ ├── ai.py 【~30 ✅】 GET /api/ai/models、POST /api/ai/copy 俄文文案生成(试算页 03 区块)
│ ├── image.py 【~12 保留】POST /api/image/edit 万相 wanx2.1 图像编辑(智能修图页专用)
│ ├── shops.py 【118 🧊冻结】店铺 CRUD + 连通测试(发布链路配套,AES 加密凭证)
│ ├── categories.py 【117 🧊冻结】Ozon 类目树/属性/字典值代理
│ ├── publish.py 【179 🧊冻结】Ozon ImportProductsV3 直传 + 任务轮询
│ └── ozon.py 【5 🧊冻结】空占位(远期直传重启时用)
│
├── services/ ──────────────────────── 业务逻辑层
│ ├── generator.py 【✅核心】生图 provider(doubao / tongyi / rightapi / pcore)
│ │ + 可恢复的 submit/poll 推进器 + 参考图解析
│ │ (variant 精确匹配 SKU 图 → 回退 main 首张;≤2 张转 data-URI)
│ ├── planner.py 【227 ✅核心】DeepSeek 出图方案规划器
│ │ (单行 JSON 解析 / kind 白名单 / count 钳 0-3 / 幻觉 SKU 丢弃绑定 / 截断修复)
│ ├── prompts/ 【✅核心】生图提示词引擎(按模型家族分发)
│ │ ├── __init__.py (provider, model) → 家族路由 + build_prompt / build_context / type_name
│ │ ├── common.py 10 种图类型 builder(white_bg/lifestyle/…)+ 5 套风格 + 图内文案语言规范
│ │ ├── alibaba.py 通义系「主体参考」语义(doubao 复用):商品逐像素一致,只改背景/角度
│ │ ├── gpt.py gpt-image-2「edits 保真」语义:以参考图为准,禁止按文案重造商品
│ │ ├── google.py nano-banana「主体保持」语义:一句话钉死主体 + 自然语言指令
│ │ └── doubao.py 豆包(复用 alibaba)
│ ├── jobs.py 【✅核心】持久化任务仓库、租约领取、取消/失败重试、任务视图
│ ├── job_worker.py【✅核心】独立 Worker:逐图提交/轮询外部模型、落图与回写素材
│ ├── tasks.py 【兼容】旧内存任务执行器,活跃 API 不再使用
│ ├── watermark.py 【122 ✅】Pillow 水印合成(图片徽章/文字,右下角,失败回退原图)
│ ├── deepseek.py 【182 ✅】DeepSeek chat 封装(俄文文案 + 出图规划共用;JSON 容错解析)
│ ├── models_catalog.py【84 ✅】读 config/models.yaml(俄文文案模型白名单:快/省、质量两档)
│ ├── storage.py 【117 ✅】存储抽象:Local(data/media/,/media 托管)+ Qiniu(七牛,生产)
│ │ + local_path(stored_url→本地路径,ZIP 打包用)+ download_bytes(带 Referer 代下)
│ ├── fx.py 【58 ✅】多源汇率 + [5,25] 区间脏数据校验(v1 移植)
│ ├── image_edit.py【185 保留】wanx2.1-imageedit 多模型图像编辑(智能修图页后端)
│ └── suite_service.py【~95 ✅】套图业务逻辑(api 层下沉):texts_to_raw / 模型白名单校验 /
│ append_generated_asset(generated 回写 + asset_counts 累加)
│
├── legacy/ ────────────────────────── 🧊 冻结代码(Ozon API 直传链路,接口仍挂载但不再投入)
│ ├── api/ shops(店铺 CRUD+连通测试)/ categories(类目字典代理)/
│ │ publish(ImportProductsV3 直传)/ ozon(空占位)
│ ├── services/ ozon_client(Seller API 薄封装)/ publish(请求体组装+必填校验)
│ ├── models/ shop / publish_task / category(字典缓存三表)
│ └── schemas/ shop
│
│
├── schemas/ ────────────────────────── Pydantic 契约(请求/响应模型)
│ ├── suite.py 【160 ✅】SuitePlanRequest / SuiteGenerateRequest / SuiteOut /
│ │ ImageEditSingleRequest(product_id + append → 回写)/ WatermarkOptions / 模型白名单
│ ├── collection.py 【✅】MaterialsRequest(camelCase:source.itemId / groupKey / variantName,对齐扩展)
│ ├── product.py 【102 ✅】列表 / 详情 / 部分更新(自动保存)
│ ├── copy.py 【✅】俄文文案(titles_ru/zh、description、tags、usage)
│ ├── image_edit.py 【125 保留】智能修图(模型白名单 / function / 强度)
│ └── (shop 已移至 legacy/schemas/;auth.py 残留已删除)
│
├── models/ ─────────────────────────── SQLAlchemy ORM(表结构)
│ ├── product.py products 商品主表(Ozon 字段 + raw/pricing/copy 三个 JSON 扩展)
│ ├── asset.py product_assets 素材表(group_key: main/sku/detail/generated/upload/…;stored_url;dedupe_key)
│ ├── user.py users(预留空置;鉴权移除后暂无用途,表已在库)
│ ├── enums.py stage 状态机(collected→editing→ready→…→archived)等枚举
│ └── types.py JSON/JSONB 兼容类型
│
├── core/security.py 【保留】店铺凭证 AES-GCM 加解密(shops/categories/publish 使用)
│ 鉴权(JWT/APP_TOKEN)已按 V2.1 决策移除,加账户体系时在此重引
├── db.py 【50】async engine + session 工厂(SQLite 开发 / PostgreSQL 生产)
├── config/
│ ├── settings.py 【94】pydantic-settings:database_url / DEEPSEEK·DASHSCOPE·ARK·RIGHTAPI 密钥 /
│ │ image_provider / request_timeout(300s) / poll_max_wait(600s) / 存储与水印配置
│ └── models.yaml 俄文文案模型白名单(deepseek-v4-flash 默认 / deepseek-v4-pro)
└── migrations/ Alembic(2 个版本:initial_v2_schema、add_shop_id_to_products)
```
---
## 2. 接口一览(20 个端点,全部无鉴权)
| 分组 | 端点 | 用途 | 消费方 |
|---|---|---|---|
| **采集上报** | POST /api/materials | 采集结果入库(返回 product_id) | extensions/collector「上报商品」 |
| | POST /api/materials/bytes | 上传图片字节(FormData) | 试算页「上传图片」 |
| | GET /api/collected?platform=&itemId= | 采集前查重 | 扩展面板 |
| | GET /api/products/{id}/fingerprints | 素材去重指纹 | 扩展 |
| **商品库** | GET·POST /api/products | 列表 / 新建 | 采集箱、登记表导出 |
| | GET·PATCH·DELETE /api/products/{id} | 详情 / 自动保存 / 删除 | 试算页、编辑页 |
| | POST /api/products/{id}/copy | 复制为新变体 | 采集箱 |
| | GET /api/products/{id}/assets | 素材列表 | 试算页 04 区块 |
| **套图生图** | POST /api/suite/plan | DeepSeek 出图方案 | 试算页「AI 智能规划」 |
| | POST /api/suite/generate | 提交套图任务(返回 suite_id) | 试算页「一键生图」 |
| | GET /api/suites/{id} | 任务状态轮询 | 试算页进度条 |
| | GET /api/suites/{id}/zip | 结果打包下载 | 试算页「导出 ZIP」 |
| | POST /api/suite/image-edit | 单张 AI 生图(可回写素材) | 试算页每图「AI 生图」 |
| **辅助** | GET /api/fx | 汇率 | 试算页计价 |
| | GET /api/ai/models · POST /api/ai/copy | 文案模型 / 生成 | 试算页 03 区块 |
| | POST /api/export/images | 采集图片 ZIP | 试算页「下载采集图片」 |
| | GET /api/proxy-image | 防盗链代理 | 前端图片回退 |
| | POST /api/image/edit | 万相图像编辑 | 智能修图页 |
| | GET /api/health | 健康检查 | 运维 |
| **🧊冻结** | /api/shops/* · /api/categories/* · /api/publish/* · /api/ozon/* | Ozon 直传链路(代码在 legacy/,接口仍挂载) | 无(保留代码) |
---
## 3. 核心数据流
```
extensions/collector 采集
→ POST /api/materials(collection.py 落库 products + 排队下载图片 → storage 落盘)
→ 试算页 /trial/{id}(studio)
├─ 01/02/03 编辑 → PATCH /api/products/{id} 自动保存(raw/pricing/copy JSON)
├─ 04 生图:POST /api/suite/plan(planner+deepseek)→ POST /api/suite/generate
│ → background_jobs / items → 独立 Worker → generator(prompts 家族 → provider API → watermark → storage)
│ → 每张成功回写 product_assets(generated) → 前端轮询 GET /api/suites/{id} + 刷新素材
└─ 05 登记(localStorage)→ 导出 CSV / 组合码(纯前端)
```
## 4. 已知特性与注意事项
| 项 | 说明 |
|---|---|
| 生图任务依赖独立 Worker | `start.command` 已自动启动 `python server/worker.py`;手动启动 uvicorn 时也必须启动 Worker |
| 外部任务恢复 | Pcore、RightAPI、通义万相的 provider task id 会保存到数据库,重启后继续轮询原任务;同步 provider 无外部 id 时只能重试 |
| 全部接口无鉴权 | V2.1 决策:先功能后鉴权;重新引入时从 `deps.py` + 路由依赖层加 |
| 残留可删 | `schemas/auth.py`(无引用);`api/ozon.py`(空占位,随冻结链路保留) |
| 密钥 | `.env`:DEEPSEEK_API_KEY(规划+文案)、RIGHTAPI_API_KEY(gpt/nano 生图)、DASHSCOPE_API_KEY(通义)、ARK_API_KEY(豆包,可选) |
| 前端契约 | 试算页 `studio/src/services/suite.ts` 与 `schemas/suite.py` 逐字段对齐(联调已验证) |
+281
View File
@@ -0,0 +1,281 @@
# 后台任务中心:设计、实施状态与路线图
> 更新:2026-08-30
> 适用范围:套图生成、单张 AI 生图 / AI 编辑、批量添加水印,以及未来的耗时导出和批处理。
> 阅读顺序:接手相关开发前,先读本文,再读 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) 与 [`api.md`](./api.md)。
## 1. 结论与边界
“单图弹窗等待”、“套图生成”和“批量水印”本质都是耗时、可失败、需要恢复的后台操作,不应分别实现异步逻辑。项目统一采用:
```text
Web API
└─ 在数据库创建父任务 + 子项,立即返回任务 ID
独立 Worker
└─ 领取一个子项 → 调模型 / 合成水印 / 保存图片 → 回写进度与结果
试算页任务入口(当前)/ 任务中心(后续)
└─ 查询状态、恢复进度、取消、重试、打开结果
```
当前是本地单机、SQLite 架构,**不引入 Redis、Celery 或分布式队列**。数据库驱动的独立 Worker 已能覆盖断开弹窗、刷新页面、Web 服务重启、模型长耗时和失败重试,复杂度与收益最平衡。未来扩展到多机器或多实例时,再迁移 PostgreSQL 并评估 Redis / Celery / RQ 等队列。
本文中的“任务中心”指统一的数据模型、调度和用户可见的任务体验;不意味着每一种任务各有一套后台实现。
---
## 2. 产品体验
### 2.1 目标体验
单张“AI 生图 / AI 编辑”弹窗只负责配置,不负责等待:
1. 用户填写要求、选择模型,点击“提交任务”。
2. 页面立即提示“已加入任务队列”,弹窗关闭;用户可继续编辑商品或离开当前页面。
3. 页面提供“处理中(N)”入口,任务完成后图片自动出现在“生图记录”。
4. 套图与批量水印同样进入任务中心,可查看进度、失败原因、取消、重试和结果。
单张 AI 生图和 AI 编辑保留不同入口、提示词与来源图,但后端都属于 `image_edit` 单图生成任务;区别只在任务快照和标题。
### 2.2 统一状态
| 用户看到的状态 | 父任务 / 子项状态 | 含义 |
|---|---|---|
| 排队中 | `queued` | 等待 Worker 领取。 |
| 处理中 | `running` | 正在生成、下载或合成水印。 |
| 等待模型结果 | 子项 `waiting_provider` | 已提交给第三方模型,Worker 后续轮询同一个模型任务。 |
| 已完成 | `done` | 所有子项成功。 |
| 部分完成 | `partial` | 至少一个成功,且有失败或取消项。 |
| 已失败 | `failed` | 没有成功项,失败原因可查看与重试。 |
| 已取消 | `cancelled` | 用户取消;后续结果不会回写商品。 |
### 2.3 当前已呈现的界面
- 单图弹窗提交成功后关闭,生成进度由“生成结果”区域继续跟踪。
- 套图在“生成结果”区域展示排队、进行、完成、取消、失败重试。
- 水印设置浮层展示本次批量水印进度、取消、全部失败项重试和单图重试。
- 试算页右下角“录入商品”上方有固定“任务 / 处理中(N)”入口;点击打开当前商品的右侧任务抽屉,统一显示套图、单图与水印任务。
- 两类任务均会在刷新试算页后恢复跟踪。
**尚未完成的是应用级全局“任务中心”**:当前抽屉只展示当前商品,且生成结果区和水印设置浮层仍保留上下文状态提示。见第 9 节第三阶段。
---
## 3. 统一数据模型
### 3.1 已实现表
#### `background_jobs`(父任务)
一条用户可感知的后台操作,关键字段包括:
| 字段 | 用途 |
|---|---|
| `id`、`product_id`、`kind` | 任务身份、归属商品和任务类型。商品删除后 `product_id` 置空,避免任务反向复活商品。 |
| `status`、`priority`、`cancel_requested` | 调度与取消控制。 |
| `provider`、`model`、`platform`、`lang`、`ratio`、`style_set` | 生图任务的可恢复执行上下文。 |
| `request_payload` | 不可变请求快照:商品上下文、参考图、提示词、风格、水印配置等。 |
| `total`、`completed`、`failed`、`error` | 整体进度和概要错误。 |
| `created_at`、`started_at`、`completed_at`、`updated_at` | 任务历史、排序和诊断。 |
#### `background_job_items`(子项)
可独立领取、失败和重试的最小单元:
| 场景 | 父任务 | 子项 |
|---|---|---|
| 套图生成 | 一次套图请求 | 一张计划生成图 |
| 单张 AI 生图 / 编辑 | 一次编辑请求 | 唯一的一张图 |
| 批量添加水印 | 一次批量操作 | 一张商品素材 |
子项保存 `payload`、状态、尝试次数、租约、结果 URL、生成回写素材 ID、错误、下次执行时间,以及第三方 `provider_task_id` / `provider_task_kind`。
### 3.2 任务类型
| 类型 | 当前状态 | 说明 |
|---|---|---|
| `image_suite` | 已实施 | 套图按“每张图一个子项”执行。 |
| `image_edit` | 已实施 | 单张 AI 生图 / AI 编辑。 |
| `watermark_batch` | 已实施 | 已选素材逐张生成当前水印版。 |
| `export_zip` / 批量下载 | 待开发 | 可复用同一父/子任务模型处理长时间 ZIP。 |
| 孤儿文件清理 | 待开发 | 后台扫描并回收存储异常残留。 |
### 3.3 可选诊断表(未实施)
未来新增 `background_job_events`,记录“提交模型”“获取 provider task ID”“轮询失败”“租约恢复”“用户取消”“人工重试”等事件。它不参与调度,作用是排障、审计和任务中心时间线;当前诊断信息主要存于子项错误文本和服务日志。
---
## 4. 执行、恢复与一致性
### 4.1 Worker 执行流程(已实施)
`start.command` 会额外启动 `python server/worker.py`。Worker 通过数据库租约领取一个符合条件的子项:
```text
queued / waiting_provider
→ Worker 写入 running + 租约
→ 执行一次推进
├─ 模型未完成:持久化 provider_task_id → waiting_provider → 延迟下次轮询
├─ 成功:保存图片 / 回写素材 → done
├─ 可诊断错误:failed
└─ 已取消或商品不存在:cancelled
→ 从所有子项重新计算父任务进度与终态
```
Worker 异常退出时,过期租约会重新回到队列;Web 服务重启不影响已提交的数据库任务。
### 4.2 第三方模型恢复(已实施范围)
- **Pcore、RightAPI、通义万相**:提交后先持久化 `provider_task_id`,Worker 重启后继续轮询同一模型任务,不重复扣费。
- **豆包、同步千问等同步接口**:供应商不提供可恢复的外部任务 ID。若进程恰好在请求期间中断,只能重试,无法保证供应商侧幂等。
- **仍待完善的极小窗口**:若“已向供应商提交”与“成功写入 `provider_task_id`”之间进程崩溃,当前无法完全避免重复提交。第三阶段应引入供应商幂等键;不支持幂等键时,把子项标为“待人工确认”,不盲目重复扣费。
### 4.3 图片生命周期配合(已实施)
批量水印任务创建时会冻结:
- `asset_id`;
- 当时的 `current_storage_key` / 当前图片 URL;
- 水印配置快照。
Worker 始终基于素材 `original` 无水印版本合成新的 `current` 水印版。执行时若素材已删除或 current 版本已经变化,子项失败而不会用旧任务覆盖新图片;单项重试会重新冻结该素材的最新 current 版本。数据库成功切换到新 current 后,才尝试删除旧水印对象。
详细的原图、当前版、删除回收规则见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md)。
### 4.4 删除与取消(已实施)
删除商品前会取消该商品所有未完成任务。Worker 在落盘和回写素材前再次检查取消标志与商品是否存在;若商品或素材已删除,只丢弃/清理本次临时结果,绝不“复活”商品或素材记录。
---
## 5. 调度与优先级
### 当前实现
当前为一个独立 Worker 进程,按子项粒度领取,排序为优先级优先、再按创建时间:
| 类型 | 当前优先级 | 含义 |
|---|---:|---|
| `image_edit` | `10` | 用户临时单图编辑优先。 |
| `image_suite` | `0` | 常规套图。 |
| `watermark_batch` | `-10` | 低优先级,不应优先于模型生成。 |
这已避免“十张套图作为整体长期占住任务”的问题:套图也是逐张领取,单图编辑可插队。
### 后续目标(未实施)
AI 生成与水印不应长期共用单线程吞吐:
1. AI 按供应商设置串行或小并发及速率限制;单图编辑优先。
2. 批量水印使用独立逻辑队列 / Worker 池,建议并发约 2,不阻塞模型任务。
3. 调度增加公平性:连续处理 2–3 个高优先级单图后,给套图一个执行机会,避免套图饿死。
在 SQLite 单机阶段,这可先通过两个启动参数不同的 Worker 和类型过滤实现;迁移 PostgreSQL 后再做跨进程可靠并发领取。
---
## 6. API 与前端契约
### 当前接口
| 接口 | 返回 / 用途 |
|---|---|
| `POST /api/suite/generate` | 返回 `{ suite_id }`,创建 `image_suite`。 |
| `POST /api/suite/image-edit` | 返回 `{ suite_id }`,创建 `image_edit`。 |
| `POST /api/assets/watermark` | 返回 `{ job_id, total }`,创建 `watermark_batch`。 |
| `GET /api/suites/{id}` | 通用任务详情、父任务进度、逐项状态和错误。 |
| `GET /api/products/{id}/suites?active_only=true` | 恢复活动的套图 / 单图生成任务。 |
| `GET /api/products/{id}/watermark-jobs?active_only=true` | 恢复活动的水印任务。 |
| `POST /api/suites/{id}/cancel` | 取消任务中尚未完成的子项。 |
| `POST /api/suites/{id}/retry` | 重试全部失败子项。 |
| `POST /api/suites/{id}/items/{item_id}/retry` | 仅重试指定失败子项。 |
`GET /api/suites/{id}` 是历史命名;它已经承载水印任务。全局任务中心落地时应新增语义更通用的 `GET /api/background-jobs/{id}`(保留旧接口兼容)。
### 当前前端轮询
前端使用递归 `setTimeout`,在上一轮请求完成后才安排下一轮;网络失败采用指数退避并使用 `AbortController` 取消页面卸载或任务切换后的请求。浏览器会保存最近任务 ID,刷新后还会从服务端查询商品活动任务,因此不依赖单次页面生命周期。
---
## 7. 已完成阶段
| 阶段 | 内容 | 状态 |
|---|---|---|
| 第一阶段:AI 任务可靠性 | 任务表与迁移;独立 Worker;套图与单图编辑迁入持久化任务;provider task ID 持久化;取消、失败重试、刷新恢复。 | ✅ 已完成 |
| 第二阶段:批量水印复用 | `watermark_batch` 逐素材子项;后台合成和版本切换;整体进度、取消、全部失败重试、单项重试、刷新恢复。 | ✅ 已完成 |
| 数据安全配合 | 商品删除取消任务;写入前二次检查;水印任务冻结素材 current 版本,拒绝旧任务覆盖新状态。 | ✅ 已完成 |
| 第三阶段:前端任务中心(第一步) | 当前商品固定任务入口、右侧任务抽屉、任务进度、取消、失败重试、跳转生成结果。 | ✅ 已完成 |
已验证:Python 编译、前端 TypeScript/生产构建;模拟存储下的“创建水印任务 → Worker 完成 → 素材 current 回写”以及“单个失败项按最新版本重试”。真实模型与七牛仍应按第 10 节进行人工回归。
---
## 8. 当前限制
- 任务中心 UI 尚未独立成页面或全局抽屉,任务入口仍分散在生成结果和水印设置中。
- `background_job_events` 尚未建立,复杂问题主要依赖日志与错误摘要。
- 当前一个 Worker 统一领取 AI 与水印任务,优先级有效,但没有独立并发池和公平调度。
- 移除水印仍是短时同步接口;大量素材场景可迁入 `watermark_batch` 同一框架。
- SQLite 适合本机单用户,不适合多机器或高并发抢占。
- 同步供应商无法做到提交中的完全幂等恢复;异步供应商的“提交后未保存 provider ID”窗口尚未用幂等键闭合。
- 历史任务和失败临时文件尚未设置保留期与定期清理策略。
---
## 9. 待开发阶段
### 第三阶段:前端任务中心后续完善
1. **已完成**:在试算页右下角“录入商品”上方提供“任务 / 处理中(N)”入口,打开当前商品任务抽屉。
2. **已完成**:抽屉合并套图、单图编辑、水印任务,展示逐图失败、取消、重试和“查看结果”。
3. 待开发:按类型、状态筛选,展示更细的 `waiting_provider` 时间线与预计耗时。
4. 待开发:新增通用列表接口,例如 `GET /api/background-jobs?product_id=&status=&kind=`;旧 `/suites/*` 继续兼容。
5. 待开发:单图弹窗提交后提供“查看任务”快捷入口,并逐步移除弹窗自身的轮询职责。
### 第四阶段:可靠性、调度与诊断
1. 新增 `background_job_events` 与结构化日志,记录供应商、模型、耗时、错误类别和重试。
2. 引入供应商幂等键或“待人工确认”状态,关闭提交模型后的重复扣费窗口。
3. 拆分 AI 与水印的逻辑 Worker 池,配置并发、供应商限流和公平调度。
4. 增加任务历史保留期、失败临时文件清理和七牛孤儿对象扫描。
5. 把移除水印、耗时 ZIP 导出、批量下载逐步迁入任务中心。
### 第五阶段:多实例基础设施
前提是产品确实需要多用户、多机器或更高吞吐:
1. SQLite 迁移 PostgreSQL。
2. 采用 PostgreSQL 行级锁 / `SKIP LOCKED` 强化子项领取。
3. 再评估 Redis + Celery、RQ 或专业队列;不要在本地单机阶段提前引入。
---
## 10. 回归与运维清单
每次修改任务系统后,至少验证:
1. `./start.command` 会同时启动 Web、前端和 Worker;手动启动 server 时必须另开 `python server/worker.py`。
2. 单图编辑提交后可关闭弹窗;刷新试算页后仍能看到任务和最终图片。
3. 套图多张任务中插入单图编辑,单图应优先领取,套图不丢进度。
4. 批量水印中删除商品或单个素材,Worker 不得回写/复活资源。
5. 改水印任务执行期间再次修改同一素材,旧任务必须失败或跳过,不能覆盖新 current。
6. Pcore / 通义等异步模型在重启 Worker 后继续轮询原 `provider_task_id`,不再次提交。
7. 失败项的全部重试与单项重试都能恢复状态;单项水印重试使用最新素材版本。
8. 真实存储环境检查旧水印对象仅在新 current 数据库提交后删除;失败时不会丢失原图。
## 11. 代码入口
| 位置 | 责任 |
|---|---|
| `server/models/job.py` | 父任务、子项 ORM 模型。 |
| `server/migrations/versions/b5d2c9a3e1f7_add_background_jobs.py` | 任务表迁移。 |
| `server/services/jobs.py` | 创建、领取、租约恢复、状态聚合、取消与重试。 |
| `server/services/job_worker.py` | 执行生成和批量水印子项。 |
| `server/worker.py`、`start.command` | 独立 Worker 命令与启动编排。 |
| `server/api/suite.py` | 套图、单图编辑和通用任务查询接口。 |
| `server/api/materials.py` | 批量水印创建和水印任务列表。 |
| `studio/src/pages/trial/TrialSuitePanel.tsx` | 试算页任务恢复、轮询、取消与重试入口。 |
| `studio/src/pages/trial/AiImageGenModal.tsx` | 单图任务配置与提交入口。 |
+74
View File
@@ -0,0 +1,74 @@
# V2.1 采集方案(扩展并入主仓 + 上报开关)
> 状态:已实施(Phase C 完成)
> 参考项目:`/Users/joey-xd/sites/seller-store/image-suite-studio`(下称 ISS)
> 架构(方案三,D2 决策修订):**ISS 扩展整体并入主仓 `extensions/collector/`**,成为唯一采集工具
> (extension-v2 原地冻结、不再维护);扩展新增「上报商品」功能,采集后一键入库主仓后台并自动打开商品试算页。
> 「ISS 独立产品化」远期通过构建/配置裁剪实现(扩展天然独立分发;server 的 suite API 模块不依赖 products),
> 不在代码层分叉——这是「单仓维护」与「独立产品可能」的折中。
---
## 1. 目录与形态
```
ozon-seller-kit/
├── extensions/ # Chrome 插件集合(复数,容纳未来多个插件)
│ └── collector/ ← ISS 扩展并入版(三平台采集 + 套图生图面板 + 商品上报)
├── extension-v1/ # 冻结(旧 1688/淘宝)
├── extension-v2/ # 冻结(旧 Ozon 采集,被 collector 取代)
├── server/ # 唯一后端(8800)
└── studio/ # 前端(试算页等)
```
- 构建:`pnpm -C extensions/collector build` → `.output/chrome-mv3`(Chrome 开发者模式加载 unpacked)
- 扩展包名:`ozon-collector-extension`;manifest 名保留「电商套图工作台」
- host_permissions 已加 `127.0.0.1:8800`(上报)与 `8900`(试算页)
## 2. 采集引擎(与 ISS 完全一致,见下表)
| 平台 | 主路径 | 兜底/补充 |
|---|---|---|
| **Ozon** | ① SSR `data-state` widget 白名单 | ② JSON-LD ③ 站内 `entrypoint-api.bx`(两 URL 合并)④ DOM + 模拟滚动 |
| **淘宝/天猫** | ① MAIN world 桥读 `__ICE_APP_CONTEXT__`(不调 mtop 防风控) | ② DOM(前缀匹配类名、详情图排除评价区;desc 故意不采) |
| **1688** | ① MAIN world 桥读 `window.context`(deepFind;skuProps 全维展开 + skuMapOriginal + 尺寸重量参数) | ② DOM(`#productAttributes` cells、`#detail`) |
采集数据结构 ScanResult:`{ platform, itemId, url, texts[], images[], breadcrumbs?, stats, warnings, source }`(texts: kind/content/pairs;images: groupKey/groupName/variantName/url/index/type)。
## 3. 商品上报(v2.1 新增)
```
快速采集(页内悬浮面板 / Side Panel)
│ scanCurrentPage() → ScanResult(面板内可编辑标题/尺寸/重量/描述)
▼
「上报商品」按钮(01 商品信息区块下方;设置里可开关)
│ POST {reportBaseUrl}/api/materials
│ payload = { source: {platform, itemId, url, collectedAt},
│ texts: editedTexts(), // 编辑后文本
│ images: 全量采集图(groupKey/groupName/variantName/url/index/type),
│ refererOrigin: 源页 origin } // 服务端下载源图带 Referer
▼
响应 { product_id } → chrome.tabs.create 打开 {studioBaseUrl}/trial/{product_id}
```
要点:
- **契约**:主仓 `MaterialsRequest`(camelCase:`source.itemId`、`groupKey`、`variantName`)与 ISS ImageMaterial 字段同名,直接映射;响应 `product_id` 用于打开试算页
- **重复上报**:服务端按 `platform + itemId` 复用商品(返回同一 product_id)→ 打开同一试算页
- **鉴权**:本地阶段主仓为宽松模式(未带 token 放行匿名),扩展直接裸调;远期收紧时在扩展 settings 加 token 即可
- **设置项**(面板「服务端设置」弹窗内新增):启用商品上报开关 / OSK 后台地址(默认 `http://127.0.0.1:8800`)/ 试算页地址(默认 `http://localhost:8900`)
- 手动上传图(ISS 的 upload 组合)本期不上报;试算页内可用「上传图片」补(`/api/materials/bytes`)
## 4. 面板能力(保持 ISS 原样)
快速采集、商品信息编辑、图片勾选/上传/下载、AI 智能规划、风格/生图要求/模型选择、一键生图、生成结果导出——全部保留,仍连 ISS 生图 server(`baseUrl`,默认 3300)。
后续 Phase B 把生图服务端平移进主仓后,`baseUrl` 与 `reportBaseUrl` 可统一指向主仓。
## 5. 与 ISS 的差异总表
| 维度 | ISS(独立项目) | 本仓 extensions/collector |
|---|---|---|
| 采集引擎 | 三平台完整版 | 相同(整体并入) |
| 生图面板 | 连 ISS server(3300) | 同左(Phase B 后可切主仓) |
| 商品入库 | 无 | 「上报商品」→ 主仓 /api/materials + 自动打开试算页 |
| 鉴权 | 无 | 本地宽松模式(远期收紧) |
| 独立产品化 | 本体 | 远期:构建/配置裁剪出 collector + suite API 即可独立 |
+127
View File
@@ -0,0 +1,127 @@
# 图片资产生命周期
> 状态:已实施(2026-08-30)
> 适用范围:采集图、手动上传图、AI 套图、单张 AI 图生图,以及图片水印。
## 1. 核心模型
每个 `product_assets` 图片素材只维护两个逻辑版本:
```text
original:无水印原图,始终保留
current:页面预览、单图下载、ZIP 导出使用的当前版本
```
| 状态 | original | current | 七牛对象数 |
|---|---|---|---|
| 未加水印 | 同一个对象 | 指向 original | 1 |
| 已加水印 | 无水印对象 | 指向水印对象 | 2 |
系统**不保留历史水印版**。修改水印时,先上传新的水印版并切换 current,再删除旧水印版。
采集图、上传图、AI 图的区别只在 original 的来源:采集源站、用户上传文件、模型输出。后续水印、下载、删除均走同一套流程。
## 2. 数据字段
`product_assets` 继续保留 `stored_url` 兼容既有前端接口;其语义为 current URL。新增字段:
| 字段 | 含义 |
|---|---|
| `original_url` | 无水印原图公开 URL |
| `original_storage_key` | 无水印原图对象 Key |
| `current_storage_key` | 当前展示对象 Key |
| `watermark_enabled` | current 是否为水印版 |
| `watermark_config` | 当前水印配置快照 |
删除对象必须使用 `storage_key`,不应由 URL 反推;`storage_key_from_url()` 仅用于兼容旧数据迁移。
迁移:`a41e8c7f4b20_add_asset_image_lifecycle.py`。`start.command` 会在启动服务前执行 `alembic upgrade head`。
## 3. 对象路径
新图片统一使用:
```text
products/{product_id}/assets/{asset_id}/original.{ext}
products/{product_id}/assets/{asset_id}/watermark-{uuid}.{ext}
```
路径前缀不仅便于单图精准删除,也允许删除商品时按 `products/{product_id}/` 兜底清理所有对象,包括异常中断留下的孤儿文件。
## 4. 创建与水印流程
### 采集、上传、无水印 AI 图
```text
写入 original
→ current 指向 original
→ watermark_enabled = false
```
### 添加或修改水印
```text
读取 original
→ 合成新水印图
→ 上传新 current
→ 提交数据库切换 current
→ 删除旧 current(水印版)
```
因此不会叠加水印。AI 生图参考图也优先使用 `original_url`,避免把水印带进下一次生成。
### 移除水印
```text
current 切回 original
→ 提交数据库
→ 删除旧水印对象
```
## 5. 删除流程
### 删除单张素材
```text
删除 products/{product_id}/assets/{asset_id}/ 前缀
→ 删除旧格式路径中的 original/current 对象(兼容迁移前数据)
→ 删除 product_assets 记录
```
若对象存储删除失败,接口返回失败并保留数据库素材记录,避免出现“数据库已删、文件未删”的静默状态。
### 硬删除商品
```text
查询商品所有素材
→ 清理每个素材目录和旧格式对象
→ 清理 products/{product_id}/ 前缀(孤儿兜底)
→ 删除商品,数据库级联删除素材记录
```
归档商品只改变商品状态,不删除图片。
## 6. 服务职责
| 模块 | 职责 |
|---|---|
| `services/image_assets.py` | original/current 生命周期、加/移水印、迁入正式路径、单图/整商品回收 |
| `services/storage.py` | 本地/七牛上传、下载、按 Key 删除、按前缀删除、URL→Key 兼容解析 |
| `services/watermark.py` | 纯图片字节合成;不操作数据库或对象存储 |
| API 路由 | 参数校验、数据库提交、调用生命周期服务 |
## 7. 前端入口
试算页“采集图片”和“生成结果”均可打开水印设置:
- 为已选采集图添加水印;
- 为已选生成图添加水印;
- 移除已选采集图或生成图水印;
- 新的一键生图和单张 AI 生图按当前设置自动加水印。
预览、下载与 ZIP 导出始终使用 `stored_url`(current);AI 生成参考图使用 `original_url`。
## 8. 旧数据兼容
旧素材首次加/移水印时会迁入新的商品/素材路径,并补齐 original/current 字段。迁移前已经产生但数据库无记录的旧七牛对象无法可靠识别;后续可按运营需要增加七牛前缀扫描与人工确认清理工具。
+95
View File
@@ -0,0 +1,95 @@
# V2.1 图片生成方案(套图 + 单张 AI 生图)
> 状态:Phase A 前端开发中;Phase B 服务端平移
> 参照:image-suite-studio(下称 ISS)`server/services/{planner,generator,prompts,watermark}.py` + 面板 03/04 区块交互
> 原则:生成能力与 ISS **功能一致**,交互移到 studio 试算页并新增「单张 AI 生图」。
---
## 1. 能力总览
| 能力 | 说明 | 状态 |
|---|---|---|
| 采集图片展示与勾选 | 按 main/sku/detail/generated 分组;勾选作为参考底图;默认全选主图+SKU 图 | Phase A 前端 ✅ |
| 手动上传补充参考图 | `POST /api/materials/bytes`,独立 `upload` 分组 | Phase A 前端 ✅(接口已有) |
| 套图方案(默认) | 7 种基础类型各 1 张,数量 0-5 可调 | Phase A 前端 ✅ |
| AI 智能规划 | DeepSeek 按商品信息/图组统计/SKU 规格生成方案(`POST /api/suite/plan`) | 前端 ✅ / 服务端 Phase B |
| 一键生成 | 方案展开为逐张任务,串行队列多模型生成(`POST /api/suite/generate` + 轮询) | 前端 ✅ / 服务端 Phase B |
| 生成图水印 | 服务端 Pillow 合成(图片徽章/文字 + 不透明度,右下角) | 前端选项 ✅ / 服务端 Phase B |
| 生成结果与导出 | 结果网格、失败格错误、导出 ZIP(`GET /api/suites/{id}/zip`) | 前端 ✅ / 服务端 Phase B |
| 下载采集图片 | 勾选图打包 ZIP(`POST /api/export/images`,防盗链代理下载) | 前端 ✅ / 服务端 Phase B |
| **单张 AI 生图(V2.1 新增)** | 每张采集图/生成图上「AI 生图」按钮 → 弹窗:要求文本域 + 模型选择 → 单张生成 | 前端 ✅ / 服务端 Phase B |
| 生成图回写商品 | 生成完成的图追加为 `product_assets(generated)` | Phase B/D |
## 2. 目标平台与规格
试算页固定目标平台 **Ozon**:俄文图内文案、3:4(1536×2048)。
(ISS 的 wb/cn 选项不做;`platform` 字段仍随请求下发,服务端按平台决定文案语言与比例,保留扩展性。)
## 3. 出图方案(Plan)
- **类型白名单(10 种)**:white_bg 白底主图 / key_features 核心卖点图 / selling_pt 卖点图 / material 材质图 / lifestyle 场景展示图 / multi_scene 多场景拼图 / ecommerce_detail 电商详情图 / size_chart 尺寸标注图 / sku_collection SKU合集 / custom 创意图。
- **默认方案**:前 7 种各 1 张;行点击切换 0↔1,± 调 0-5,「全部方案」批量开关。
- **AI 规划**:请求 `{product_id, texts, sku_variants, image_stats, platform, requirements}`;DeepSeek 输出单行 JSON `{summary, items:[{kind,title,detail,prompt_hint,count,variant_name}]}`;服务端清洗(kind 白名单、count 钳 0-3、幻觉 SKU 丢弃绑定、截断修复)。规则:每个带图 SKU 1 张 white_bg 且绑定 variant_name。**总张数由 AI 自主决定(不设硬性数量,建议 8 张左右)**;「生图要求」写「N 张」可指定张数(AI 必须执行);服务端只设防跑飞安全上限(默认 15、指定时按 N 最大 24),超限从尾部截断(2026-08-29)。
- **规划并生成**:勾选后规划完成直接进入生成。
- SKU 规格来源:`product_assets` 中 `group_key='sku'` 的 `variant_name` 去重。
## 4. 生成(Generate)
```
POST /api/suite/generate
{ product_id, texts, images:[{url, group_key, variant_name}], // 勾选的参考底图
style_set, style_prompt, requirements, plan, platform:'ozon', model, watermark }
→ { suite_id }
```
- **texts 组装**(前端已实现,source = product.raw + 包装字段覆盖):
`title(title_zh||title) / price / brand / sales / shop / params(含尺寸、重量覆盖回参数表) / selling_point / desc`。
- **参考图解析**(服务端,ISS 逻辑):variant_name 精确匹配 SKU 图 → 回退 main 组第 1 张;material 偏好第 2 张;最多 2 张;本地 storage 直读,远程 URL 带站点 Referer 下载,转 data-URI 进请求。
- **提示词**:按模型家族分册(alibaba 主体参考语义 / gpt edits 保真语义 / google 主体保持),共用 10 种图类型 builder + 商品上下文(参数表提炼卖点)+ 5 套风格 + `requirements` 置顶强制约束 + 图内文案按语言规范(俄文 ≤4 词)。
- **任务模型**:数据库父任务 + 逐图子任务,独立 Worker 按子项领取;逐张生成 → Pillow 水印 → 按 PNG 魔数定扩展名落 `storage`。Pcore、RightAPI、通义万相的外部任务 ID 会持久化,Worker/Web 重启后继续查询原任务,不重复提交。详见 [`background-jobs.md`](./background-jobs.md)。
- **前端轮询**:请求结束后再延迟下一次(正常 3s),因此不会发生异步 `setInterval` 重叠;在途请求由 `AbortController` 管理,切换任务/卸载时取消。请求失败最多重试 3 次,并按 6s、12s 指数退避;当前任务 ID 存入浏览器会话,刷新时也会查询商品活动任务。`done/partial/failed/cancelled` 为终态;超预算(5min/张+10min)提示。
## 5. 模型路由(与 ISS 一致,服务端 MODEL_PROVIDERS 表)
| Provider | 模型 | 调用 |
|---|---|---|
| doubao(火山方舟) | doubao-seedream-4-5-251128 | `images/generations` 同步,image 传 data-URI |
| tongyi(DashScope) | qwen-image-3.0-pro / wan2.7-image-pro / wan2.6-image / wan2.6-t2i | qwen 同步;wan 异步 task 轮询;t2i 纯文生图 |
| rightapi(中转) | gpt-image-2 / gpt-image-2-vip / nano-banana(-2/-2-lite/-pro) | `POST /v1/images/generations` async + 任务轮询 + 退避重试 |
前端下拉 10 个模型带中文特点说明,默认 `gpt-image-2-vip`。密钥:`ARK_API_KEY` / `DASHSCOPE_API_KEY` / `RIGHTAPI_API_KEY` / `DEEPSEEK_API_KEY`(.env,Phase B 接入本仓 settings)。
## 6. 风格与要求
- 5 套风格(北欧极简/清新明亮/高级感深色/暖调生活/纯净棚拍)+ 每套默认提示词;**风格提示词可改写**(按风格 id 记忆,切风格不丢,可恢复默认)。
- 「生图要求」文本域:最高优先级强制约束,服务端置于 prompt 最前并声明覆盖一切冲突指令。
- 水印选项(Popover):开关 + 类型(图片徽章/文字)+ 水印文字 + 不透明度(默认关、`xiongmaoyx`、30%);随生成请求下发,关闭时不带字段;位置右下角;重新生成后生效。
## 7. 单张 AI 生图(V2.1 新增)
交互(前端已实现,`AiImageGenModal.tsx`):
```
采集图/生成图单元格 → 「AI生图」按钮
→ 弹窗:左侧原图预览;右侧要求文本域(必填)+ 模型下拉(同上 10 个)+ 提交任务按钮
→ POST /api/suite/image-edit { product_id, image_url, prompt, model, append: true }
→ 立即返回 { suite_id },弹窗轮询 GET /api/suites/{suite_id}
→ 终态后展示 images[0].url,可下载/打开原图
→ 服务端把结果追加为 product_assets(group_key='generated', variant_name=null)
```
- 与「智能修图」页(`/ai-image`,wanx 注解编辑)并存:本入口是**轻量单张再生成**(选模型 + 一句话要求),不做标注/遮罩。
- 服务端实现 = 复用套图的持久化任务和模型路由(参考图即传入的单图 + prompt = 用户要求 + Ozon 平台规格),生成后落 storage 并回写素材;`append=false` 时不回写。提交接口不等待模型完成,用户可关闭弹窗继续工作。
## 8. 展示与防盗链
- 素材展示优先 `stored_url`(服务端已转存,本地 `/media/...` 或七牛),缺失时回退 `source_url`;
源站防盗链导致破图时走 `GET /api/proxy-image?url=`(Phase B 提供,ISS 同款)。
- 预览用 antd `Image.PreviewGroup` 画廊( ← → / Esc 与 ISS 行为一致)。
## 9. 导出
- **导出 ZIP**(生成结果):`GET /api/suites/{id}/zip`,文件名 `cleanFilename(标题).zip`;
- **下载采集图片**:`POST /api/export/images {title, images:[{url, groupName, variantName, key}]}`,按分组建文件夹打包;
- 前端经 blob + `<a download>` 落盘(非扩展环境没有 chrome.downloads)。
+172
View File
@@ -0,0 +1,172 @@
# 多 SKU 的实物规格与包装物流规格设计
> 状态:调研与设计结论,尚未实施。
> 关联:[多 SKU 商品处理与试算页扩展方案](./multi-sku-pricing.md)。本文补充其“尺寸与重量”的双口径模型。
> 核心原则:每个 SKU 可以有独立的实物规格和包装物流规格;两者绝不混用。
---
## 1. 问题与结论
尺寸与重量有两种不同的业务口径:
| 口径 | 数据 | 用途 |
|---|---|---|
| **实物规格** | 商品本体长、宽、高、净重 | 商品规格展示、俄文文案、AI 尺寸标注图 |
| **包装物流规格** | 发货包装长、宽、高、含包装毛重 | 物流尺寸限制、物流费、价格试算、CSV 上品 |
同一个商品还可能有多个 SKU。容量、尺寸、套装数等 SKU 往往在两组数据上都不同;仅颜色不同的 SKU 则通常可继承父商品的两组默认值。
因此目标模型不是“商品加一张 SKU 价格表”,而是:
```text
商品(SPU / 父记录)
├─ 共用:标题、描述、采购链接、图片、文案、计价默认值
├─ 默认实物规格:长宽高、净重
├─ 默认包装物流规格:长宽高、包装毛重
├─ SKU A:规格、两组规格覆盖、采购价、试算结果、货号
├─ SKU B:规格、两组规格覆盖、采购价、试算结果、货号
└─ SKU C:规格、两组规格覆盖、采购价、试算结果、货号
```
SKU 的某个值为空代表继承父商品默认值,不代表零。采集不到或来源未说明口径的“尺寸/重量”必须标记为待确认,不能猜测写入任意一组。
## 2. 当前实现风险
当前 `/trial/:id` 将 `price`、`old_price`、`weight`、`depth/width/height`、`offer_id` 放在单一 `products` 记录中:
- `TrialPricingPanel` 使用重量尺寸做物流费和尺寸校验,且会回填商品价格;一个 SKU 会覆盖另一个 SKU。
- AI 套图规划从采集参数表寻找“尺寸”,无法确认取到的是实物尺寸还是包装尺寸。
- `/api/materials` 的 `_apply_weight_dims` 以“包装重量/重量”“包装长度/长度”等泛关键词解析,通用“产品尺寸”“重量”可能被当成物流规格。
- 图片资产仅保存 `variant_name`,显示名能重复或改名,无法稳定绑定 SKU。
- CSV 目前按商品一行导出,无法表达 SKU 的独立包装数据和报价。
因此不能只给当前价格卡增加输入框,必须先建立 SKU 与双规格的持久化模型。
## 3. 数据模型
### 3.1 命名、单位和继承
数据库统一使用 `mm`、`g`;前端用 `cm`、`g` 编辑。新功能不得再用裸 `weight`、`depth`、`width`、`height` 表达业务规格。
父商品和 `product_variants` 均使用相同、口径明确的字段:
| 字段组 | 字段 | 使用者 |
|---|---|---|
| 实物规格 | `physical_length_mm`、`physical_width_mm`、`physical_height_mm`、`net_weight_g` | 尺寸图、规格展示、文案 |
| 包装物流规格 | `package_length_mm`、`package_width_mm`、`package_height_mm`、`shipping_weight_g` | 试算、物流校验、导出 |
| SKU 身份 | `source_variant_key`、`variant_name`、`specs` | 平台原始 SKU ID、展示规格、颜色/尺寸/容量 |
| 销售与计价 | `source_price`、`purchase_price`、`offer_id`、`barcode`、`logistics_level`、`pricing` | SKU 独立的输入、结果和上品标识 |
| 审计 | `measurements_meta`、`enabled`、时间戳 | 来源、原始文本、确认状态、是否参与导出 |
`measurements_meta` 至少记录两组数据各自的 `status`(`confirmed` / `ambiguous` / `missing`)、来源、原始文本和更新时间。采集提供的数据不是人工确认的事实时,不应伪装为已确认。
SKU 的有效值按“SKU 覆盖 → 父商品默认值”逐字段合并。实物规格与包装物流规格独立继承:实物尺寸相同不代表包装尺寸也相同。
### 3.2 关联和旧字段
- 新增持久化 `product_variants` 表,而不是长期把 SKU 数组放进 `product.pricing` JSON。
- `product_assets` 增加可空 `product_variant_id`;现有 `variant_name` 仅保留展示兼容。
- 父商品 `pricing` 只保存共享默认参数,如汇率、利润率、贴单费和默认物流等级;SKU `pricing` 保存输入快照、有效包装规格、计算结果和计算时间。
- 当前 `products.depth/width/height/weight` 在迁移期间仅是兼容读写别名;双规格字段是新功能唯一事实来源。
## 4. 采集和后端接口
`POST /api/materials` 保持旧契约兼容,并新增可选 `variants`:
```jsonc
{
"source": { "platform": "1688", "itemId": "...", "url": "..." },
"variants": [
{
"source_variant_key": "平台原始 SKU ID",
"variant_name": "白色 / 33 cm",
"specs": { "颜色": "白色", "尺寸": "33 cm" },
"source_price": 28.5,
"physical_measurements": {
"length_mm": 150, "width_mm": 150, "height_mm": 330, "net_weight_g": null,
"status": "confirmed"
},
"package_measurements": {
"length_mm": 180, "width_mm": 180, "height_mm": 380, "shipping_weight_g": 850,
"status": "confirmed"
},
"image_url": "..."
}
]
}
```
规则:
- `source_variant_key` 优先使用来源平台稳定 ID;展示名不能作为幂等键。
- 未采到的字段传 `null`;无法判断口径时保留原始参数、设 `status: "ambiguous"`。
- 只有明确“包装/外箱/发货”口径的参数可写入包装物流规格;明确“商品/产品/本体”的参数可写入实物规格;泛称参数不自动落入任何组。
- 服务端按来源 SKU ID 幂等更新,并把 SKU 图片关联至 `product_variant_id`。
- SKU 使用独立接口,例如 `GET/POST /api/products/{id}/variants`、`PATCH/DELETE /api/products/{id}/variants/{variant_id}`;不要用整份 `ProductUpdate` 承担 SKU 并发编辑。
服务端新增统一的 `PricingService`:前端可即时预览,但保存与导出时必须按“有效包装物流规格”重新计算,避免浏览器传入结果被篡改或前后端公式漂移。
## 5. 前端设计
页面拆成两个清晰区域:
1. **商品规格与尺寸图**:维护实物长宽高和净重;多 SKU 时可维护商品默认值,或为一个 SKU 覆盖。尺寸标注图唯一读取这里的已确认实物尺寸。
2. **价格试算**:维护包装长宽高、包装毛重、进货价、利润率、物流等级、贴单费、汇率;物流校验与价格结果只在此区域展示。
多 SKU 时,价格区使用明细表,列为“规格 / 实物规格摘要 / 包装规格摘要 / 进货价 / 销售价 / 状态”。点击一行打开抽屉或展开编辑器,而不是把八项字段强塞进表格:
```text
实物规格(用于尺寸图) 包装物流规格(用于试算)
长 / 宽 / 高 / 净重 长 / 宽 / 高 / 包装毛重
状态:SKU 覆盖 / 继承 / 待确认 状态:SKU 覆盖 / 继承 / 待确认
```
交互约束:
- 每个字段可以“恢复继承”;空值不是 `0`。
- 修改父商品默认值只影响未覆盖字段;可以将某一组默认规格批量应用给选中的 SKU。
- 包装物流规格缺失/无效时,该 SKU 不能试算和导出,但其他 SKU 不受影响。
- 实物规格缺失时仍可试算/导出,但不能生成有数值的尺寸标注图。
- 父商品列表不应任选一个 SKU 回写 `product.price`;应展示最低价、价格区间,或显式选择代表 SKU。
## 6. AI 尺寸图、导出与迁移
### AI 尺寸图
尺寸标注图是实物信息,不是物流信息。规划器、提示词和任务上下文必须接收显式 `physical_*` 字段,不能从混合原始参数表兜底。
- 多 SKU 实物尺寸不同:生成前必须选择 SKU;任务保存 `product_variant_id` 与实物规格快照。
- 多 SKU 实物尺寸相同:可以使用父商品默认规格生成一张通用图。
- 实物规格为 `missing` 或 `ambiguous`:规划器不安排尺寸图,也不能提交带数值的尺寸图任务。
- 规格修改后,历史图依其快照标记为“基于旧规格生成”。
### CSV 和上品
CSV 从“每商品一行”改为“每启用 SKU 一行”。进货价、包装毛重、包装尺寸和物流等级来自合并后的 SKU 有效值;实物尺寸绝不作为物流尺寸导出。导出前逐 SKU 校验货号、价格和包装物流规格,并列出失败原因。
每 SKU 一个 Ozon offer、只上代表 SKU、或未来平台变体合并,仍是待确认的商业策略;未确认前系统只生成可审阅的 SKU 级 CSV。
### 历史数据迁移
历史商品无 SKU 时视为一个隐式 SKU。把旧 `pricing`、`weight`、`depth/width/height`、`offer_id` 迁为默认 SKU 的**包装物流规格**,因为它们原本用于价格试算;实物规格一律为 `missing`,不得依据旧字段自动生成尺寸图。
## 7. 实施顺序与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| 0. 业务确认 | 明确 Ozon offer 策略、货号规则、尺寸图是否逐 SKU 生成 | 形成 CSV/上品规则 |
| 1. 数据和采集 | SKU 表、父/SKU 双规格字段、迁移、`variants` 契约;收窄泛规格解析 | 未知口径不被伪造成实物或包装数据 |
| 2. 服务端计算 | 继承合并、统一计算/校验、SKU 级保存和查询接口 | 两 SKU、两组规格不相互覆盖 |
| 3. 试算页 | 实物规格区、包装物流试算区、SKU 表和编辑抽屉 | 刷新后继承、状态、价格和告警一致 |
| 4. 导出和图片 | SKU 行 CSV;尺寸图上下文绑定 SKU 与实物快照 | 包装尺寸绝不进入尺寸图 |
至少覆盖:单 SKU 回归、多 SKU 不同价格、仅颜色不同继承两组规格、实物相同包装不同、包装相同实物不同、泛称规格待确认、一个 SKU 导出失败但其他可导出、尺寸图只使用实物规格、SKU 改名但稳定 ID 不变、历史商品迁移。
## 8. 待确认决策
1. Ozon 中多 SKU 是否一律拆成独立 offer,还是要保留变体合并能力?
2. 货号由父商品自动派生,还是必须人工逐 SKU 维护?
3. 哪些采集来源的实物/包装字段可信,哪些只能作为待确认原始信息?
4. 尺寸图是否必须逐 SKU 生成;AI 主图和场景图由谁指定代表 SKU?
5. 父商品在采集箱中展示最低价、价格区间,还是代表 SKU 的价格?
+172
View File
@@ -0,0 +1,172 @@
# 多 SKU 商品处理与试算页扩展方案
> 状态:设计方案,尚未实施。
> 范围:采集 → 采集箱商品 → `/trial/:id` 价格试算 → CSV 导出;Ozon API 直传仍处于冻结状态。
> 目标:同一商品的不同规格可以独立试算,避免以一个 SKU 的价格、重量和尺寸覆盖全部规格。
> 尺寸/重量的实物与包装物流双口径设计见 [`multi-sku-measurements.md`](./multi-sku-measurements.md);该文补充并约束本方案。
---
## 1. 问题与目标
采集到的商品可能是多 SKU 商品:不同颜色、容量、套装、尺寸或型号往往有不同的进货价、包装重量和长宽高。当前试算页将这些字段保存在 `products` 的单一商品记录中,因此只能计算一个结果;若直接在现有页面逐个修改,会覆盖前一个 SKU 的数据,导出也无法还原各规格的价格。
本方案的目标是把商品拆分为「共享商品信息」和「可独立销售的 SKU」,并保持单 SKU 商品的现有操作不变:
```text
商品(SPU / 父记录)
├─ 共用:标题、俄文文案、采购地址、描述、图片、统一计价默认值
├─ SKU A:规格、采购价、重量、尺寸、独立试算结果、货号
├─ SKU B:规格、采购价、重量、尺寸、独立试算结果、货号
└─ SKU C:规格、采购价、重量、尺寸、独立试算结果、货号
```
不应从图片名或规格文案中猜测重量、尺寸和价格。采集不到的值必须保留为空、继承共用默认值,或要求人工补齐。
## 2. 当前现状与限制
目前的 `/trial/:id` 以一条 `products` 记录作为一个可售单元:
- `price`、`old_price`、`weight`、`depth/width/height`、`offer_id` 各只有一个值;
- `product.pricing` 保存一份试算输入和结果,修改后会回填上述商品字段;
- CSV 导出按商品生成一行,无法表达同一商品的多个独立报价;
- 采集侧已能识别 SKU 名称和 SKU 图片;1688 采集状态中也可能含 SKU 价格或规格信息,但当前上报 `/api/materials` 时尚未稳定传递 SKU 级的价格、重量和尺寸。
因此,不能只在现有「02 价格试算」中添加一张前端表格:那样数据仍会落回一份商品字段,刷新、导出和后续上架都会丢失区分。
## 3. 业务边界
### 3.1 父商品与 SKU 的职责
| 层级 | 保存内容 | 原则 |
|---|---|---|
| 商品(父记录) | 标题、描述、采购链接、参数、共用图片、AI 套图、文案、计价默认值 | 一处维护,供所有 SKU 继承 |
| SKU(子记录) | 规格名/规格值、来源 SKU 标识、SKU 图片、进货价、重量、尺寸、物流等级覆盖、货号、独立计价结果、启用状态 | 每个可单独售卖的规格一条 |
仅颜色不同且包装、进货价相同的 SKU 可以完全继承父商品参数;容量、套装数或尺寸不同的 SKU 通常需要各自维护价格和包装数据。
### 3.2 不在本阶段解决的问题
- 不恢复 Ozon Seller API 自动发布;V2.1 当前仍以 CSV / 组合码辅助人工上品为主。
- 不强行将平台的变体关系映射为 Ozon 的变体关系。是否将每个 SKU 建成独立 Ozon offer,需要在导出/上架阶段单独确认。
- 不把 AI 套图按 SKU 数量机械复制。图片规划应以商品卖点和规格差异为依据;多 SKU 图片选择规则另行细化。
## 4. 推荐数据模型
推荐新增持久化的 `product_variants`(或语义等价的 `product_skus`)表,而不是长期将全部 SKU 塞入 `product.pricing` JSON。独立表可以支持单行导出、筛选、校验、后续独立 offer 和稳定的外部 SKU 映射。
建议字段如下:
| 字段 | 用途 |
|---|---|
| `id`、`product_id` | SKU 主键及所属父商品 |
| `source_variant_key` | 来源平台的稳定 SKU 标识;不能只用展示名称匹配 |
| `variant_name`、`specs` | 规格展示名和结构化规格值,如颜色/尺寸/容量 |
| `source_price` / `purchase_price` | 采集到的价格与人工确认后的进货价 |
| `weight_g`、`length_cm`、`width_cm`、`height_cm` | SKU 包装数据;为空时继承父商品默认值 |
| `logistics_level`、`td_price`、`profit_rate` 等覆盖字段 | 仅保存与父商品默认值不同的项 |
| `pricing` | 该 SKU 的计算快照、告警和 `calculated_at` |
| `offer_id`、`barcode` | 导出/人工上品所需的 SKU 级标识 |
| `image_asset_id`、`enabled`、时间戳 | SKU 主图、是否参与试算/导出和审计 |
父商品的 `pricing` 应转为「默认计价参数」,例如汇率、默认利润率、贴单费和物流等级。SKU 计算时按「SKU 覆盖值 → 父商品默认值」合并,再复用现有 `calculatePricing` 和尺寸校验逻辑。
### 为什么不建议仅扩展 JSON
短期可在 `product.pricing.variants` 中暂存数组,但这会让查询、校验、并发更新、CSV 行导出和后续 offer 对应关系都更复杂。若只是验证交互可暂用 JSON;只要计划实际导出多个 SKU,就应迁移到独立表。
## 5. 采集与接口契约
采集上报需要在保留现有单 SKU 字段的同时,可选地提交 `variants`:
```jsonc
{
"source_url": "...",
"title": "...",
"variants": [
{
"source_variant_key": "平台原始 SKU ID",
"variant_name": "白色 / 33 cm",
"specs": { "颜色": "白色", "尺寸": "33 cm" },
"source_price": 28.5,
"weight_g": null,
"dimensions_cm": { "l": null, "w": null, "h": null },
"image_url": "..."
}
]
}
```
约束:
- `source_variant_key` 优先使用平台原始 ID;显示名称可能重复,不能作为唯一键。
- 不同平台可分步补齐字段。未采到的值传 `null`,不伪造默认值。
- 服务端根据来源 SKU 标识做幂等更新;没有 `variants` 的历史采集商品继续按单 SKU 处理。
- 采集图与 SKU 的关系应单独保存,避免仅靠图名在后续页面猜测归属。
## 6. 试算页交互
现有「02 价格试算」建议扩展为两层,单 SKU 商品默认保持当前单卡片体验:
1. **通用参数**:汇率、默认净利率、贴单费、默认物流等级、划线价倍数等,修改后可批量应用到选中的 SKU。
2. **SKU 明细**:每行显示规格、SKU 图片、进货价、重量、长宽高、物流等级、销售价 ¥/₽、划线价、利润和校验告警;可单行编辑、恢复继承、批量填充或只计算已选 SKU。
建议交互规则:
- 值为空表示「继承商品默认值」,而不是零;界面须明确显示继承来源。
- 修改父级默认值只影响未覆盖该字段的 SKU。
- 某 SKU 尺寸/价格无效时,只阻止该 SKU 的结果和导出,不应影响其他 SKU。
- SKU 可被停用;停用项保留历史但不参与默认计算和导出。
- 父商品顶部不应再把任意一个 SKU 的报价写回 `product.price`。过渡期可展示「¥最低价起」或显式指定一个代表 SKU;具体语义必须固定,避免误导。
## 7. 计算、导出与人工上品
每个启用 SKU 独立调用既有计价公式与尺寸校验,使用同一汇率快照。计算结果应与输入一起持久化,便于追溯导出时的报价来源。
CSV 导出应从「每商品一行」改为「每启用 SKU 一行」:
| CSV 字段 | 来源 |
|---|---|
| 商品共用标题、描述、采购地址 | 父商品 |
| 规格、货号、条码 | SKU |
| 进货价、重量、尺寸、物流等级 | SKU 覆盖值或继承后的有效值 |
| 销售价、划线价、利润、告警状态 | SKU 最新试算结果 |
导出前需校验每个启用 SKU 都有货号、有效价格和有效包装数据;失败项应列出原因,不能静默使用父商品或其他 SKU 的结果。
对于 Ozon 人工上品,必须先确认商业策略:
- **每个 SKU 一个 Ozon offer**:CSV 每个 SKU 一行,最易与独立价格和物流数据对应;
- **只上一个代表 SKU**:在试算页显式选择代表 SKU,其余 SKU 仅作采购参考;
- **平台变体合并**:需要重新启用并完成 Ozon 变体属性映射,不属于 V2.1 当前导出主链路。
在策略确认前,系统只应生成可审阅的 SKU 级试算与 CSV,不自动假定平台变体关系。
## 8. 兼容与迁移
- 既有商品无 `product_variants` 时视为一个隐式 SKU,继续使用当前页面和导出逻辑。
- 新采集的多 SKU 商品创建父商品及 SKU 子记录;可选地将第一个/最低价 SKU 作为界面初始代表,但不覆盖其余数据。
- 迁移时把历史 `product.pricing`、重量、尺寸、`offer_id` 复制为一条默认 SKU;保留父商品字段直到所有读取路径完成切换。
- 试算接口和前端应同时兼容单 SKU、隐式 SKU 和已迁移多 SKU,避免一次性迁移造成历史商品不可用。
## 9. 分阶段实施与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| 0. 业务确认 | 确认 Ozon 上品策略、货号规则、何时需要 SKU 独立包装数据 | 形成明确的 offer/CSV 规则 |
| 1. 数据与采集 | 增加 SKU 表、迁移与 `/api/materials` 的 `variants` 契约;1688 优先接入 | 多 SKU 采集后能稳定落库,缺失字段不被伪造 |
| 2. 试算页 | 增加通用参数和 SKU 明细表,逐行计算/校验/保存 | 两个 SKU 互不覆盖;刷新后数据和告警一致 |
| 3. 导出 | CSV 按启用 SKU 出行,提供失败明细 | 多 SKU 商品导出行数、货号、价格和尺寸与明细一致 |
| 4. 后续发布 | 如恢复 API 发布,再设计 Ozon 变体/offer 映射 | 不影响 V2.1 的人工 CSV 主流程 |
至少覆盖以下测试:单 SKU 回归、多 SKU 不同价格、仅颜色不同且继承包装、缺重量/尺寸、一个 SKU 校验失败但其他可导出、来源 SKU 改名但稳定 ID 未变、历史商品迁移。
## 10. 待确认决策
1. Ozon 后台中,多 SKU 是否一律拆成独立 offer,还是需要保留变体合并能力?
2. 货号由父商品后缀自动派生,还是必须人工逐 SKU 录入?
3. 哪些采集来源可视为 SKU 级重量/尺寸可信,哪些仅作为商品级参考?
4. 多 SKU 的 AI 套图是否要指定代表 SKU,或按 SKU 分组生成?
5. 父商品列表中价格展示为最低价、价格区间,还是指定代表 SKU 的价格?
这些决策确定后,再开始阶段 1;它们会直接影响表结构、导出格式和后续上品方式。
+171
View File
@@ -0,0 +1,171 @@
# 前后端优化审查清单
> 审查日期:2026-08-29
> 状态:审查项持续跟踪;“图片原图与当前水印版管理”已于 2026-08-30 实施基础版本。
> 范围:商品试算页、图片生成/水印/导出链路,以及对应 FastAPI 服务端。
---
## 优先级说明
| 优先级 | 含义 |
|---|---|
| P0 | 若服务不再局限于本机,需优先处理的安全或资源风险 |
| P1 | 会影响数据可靠性、生成任务稳定性或存储成本 |
| P2 | 提升维护效率、性能与用户体验 |
---
## 1. 服务端:任务可靠性
### P1:生成任务应持久化并异步执行(第一阶段已实施)
- 已实施:新增 `background_jobs`、`background_job_items`,保存请求快照、状态、进度、逐图结果、错误、重试计数与第三方任务 ID;套图和单张编辑均已迁入。
- 已实施:`server/worker.py` 为独立 Worker;Pcore、RightAPI、通义万相在提交模型后先持久化外部任务 ID,再分次轮询,服务或 Worker 重启后继续原任务。
- 已实施:前端可恢复商品活动任务、取消任务、仅重试失败图。
- 边界:SQLite 适用于当前单机;跨机器/多实例前需迁移 PostgreSQL。同步供应商接口没有外部任务 ID,进程恰好中断时不能保证幂等。任务事件日志与历史清理策略留待后续。
### P1:单张 AI 编辑改为异步任务(已实施)
- 已实施:`POST /api/suite/image-edit` 创建并提交与套图共用的任务,立即返回 `suite_id`;单张编辑因此会走统一的模型、队列、水印、落库与追加素材链路。
- 已实施:弹窗提交后交由试算页统一任务区跟踪;请求不再受前端 120 秒 HTTP 超时影响,并支持取消、仅重试失败图与刷新恢复。
- 边界:任务事件日志、历史清理和供应商主动取消仍是后续项;同步供应商没有外部任务 ID 时无法保证中断后的幂等恢复。
### P2:前端轮询避免异步 `setInterval` 重叠(已实施)
- 已实施:套图和单张编辑均改为递归 `setTimeout`;只有前一次 `GET /api/suites/{id}` 完成后才安排下一次请求。
- 已实施:使用 `AbortController` 取消过期/卸载请求;网络失败采用最多 3 次、6s→12s 的指数退避;套图任务 ID 写入 `sessionStorage`,刷新页面后可恢复跟踪。
- 边界:状态已持久化,服务端重启不会导致 404;同步供应商恰在请求中断时需要重试,异步供应商则继续使用保存的外部任务 ID 轮询。
---
## 2. 服务端:图片、水印与存储生命周期
### P1:统一管理原图与当前水印版(已实施基础版本)
- 已实施:素材记录保存原图、当前展示图、对象 key 与水印配置;未加水印时原图即当前图,加水印时仅保留一张当前水印版,不保留水印历史。
- 已实施:采集、上传、AI 生成图片统一迁入 `products/{product_id}/assets/{asset_id}/`;重新加水印在提交后回收旧水印对象,移除水印回到原图。
- 已实施:删除单图清理素材目录,硬删除商品清理商品目录,并兼容清理旧路径引用。详见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md)。
- 后续:可增加定期扫描与清理孤儿对象的后台任务,用于处理存储服务瞬时失败后的残留。
### P1:批量水印改后台任务(第二阶段已实施)
- 已实施:`POST /api/assets/watermark` 只创建 `watermark_batch` 持久化任务并返回任务 ID;Worker 逐图从原图合成、上传、切换当前展示版本和回收旧水印对象。
- 已实施:页面独立轮询水印任务,刷新可恢复,支持取消、重试全部失败项和按单张重试;等待期间素材被修改时会拒绝旧任务覆盖,单项重试会重新冻结最新版本。
- 边界:移除水印目前仍是同步短操作;若未来单次处理规模继续提高,可迁入同一 Worker 框架。
### P2:统一图片格式、尺寸和元数据校验(已实施)
- 已实施:`normalize_image_bytes()` 在上传、采集转存和 AI 生成图写入素材库时,使用 Pillow 解码真实字节,不再信任 URL 后缀或客户端 Content-Type;仅接受 JPEG、PNG、WebP、GIF、BMP。
- 已实施:维持原有单文件字节上限,并新增 `IMAGE_MAX_PIXELS`(默认 40,000,000)限制;强制完整解码,拒绝损坏、伪造或像素过大的图片。
- 已实施:静态图纠正 EXIF 方向;记录可信的实际宽高到 `product_assets.width`、`height`。动画图只校验并保留原字节,不重编码以避免丢帧。
- 已实施:历史本地库的 39 条图片素材已通过 `server/scripts/backfill_asset_dimensions.py` 回填宽高;该脚本不重写图片文件,可用于其他旧库。
- 边界:当前不做强制压缩/转码,避免无谓损失采集原图质量;后续若存储成本成为问题,再按真实运营数据引入按格式和尺寸分级压缩策略。
---
## 3. 服务端:安全与资源保护
### P0:限制图片代理和导出 URL,防止 SSRF(已实施)
- 已实施:`services.storage.download_bytes()` 统一校验代理、采集转存、AI 参考图和远程导出的 URL,仅允许公网 `http(s)` 地址;拒绝 `localhost`、回环、内网、链路本地、多播及其他保留 IP。
- 已实施:关闭 HTTP 客户端自动重定向,最多手动跟随 3 跳,并在每一跳重新解析和校验目标地址;限制连接、读写和整次下载总耗时。原有的流式大小上限继续生效。
- 兼容:项目自身已经转存的 `/media/...` 素材仍通过受限本地文件读取;采集扩展的 `GET /api/proxy-image?url=https://公网源图` 防盗链回退不受影响。
- 涉及:`server/api/proxy.py`、`server/api/export.py`、`server/services/storage.py`、`.env.example`。
- 边界:当前策略按 DNS 解析结果判定公网地址;若未来服务面向高风险公网环境,可进一步使用出站网络策略或固定 DNS/代理以降低 DNS 重绑定窗口。
### P0:限制上传与 ZIP 导出的内存占用(已实施基础版本)
- 已实施:上传与远程下载按块读取,默认单张限制 20 MB;超限返回 HTTP 413 和可读错误。限制可通过 `.env` 覆盖。
- 已实施:每次采集最多 100 张;图片导出最多 50 张;ZIP 源图片总量和成品最大 200 MB。
- 已实施:ZIP 使用 `SpooledTemporaryFile`,超过 2 MB 自动转临时磁盘,响应以 64 KB 分块输出,不再使用无上限 `BytesIO`。
- 后续:若部署为多实例/容器,需要为临时目录增加独立容量配额与定期清理监控。
### P0:服务脱离本机后补鉴权与资源归属
- 现状:V2.1 按决策未启用鉴权,商品、素材、生成、导出、代理接口都可直接访问。
- 涉及:`server/main.py`、全部业务路由。
- 建议:上线局域网/公网前先接入认证、商品/素材归属校验、请求限流和审计日志。
- 备注:若服务始终只绑定本机回环地址,可作为后续事项,但不应暴露端口。
---
## 4. 前端:可维护性与交互
### P2:拆分 TrialSuitePanel(已实施)
- 已实施:`TrialSuitePanel.tsx` 从约 1,829 行缩减为约 1,326 行;采集素材网格、生成结果网格、失败结果卡片提取到 `TrialImageGrids.tsx`,水印设置和水印任务状态提取到 `TrialWatermarkSettings.tsx`,图片分组、预览和结果类型统一到 `trialImageTypes.ts`。
- 已实施:采集图与生成图统一复用卡片的选择、删除、下载、AI 编辑和预览行为,避免两处网格逻辑继续分叉。
- 边界:套图和水印轮询保留在父组件,继续集中管理页面级状态、刷新与取消逻辑;若轮询逻辑再次扩张,再独立为 hook。
### P2:图片多时采用懒加载/虚拟化(已实施基础版本)
- 已实施:采集素材按分组、套图结果和历史生成记录均按每页 24 张分页;仅当前页创建 Ant Design `Image` 及预览节点,避免历史图片一次性进入 DOM。
- 已实施:缩略图使用 `loading="lazy"`,滚动接近可视区才请求图片资源。
- 边界:当前采用分页而非滚动虚拟列表,预览导航限于当前页;若单个分组进一步增长到数百张以上,可基于真实使用情况再替换为虚拟网格或服务端游标分页。
### P2:补触屏与键盘操作
- 现状:删除、下载、AI 编辑等操作多为 hover 展示。
- 建议:增加焦点可见性、键盘标签与快捷操作;移动端使用常显按钮或“更多”菜单。
### P2:将水印设置按商品或用户保存
- 现状:水印设置写入浏览器全局 localStorage,同一浏览器的所有商品共享。
- 涉及:`trialWatermark`。
- 建议:明确产品语义:若按店铺/用户通用则服务端保存偏好;若按商品不同则保存到商品配置。
---
## 5. 数据、导出与接口契约
### P1:登记表与批量 CSV 服务端化
- 现状:登记记录保存在 `trialRegisterRecords` localStorage,不跨设备、清理浏览器数据后丢失,也无法多人/多窗口协作。
- 涉及:`studio/src/pages/trial/TrialExportPanel.tsx`。
- 建议:建立登记记录表,并实现服务端批量 CSV 导出;保留当前本地模式作为离线兜底(如有需要)。
### P2:减少前后端手工接口类型重复
- 现状:前端服务层与 FastAPI schema 分别维护,请求字段随功能增加后容易漂移。
- 涉及:`studio/src/services/*.ts`、`server/schemas/*.py`。
- 建议:从 OpenAPI 自动生成 TypeScript 客户端,或至少以契约测试校验关键请求/响应。
### P2:补充高频查询组合索引
- 现状:商品与素材已有单列索引;随着数据增长,按商品、分组、排序、状态组合查询会变多。
- 涉及:`server/models/product.py`、`server/models/asset.py`。
- 建议:在真实数据库和访问量出现后,用慢查询确认并增加如 `(product_id, group_key, sort_order)` 的组合索引;避免过早添加无效索引。
---
## 6. 测试、交付与可观测性
### P1:建立自动化测试基础
- 现状:未发现前后端单测、契约测试或端到端测试目录;`studio` 仅提供 dev/build/preview 脚本。
- 建议:
1. 后端:计价、水印、文件名、任务状态、URL 安全校验、导出。
2. 前端:价格计算、选择/导出、水印设置、图片操作组件。
3. E2E:采集素材 → 试算 → 单张/套图生成 → 水印 → 导出。
### P2:统一日志、指标和错误码
- 现状:生成失败主要保存在任务错误文本或日志中,缺少 provider、模型、耗时、重试、存储失败等聚合指标。
- 建议:结构化日志中加入 task_id、product_id、provider、model、耗时、错误类别;健康检查补充存储与关键第三方状态。
### P1:先提交当前已验证改动,再做重构
- 现状:工作区存在多个未提交的前后端改动。
- 建议:按“水印/图片交互”“提示词”“导出”等模块提交;后续优化在独立提交或分支完成,便于回滚与定位。
---
## 建议实施顺序
1. **P0 资源与 URL 防护**:SSRF、文件大小、ZIP 限制。
2. **P1 任务与文件可靠性**:异步持久化任务、图片版本和清理策略。
3. **P1 测试基础**:先覆盖水印、导出、任务、URL 安全。
4. **P1 登记表服务端化**:持久化登记记录和批量导出。
5. **P2 前端拆分与性能**:拆 TrialSuitePanel、图片虚拟化、触屏/键盘体验。
6. **P2 契约、索引、可观测性**:依据真实使用量逐步完善。
+162
View File
@@ -0,0 +1,162 @@
# V2.1 商品试算页方案(studio /trial/:id)
> 状态:Phase A 前端开发中(本次交付前端,服务端接口见 api.md)
> 参照:`web/ozonSeller.html` + `web/js/app.js`(v1 工具台,冻结只读)。试算页 = v1 流水线的 studio 化 + 数据落库。
> 前端代码:`studio/src/pages/trial/`
> 多 SKU 的后续扩展、采集契约和迁移方案见 [`multi-sku-pricing.md`](./multi-sku-pricing.md)。本文以下计价字段以当前单 SKU 实现为准。
---
## 1. 定位与入口
商品试算页是 V2.1 的**主工作流页面**:采集落库后的一切加工都在这里完成——
核对商品信息 → 价格试算 → 俄文文案 → 图片/AI 生图 → 入库 → 导出 CSV/组合码 → 人工上 Ozon 后台。
入口:
1. **插件采集后自动打开**(Phase C):`/trial/{product_id}` 新标签页;
2. **采集箱「试算」按钮**(Phase A 已加):每行操作列;
3. URL 直达 / 从商品编辑页切换。
商品编辑页(`/product/:id`)保留,定位降级为「Ozon 结构化字段的长期储备」;试算页不依赖它。
## 2. 页面结构
```
┌──────────────────────────────────────────────────────┬────────────┐
│ 标题(中文/俄文) · stage 标签 · 保存状态 · 来源链接 · 去编辑页 │ 区域导航 │
├──────────────────────────────────────────────────────┤ (锚点滚动) │
│ 01 商品信息 标题/采集价/品牌/销量/店铺/货号/采买地址/ │ │
│ 参数表/描述(可编辑,onBlur 落库) │ │
│ 02 价格试算 重量/尺寸/进货价/净利率/贴单费/物流等级/划线价 │ │
│ 倍数/汇率 → 结果卡(物流费→实收价/净利润→完全 │ │
│ 成本/总抽成→销售价¥/₽/划线价) + 告警 │ │
│ 03 俄文文案 商品资料 → 模型选择 → 生成标题/简介/标签 → 回填 │ │
│ 04 图片与AI生图 采集图片(分组勾选/上传/AI生图) + 出图方案(规划/ │ │
│ 风格/要求/模型/一键生成+进度) + 生成结果(导出) │ → image-suite.md │
│ 05 入库与导出 入库状态 + 导出本商品CSV + 批量导出CSV + 组合码 │ │
└──────────────────────────────────────────────────────┴────────────┘
```
## 3. 01 商品信息
编辑即落库(onBlur/onChange → `PATCH /api/products/{id}`,与编辑页一致的自动保存模式)。
| 字段 | 落库位置 | 说明 |
|---|---|---|
| 标题(中文) | `raw.title_zh`(回退展示 `raw.title`) | 采集原标题写入 `raw.title`,人工整理后的中文名写 `title_zh` |
| 俄文标题 | `name` | 文案生成回填,也可手改 |
| 采集价 / 品牌 / 销量 / 店铺 | `raw.price` / `raw.brand` / `raw.sales` / `raw.shop` | 只读展示 |
| 重量 g | `weight`(`weight_unit='g'`) | 输入在 **02 价格试算左列顶部**(计价强相关);采集已自动 kg→g;变化即重算落库 |
| 长×宽×高 cm | `depth/width/height`(`dimension_unit='mm'`,cm×10 落库) | 同上,计价与尺寸校验用 |
| 货号(SKU) | `offer_id` | 对齐 v1「型号-后缀」,v2.1 用单字段;CSV/组合码的主键 |
| 采买地址 | `raw.purchase_url` | 1688/淘宝采集时默认取 `source_url`;可改;导出 CSV 列 |
| 参数表 | `raw.params`(`[{key,value}]`) | 只读折叠展示;供文案/生图上下文 |
| 描述 | `raw.desc` | 可编辑;供文案/生图上下文 |
## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineDiscountRate` 划线折扣率%(默认50,0-99.99)、
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
> 划线价按「划线折扣率」计算:`划线价 = 销售价 ÷ (1 - 划线折扣率)`。例如折扣率 50%、销售价 ¥50 时,划线价为 ¥100。折扣率必须 ≥ 0 且 < 100%。
**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
| 等级 | 条件 | 公式 | 规则文案 |
|---|---|---|---|
| low | ≤500g | `3.12 + 0.026×重量` | low:3.12 + 0.026×重量 |
| low | >500g | `23.92 + 0.01768×重量` | low:23.92 + 0.01768×重量 |
| high2 | ≤5000g | `22.88 + 0.026×重量` | 高2:22.88 + 0.026×重量 |
| high2 | >5000g | `64.48 + 0.024×重量` | 高2:64.48 + 0.024×重量 |
| high | ≤2000g | `16.64 + 0.026×重量` | 普通:16.64 + 0.026×重量 |
| high | >2000g | `37.44 + 0.01768×重量` | 普通:37.44 + 0.01768×重量 |
最后 `+ 贴单费`(规则文案追加「 + 通递价」)。
**计价链**(与 web/app.js:1035-1053 逐行一致):
```
实收价 = 进货价 × (1 + 净利率%)
净利润 = 进货价 × 净利率%
净得率 = low→0.845,其余→0.785
销售价¥ = (实收价 + 物流费) / 净得率
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
完全成本 = 进货价 + 物流费 + 平台总抽成
划线价¥ = 销售价 ÷ (1 - 划线折扣率);差额 = 划线价 - 销售价
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
```
**校验**(v2.1 新增移植到 `pricing.ts`:`validateDimensions` / `validateLogisticsLevelCny` / `validatePriceRange`,注意等级/区间建议用的销售价单位是 **CNY**,与 v1 一致):
| 校验 | 规则 | 表现 |
|---|---|---|
| 尺寸硬校验 | low≤500g:三边和≤90 且最长边≤60;low>500g:≤150/≤60;high≤2000g:≤150/≤60;high>2000g:≤250/≤150(high2 按 high 档执行,v1 即如此) | 红色错误,结果卡显示 `--`,不落计价结果 |
| 物流等级建议 | 销售价>140¥ 且 low → 建议高等级;<135¥ 且非 low → 建议低等级 | 橙色提示 |
| 价格区间 | 销售价 ∈ [135,140]¥ → 汇率波动风险 | 橙色提示 |
**落库**:参数或结果任一变化即重算并保存(对齐编辑页 PriceInfoPanel 模式):
```jsonc
// product.pricing(PriceInfoPanel 字段的超集,向后兼容)
{
"purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3,
"lineDiscountRate": 50, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 },
"logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63,
"fullCommission": 15.02, "totalCost": 67.14,
"sellingPriceCny": 77.51, "linePriceCny": 155.02,
"sellingPriceRub": 891.4, "linePriceRub": 1782.7,
"calculatedAt": "2026-08-26T12:00:00Z"
}
// 同时回填:price=销售价CNY,old_price=划线价CNY,currency_code=CNY,fx_rate=汇率
```
## 5. 03 俄文文案
直接复用编辑页 CopyPanel(`studio/src/pages/product/CopyPanel.tsx`)与服务端 `/api/ai/models`、`/api/ai/copy`:
- 输入区预填 `raw.title_zh + raw.params + raw.desc + 卖点`;
- 生成 2 个推荐标题(俄+中对照)、俄文简介、标签芯片;
- 「回填」标题写 `name + raw.title_zh`,简介写 `description`,结果另存 `product.copy`(后续服务端可加,前端先回填字段)。
## 6. 04 图片与 AI 生图
详见 [`image-suite.md`](./image-suite.md)。前端(Phase A)已实现完整交互:
采集图片分组展示与勾选、上传(`POST /api/materials/bytes`,已可用)、每张图「AI 生图」弹窗、
套图方案(默认/AI 规划/数量微调)、5 套风格 + 可编辑风格提示词、生图要求、10 个生图模型、
一键生成 + 进度轮询 + 结果网格 + 导出 ZIP、下载采集图片。
套图相关服务端接口 Phase B 提供,前端先行按 api.md 契约调用。
## 7. 05 入库与导出
**入库**:无独立「录入」按钮——01/02/03 区块的每次编辑都自动落库(v1 的 localStorage 登记表 → products 表)。
本区块显示入库状态(计价时间 `pricing.calculatedAt`、货号、来源)。
**CSV(对齐 web/app.js:1376-1424 的 16 列,UTF-8 BOM + RFC 转义)**:
```
货号(sku=offer_id)、商品名(title_zh)、进货价、物流费、平台总抽成、实收价、完全成本、
销售价、净利润、净利率、卢布销价、重量(g)、尺寸(cm L×W×H)、状态(stage 中文)、
Ozon地址(https://www.ozon.ru/product/<sku>)、采买地址(raw.purchase_url)
```
- **导出本商品 CSV**:当前商品一行,立即下载;
- **批量导出 CSV**:拉取商品列表(有 `pricing.calculatedAt` 的)逐个取详情拼装(Phase A 前端拼装,Phase D 可换 `GET /api/export/trial-csv`);
- **组合码**:`货号 预留₽价(两位小数)` 每行一条(无预留₽回退销售价₽),弹窗展示 + 一键复制,用于 Ozon 后台批量改价(对齐 v1「上品组合码」)。
## 8. 前端文件清单(Phase A)
| 文件 | 职责 |
|---|---|
| `studio/src/pages/trial/TrialPage.tsx` | 页面骨架:加载商品+素材、自动保存、区块导航 |
| `studio/src/pages/trial/TrialInfoPanel.tsx` | 01 商品信息 |
| `studio/src/pages/trial/TrialPricingPanel.tsx` | 02 价格试算 |
| `studio/src/pages/product/CopyPanel.tsx` | 03 俄文文案(复用,不新建) |
| `studio/src/pages/trial/TrialSuitePanel.tsx` | 04 图片与 AI 生图(素材/方案/生成/结果) |
| `studio/src/pages/trial/AiImageGenModal.tsx` | 单张 AI 生图弹窗 |
| `studio/src/pages/trial/TrialExportPanel.tsx` | 05 入库与导出 |
| `studio/src/services/suite.ts` | 套图/生图/导出 服务层(按 api.md 契约) |
| `studio/src/pricing/pricing.ts` | 追加 validateDimensions 等三个校验 + 规则文案 |
| `studio/src/utils/file.ts` | CSV 转义 / blob 下载 / 文件名清理 |
| `studio/src/router/index.tsx` + `layouts/menuConfig.tsx` + `CollectionPage.tsx` | 路由 `/trial/:id`、页头信息、采集箱「试算」入口 |
+50
View File
@@ -0,0 +1,50 @@
# V2.2 商品 AI 编辑页
> 状态:需求与架构设计,尚未开发。本文及同目录文档优先于 `docs/v2.1/` 中关于商品编辑页、试算页和多 SKU 的旧设计。
## 1. 目标
V2.2 用一张「商品(SPU)」和多个「可售 SKU」表达一个采集商品,重做当前商品编辑页为 **商品 AI 编辑页**。不在旧 `/trial/:id` 上继续叠加多 SKU 功能;旧试算页在 V2.2 开发完成前保持可用但冻结。
编辑页需要支持:
- 维护可扩展的通用属性和 SKU 独立属性;
- 为每个 SKU 管理图片、视频与视频封面;
- 从包装物流规格发起定价,确认后回填 SKU 售价和划线价;
- AI 生成标题、标签、简介等通用内容,以及按 SKU 生成尺寸图和套图;
- 为后续「上架至 Ozon」保留一 SKU 一 offer 的稳定发布模型。
## 2. 已确认决策
| 主题 | 决策 |
|---|---|
| Ozon 可售单元 | 一个本地 SKU 对应一个独立 Ozon `offer_id` 和一个 `/v3/product/import.items[]` 项。 |
| Ozon 变体 | 类目接口的 `is_aspect=true` 属性(如颜色/尺码)作为 SKU 属性提交;不假定 Ozon 会自动把多个 item 合并为一个前台变体组,发布阶段另行真机验证。 |
| 型号和货号 | 型号是父商品的通用属性;同一商品所有 SKU 共用。货号是 SKU 属性,后续可由 AI 生成、用户可编辑。 |
| 尺寸图 | 必须逐 SKU 生成,只读取该 SKU 已确认的实物规格快照。 |
| 计价 | 不在页面常驻展示试算卡;每个 SKU 的「定价」弹窗使用其有效包装物流规格计算,确认后回填售价与划线价。 |
| 图片采集 | 1688、淘宝、天猫当前不能可靠地按 SKU 采集图片。采集主图、详情图作为商品候选素材;已能稳定归属的 SKU 图才绑定 SKU。 |
| 单规格商品 | 单规格商品也必须有一条默认 SKU。SKU 不是“有多规格时才存在”的可选结构;它是定价、媒体选择、货号和未来 Ozon offer 的统一可售单元。 |
| 字段继承 | “SKU 字段为空”只表示该 SKU 的某一可继承字段未覆盖父商品默认值,绝不表示 SKU 记录、SKU 名称或货号为空,更不表示单规格商品。 |
| 动态类目属性 | 先选 Ozon `description_category_id + type_id`,再读取该类目的属性 schema。`is_aspect=true` 默认归入 SKU 变体属性;其他属性默认归入通用属性,但允许业务显式改为 SKU 属性。 |
| 编辑页 | 用新的商品 AI 编辑页替代旧商品编辑页;不修改旧试算页来承载多 SKU。 |
## 3. Ozon 文档依据
仓内 `docs/ozon-seller-api/04-product-import.md` 表明,`/v3/product/import` 的每个 `items[]` 都必须提供自己的 `offer_id`、`price`、包装 `depth/width/height/weight` 和图片;尺寸与重量不得为零。一次请求可提交最多 100 个 item。`03-category-attributes.md` 定义 `is_aspect=true` 为区分 SKU 变体的类目属性。
因此,独立 offer 不是仅为本地方便的选择,而是与 Ozon 导入契约相符的最小稳定粒度。现有本地文档没有描述可通过一个变体组 ID 将多个 import item 强制合并,故发布设计应以独立 offer 为事实来源;前台变体合并只能作为后续经验证的增强。
## 4. 文档索引
| 文档 | 内容 |
|---|---|
| [product-ai-editor.md](./product-ai-editor.md) | 页面信息架构、交互和保存行为 |
| [data-model.md](./data-model.md) | SPU/SKU、属性、素材、定价和迁移数据模型 |
| [api.md](./api.md) | 编辑页、SKU、素材、定价与 AI 的服务端契约 |
## 5. 范围与非目标
本期先完成编辑、校验、草稿保存和可审阅的数据模型。按钮「上架至 Ozon」可以先呈现为不可用或提示“后续开发”,不得在本期调用 Ozon。AI 套图抽屉沿用现有方案能力作为设计基线,具体交互与任务契约另行讨论后开发。
CSV、Ozon API 直传、库存、审核轮询和前台变体归并均属于后续发布阶段;但字段与接口必须避免妨碍它们。
+100
View File
@@ -0,0 +1,100 @@
# V2.2 商品 AI 编辑 API
> 状态:契约设计,尚未实现。所有写接口由服务端验证所有权、单位、继承与状态;前端不可提交计算结果冒充服务端结果。
## 1. 采集入库与 SKU 归属
`POST /api/materials` 保持现有商品级采集契约,并新增可选 `variants`。服务端始终落一条父商品和至少一条 SKU:有 `variants` 时逐项创建/更新;没有时创建一条默认 SKU。单规格商品不会省略 SKU 表记录,使编辑页、定价和未来发布永远面对同一模型;字段 `null` 只表示该字段未覆盖默认值。
```jsonc
{
"source": { "platform": "1688", "itemId": "123", "url": "https://..." },
"texts": ["...现有采集文本..."],
"images": ["...现有商品级采集图..."],
"variants": [
{
"sourceVariantKey": "stable-source-sku-id",
"variantName": "白色 / 500 ml",
"specs": { "颜色": "白色", "容量": "500 ml" },
"sourcePrice": 28.5,
"physicalMeasurements": {
"lengthMm": null, "widthMm": null, "heightMm": null, "netWeightG": null,
"status": "missing"
},
"packageMeasurements": {
"lengthMm": null, "widthMm": null, "heightMm": null, "shippingWeightG": null,
"status": "missing"
},
"imageUrls": []
}
]
}
```
服务端按 `platform + itemId + sourceVariantKey` 幂等写入 SKU;改名只更新 `variant_name`,不会创建重复 SKU。只有来源给出可靠 SKU—图片对应关系时,`imageUrls` 才能绑定该 SKU。1688、淘宝、天猫当前没有这种可靠映射时,图片仍作为商品级 `collected_main` / `collected_detail` 候选素材,`imageUrls` 保持空数组。来源口径不明的重量和尺寸不得写入任一 measurements 对象,需保留原始文本并写 `ambiguous`。
## 2. 读取与草稿保存
| 接口 | 用途 |
|---|---|
| `GET /api/products/{product_id}/editor` | 返回父商品、动态属性 schema、SKU 摘要/详情、已选媒体和候选素材统计。 |
| `PUT /api/products/{product_id}/editor-draft` | 原子保存父商品、属性、SKU 新增/修改/排序/停用与媒体选择。响应返回规范化完整编辑数据。 |
| `GET /api/products/{product_id}/validation` | 返回父商品及逐 SKU 的本地上架校验结果。 |
`editor-draft` 使用版本号/ETag 防止两个页面静默互相覆盖;版本冲突返回 `409` 和当前版本摘要。复杂的单一操作也提供下列细粒度接口,供弹窗立即保存使用。
## 3. SKU 与属性
| 接口 | 用途 |
|---|---|
| `POST /api/products/{product_id}/variants` | 创建手工 SKU 或复制 SKU,返回新 ID。 |
| `PATCH /api/products/{product_id}/variants/{variant_id}` | 修改单个 SKU 字段或恢复继承(字段值传 `null`)。 |
| `DELETE /api/products/{product_id}/variants/{variant_id}` | 软删除未发布 SKU;已发布 SKU 改为停用并要求后续发布流程处理。 |
| `PUT /api/products/{product_id}/attributes` | 保存通用属性。 |
| `PUT /api/products/{product_id}/variants/{variant_id}/attributes` | 保存 SKU/变体属性。 |
| `GET /api/products/{product_id}/attribute-schema` | 按当前 `description_category_id + type_id` 返回 Ozon schema、字典选项和 `is_aspect` 标识。 |
服务端通过 Ozon `/v1/description-category/attribute` 获取 schema,并按 `type`、`is_required`、`is_collection`、`max_value_count` 和字典值校验结构化值。`is_aspect=true` 是默认的 `variant` scope;客户端可显式将业务上按 SKU 区分的非 aspect 属性保存为 `variant` scope。服务端不得因该业务覆盖而丢弃属性,但会在发布校验中按 Ozon 最终 schema 提示兼容性问题。来源采集接口以稳定 `source_variant_key` 做 SKU 幂等,而不按 `variant_name` 匹配。
## 4. 定价
| 接口 | 用途 |
|---|---|
| `POST /api/products/{product_id}/variants/{variant_id}/pricing/quote` | 以请求的编辑值和服务端合并后的包装规格计算预览,不写入售价。 |
| `POST /api/products/{product_id}/variants/{variant_id}/pricing/confirm` | 重新计算、校验并保存快照,同时写入 `selling_price`、`old_price`。 |
| `GET /api/products/{product_id}/variants/{variant_id}/pricing` | 读取最新确认快照和可解释的告警。 |
`confirm` 的请求只包含用户可编辑的定价输入;服务端自行读取 SKU 和父商品规格,写入计算结果、公式版本和有效包装规格快照。包装值缺失、`ambiguous`、小于等于零或不满足物流尺寸限制时返回 `422`,不更改原价格。
## 5. 媒体
| 接口 | 用途 |
|---|---|
| `GET /api/products/{product_id}/variants/{variant_id}/media/candidates?group=` | 返回 AI 记录、采集主图、采集 SKU 图、采集详情图;带稳定 SKU 归属的候选项只返回给对应 SKU。 |
| `PUT /api/products/{product_id}/variants/{variant_id}/media` | 原子保存该 SKU 图片/视频/封面选中项及顺序。 |
| `POST /api/products/{product_id}/assets` | 上传商品级或明确指定 SKU 的候选媒体。 |
| `DELETE /api/products/{product_id}/assets/{asset_id}` | 删除未被引用的候选资产;仍被 SKU 选中时返回引用信息并要求先解除选择。 |
资产引用必须使用 `asset_id` 和 `product_variant_id`,不使用图片 URL 或 SKU 名称作为关联键。
## 6. AI
| 接口 | 用途 |
|---|---|
| `POST /api/products/{product_id}/ai/copy` | 输入通用商品上下文,返回标题、标签、简介、富内容等候选草稿;不直接覆盖数据库。 |
| `POST /api/products/{product_id}/ai/model-and-offers` | 为父商品返回型号和每个 SKU 的货号候选;用户确认后通过草稿保存接口写入。 |
| `POST /api/products/{product_id}/ai/suite-plans` | 创建套图规划;请求必须显式给出目标 SKU 集合。 |
| `POST /api/products/{product_id}/variants/{variant_id}/ai/dimension-image` | 为单 SKU 创建尺寸图任务;服务端固化已确认实物规格快照。 |
AI 与生图任务沿用现有持久化后台任务模型。任何任务都要保存 `product_variant_id`,且尺寸图额外保存 `physical_measurements_snapshot`;SKU 改名不会丢失任务归属。
## 7. 后续 Ozon 发布契约
未来 `POST /api/products/{product_id}/publish` 的内部实现应把每个启用 SKU 转换成一个 `/v3/product/import.items[]`:
- 父商品标题、描述、类目、通用属性复制到每个 item;
- SKU 的 `offer_id`、价格、划线价、包装尺寸/重量、已选图片和变体属性写入对应 item;
- 每个 SKU 独立保存 Ozon task item 结果、`ozon_product_id` 和 Ozon SKU;
- 请求按最多 100 个 item 分批,单 SKU 失败不应抹掉其他 item 的状态。
本期不实现该接口;本文仅规定数据与错误归属,避免后续发布阶段重新拆分单商品记录。
+80
View File
@@ -0,0 +1,80 @@
# V2.2 数据库与领域模型
> 状态:设计,尚未创建迁移。数据库单位统一为 `mm` 与 `g`。
## 1. 模型边界
```text
products(父商品 / SPU) 1 ─── N product_variants(可售 SKU / Ozon offer)
│ │
├── product_attributes ├── product_variant_media ── product_assets
├── product_assets(候选池) ├── product_attributes(scope=variant)
└── product_pricing_defaults └── variant_pricing_snapshots
```
保留现有 `products` 主键和采集来源字段,以便采集箱和历史链接继续有效;其单 SKU 价格、尺寸、`offer_id` 字段只作为迁移兼容层,V2.2 写路径不再以它们为事实来源。每个父商品至少有一条 `product_variants` 记录:单规格商品拥有默认 SKU,多规格商品拥有多个 SKU;两者不采用不同的数据结构。
## 2. 父商品 `products`
新增/规范的核心字段:
| 字段 | 说明 |
|---|---|
| `model_code` | 通用型号,所有 SKU 共用;可由 AI 建议,用户可编辑。 |
| `title_zh`、`name`、`description` | 中文标题、俄文标题、俄文简介。 |
| `rich_content` | 已验证的 JSON 富内容。 |
| `barcode` | 当前按业务决策作为通用字段。发布阶段若 Ozon/业务要求 SKU 级条码,再增加显式覆盖字段,不从图片或名称推断。 |
| `purchase_url`、`supply_note` | 采买地址和货源备注。 |
| `description_category_id`、`type_id` | Ozon 类目和商品类型。 |
| `pricing_defaults` | 默认利润率、贴单费、物流等级、划线价倍数、汇率等可继承计价参数。 |
| `package_*_default` | 默认包装物流规格;只作为 SKU 的可继承默认值。 |
| `physical_*_default` | 默认实物规格;只作为 SKU 的可继承默认值。 |
历史 `raw` 继续存原始采集文本/参数,绝不可将口径不明的原始“尺寸/重量”自动写入新规格字段。
## 3. SKU `product_variants`
| 字段组 | 字段 | 说明 |
|---|---|---|
| 身份 | `id`、`product_id`、`source_variant_key`、`variant_name`、`specs`、`sort_order`、`enabled` | `source_variant_key` 与来源平台联合唯一;显示名称可以变化或重复。 |
| 销售 | `offer_id`、`purchase_price`、`selling_price`、`old_price`、`currency_code` | `offer_id` 对启用 SKU 必须唯一;售价和划线价仅由定价确认或人工编辑写入。 |
| 包装物流 | `package_length_mm`、`package_width_mm`、`package_height_mm`、`shipping_weight_g`、`package_measurements_meta` | 用于试算、物流校验和 Ozon import。`null` 表示继承,不表示零。 |
| 实物 | `physical_length_mm`、`physical_width_mm`、`physical_height_mm`、`net_weight_g`、`physical_measurements_meta` | 用于尺寸图、规格展示、文案,绝不用于物流计算。 |
| 发布 | `ozon_product_id`、`ozon_sku`、`publish_state`、`published_at` | 为一 SKU 一 offer 的后续同步预留。 |
| 审计 | `created_at`、`updated_at`、`deleted_at` | SKU 采用软删除,防止丢失历史报价与外部映射。 |
两组 `*_measurements_meta` 至少存每个字段/字段组的 `status`(`confirmed`、`ambiguous`、`missing`)、来源、原始文本、确认时间。有效值按“SKU 覆盖 → 父商品默认值”逐字段合并;两组规格独立合并。这里的 `null` 仅表示某个可继承字段未覆盖父商品默认值;绝不表示 SKU 记录不存在,也不以此判断商品是否为单规格。
## 4. 动态属性 `product_attributes`
类目不同且将来可能持续增加属性,不能靠不断扩展 `products` 列。使用结构化属性表:
| 字段 | 说明 |
|---|---|
| `id`、`product_id`、`product_variant_id` | 通用属性只填 `product_id`;SKU 属性还填所属 `product_variant_id`。 |
| `scope` | `common` 或 `variant`;数据库约束与两个外键一致。 |
| `attribute_key`、`attribute_name` | 本地稳定键和最近一次展示名称。 |
| `ozon_attribute_id`、`complex_id` | 已映射时记录 Ozon 属性身份。 |
| `values` | JSON 数组,保存文本与可选 `dictionary_value_id`,能表达单值、多值、数值与布尔。 |
| `source`、`status` | `collected`/`ai`/`manual` 与 `confirmed`/`ambiguous`/`missing`。 |
动态属性 schema 来自 Ozon:先由父商品的 `description_category_id + type_id` 调用 `/v1/description-category/attribute`,再按返回的 `id`、`type`、`is_required`、`is_collection`、`max_value_count`、`dictionary_id` 和 `is_aspect` 渲染与校验。`dictionary_id > 0` 时,值通过属性值字典/搜索接口确认 `dictionary_value_id` 后保存。
通用属性包括品牌、型号、主题标签及默认的类目共有属性。变体属性包括颜色、颜色名称以及 Ozon schema 标记 `is_aspect=true` 的所有属性;`is_aspect` 是默认归属,不是硬编码限制。用户或采集规则可把某个非 `is_aspect` 属性(例如按容量销售的商品的容量)标记为 `variant` scope,并为每个 SKU 保存不同值。`specs` 仅是来源规格的原始/展示快照,不能替代已确认的属性值。
## 5. 素材与媒体选择
`product_assets` 保存一次资产事实:`product_id`、可空 `product_variant_id`、`media_type`(image/video)、`source_group`(collected_main/collected_sku/collected_detail/generated/upload)、原始/当前 URL、尺寸、生成任务/规格快照及来源信息。若采集平台无法稳定给出 SKU 归属,`product_variant_id` 必须为 `null`。
新增 `product_variant_media` 表保存每个 SKU 的已选媒体,而不是复制资产:`product_variant_id`、`asset_id`、`role`(image/video/video_cover)、`sort_order`、`selected_at`。同一商品级素材可安全地被多个 SKU 选用;同一 SKU 的排序独立。发布时仅取该 SKU 已选图片,并执行 Ozon 的图片数量、HTTPS 可访问性等校验。
## 6. 定价快照
`variant_pricing_snapshots` 记录不可变的确认结果:SKU、输入参数、合并后的包装物流规格、公式版本、结果、告警、汇率、`calculated_at`、确认人/来源。SKU 表可缓存最新快照 ID 以加速展示;导出或上架时服务端必须以有效包装规格重新校验,不能信任浏览器传回的价格结果。
## 7. 迁移原则
1. 每个历史 `products` 记录创建一个默认 SKU,复制原 `offer_id`、价格和 `pricing`。
2. 历史 `weight/depth/width/height` 迁入默认 SKU 的包装物流规格,因为旧字段只服务于试算;实物规格标记 `missing`。
3. 历史素材先保留为商品级候选素材;不根据 `variant_name` 倒推 SKU 绑定。
4. 新读路径优先 `product_variants`;在迁移完成前,旧字段只读兼容,禁止双向覆盖。
+92
View File
@@ -0,0 +1,92 @@
# 商品 AI 编辑页设计
> 状态:需求设计,尚未开发。
## 1. 路由与页面定位
建议以 `/product/:id` 作为新页面路由,直接替换旧商品编辑页,从而保留现有列表、链接和深链兼容性;页面标题为「商品 AI 编辑」。旧页面组件移除前先完成数据迁移和路由切换。`/trial/:id` 不再承接新功能,完成迁移后引导用户使用新页。
页面编辑的是一个父商品和其 SKU 集合。它不是按 SKU 复制的多张商品表单:通用信息只维护一次,SKU 卡片只维护可售单元差异。**单规格商品也创建并展示一张默认 SKU 卡片**,因此所有商品都用相同的定价、素材、货号和发布流程。
## 2. 页面结构
```text
页头:返回 · 商品中文名 / 俄文标题 · 草稿状态 · 来源链接
├─ 商品通用信息
│ ├─ 类目、标题、品牌、型号、主题标签、简介、JSON 富内容
│ ├─ 条形码、采买地址、货源备注
│ └─ 类目属性(由当前 Ozon 类目动态渲染)
├─ SKU 商品
│ ├─ 显示模式:卡片(默认) / 紧凑列表
│ ├─ SKU 卡片 × N
│ └─ 新增、复制、停用、删除 SKU
└─ 固定操作栏
├─ 左:一键生成标题/标签/描述 · AI 套图生成
└─ 右:取消并关闭 · 保存草稿 · 上架至 Ozon(后续)
```
页面不采用现有 onBlur 即写库模式。页面保存一个编辑草稿;「保存草稿」以一个事务提交父商品、变更的 SKU、属性和素材顺序。关闭存在未保存改动时提示确认。定价确认是例外:确认后立刻写入该 SKU 的价格和定价快照,同时在页面草稿中同步结果。
## 3. 通用信息区
以下字段属于父商品,全部 SKU 共享:类目、标题、品牌、型号、主题标签、简介、JSON 富内容、条形码、采买地址、货源备注,以及后续增加的商品级字段。
- 标题应保存中文采集标题和可编辑的俄文发布标题;AI 生成后先填入编辑草稿,由用户检查后随“保存草稿”持久化。
- 类目变更会刷新该类目的 Ozon 属性 schema。已填写但不再属于新类目的属性保留为未映射草稿,不自动带到发布 payload。
- 类目属性不硬编码到前端。前端按后端返回的 Ozon schema 显示必填、字典、数值、多值和变体属性提示:`is_aspect=true` 默认展示在 SKU 属性区,其他属性默认展示在通用区。
- `is_aspect` 是 Ozon 的默认归属依据,不是不可推翻的业务限制。比如容量在某类目不是 `is_aspect`,但采购规格实际按容量拆分时,用户可将它标记为 SKU 属性;保存后该属性在每个 SKU 上独立编辑和发布。
- 「JSON 富内容」保存为经过 JSON 校验的结构化值,而不是一段不可验证文本。
## 4. SKU 区
### 4.1 默认卡片,兼顾紧凑列表
默认使用卡片,方便图片、视频和多组规格编辑;切换到紧凑列表时只改变展示,不改变编辑数据。列表列为:序号、SKU 名称、图片缩略图、视频封面、视频、货号、采购价、售价、划线价、包装规格、实物规格、商品颜色、颜色名称、状态和操作。
卡片显示:
- 头部:序号、SKU 名称、启用状态、复制/删除;
- 媒体:最多展示 3 张缩略图,可在偏好中改为 5 张;视频封面与视频状态单独展示;
- 销售:货号、采购价、售价、划线价,以及「定价」按钮;
- 规格:包装物流(长宽高、包装毛重)和实物(长宽高、净重)两个明确区块;
- 变体属性:商品颜色、颜色名称以及当前类目所有 `is_aspect=true` 的属性。
SKU 名称是展示名,不可作为同步键。每项始终保存来源的稳定 SKU 标识;没有来源标识的手工 SKU 由本地 UUID 标识。SKU 的存在不依赖字段是否已填写:规格字段的空值只代表“未覆盖父商品默认值”,不代表单规格商品或缺少 SKU。
### 4.2 定价弹窗
点击某一 SKU 的「定价」后打开弹窗,默认读取并显示该 SKU 的 **有效包装物流规格**(SKU 覆盖值优先,再读父商品默认值)。弹窗允许修改采购价、利润率、贴单费、物流等级、划线价倍数、汇率和包装规格;不显示为常驻结果卡。
服务端计算并返回物流校验、价格建议、人民币/卢布结果。用户点击“确认定价”后:
1. 保存本次输入和有效包装规格快照;
2. 保存计算结果和计算时间;
3. 将卢布销售价回填 SKU `selling_price`,划线价回填 `old_price`;
4. 若包装规格缺失、待确认或尺寸校验失败,则不得确认。
实物规格绝不进入物流计算。若实物规格缺失,定价仍可完成;但该 SKU 的尺寸图按钮不可用。
### 4.3 SKU 图片编辑弹窗
点击 SKU 的图片区域打开素材编辑弹窗:
```text
左侧:该 SKU 已选图片(可排序、删除;限制由发布规则校验)
右侧:AI 生图记录 | 采集主图 | 采集 SKU 图 | 采集详情图
```
用户从右侧候选素材选择后加入左侧,完成后保存该 SKU 的选图顺序。采集主图和详情图是商品级候选池,可被多个 SKU 选择;采集到且有稳定 SKU 归属的 SKU 图默认只出现在对应 SKU 的候选池。AI 生成结果必须记录生成上下文所绑定的 SKU,不能只凭显示名称匹配。
视频封面和视频沿用相同的“候选资产 + SKU 已选媒体”机制,但不与图片混排。发布时按 Ozon 支持能力再确定其序列化方式。
## 5. AI 操作
- 「一键生成标题、标签、描述等信息」只针对父商品;服务端返回按字段区分的候选内容和依据,前端将选中的结果填入草稿。
- 「AI 套图生成」从左侧抽屉展开。抽屉以现有试算页套图规划、风格、要求、模型、任务进度和结果为基线,但需要显式选择目标 SKU;尺寸图任务必须一 SKU 一任务并固化实物规格快照。
- AI 生成不得覆盖用户已编辑字段或已选图片。覆盖只能由用户在结果应用时显式确认。
## 6. 编辑与校验状态
SKU 卡片状态建议显示为:`草稿`、`待补全`、`可定价`、`已定价`、`可上架`、`已停用`。其中“可上架”仅表示本地校验通过,不代表已调用 Ozon。
本地“可上架”最低条件:父商品类目、俄文标题、简介、必填通用/类目属性齐全;每个启用 SKU 有唯一货号、有效售价、有效包装物流规格、所需图片和 SKU 必填属性。实际 Ozon 发布仍须重新做一次服务器端校验。
@@ -0,0 +1,66 @@
import { generateSuite, getSuite, planSuite } from '../src/api/client';
import { reportProduct } from '../src/api/report';
// Background Service Worker —— 唯一出网口(生成 / 规划 / 轮询任务,绕 CORS)
export default defineBackground(() => {
console.log('[电商套图工作台] background started');
// 点击扩展图标 → 开关当前商品页的悬浮面板(页面无 content script 时忽略)
chrome.action.onClicked.addListener(async (tab) => {
if (tab.id == null) return;
try {
await chrome.tabs.sendMessage(tab.id, { action: 'toggle-suite-panel' });
} catch {
// 非四站点页面,未注入面板
}
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'generateSuite') {
generateSuite(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;
}
if (msg?.action === 'planSuite') {
planSuite(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;
}
if (msg?.action === 'getSuite') {
getSuite(msg.baseUrl, msg.token, msg.suiteId)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
// 商品上报:采集结果 POST 到 ozon-seller-kit 后台 /api/materials
if (msg?.action === 'reportProduct') {
reportProduct(msg.reportBaseUrl, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API)
if (msg?.action === 'fetchText') {
fetch(msg.url, {
credentials: msg.credentials ? 'include' : 'omit',
// Referer 是受保护请求头,不能通过 headers 设置;使用 Fetch 的标准选项。
referrer: typeof msg.referer === 'string' ? msg.referer : undefined,
referrerPolicy: 'strict-origin-when-cross-origin',
})
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
sendResponse({ ok: true, text: await res.text() });
})
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
return false;
});
});
@@ -0,0 +1,36 @@
// MAIN world 桥 —— 跑在页面主世界,读取页面 JS 变量(isolated world 读不到)。
// 协议(对齐竞品 inject.js 模式):
// isolated → MAIN: {type:'sc-bridge-req', requestId, keys: [...]} keys 含 '*' 时返回诊断键列表
// MAIN → isolated: {type:'sc-bridge-res', requestId, payload: {key: value}}
// MAIN 侧零业务逻辑:只读白名单键、JSON 序列化过滤后回传,不注入任何页面行为。
export default defineContentScript({
matches: [
'https://*.ozon.ru/*', 'https://*.ozon.kz/*', 'https://*.ozon.by/*',
'https://detail.1688.com/*',
'https://item.taobao.com/*', 'https://detail.tmall.com/*',
],
world: 'MAIN',
main() {
window.addEventListener('message', (ev: MessageEvent) => {
if (ev.source !== window) return;
const d = ev.data as { type?: string; requestId?: string; keys?: string[] } | null;
if (!d || d.type !== 'sc-bridge-req' || !d.requestId || !Array.isArray(d.keys)) return;
const payload: Record<string, unknown> = {};
if (d.keys.includes('*')) {
// 诊断模式:列出页面上可能有数据的全局键
payload['__sc_window_keys__'] = Object.keys(window).filter(k =>
/^(__|_)?[A-Za-z]/.test(k) && /(context|rawData|ICE|sku|item|g_config|DATA|state)/i.test(k)
);
}
for (const k of d.keys) {
if (k === '*') continue;
try {
const v = (window as unknown as Record<string, unknown>)[k];
if (v !== undefined) payload[k] = JSON.parse(JSON.stringify(v)); // 过滤函数/循环引用
} catch { /* 不可序列化的跳过 */ }
}
window.postMessage({ type: 'sc-bridge-res', requestId: d.requestId, payload }, '*');
});
},
});
@@ -0,0 +1,26 @@
// Content Script —— 注入四个平台的商品页,暴露采集入口
import { scanCurrentPage } from '../../src/collector/scan';
export default defineContentScript({
matches: [
// Ozon
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
// 1688
'https://detail.1688.com/*',
// 淘宝 / 天猫
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
main() {
console.log('[电商套图工作台] Content script loaded');
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
};
console.log('[电商套图工作台] 就绪。Console 可测: await window.__SuiteCollector.scan()');
},
});
@@ -0,0 +1,182 @@
// Panel Content Script —— 商品页右下角悬浮按钮 + 页内悬浮面板
//
// 面板 = iframe 加载插件内置 sidepanel.html(扩展页面在 iframe 里仍有 chrome.* 权限,
// 采集/生成/导出逻辑零改动);按钮与面板容器渲染在独立 Shadow DOM 中,
// 不受商品页全局 CSS 影响,面板悬浮覆盖页面、不挤压原页面布局。
import { matchProfile } from '../src/profiles/index';
/** 面板内 App.tsx → 宿主页的收起消息 */
const PANEL_CLOSE_MSG = 'sc-panel-close';
const STYLES = `
:host { all: initial; }
.fab {
position: fixed;
right: 24px;
bottom: 24px;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font: 700 20px/1 -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: #fff;
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
box-shadow: 0 4px 16px rgba(109, 40, 217, 0.45);
z-index: 3;
transition: transform 0.15s ease;
}
.fab:hover { transform: scale(1.08); }
.fab.hidden { display: none; }
/* 收起按钮:骑在面板左上边缘(一半在面板外)。必须在 iframe 外渲染——
iframe 裁剪内容,iframe 内的元素永远溢不出面板边界 */
.panel-close {
position: fixed;
top: 20px;
right: min(880px, 100vw); /* 面板左边缘 = 视口右沿 - 面板宽度 */
transform: translateX(50%);
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff;
color: #555;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
z-index: 4;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.15s ease;
}
.panel-close.visible { opacity: 1; pointer-events: auto; }
.panel-close:hover { color: #6d28d9; border-color: #6d28d9; transform: translateX(50%) scale(1.08); }
.panel {
position: fixed;
top: 0;
bottom: 0;
right: 0;
width: min(880px, 100vw);
border-radius: 14px 0 0 14px;
overflow: hidden;
background: #fff;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.06);
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 2;
pointer-events: none;
}
.panel.open { transform: translateX(0); pointer-events: auto; }
.panel iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
}
`;
export default defineContentScript({
matches: [
// Ozon
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
// 1688
'https://detail.1688.com/*',
// 淘宝 / 天猫
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: 'suite-studio-panel',
position: 'overlay',
anchor: 'body',
alignment: 'bottom-right',
zIndex: 2147483646,
css: STYLES,
isolateEvents: true,
onMount(container) {
const fab = document.createElement('button');
fab.className = 'fab hidden';
fab.title = '电商套图工作台';
fab.textContent = '套';
const panel = document.createElement('div');
panel.className = 'panel';
// iframe 懒加载:首次展开才设 src,避免每个商品页都加载整个面板应用
const iframe = document.createElement('iframe');
iframe.title = '电商套图工作台';
panel.append(iframe);
// 外置收起按钮(骑在面板左上边缘,一半在面板外)
const closeBtn = document.createElement('button');
closeBtn.className = 'panel-close';
closeBtn.title = '收起面板(Esc)';
closeBtn.innerHTML =
'<svg width="12" height="12" viewBox="0 0 12 12" fill="none">' +
'<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
container.append(fab, panel, closeBtn);
const open = () => {
if (!iframe.src) iframe.src = chrome.runtime.getURL('/sidepanel.html');
panel.classList.add('open');
closeBtn.classList.add('visible');
fab.classList.add('hidden');
// 聚焦进面板,键盘操作(Esc 关闭 / 预览翻页)直接可用
iframe.focus();
};
const close = () => {
panel.classList.remove('open');
closeBtn.classList.remove('visible');
if (isProductPage()) fab.classList.remove('hidden');
};
fab.addEventListener('click', open);
closeBtn.addEventListener('click', close);
// 面板内 App(Esc)→ 收起;✕ 按钮已外置到宿主层(closeBtn)
window.addEventListener('message', (e) => {
if (e.source === iframe.contentWindow && (e.data as any)?.type === PANEL_CLOSE_MSG) close();
});
// 工具栏图标点击 → 开关面板
chrome.runtime.onMessage.addListener((msg: any) => {
if (msg?.action === 'toggle-suite-panel') {
panel.classList.contains('open') ? close() : open();
}
});
// 仅商品详情页显示按钮;站内软导航后重判(WXT 内置事件,自动拦截 pushState/replaceState/popState)
const refresh = () => {
if (isProductPage()) {
if (!panel.classList.contains('open')) fab.classList.remove('hidden');
} else {
fab.classList.add('hidden');
close();
}
};
ctx.addEventListener(window, 'wxt:locationchange', refresh);
refresh();
return { open, close };
},
});
ui.mount();
},
});
/** 是否为四站点支持的商品详情页(与采集 profile 一致) */
function isProductPage(): boolean {
return matchProfile(location.href) !== null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,334 @@
<!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>
:root {
--bg: #f5f5f5; /* 页面背景(中性灰) */
--card: #ffffff;
--card-soft: #fafafa;
--border: #f0f0f0;
--border-strong: #e0e0e0;
--primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */
--primary-hover: #7c3aed;
--primary-ring: rgba(139, 92, 246, 0.12);
--primary-soft: #a78bfa; /* 主题色同色系偏淡(未勾选描边/✓) */
--green: #52c41a;
--red: #ff4d4f;
--warn-bg: #fffbe6;
--warn-border: #ffe58f;
--warn-text: #8c6d1f;
--text: #262626;
--text-2: #8c8c8c;
}
html, body, #root {
min-width: 860px;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 14px;
}
* { box-sizing: border-box; }
/* ── 页面骨架 ── */
.page { padding: 16px 18px 22px; }
/* 采集区两列等高:左右卡片拉伸到同一高度 */
.two-col { display: flex; gap: 14px; align-items: stretch; margin-bottom: 14px; }
.two-col .section { flex: 1; min-width: 0; margin-bottom: 0; display: flex; flex-direction: column; }
.two-col .section .section-head { flex-shrink: 0; }
/* 图片列表占满 section 除标题外的剩余高度;min-height:0 是 flex 子项内滚动的关键 */
.img-groups { flex: 1 1 auto; min-height: 0; overflow-y: auto; max-height: 78vh; }
/* 采集图片区:section 自身去掉左右 padding,标题行自持 padding;
图片区左侧对齐标题,右侧只留窄缝给滚动条(滚动条贴卡片内缘,图片与滚动条之间有小间距) */
.section-images { padding: 14px 0 !important; }
.section-images .section-head { padding: 0 16px; }
.section-images .img-groups { padding: 2px 8px 0 16px; }
.section-images .empty { margin: 0 16px; }
.img-export-bar {
display: flex; align-items: center; gap: 10px;
margin: 10px 16px 2px; padding-top: 10px;
border-top: 1px solid var(--border);
}
.img-export-bar .hint { font-size: 12px; color: var(--text-2); }
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
/* ── 顶部 ── */
.topbar {
display: flex; align-items: center; gap: 11px;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.logo {
width: 38px; height: 38px; border-radius: 9px;
background: var(--primary); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 19px; font-weight: 700;
}
.topbar h1 { font-size: 17px; margin: 0; font-weight: 700; }
.topbar .sub { font-size: 12px; color: var(--text-2); margin-top: 1px; }
.topbar .spacer { flex: 1; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-strong);
font-size: 14px; cursor: pointer; user-select: none;
background: #fff; color: var(--text);
transition: all .15s;
}
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn-primary {
background: var(--primary); border-color: var(--primary); color: #fff;
font-weight: 600;
}
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
/* AI 智能规划:深靛紫渐变(智慧/深度感) */
.btn-ai {
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%);
border: none; color: #fff; font-weight: 600;
box-shadow: 0 2px 10px rgba(88, 60, 210, 0.35);
}
.btn-ai:hover:not([disabled]) {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #fff; box-shadow: 0 3px 14px rgba(88, 60, 210, 0.45);
}
/* 三个主操作按钮统一宽度 */
.btn-main { width: 160px; }
/* 「规划并生成」复选框 */
.auto-chk {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12.5px; color: var(--text-2); cursor: pointer; user-select: none;
}
.auto-chk input { accent-color: var(--primary); width: 14px; height: 14px; cursor: pointer; }
.auto-chk:hover { color: var(--text); }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
.icon-btn {
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border-strong);
background: #fff; cursor: pointer; color: var(--text-2);
display: inline-flex; align-items: center; justify-content: center;
}
.icon-btn:hover { color: var(--primary); border-color: var(--primary); }
/* ── 编号步骤卡片 ── */
.section {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 14px 16px; margin-bottom: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.section-head { display: flex; align-items: baseline; gap: 9px; margin-bottom: 12px; }
.section-no {
font-size: 20px; font-weight: 800; color: var(--primary);
font-variant-numeric: tabular-nums; line-height: 1;
}
.section-title { font-size: 15px; font-weight: 700; }
.section-extra { margin-left: auto; font-size: 12px; color: var(--text-2); }
/* ── 字段 ── */
.field { margin-bottom: 10px; }
.field label { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 4px; }
.field input, .field textarea {
width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong);
border-radius: 6px; font-size: 14px; font-family: inherit; line-height: 1.5;
background: var(--card-soft); color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus, .field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring);
background: #fff;
}
.kv-table {
width: 100%; border-collapse: collapse; font-size: 13px;
background: var(--card-soft); border-radius: 6px; overflow: hidden;
}
.kv-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.kv-table tr:last-child td { border-bottom: none; }
.kv-table td.k { color: var(--text-2); white-space: nowrap; width: 1%; padding-right: 16px; }
/* ── 药丸选择 ── */
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
.pill {
display: inline-flex; align-items: center; justify-content: center;
padding: 5px 13px; border-radius: 999px; border: 1px solid var(--border-strong);
background: #fff; font-size: 13px; cursor: pointer; color: var(--text-2);
user-select: none; transition: all .15s; line-height: 1.4;
}
.pill:hover { border-color: var(--primary); color: var(--primary); }
.pill.on {
background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600;
}
/* ── 图片网格 ── */
.group-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 9px; }
.group-head .name { font-size: 13px; font-weight: 600; color: var(--text-2); }
.group-head .count {
font-size: 12px; color: var(--text-2); background: var(--card-soft);
border: 1px solid var(--border); border-radius: 999px; padding: 0 8px;
}
.group-head .mini-check { margin-left: auto; font-size: 12px; color: var(--primary); cursor: pointer; user-select: none; }
.group-head .mini-check:hover { text-decoration: underline; }
.img-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
.img-cell {
position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden;
border: 2px solid transparent; cursor: zoom-in; background: var(--card-soft);
}
.img-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.img-cell.on { border-color: var(--primary); }
.img-cell .tick {
position: absolute; top: 5px; left: 5px; width: 18px; height: 18px;
border-radius: 50%; border: 1.5px solid var(--primary-soft);
background: rgba(255, 255, 255, 0.55); display: flex; align-items: center; justify-content: center;
color: var(--primary-soft); font-size: 11px; transition: all .15s; cursor: pointer;
}
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); color: #fff; }
.img-cell .variant {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px;
padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── 生成结果(整行,6 列)── */
.result-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.result-cell { position: relative; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.result-cell img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.result-cell .cap {
font-size: 11.5px; text-align: center; padding: 3px 0;
background: var(--card-soft); color: var(--text-2);
border-top: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.result-cell.fail { opacity: .55; }
.result-cell .fail-tag {
position: absolute; top: 5px; right: 5px; font-size: 11px;
background: var(--red); color: #fff; border-radius: 4px; padding: 0 5px;
}
/* ── 目标平台切换条 ── */
.platform-bar {
display: flex; align-items: center; gap: 10px;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 16px; margin-bottom: 14px;
}
.platform-label { font-size: 13px; font-weight: 700; }
.platform-spec { margin-left: auto; font-size: 12.5px; color: var(--text-2); }
/* 平台切换:Button.Group 形式,选中态用低饱和灰绿(不抢主题色) */
.seg-group { display: inline-flex; }
.seg-btn {
padding: 7px 20px; font-size: 13.5px; font-family: inherit;
min-width: 120px; text-align: center; /* 选中加粗会让文字变宽,固定宽度消除跳动 */
background: #fff; border: 1px solid var(--border-strong); border-left-width: 0;
color: var(--text-2); cursor: pointer; user-select: none; transition: all .15s;
}
.seg-group .seg-btn:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
.seg-group .seg-btn:last-child { border-radius: 0 8px 8px 0; }
.seg-btn:hover { color: var(--text); background: var(--card-soft); }
.seg-btn.on {
background: #eef0eb; border-color: #c9cec6; color: #3f453c; font-weight: 700;
}
.seg-group .seg-btn.on + .seg-btn { border-left-color: #c9cec6; }
/* 三个平台药丸等宽:选中态加粗会让文字变宽,用固定 min-width 消除抖动 */
.platform-bar .pill { min-width: 108px; text-align: center; }
/* ── 图片放大预览(画廊)── */
.lightbox {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.82);
display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 12px; cursor: zoom-out;
}
.lightbox img {
max-width: 88%; max-height: 82%;
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.lightbox-nav {
position: absolute; top: 50%; transform: translateY(-50%);
width: 40px; height: 64px; border: none; border-radius: 8px;
background: rgba(255, 255, 255, 0.12); color: #fff;
font-size: 30px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s; user-select: none;
}
.lightbox-nav:hover { background: rgba(255, 255, 255, 0.28); }
.lightbox-nav.prev { left: 14px; }
.lightbox-nav.next { right: 14px; }
.lightbox-counter {
position: absolute; top: 14px; right: 16px;
background: rgba(0, 0, 0, 0.5); color: #fff;
font-size: 13px; padding: 3px 10px; border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.lightbox-tip { color: rgba(255, 255, 255, 0.75); font-size: 12.5px; }
/* ── 出图方案 ── */
.plan-list { display: flex; flex-direction: column; gap: 4px; }
.plan-row {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--card-soft); cursor: pointer;
}
.plan-row:hover { border-color: var(--primary); }
.plan-row.off { opacity: .45; }
.plan-all-toggle {
display: flex; align-items: center; gap: 10px;
margin: 8px 2px 2px; padding-top: 6px;
border-top: 1px dashed var(--border);
}
.plan-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.plan-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.variant-chip {
flex-shrink: 0; font-size: 11.5px; padding: 0 8px; line-height: 1.8;
border-radius: 999px; background: #f3efff; border: 1px solid #ddd3fa; color: #6d28d9;
}
.plan-detail {
font-size: 12px; color: var(--text-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* 构图提示(prompt_hint):生图要求的落地处,用主题蓝区分 */
.plan-detail.plan-hint { color: #4f6bed; }
.stepper { display: inline-flex; align-items: center; gap: 0; flex-shrink: 0; }
.step-btn {
width: 24px; height: 24px; border: 1px solid var(--border-strong); background: #fff;
border-radius: 5px; cursor: pointer; font-size: 14px; line-height: 1; color: var(--text);
display: inline-flex; align-items: center; justify-content: center;
}
.step-btn:hover:not([disabled]) { border-color: var(--primary); color: var(--primary); }
.step-btn[disabled] { opacity: .35; cursor: not-allowed; }
.step-num {
min-width: 28px; text-align: center; font-size: 13.5px; font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ai-tag {
background: var(--primary); color: #fff; font-size: 11px;
border-radius: 4px; padding: 1px 6px; margin-right: 4px;
}
.hint { font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
/* ── 模型下拉选项 ── */
.model-opt-name { font-size: 13.5px; font-weight: 600; color: var(--text); }
.model-opt-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; }
.ok-chip {
display: inline-flex; align-items: center; gap: 5px;
background: #f6ffed; border: 1px solid #b7eb8f; color: #389e0d;
border-radius: 6px; padding: 4px 9px; font-size: 12.5px;
}
.warn-box {
background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text);
border-radius: 6px; padding: 7px 10px; font-size: 12.5px; margin-top: 6px; line-height: 1.6;
}
.empty {
text-align: center; color: var(--text-2); font-size: 13px;
padding: 22px 0; background: var(--card-soft); border-radius: 6px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ozon-collector-extension",
"version": "0.1.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",
"typescript": "^5.5.3",
"wxt": "^0.19.0"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}
+4411
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
spawn-sync: true
@@ -0,0 +1,71 @@
/**
* 1688 提取器离线验证 —— 用 reference/1688.html 快照跑真实提取逻辑。
*
* 用法:node scripts/verify-1688.mjs [快照路径]
* 依赖 esbuild 打包 TS 提取器(node_modules 里有)。
*/
import { readFileSync, readdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const htmlPath = process.argv[2] ?? '/Users/joey/sites/seller-store/ozon-seller-kit/reference/1688.html';
// 1. 打包提取器(纯函数无 DOM 依赖);pnpm 布局下 esbuild bin 可能不在 .bin,动态查找
function findEsbuild() {
const candidates = [join(root, 'node_modules/.bin/esbuild')];
try {
const pnpmDir = join(root, 'node_modules/.pnpm');
for (const d of readdirSync(pnpmDir)) {
if (d.startsWith('esbuild@')) {
candidates.push(join(pnpmDir, d, 'node_modules/esbuild/bin/esbuild'));
}
}
} catch { /* ignore */ }
candidates.push('esbuild'); // 全局兜底
for (const c of candidates) {
try { execSync(`${c} --version`, { stdio: 'pipe' }); return c; } catch { /* try next */ }
}
throw new Error('找不到可用的 esbuild');
}
const outFile = '/tmp/1688-state.bundle.mjs';
execSync(`${JSON.stringify(findEsbuild())} src/collector/1688-state.ts --bundle --format=esm --outfile=${outFile}`, { cwd: root });
const { extract1688State } = await import(`file://${outFile}`);
// 2. 从快照提取 script#3 并在 window 垫片里求值(还原 window.context)
const html = readFileSync(htmlPath, 'utf8');
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(m => m[1]);
const ctxScript = scripts.find(s => s.includes('window.context')) ?? scripts[3];
const windowShim = {};
new Function('window', 'document', 'location', ctxScript)(
windowShim, { querySelector: () => null }, { hostname: 'detail.1688.com' },
);
const context = windowShim.context;
if (!context) {
console.error('✗ window.context 求值失败');
process.exit(1);
}
console.log(`✓ window.context 就绪(keys: ${Object.keys(context).join(', ')})`);
// 3. 跑提取器并断言
const st = extract1688State(context);
const assert = (cond, msg) => { if (!cond) { console.error(`✗ ${msg}`); process.exit(1); } console.log(`✓ ${msg}`); };
assert(st !== null, '提取器返回非空');
assert(!!st.title && st.title.length >= 5, `标题: ${st.title}`);
assert(st.galleryImages.length >= 5, `主图 ${st.galleryImages.length} 张`);
assert(st.videos.length >= 1 && /\.mp4/.test(st.videos[0].url), `视频: ${st.videos[0]?.url?.slice(0, 60) ?? '无'}…`);
assert(st.skus.length >= 3, `SKU ${st.skus.length} 个(含规格名/图)`);
assert(st.skus.every(s => /:/.test(s.name)), `SKU 名称带维度前缀: ${st.skus.slice(0, 3).map(s => s.name).join(' | ')}…`);
assert(!!st.price && /\d/.test(st.price), `价格区间: ${st.price}`);
assert(!!st.sales && /\d/.test(st.sales), `销量: ${st.sales}`);
assert(!!st.shop && st.shop.length >= 2, `店铺: ${st.shop}`);
const dimPair = st.params.find(p => p.key === '产品尺寸');
assert(!!dimPair && /\d+×\d+×\d+/.test(dimPair.value), `产品尺寸: ${dimPair?.value}`);
assert(st.params.some(p => p.key === '重量'), `重量: ${st.params.find(p => p.key === '重量')?.value}`);
assert(!!st.detailUrl?.startsWith('https://'), `detailUrl: ${st.detailUrl?.slice(0, 70)}…`);
const priced = st.skus.filter(s => s.price);
assert(priced.length >= 1, `SKU 价格明细 ${priced.length} 条(如 ${priced[0]?.name} ${priced[0]?.price})`);
console.log('\n全部断言通过 ✅');
+302
View File
@@ -0,0 +1,302 @@
/**
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
* 契约对齐 server 端 /api/plan、/api/generate 与 /api/suites。
*/
import type { ImageMaterial } from '../collector/scan';
/** 服务端支持的套图类型(与 server/services/prompts/common.py 保持一致) */
export const SUITE_TYPE_OPTIONS = [
{ value: 'white_bg', label: '白底主图' },
{ value: 'key_features', label: '核心卖点图' },
{ value: 'selling_pt', label: '卖点图' },
{ value: 'material', label: '材质图' },
{ value: 'lifestyle', label: '场景展示图' },
{ value: 'multi_scene', label: '多场景拼图' },
{ value: 'ecommerce_detail', label: '电商详情图' },
{ value: 'size_chart', label: '尺寸标注图' },
{ value: 'sku_collection', label: 'SKU合集图' },
{ value: 'custom', label: '创意图' },
] as const;
/** 出图方案项:一类图 × 数量,可绑定 SKU 规格 */
export interface PlanItem {
kind: string;
title: string;
detail: string;
prompt_hint: string;
count: number;
variant_name?: string | null;
}
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map(t => ({
kind: t.value, title: t.label, detail: '', prompt_hint: '', count: 1, variant_name: null,
}));
/** 视觉风格(名称 + 默认提示词,提示词可在插件里改写,随生成请求覆盖后端模板) */
export const STYLE_SET_OPTIONS = [
{
value: 1,
label: '北欧极简',
prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净',
},
{
value: 2,
label: '清新明亮',
prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净',
},
{
value: 3,
label: '高级感深色',
prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级',
},
{
value: 4,
label: '暖调生活',
prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强',
},
{
value: 5,
label: '纯净棚拍',
prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
},
] as const;
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
export const PLATFORM_OPTIONS = [
{ value: 'ozon', label: 'Ozon' },
{ value: 'wb', label: 'Wildberries' },
{ value: 'cn', label: '中文' },
] as const;
export type PlatformId = (typeof PLATFORM_OPTIONS)[number]['value'];
/** 生图模型(下拉可选 + 中文特点说明;服务端按模型名路由 provider) */
export const IMAGE_MODEL_OPTIONS = [
{
value: 'qwen-image-3.0-pro',
label: 'qwen-image-3.0-pro',
desc: '同步生成,响应快、图文理解强,适合快速批量出图',
},
{
value: 'wan2.7-image-pro',
label: 'wan2.7-image-pro',
desc: '异步精修,质感与细节更强,适合高质量电商大片',
},
{
value: 'wan2.6-image',
label: 'wan2.6-image',
desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好',
},
{
value: 'wan2.6-t2i',
label: 'wan2.6-t2i',
desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快',
},
{
value: 'gpt-image-2',
label: 'gpt-image-2',
desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟',
},
{
value: 'gpt-image-2-vip',
label: 'gpt-image-2-vip',
desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图',
},
{
value: 'nano-banana',
label: 'nano-banana',
desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然',
},
{
value: 'nano-banana-2',
label: 'nano-banana-2',
desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选',
},
{
value: 'nano-banana-2-lite',
label: 'nano-banana-2-lite',
desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错',
},
{
value: 'nano-banana-pro',
label: 'nano-banana-pro',
desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片',
},
] as const;
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
wb: { lang: '俄文', ratio: '3:4', label: 'Wildberries' },
cn: { lang: '中文', ratio: '1:1', label: '中文' },
};
export interface SuiteImageInfo {
type_id: string;
name: string;
url: string;
status: string;
error?: string | null;
}
export interface SuiteInfo {
id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
style_set: number;
platform: string;
lang: string;
ratio: string;
provider: string;
total?: number; // 计划生成总张数(后端返回;images 逐张追加,过程中 length < total)
images: SuiteImageInfo[];
error?: string | null;
}
function authHeaders(token: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
/** 生成图水印选项(服务端在 AI 出图后合成;shape 对齐 server WatermarkOptions) */
export interface WatermarkPayload {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number;
}
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
export interface GeneratePayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
style_set: number;
style_prompt?: string;
requirements?: string | null;
plan: PlanItem[];
platform: string;
model?: string | null;
watermark?: WatermarkPayload;
}
/** 组装无状态生成请求:编辑后的文本 + 已勾选图片(含手动上传)+ 出图方案 */
export function buildGeneratePayload(
images: ImageMaterial[],
selectedKeys: Set<string>,
texts: GeneratePayload['texts'],
config: { style_set: number; style_prompt?: string; requirements?: string | null; plan: PlanItem[]; platform: string; model?: string | null; watermark?: WatermarkPayload },
): GeneratePayload {
const selected = images
.filter((img) => selectedKeys.has(img.key))
.map((img) => ({ url: img.url, group_key: img.groupKey, variant_name: img.variantName ?? null }));
return { texts, images: selected, ...config };
}
/** 出图方案规划请求体 */
export interface PlanPayload {
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
sku_variants: string[];
image_stats: Record<string, number>;
platform: string;
requirements?: string | null;
}
/** AI 智能规划:DeepSeek 根据商品信息生成出图方案 */
export async function planSuite(
baseUrl: string,
token: string,
payload: PlanPayload,
): Promise<{ summary: string; items: PlanItem[] }> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/plan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `规划失败 HTTP ${res.status}`);
return data;
}
/** 无状态一键生成:后端直接用请求数据生图,不落商品库 */
export async function generateSuite(
baseUrl: string,
token: string,
payload: GeneratePayload,
): Promise<{ suite_id: string }> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `提交失败 HTTP ${res.status}`);
return data;
}
/** 查询套图任务状态(轮询用) */
export async function getSuite(baseUrl: string, token: string, suiteId: string): Promise<SuiteInfo> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}`, {
headers: authHeaders(token),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `查询失败 HTTP ${res.status}`);
return data;
}
export function suiteZipUrl(baseUrl: string, suiteId: string): string {
return `${baseUrl.replace(/\/$/, '')}/api/suites/${suiteId}/zip`;
}
/** 下载生成结果 ZIP:GET → blob(由调用方经 chrome.downloads 落盘,文件名用标题) */
export async function downloadSuiteZip(
baseUrl: string,
token: string,
suiteId: string,
): Promise<Blob> {
const res = await fetch(suiteZipUrl(baseUrl, suiteId), { headers: authHeaders(token) });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.detail || `下载失败 HTTP ${res.status}`);
}
return res.blob();
}
/** 手动上传本地图片到服务端,返回可访问 URL(补充参考图用) */
export async function uploadImage(
baseUrl: string,
token: string,
file: File,
): Promise<{ url: string; key: string }> {
const form = new FormData();
form.append('file', file);
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/upload-image`, {
method: 'POST',
headers: authHeaders(token), // 不显式设 Content-Type,交给浏览器生成 boundary
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
return data;
}
/** 导出采集图片请求体:后端打包成 ZIP(内部按分组名建文件夹,文件名沿用采集 key) */
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
}
/** 导出采集图片:POST /api/export-images → ZIP blob(由调用方经 chrome.downloads 落盘) */
export async function exportImages(
baseUrl: string,
token: string,
payload: ExportImagesPayload,
): Promise<Blob> {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/export-images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.detail || `导出失败 HTTP ${res.status}`);
}
return res.blob();
}
+46
View File
@@ -0,0 +1,46 @@
/**
* 商品上报客户端:把采集结果 POST 到 ozon-seller-kit 后台 /api/materials,
* 返回 product_id 后由调用方打开商品试算页。契约对齐主仓 server/schemas/collection.py。
*/
import type { TextMaterial } from '../collector/merge';
export interface ReportProductPayload {
source: {
platform: string;
itemId: string | null;
url: string;
collectedAt?: number;
};
texts: TextMaterial[];
images: Array<{
groupKey: string;
groupName: string;
variantName: string | null;
url: string;
index: number;
type: string;
}>;
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
refererOrigin?: string;
}
export interface ReportProductResult {
product_id: string;
stage?: string;
assets_queued?: number;
}
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS) */
export async function reportProduct(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
const res = await fetch(`${reportBaseUrl.replace(/\/$/, '')}/api/materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
return data as ReportProductResult;
}
@@ -0,0 +1,43 @@
/**
* isolated world 侧的桥客户端:请求 MAIN world 读取页面全局变量。
* 自带重试(桥脚本可能比内容脚本晚注入),超时返回空对象——调用方降级 DOM。
*/
let seq = 0;
export function readWindowKeys(keys: string[], timeoutMs = 1200): Promise<Record<string, any>> {
const requestId = `sc-${Date.now()}-${seq++}`;
return new Promise((resolve) => {
let done = false;
const started = Date.now();
const cleanup = () => {
window.removeEventListener('message', onMsg);
clearTimeout(retryTimer);
clearTimeout(giveUpTimer);
};
const onMsg = (ev: MessageEvent) => {
if (ev.source !== window) return;
const d = ev.data as { type?: string; requestId?: string; payload?: Record<string, any> } | null;
if (d?.type === 'sc-bridge-res' && d.requestId === requestId) {
done = true;
cleanup();
resolve(d.payload ?? {});
}
};
window.addEventListener('message', onMsg);
const send = () => window.postMessage({ type: 'sc-bridge-req', requestId, keys }, '*');
send();
const retryTimer = setInterval(() => {
if (done) return;
if (Date.now() - started > timeoutMs) return;
send();
}, 250);
const giveUpTimer = setTimeout(() => {
if (done) return;
cleanup();
resolve({});
}, timeoutMs);
});
}
@@ -0,0 +1,177 @@
/**
* 1688 SSR 状态提取器(主路径)——读取 MAIN world 桥回传的 window.context。
*
* 页面第 4 个内联 script 把完整商品数据挂在 window.context:
* window.context.result.data.<模块名>.fields
* 结构与 Ozon 的 data-state 同构(34 个模块)。本文件只做纯数据提取,
* 不碰 DOM / URL,方便用 reference/1688.html 快照离线验证(scripts/verify-1688.mjs)。
*/
export interface Sku1688 {
name: string; // "规格型号:黑盒【27件套】"
image?: string;
price?: string; // 该 SKU 价格
canBookCount?: number; // 该 SKU 库存
length?: number;
width?: number;
height?: number;
weight?: number;
}
export interface State1688 {
title?: string;
price?: string; // "19.90-24.60"
sales?: string; // 销量
shop?: string; // 公司/店铺名
unit?: string; // 单位(套/件)
offerId?: string;
categoryIds?: string[];
galleryImages: string[]; // 原图
videos: Array<{ url: string; cover?: string }>;
skus: Sku1688[];
params: Array<{ key: string; value: string }>;
detailUrl?: string;
}
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
export function deepFind(root: unknown, key: string, maxNodes = 300_000): any {
if (root == null || typeof root !== 'object') return undefined;
const queue: unknown[] = [root];
const seen = new Set<object>();
let visited = 0;
while (queue.length) {
const cur = queue.shift();
if (cur == null || typeof cur !== 'object') continue;
if (++visited > maxNodes) return undefined;
if (seen.has(cur as object)) continue;
seen.add(cur as object);
for (const [k, v] of Object.entries(cur as Record<string, unknown>)) {
if (k === key) return v;
if (v && typeof v === 'object') queue.push(v);
}
}
return undefined;
}
const num = (v: unknown): number | undefined => {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
export function extract1688State(context: unknown): State1688 | null {
if (!context || typeof context !== 'object') return null;
// ── gallery:主图 + 视频 ──
const gallery = deepFind(context, 'gallery');
const galleryFields = gallery?.fields ?? gallery ?? {};
const mainImages: string[] = [];
const pushImg = (u: unknown) => {
if (typeof u === 'string' && /^https?:\/\//.test(u) && !mainImages.includes(u)) mainImages.push(u);
};
(galleryFields.mainImage ?? []).forEach(pushImg);
(galleryFields.offerImgList ?? []).forEach((it: any) => typeof it === 'string' ? pushImg(it) : pushImg(it?.imgUrl ?? it?.url ?? it?.image));
const videos: Array<{ url: string; cover?: string }> = [];
const videoObj = galleryFields.video;
if (videoObj?.videoUrl) videos.push({ url: videoObj.videoUrl, cover: videoObj.coverUrl });
(galleryFields.videos ?? []).forEach((v: any) => v?.videoUrl && videos.push({ url: v.videoUrl, cover: v.coverUrl }));
// ── tempModel(在 Root 模块里):标题/销量/公司/类目 ──
const temp = deepFind(context, 'tempModel') ?? {};
const title = typeof temp.offerTitle === 'string' ? temp.offerTitle : undefined;
// ── SKU:skuModel.skuProps 全维度展开 ──
const skus: Sku1688[] = [];
const skuModel = deepFind(context, 'skuModel');
for (const prop of skuModel?.skuProps ?? []) {
for (const v of prop?.value ?? []) {
if (!v?.name) continue;
skus.push({
name: `${prop.prop ?? '规格'}:${v.name}`,
image: typeof v.imageUrl === 'string' ? v.imageUrl : undefined,
});
}
}
// ── 价格:区间 + 每 SKU 明细 ──
const tradeModel = deepFind(context, 'tradeModel') ?? {};
let price: string | undefined;
if (typeof tradeModel.minPrice === 'string' && typeof tradeModel.maxPrice === 'string') {
price = tradeModel.minPrice === tradeModel.maxPrice
? `¥${tradeModel.minPrice}`
: `¥${tradeModel.minPrice}-${tradeModel.maxPrice}`;
}
const skuMap = deepFind(context, 'skuMapOriginal') ?? [];
const byName = new Map(skus.map(s => [s.name.split(':').pop() ?? s.name, s]));
for (const row of skuMap) {
const s = byName.get(row?.specAttrs);
if (s) {
if (typeof row.price === 'string') s.price = `¥${row.price}`;
s.canBookCount = num(row.canBookCount);
}
}
// ── 件重尺:每个 SKU 的长宽高/体积/重量 ──
const packRows: any[] = deepFind(context, 'pieceWeightScaleInfo') ?? [];
const params: Array<{ key: string; value: string }> = [];
if (packRows.length) {
for (const r of packRows) {
const s = byName.get(r?.sku1);
if (s) {
s.length = num(r.length); s.width = num(r.width);
s.height = num(r.height); s.weight = num(r.weight);
}
}
const first = packRows[0];
if (num(first.length) && num(first.width) && num(first.height)) {
params.push({ key: '产品尺寸', value: `${first.length}×${first.width}×${first.height}cm` });
}
if (num(first.weight)) {
params.push({ key: '重量', value: `${first.weight}g` });
}
}
// ── 参数表:productAttributes(模块可能服务端报错为空,DOM 兜底)──
const attrs = deepFind(context, 'productAttributes');
const attrFields = attrs?.fields ?? {};
for (const row of attrFields.attributes ?? attrFields.props ?? []) {
const k = typeof row?.name === 'string' ? row.name : row?.propertyName;
const v = typeof row?.value === 'string' ? row.value : row?.valueName;
if (k && v) params.push({ key: String(k), value: String(v) });
}
// ── SKU 价格明细(少量时并入参数,供规划/尺寸图参考)──
const priced = skus.filter(s => s.price);
if (priced.length > 0 && priced.length <= 6) {
params.push({ key: 'SKU价格', value: priced.map(s => `${s.name.split(':').pop()} ${s.price}`).join(';') });
}
const categoryIds = [
temp.postCategoryId ? String(temp.postCategoryId) : '',
temp.topCategoryId ? String(temp.topCategoryId) : '',
].filter(Boolean);
// 图文详情位于独立 CDN,SSR 直接给出地址;无需点击标签或滚动页面。
const description = deepFind(context, 'description');
const candidateDetailUrl = description?.fields?.detailUrl ?? description?.detailUrl;
const detailUrl = typeof candidateDetailUrl === 'string' && /^https:\/\//.test(candidateDetailUrl)
? candidateDetailUrl
: undefined;
if (!title && mainImages.length === 0 && skus.length === 0) return null;
return {
title,
price,
sales: temp.saledCount != null ? `${temp.saledCount}` : undefined,
shop: typeof temp.companyName === 'string' ? temp.companyName : undefined,
unit: typeof temp.offerUnit === 'string' ? temp.offerUnit : undefined,
offerId: temp.offerId != null ? String(temp.offerId) : undefined,
categoryIds,
galleryImages: mainImages,
videos,
skus,
params,
detailUrl,
};
}
@@ -0,0 +1,97 @@
/**
* 图文详情资源提取。接口可返回 HTML,也可返回 `var desc = '...'` 形式的脚本;
* 本模块只解析字符串和 HTML,绝不执行远端脚本。
*/
import { dedupeKey, pickBestFromSrcset, toOriginalUrl } from './url';
import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
function normalizeEscapedText(raw: string): string {
return raw
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\\//g, '/');
}
function normalizeUrl(raw: string, baseUrl: string): string | null {
const value = raw.trim().replace(/^['"]|['"]$/g, '');
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
try {
const url = new URL(value, baseUrl);
return /^https?:$/i.test(url.protocol) ? url.href : null;
} catch {
return null;
}
}
function addUrl(out: string[], seen: Set<string>, raw: string | null, baseUrl: string): void {
if (!raw) return;
const url = normalizeUrl(raw, baseUrl);
if (!url || seen.has(url)) return;
seen.add(url);
out.push(url);
}
/** 从详情 HTML 或字符串化的详情脚本中提取图片地址。 */
export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const decoded = normalizeEscapedText(raw);
const doc = new DOMParser().parseFromString(decoded, 'text/html');
for (const el of Array.from(doc.querySelectorAll('img, source'))) {
for (const attr of IMAGE_ATTRS) addUrl(urls, seen, el.getAttribute(attr), baseUrl);
const srcset = el.getAttribute('srcset');
if (srcset) addUrl(urls, seen, pickBestFromSrcset(srcset), baseUrl);
}
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('[style]'))) {
addUrl(urls, seen, el.style.backgroundImage.match(/url\(([^)]+)\)/i)?.[1] ?? null, baseUrl);
}
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
for (const candidate of candidates) {
const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl);
if (!url) continue;
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
if (hostIsImageCdn || hasImageExt) addUrl(urls, seen, url, baseUrl);
}
return urls;
}
/** 由 background service worker 跨域读取详情接口。 */
export function fetchDetailText(url: string, referer: string): Promise<string> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{ action: 'fetchText', url, referer, credentials: true },
(response: { ok?: boolean; text?: string; error?: string } | undefined) => {
const runtimeError = chrome.runtime.lastError;
if (runtimeError) return reject(new Error(runtimeError.message));
if (!response?.ok || typeof response.text !== 'string') {
return reject(new Error(response?.error || '详情接口未返回内容'));
}
resolve(response.text);
},
);
});
}
export function detailUrlsToMaterials(urls: string[], profile: SiteProfile): ImageMaterial[] {
const seen = new Set<string>();
const result: ImageMaterial[] = [];
for (const rawUrl of urls) {
const url = toOriginalUrl(rawUrl, profile.originalUrlRules);
const key = dedupeKey(url, profile.originalUrlRules);
if (seen.has(key)) continue;
seen.add(key);
result.push({
key: `detail-${String(result.length + 1).padStart(3, '0')}`,
groupKey: 'detail', groupName: '详情图', url, thumbUrl: rawUrl,
index: result.length, type: 'img',
});
}
return result;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* 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;
}
/**
* 淘宝等页面会把图文详情放在延迟挂载的标签页中;仅滚动页面不会触发该标签的内容加载。
* 只匹配明确的详情标签,避免误点商品页中的普通链接或推荐内容。
*/
export async function activateDetailTab(): Promise<void> {
const labels = new Set(['图文详情', '商品详情', '详情']);
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[role="tab"], button, [class*="tab"]'));
const tab = candidates.find((el) => {
const label = (el.textContent || '').replace(/\s+/g, ' ').trim();
const rect = el.getBoundingClientRect();
return labels.has(label) && rect.width > 0 && rect.height > 0 && el.getAttribute('aria-selected') !== 'true';
});
if (!tab) return;
tab.click();
// 给懒加载容器一次渲染机会,再由后续滚动加载实际图片。
await new Promise((resolve) => setTimeout(resolve, 600));
}
/**
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
* 等待页面高度增长,页面不再变高或达到步数上限即停——防止底部「为你推荐」无限加载把采集卡死。
* 滚完恢复原位。
*/
export async function autoScrollToBottom(
opts: { stepPx?: number; stepMs?: number; maxSteps?: number } = {}
): Promise<void> {
const { stepPx = 500, stepMs = 400, maxSteps = 80 } = opts;
const startY = window.scrollY;
let lastHeight = document.body.scrollHeight;
let stagnant = 0; // 连续不增长的步数
// 每步在 0.7~1.3 倍步长、0.7~1.5 倍间隔内随机抖动,模拟人工节奏
const rand = (min: number, max: number) => min + Math.random() * (max - min);
for (let i = 0; i < maxSteps; i++) {
window.scrollBy({ top: Math.round(stepPx * rand(0.7, 1.3)), behavior: 'auto' });
await new Promise(r => setTimeout(r, Math.round(stepMs * rand(0.7, 1.5))));
const atBottom = window.scrollY + window.innerHeight >= document.body.scrollHeight - 4;
const h = document.body.scrollHeight;
if (h > lastHeight + 50) {
lastHeight = h;
stagnant = 0; // 页面还在长(懒加载进来新内容),继续
} else if (atBottom) {
stagnant++;
if (stagnant >= 2) break; // 到底且连续两步没有新内容,收工
}
}
// 多数详情图是进入视口才加载,到底后再等一拍让 <img> 完成 src 替换
await new Promise(r => setTimeout(r, stepMs));
window.scrollTo({ top: startY, behavior: 'auto' });
}
+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;
}
@@ -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;
}
+86
View File
@@ -0,0 +1,86 @@
/** 小型 MD5 实现,仅用于淘宝公开 H5 接口的请求签名。 */
function add32(a: number, b: number): number { return (a + b) & 0xffffffff; }
function rol(num: number, cnt: number): number { return (num << cnt) | (num >>> (32 - cnt)); }
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
return add32(rol(add32(add32(a, q), add32(x, t)), s), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function cycle(state: number[], block: number[]): void {
let [a, b, c, d] = state;
a = ff(a, b, c, d, block[0], 7, -680876936); d = ff(d, a, b, c, block[1], 12, -389564586);
c = ff(c, d, a, b, block[2], 17, 606105819); b = ff(b, c, d, a, block[3], 22, -1044525330);
a = ff(a, b, c, d, block[4], 7, -176418897); d = ff(d, a, b, c, block[5], 12, 1200080426);
c = ff(c, d, a, b, block[6], 17, -1473231341); b = ff(b, c, d, a, block[7], 22, -45705983);
a = ff(a, b, c, d, block[8], 7, 1770035416); d = ff(d, a, b, c, block[9], 12, -1958414417);
c = ff(c, d, a, b, block[10], 17, -42063); b = ff(b, c, d, a, block[11], 22, -1990404162);
a = ff(a, b, c, d, block[12], 7, 1804603682); d = ff(d, a, b, c, block[13], 12, -40341101);
c = ff(c, d, a, b, block[14], 17, -1502002290); b = ff(b, c, d, a, block[15], 22, 1236535329);
a = gg(a, b, c, d, block[1], 5, -165796510); d = gg(d, a, b, c, block[6], 9, -1069501632);
c = gg(c, d, a, b, block[11], 14, 643717713); b = gg(b, c, d, a, block[0], 20, -373897302);
a = gg(a, b, c, d, block[5], 5, -701558691); d = gg(d, a, b, c, block[10], 9, 38016083);
c = gg(c, d, a, b, block[15], 14, -660478335); b = gg(b, c, d, a, block[4], 20, -405537848);
a = gg(a, b, c, d, block[9], 5, 568446438); d = gg(d, a, b, c, block[14], 9, -1019803690);
c = gg(c, d, a, b, block[3], 14, -187363961); b = gg(b, c, d, a, block[8], 20, 1163531501);
a = gg(a, b, c, d, block[13], 5, -1444681467); d = gg(d, a, b, c, block[2], 9, -51403784);
c = gg(c, d, a, b, block[7], 14, 1735328473); b = gg(b, c, d, a, block[12], 20, -1926607734);
a = hh(a, b, c, d, block[5], 4, -378558); d = hh(d, a, b, c, block[8], 11, -2022574463);
c = hh(c, d, a, b, block[11], 16, 1839030562); b = hh(b, c, d, a, block[14], 23, -35309556);
a = hh(a, b, c, d, block[1], 4, -1530992060); d = hh(d, a, b, c, block[4], 11, 1272893353);
c = hh(c, d, a, b, block[7], 16, -155497632); b = hh(b, c, d, a, block[10], 23, -1094730640);
a = hh(a, b, c, d, block[13], 4, 681279174); d = hh(d, a, b, c, block[0], 11, -358537222);
c = hh(c, d, a, b, block[3], 16, -722521979); b = hh(b, c, d, a, block[6], 23, 76029189);
a = hh(a, b, c, d, block[9], 4, -640364487); d = hh(d, a, b, c, block[12], 11, -421815835);
c = hh(c, d, a, b, block[15], 16, 530742520); b = hh(b, c, d, a, block[2], 23, -995338651);
a = ii(a, b, c, d, block[0], 6, -198630844); d = ii(d, a, b, c, block[7], 10, 1126891415);
c = ii(c, d, a, b, block[14], 15, -1416354905); b = ii(b, c, d, a, block[5], 21, -57434055);
a = ii(a, b, c, d, block[12], 6, 1700485571); d = ii(d, a, b, c, block[3], 10, -1894986606);
c = ii(c, d, a, b, block[10], 15, -1051523); b = ii(b, c, d, a, block[1], 21, -2054922799);
a = ii(a, b, c, d, block[8], 6, 1873313359); d = ii(d, a, b, c, block[15], 10, -30611744);
c = ii(c, d, a, b, block[6], 15, -1560198380); b = ii(b, c, d, a, block[13], 21, 1309151649);
a = ii(a, b, c, d, block[4], 6, -145523070); d = ii(d, a, b, c, block[11], 10, -1120210379);
c = ii(c, d, a, b, block[2], 15, 718787259); b = ii(b, c, d, a, block[9], 21, -343485551);
state[0] = add32(a, state[0]); state[1] = add32(b, state[1]);
state[2] = add32(c, state[2]); state[3] = add32(d, state[3]);
}
function block(input: string): number[] {
const out: number[] = [];
for (let i = 0; i < 64; i += 4) {
out[i >> 2] = input.charCodeAt(i) + (input.charCodeAt(i + 1) << 8)
+ (input.charCodeAt(i + 2) << 16) + (input.charCodeAt(i + 3) << 24);
}
return out;
}
/** 返回 UTF-8 字符串的 32 位小写 MD5。 */
export function md5(input: string): string {
const text = unescape(encodeURIComponent(input));
const state = [1732584193, -271733879, -1732584194, 271733878];
let i = 64;
for (; i <= text.length; i += 64) cycle(state, block(text.slice(i - 64, i)));
const tail = new Array<number>(16).fill(0);
const rest = text.slice(i - 64);
for (let j = 0; j < rest.length; j++) tail[j >> 2] |= rest.charCodeAt(j) << ((j % 4) << 3);
tail[rest.length >> 2] |= 0x80 << ((rest.length % 4) << 3);
if (rest.length > 55) cycle(state, tail);
tail.fill(0, rest.length > 55 ? 0 : 15);
tail[14] = text.length * 8;
cycle(state, tail);
return state.map((word) => {
let out = '';
for (let j = 0; j < 4; j++) out += ((word >> (j * 8)) & 0xff).toString(16).padStart(2, '0');
return out;
}).join('');
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 通用合并器:各平台采集路径共用的结果装配逻辑。
* 从 scan.ts 抽出(平台拆分),平台文件只负责各路径的数据获取。
*/
import type { ImageMaterial } from './image';
import type { TextMaterial } from './text';
import type { BreadcrumbItem } from './ozon-state';
import type { SiteProfile } from '../profiles/types';
export interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
breadcrumbs: BreadcrumbItem[];
scannedAt: number;
stats: Record<string, number>; // 分组统计
warnings: string[]; // 警告(如详情图为 0)
source: 'state' | 'ssr' | 'jsonld' | 'api' | 'dom' | 'mixed'; // 主路径
}
export type { ImageMaterial, TextMaterial };
const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [
{ key: 'main', name: '主图' },
{ key: 'sku', name: 'SKU图片' },
{ key: 'detail', name: '详情图' },
{ key: 'video', name: '视频' },
];
/** 按组分组合并:靠前来源优先,靠后来源填缺,按 dedupeKey 去重后重排 index */
export function mergeImages(
primary: ImageMaterial[],
fallback: 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 primary) push(m);
for (const m of fallback) push(m);
const out: ImageMaterial[] = [];
for (const g of GROUP_ORDER) {
const arr = byGroup.get(g.key);
if (!arr) continue;
arr.forEach((m, i) => {
m.key = `${g.key}-${String(i + 1).padStart(3, '0')}`;
m.groupName = g.name;
});
out.push(...arr);
}
return out;
}
import { dedupeKey } from './url';
/** 汇总统计与警告,产出最终 ScanResult */
export function finalize(
profile: SiteProfile,
itemId: string | null,
texts: TextMaterial[],
images: ImageMaterial[],
breadcrumbs: BreadcrumbItem[],
source: ScanResult['source']
): ScanResult {
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 张:页面未提供可读取的图文详情数据');
return {
platform: profile.id,
itemId,
url: location.href,
texts,
images,
breadcrumbs,
scannedAt: Date.now(),
stats,
warnings,
source,
};
}
@@ -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();
// 图片/视频:只收主画廊 widget(webGallery),
// 不能按 "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;
}
@@ -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 即可,无需访问页面 JS(main 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=7041(highlight 样式链接)
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;
}
@@ -0,0 +1,108 @@
/**
* 1688 采集编排(平台文件):
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
* ② description.detailUrl:直接读取图文详情,不操作用户页面
* ③ DOM 兜底 + 补充当前已挂载内容
*/
import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window';
import { extract1688State, type State1688 } from '../1688-state';
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from '../detail-images';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
export async function scan1688(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
let st: State1688 | null = null;
const detailWarnings: string[] = [];
// ① 桥读 window.context
const keys = await readWindowKeys(['context']);
st = extract1688State(keys['context']);
if (st) {
if (st.title) primaryTexts.push({ kind: 'title', content: st.title });
if (st.price) primaryTexts.push({ kind: 'price', content: st.price });
if (st.sales) primaryTexts.push({ kind: 'sales', content: st.sales });
if (st.shop) primaryTexts.push({ kind: 'shop', content: st.shop });
if (st.params.length) {
primaryTexts.push({
kind: 'params',
content: st.params.map(p => `${p.key}: ${p.value}`).join('\n'),
pairs: st.params,
});
}
let idx = 0;
st.galleryImages.forEach(u => {
primaryImages.push({
key: `main-${String(idx + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: toOriginalUrl(u),
thumbUrl: u,
index: idx++,
type: 'img',
});
});
st.skus.forEach(s => {
if (!s.image) return;
primaryImages.push({
key: `sku-${String(primaryImages.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: s.name || undefined,
url: toOriginalUrl(s.image),
thumbUrl: s.image,
index: idx++,
type: 'img',
});
});
st.videos.forEach(v => {
primaryImages.push({
key: `video-${String(primaryImages.filter(m => m.groupKey === 'video').length + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: v.url,
thumbUrl: v.cover ?? '',
index: idx++,
type: 'video',
});
});
source = 'state';
if (st.detailUrl) {
try {
const rawDetail = await fetchDetailText(st.detailUrl, location.href);
const detailImages = detailUrlsToMaterials(extractDetailImageUrls(rawDetail, st.detailUrl), profile);
primaryImages.push(...detailImages);
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
} catch (error) {
detailWarnings.push(`详情接口读取失败: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
detailWarnings.push('页面未提供详情接口地址');
}
}
// ③ DOM 兜底:只读取当前已经挂载的内容,不点击标签、不滚动页面。
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
const domImages = collectImages(profile);
if (primaryImages.length > 0 && domImages.length > 0 && source === 'state') source = 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId, texts, images, [], source);
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -0,0 +1,161 @@
/**
* Ozon 采集编排(平台文件):四路径合并(来自 extension-v2 生产逻辑)。
* ① SSR widget state(DOM data-state 属性,白名单)★主路径
* ② JSON-LD(schema.org/Product)
* ③ 站内页 JSON API(entrypoint-api.bx)
* ④ DOM data-widget 选择器兜底 + 详情图补充
*/
import { activateDetailTab, autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl, toThumbUrl } from '../url';
import { extractJsonLd } from '../jsonld';
import { fetchOzonPageData, type OzonPageData } from '../ozon-api';
import { extractOzonState, type OzonStateData } from '../ozon-state';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
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 }>;
}
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],
};
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 };
}
export async function scanOzon(profile: SiteProfile, itemId: string | null): Promise<ScanResult> {
const state = extractOzonState();
let source: ScanResult['source'] = state.title || state.galleryImages.length ? 'state' : 'dom';
const jsonld = extractJsonLd();
let api: OzonPageData | null = null;
if (itemId) {
try {
api = await fetchOzonPageData(itemId);
} catch (err) {
console.warn('[SuiteCollector] API 提取异常:', err);
}
}
const bundle = mergeStructured(state, jsonld, api);
const structured = buildFromBundle(profile, bundle);
if ((structured.texts.some((t) => t.kind === 'title') || structured.images.length > 0) && source === 'dom') {
source = 'mixed';
}
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await activateDetailTab();
await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const domTexts = collectTexts(profile).materials;
const domImages = collectImages(profile);
const texts = mergeTexts(structured.texts, domTexts);
const images = mergeImages(structured.images, domImages, profile);
return finalize(profile, itemId, texts, images, state.breadcrumbs, source);
}
@@ -0,0 +1,62 @@
/**
* 淘宝/天猫 采集编排(平台文件):
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
* (isolated world 读不到 window 变量,v1 直读是无效的)
* ② DOM 兜底 + 补充
* 说明:图文详情通过页面已有登录态请求 mtop 描述接口;不点击标签、不滚动页面。
*/
import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { readWindowKeys } from '../../bridge/read-window';
import { buildFromSSR } from '../ssr-builder';
import { taobaoStateFromBridge } from '../taobao-state';
import { collectTaobaoDetailImages } from '../taobao-detail';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
export async function scanTaobao(profile: SiteProfile, _itemId: string | null): Promise<ScanResult> {
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
const detailWarnings: string[] = [];
// ① 桥读页面全局
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
const ssrData = taobaoStateFromBridge(keys);
if (ssrData && (ssrData.item.title || (ssrData.item.images ?? []).length > 0)) {
const built = buildFromSSR(ssrData, profile);
// ssr-builder 的本地类型 groupKey 是 string,这里对齐到 ImageGroupKey
primaryTexts = built.texts;
primaryImages = built.images as ImageMaterial[];
source = 'ssr';
}
// ② 图文详情 API:使用页面已有登录态签名请求,不点击标签、不滚动页面。
if (_itemId) {
try {
const detailImages = await collectTaobaoDetailImages(_itemId, profile);
primaryImages.push(...detailImages);
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
} catch (error) {
detailWarnings.push('详情接口读取失败: ' + (error instanceof Error ? error.message : String(error)));
}
} else {
detailWarnings.push('未识别到商品 ID');
}
// ③ DOM 兜底:只读取首屏已挂载内容,不点击详情标签、不滚动页面。
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
const domImages = collectImages(profile);
if (primaryImages.length > 0 && domImages.length > 0) source = source === 'dom' ? source : 'mixed';
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, _itemId, texts, images, [], source);
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -0,0 +1,63 @@
/**
* 统一采集引擎入口 - 只做路由:按平台分发到 platforms/ 下的平台文件。
*
* 平台编排逻辑(各路径与合并策略)见:
* platforms/ozon.ts Ozon 四路径(SSR data-state / JSON-LD / 站内 API / DOM)
* platforms/taobao.ts 淘宝/天猫(桥读全局 SSR / DOM)
* platforms/1688.ts 1688(桥读 window.context SSR / DOM)
* 共用合并器见 merge.ts。
*/
import { matchProfile } from '../profiles';
import { readWindowKeys } from '../bridge/read-window';
import { scanOzon } from './platforms/ozon';
import { scanTaobao } from './platforms/taobao';
import { scan1688 } from './platforms/1688';
import type { ScanResult } from './merge';
export type { ScanResult };
export type { ImageMaterial, TextMaterial } from './merge';
export async function scanCurrentPage(): Promise<ScanResult | null> {
const profile = matchProfile(location.href);
if (!profile) {
console.warn('[SuiteCollector] 当前页面不支持采集:', location.href);
return null;
}
const itemId = profile.extractItemId(location.href);
console.log('[SuiteCollector] 开始采集:', profile.name, itemId, location.href);
let result: ScanResult | null = null;
try {
if (profile.id === 'ozon') result = await scanOzon(profile, itemId);
else if (profile.id === 'taobao') result = await scanTaobao(profile, itemId);
else result = await scan1688(profile, itemId);
} catch (err) {
console.error('[SuiteCollector] 采集异常:', err);
return null;
}
console.log('[SuiteCollector] 采集完成:', {
platform: result.platform,
texts: result.texts.map((t) => t.kind),
images: result.images.length,
stats: result.stats,
warnings: result.warnings,
source: result.source,
});
return result;
}
// 诊断工具:列出页面全局数据键(在商品页 console 跑 __SuiteCollector.probe())
export async function probeWindowKeys(): Promise<string[]> {
const res = await readWindowKeys(['*'], 1500);
return (res['__sc_window_keys__'] as string[]) ?? [];
}
// 暴露到全局供 side panel / console 调用
if (typeof window !== 'undefined') {
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
probe: probeWindowKeys,
};
}
@@ -0,0 +1,157 @@
/**
* 从 SSR JSON 构建 ScanResult
*/
import { toOriginalUrl } from './url';
import type { SiteProfile } from '../profiles/types';
import type { SSRData } from './ssr';
// 直接定义类型避免循环依赖
interface TextMaterial {
kind: 'title' | 'price' | 'params' | 'desc' | 'selling_point' | 'brand' | 'sales' | 'shop';
content: string;
pairs?: Array<{ key: string; value: string }>;
}
interface ImageMaterial {
key: string;
groupKey: string;
groupName: string;
variantName?: string;
url: string;
thumbUrl: string;
index: number;
type: 'img' | 'video';
width?: number;
height?: number;
}
interface ScanResult {
platform: string;
itemId: string | null;
url: string;
texts: TextMaterial[];
images: ImageMaterial[];
scannedAt: number;
stats: Record<string, number>;
warnings: string[];
}
export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
const texts: TextMaterial[] = [];
const images: ImageMaterial[] = [];
// 1. 标题(必需)
texts.push({
kind: 'title',
content: data.item.title
});
// 2. 价格
if (data.price?.priceText) {
texts.push({
kind: 'price',
content: `¥${data.price.priceText}`
});
}
// 3. 参数表
const allParams = [
...(data.params?.basicParamList || []),
...(data.params?.enhanceParamList || [])
];
if (allParams.length > 0) {
const pairs = allParams
.filter(p => p.propertyName && p.valueName)
.map(p => ({ key: p.propertyName, value: p.valueName }));
if (pairs.length > 0) {
texts.push({
kind: 'params',
content: pairs.map(p => `${p.key}: ${p.value}`).join('\n'),
pairs
});
}
}
// 4. 主图(item.images)
let idx = 0;
(data.item.images || []).forEach((url, i) => {
if (!url) return;
const origUrl = toOriginalUrl(url);
images.push({
key: `main-${String(i + 1).padStart(3, '0')}`,
groupKey: 'main',
groupName: '主图',
url: origUrl,
thumbUrl: url,
index: idx++,
type: 'img'
});
});
// 5. SKU 图(skuBase.props 全维度展开:颜色分类、尺码等)
// 多维规格时名称带维度前缀("尺码:M"),单维保持原名("粉色")
const skuPropsList = data.skuBase?.props ?? [];
const multiDim = skuPropsList.length > 1;
for (const skuProp of skuPropsList) {
(skuProp.values ?? []).forEach(v => {
if (!v.image) return; // 有些 SKU 没配图(如纯文字规格)
const origUrl = toOriginalUrl(v.image);
const name = multiDim ? `${skuProp.name}:${v.name}` : v.name;
images.push({
key: `sku-${String(images.filter(m => m.groupKey === 'sku').length + 1).padStart(3, '0')}`,
groupKey: 'sku',
groupName: 'SKU图片',
variantName: name || undefined,
url: origUrl,
thumbUrl: v.image,
index: idx++,
type: 'img'
});
});
}
// 6. 视频(item.videos)
(data.item.videos || []).forEach((v, i) => {
if (!v.url) return;
images.push({
key: `video-${String(i + 1).padStart(3, '0')}`,
groupKey: 'video',
groupName: '视频',
url: v.url,
thumbUrl: v.videoThumbnailURL || v.url,
index: idx++,
type: 'video'
});
});
// 统计各组数量
const stats: Record<string, number> = {};
for (const img of images) {
stats[img.groupKey] = (stats[img.groupKey] ?? 0) + 1;
}
// 生成警告
const warnings: string[] = [];
if (texts.length === 0) {
warnings.push('未提取到任何文本');
}
if (images.length === 0) {
warnings.push('未扫描到任何图片/视频');
}
// SSR 数据里没有详情图;由平台接口或当前已挂载 DOM 补充。
if (stats.detail === undefined) {
warnings.push('详情图需补充:页面未提供可读取的图文详情数据');
}
return {
platform: profile.id,
itemId: data.item.itemId,
url: location.href,
texts,
images,
scannedAt: Date.now(),
stats,
warnings
};
}
+79
View File
@@ -0,0 +1,79 @@
/**
* SSR 数据提取器 - 淘宝/天猫页面内嵌 JSON
*
* 页面 HTML 里有完整商品数据挂在 window.__ICE_APP_CONTEXT__,
* 包含标题、主图、SKU(图+名)、价格、参数,比 DOM 采集稳定 10 倍:
* - 不受懒加载影响
* - 不受改版影响(JSON 结构远比 CSS 类名稳定)
* - 一次拿全所有 SKU,无需滚动
*
* 当前只支持淘宝/天猫(__ICE_APP_CONTEXT__),
* 其他平台返回 null,触发 DOM 降级。
*/
export interface SSRData {
item: {
title: string;
itemId: string;
images: string[];
videos?: Array<{ url: string; videoThumbnailURL?: string }>;
};
skuBase?: {
props: Array<{
pid: string;
name: string; // "颜色分类" / "商品规格"
values: Array<{
vid: string;
name: string; // SKU 规格名
image?: string; // SKU 图片
}>;
}>;
};
params?: {
basicParamList?: Array<{ propertyName: string; valueName: string }>;
enhanceParamList?: Array<{ propertyName: string; valueName: string }>;
};
price?: {
priceText?: string;
priceMoney?: string;
};
}
/**
* 尝试从页面提取 SSR 数据(淘宝/天猫 __ICE_APP_CONTEXT__)
*/
export function extractSSRData(): SSRData | null {
try {
const ctx = (window as any).__ICE_APP_CONTEXT__;
if (!ctx?.loaderData?.home?.data?.res) return null;
const res = ctx.loaderData.home.data.res;
// 基础结构验证
if (!res.item?.title || !res.item?.itemId) return null;
// 提取参数(两个来源都试)
const industryParams = res.plusViewVO?.industryParamVO;
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
(i: any) => i.type === 'BASE_PROPS'
);
return {
item: {
title: res.item.title,
itemId: res.item.itemId,
images: res.item.images || [],
videos: res.item.videos
},
skuBase: res.skuBase,
params: {
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
enhanceParamList: industryParams?.enhanceParamList || []
},
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice
};
} catch (err) {
console.warn('[SSR] 提取失败:', err);
return null;
}
}
@@ -0,0 +1,56 @@
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from './detail-images';
import { md5 } from './md5';
import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
function getMtopToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)_m_h5_tk=([^;]+)/);
if (!match) return null;
try {
return decodeURIComponent(match[1]).split('_')[0] || null;
} catch {
return match[1].split('_')[0] || null;
}
}
function mtopUrl(itemId: string, token: string): string {
const now = Date.now().toString();
const current = new URL(location.href);
const data = JSON.stringify({
abbucket: current.searchParams.get('abbucket') || '',
id: itemId,
ns: current.searchParams.get('ns') || '',
spm: current.searchParams.get('spm') || '',
detail_v: '3.3.0',
preferWireless: 'true',
});
const host = current.hostname === 'detail.tmall.com' ? 'h5api.m.tmall.com' : 'h5api.m.taobao.com';
const url = new URL('https://' + host + '/h5/mtop.taobao.detail.getdesc/7.0/');
url.search = new URLSearchParams({
jsv: '2.7.0', appKey: '12574478', t: now, sign: md5(token + '&' + now + '&12574478&' + data),
api: 'mtop.taobao.detail.getdesc', v: '7.0', isSec: '0', ecode: '0',
AntiFlood: 'true', AntiCreep: 'true', H5Request: 'true', ttid: '2022@tmall_litepc_9.17.0',
type: 'json', dataType: 'json', data,
}).toString();
return url.href;
}
/** 无滚动读取淘宝/天猫图文详情。仅使用浏览器现有登录态,不会改动页面。 */
export async function collectTaobaoDetailImages(itemId: string, profile: SiteProfile): Promise<ImageMaterial[]> {
const token = getMtopToken();
if (!token) throw new Error('未找到淘宝登录态;请登录后重新采集');
const raw = await fetchDetailText(mtopUrl(itemId, token), location.href);
let response: any;
try {
response = JSON.parse(raw);
} catch {
throw new Error('详情接口返回了无效数据');
}
if (!response?.data?.components?.componentData) {
throw new Error(response?.ret?.[0] || '详情接口未返回图文详情');
}
const components = response.data.components.componentData;
const richText = components.desc_richtext_pc?.model?.text;
const source = typeof richText === 'string' ? richText : JSON.stringify(components);
return detailUrlsToMaterials(extractDetailImageUrls(source, location.href), profile);
}
@@ -0,0 +1,115 @@
/**
* 淘宝/天猫 SSR 状态提取 —— 通过 MAIN world 桥读取页面全局变量。
*
* 候选键(selectors-taobao.md §5.3 列出的待评估项):
* __ICE_APP_CONTEXT__ ICE 框架上下文(loaderData.home.data.res,v1 已知结构)★主
* __general_skupanel_cache_data 疑似完整 SKU 面板缓存(结构未知,容错深搜)
* __ICE_DATA_LOADER__ ICE 框架数据层(容错深搜)
*
* 输出对齐 ssr.ts 的 SSRData,供 buildFromSSR 消费。
*/
import type { SSRData } from './ssr';
/** 从 __ICE_APP_CONTEXT__ 结构映射(原 v1 extractSSRData 的对象版) */
function fromIceContext(ctx: unknown): SSRData | null {
const res = (ctx as any)?.loaderData?.home?.data?.res;
if (!res?.item?.title || !res?.item?.itemId) return null;
const industryParams = res.plusViewVO?.industryParamVO;
const extensionParams = res.componentsVO?.extensionInfoVO?.infos?.find(
(i: any) => i.type === 'BASE_PROPS'
);
return {
item: {
title: res.item.title,
itemId: res.item.itemId,
images: res.item.images || [],
videos: res.item.videos,
},
skuBase: res.skuBase,
params: {
basicParamList: industryParams?.basicParamList || extensionParams?.items || [],
enhanceParamList: industryParams?.enhanceParamList || [],
},
price: res.componentsVO?.priceVO?.price || res.componentsVO?.priceVO?.extraPrice,
};
}
/** 容错:未知结构里深搜「SKU props 数组」(元素含 name + values/props 嵰 name/imageUrl) */
function skuPropsFromUnknown(root: unknown): SSRData['skuBase'] | null {
const candidates: any[] = [];
const collect = (node: unknown, depth = 0) => {
if (!node || typeof node !== 'object' || depth > 6 || candidates.length) return;
if (Array.isArray(node)) {
if (
node.length >= 1 && node.every((x: any) => x && typeof x === 'object' &&
typeof (x.prop ?? x.name) === 'string' && Array.isArray(x.values ?? x.props))
) { candidates.push(node.map((x: any) => ({
pid: String(x.pid ?? ''),
name: x.prop ?? x.name,
values: (x.values ?? x.props).map((v: any) => ({
vid: String(v.vid ?? ''),
name: v.name ?? v.valueName ?? '',
image: v.image ?? v.imageUrl,
})),
}))); return;
}
node.forEach(n => collect(n, depth + 1));
return;
}
for (const v of Object.values(node as Record<string, unknown>)) collect(v, depth + 1);
};
collect(root);
return candidates.length ? { props: candidates[0] } : null;
}
/** 容错:深搜参数数组(元素含 propertyName/valueName) */
function paramsFromUnknown(root: unknown): Array<{ propertyName: string; valueName: string }> {
const out: Array<{ propertyName: string; valueName: string }> = [];
const seen = new Set<string>();
const walk = (node: unknown, depth = 0) => {
if (!node || typeof node !== 'object' || depth > 6 || out.length > 60) return;
if (Array.isArray(node)) {
if (node.length >= 2 && node.every((x: any) => x && typeof x === 'object' &&
typeof x.propertyName === 'string' && typeof x.valueName === 'string')) {
for (const p of node) {
const k = `${p.propertyName}=${p.valueName}`;
if (!seen.has(k)) { seen.add(k); out.push({ propertyName: p.propertyName, valueName: p.valueName }); }
}
}
node.forEach(n => walk(n, depth + 1));
return;
}
for (const v of Object.values(node as Record<string, unknown>)) walk(v, depth + 1);
};
walk(root);
return out;
}
export function taobaoStateFromBridge(keys: Record<string, any>): SSRData | null {
// 主路径:ICE 上下文
const ice = fromIceContext(keys['__ICE_APP_CONTEXT__']);
if (ice) {
// 主路径缺 SKU 时用面板缓存补
if (!ice.skuBase?.props?.length) {
const fromCache = skuPropsFromUnknown(keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__']);
if (fromCache?.props?.length) ice.skuBase = fromCache;
}
return ice;
}
// 降级:只有面板缓存/数据层 —— 尽力拼一个最小 SSRData(标题给空,DOM 会补)
const cacheRoot = keys['__general_skupanel_cache_data'] ?? keys['__ICE_DATA_LOADER__'];
if (cacheRoot) {
const skuBase = skuPropsFromUnknown(cacheRoot);
const params = paramsFromUnknown(cacheRoot);
if (skuBase?.props?.length || params.length) {
return {
item: { title: '', itemId: '', images: [], videos: [] },
skuBase: skuBase ?? undefined,
params: { basicParamList: params, enhanceParamList: [] },
price: undefined,
};
}
}
return null;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* 文本提取 - 标题、价格、参数表、卖点、描述、品牌
* 从 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;
// cells 模式:兄弟键值对(antd Descriptions 的 th/td、dl 的 dt/dd)
// selectors 命中的每个节点就是「键」,值取它的下一个兄弟元素
if (rule.extract === 'cells') {
const pairs: Array<{ key: string; value: string }> = [];
nodes.forEach((k) => {
const v = k.nextElementSibling;
if (!v) return;
const kc = clean(k.textContent ?? '');
const vc = clean(v.textContent ?? '');
if (kc && vc) pairs.push({ key: kc.replace(/[::]$/, ''), value: vc });
});
if (pairs.length) {
return {
kind: rule.kind,
content: pairs.map((p) => `${p.key}: ${p.value}`).join('\n'),
pairs,
};
}
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);
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 1688 采集配置(DOM 兜底路径)
*
* 新版(2026-08 实测,快照:宝宝平衡车)DOM 大改,锚点从业务类名换成稳定的
* id / data-module 属性;旧选择器保留做兼容(旧版页面仍在线上轮转)。
* 主路径(window.context)见 collector/platforms/1688.ts——DOM 只负责兜底
* 和补充参数表(#productAttributes)与详情图(#detail)。
*/
import type { SiteProfile } from './types';
export const profile1688: SiteProfile = {
id: '1688',
name: '1688',
urlPatterns: [/^https:\/\/detail\.1688\.com\/offer\/\d+\.html/],
extractItemId: (url) => url.match(/\/offer\/(\d+)\.html/)?.[1] ?? null,
readySelectors: ['#productTitle', '.title-content', '#detail', '#dt-tab', '#screen', '#content'],
readyTimeoutMs: 10_000,
// 懒加载真实地址在 data-* 上(顺序不能动)
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
refererOrigin: 'https://www.1688.com',
textRules: [
{
kind: 'title',
selectors: [
'#productTitle .title-content', // 新版:data-module="od_title"
'.title-content .title-text', // 旧版:标题拆多个 span,必须 join
'.title-content h1',
'.od-pc-offer-title',
'h1',
],
extract: 'join',
required: true
},
{
kind: 'price',
selectors: ['.price-original', '.od-pc-offer-price-priceRange', '.price .value'],
extract: 'first'
},
// 参数表(新版):#productAttributes 是 antd Descriptions 表格,
// th(键)/td(值) 成对平铺在 tr 里——用 cells 模式取兄弟节点
{
kind: 'params',
selectors: [
'#productAttributes th.ant-descriptions-item-label',
'#productAttributes th',
],
extract: 'cells'
},
// 参数表(旧版):行式键值表
{
kind: 'params',
selectors: [
'.offer-attr-list .offer-attr-item',
'.od-pc-attribute-table tr',
'.obj-content .table-tr'
],
extract: 'table',
tableKeySelector: '.offer-attr-item-name, td:first-child, .table-th',
tableValueSelector: '.offer-attr-item-value, td:last-child, .table-td'
},
{
kind: 'desc',
selectors: ['.de-description-detail', '#detailContentContainer', '.html-description'],
extract: 'join'
}
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
selectors: [
// 新版:模块锚点(data-module / module- 类名)
'[data-module="od_picture_gallery"] img',
'.module-od-picture-gallery img',
// 旧版四套画廊变体
'#recyclerview .detail-gallery-turn-wrapper .detail-gallery-img',
'#screen .od-gallery-turn-item-wrapper .od-gallery-img',
'#content .od-scroller-item .v-image-cover',
'#content .od-picture-gallery-list .v-image-cover',
'#dt-tab img',
'.detail-gallery-turn img.detail-gallery-img',
'.img-list-wrapper img.od-gallery-img'
],
activeSelectors: [
'.detail-gallery-turn-wrapper.prepic-active .detail-gallery-img',
'.od-gallery-turn-item-wrapper.prepic-active .od-gallery-img',
'.v-image-cover.image-item-active'
],
minWidth: 200,
minHeight: 200
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
selectors: [
'[data-module="od_sku_selection"] img', // 新版
'.module-od-sku-selection img',
'.pc-sku-wrapper .prop-item-inner-wrapper',
'.sku-item-wrapper',
'.specification-cell',
'.sku-filter-button',
'.expand-view-item',
'.feature-item img'
],
srcProps: ['backgroundImage'],
nameSelectors: ['.prop-name', '.sku-item-name', '.item-label', '.label-name', '.normal-text'],
minWidth: 20,
minHeight: 20
},
{
key: 'detail',
name: '详情图',
type: 'img',
selectors: [
'#detail img', // 新版:详情容器(实测 69 张,含少量图标需过滤)
'.de-description-detail img',
'#detailContentContainer img',
'.html-description img'
],
minWidth: 300,
minHeight: 100
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: ['.lib-video video', 'video']
}
]
};
@@ -0,0 +1,21 @@
/**
* Profile 路由 - 根据 URL 匹配平台(ozon / 1688 / 淘宝 / 天猫)
*/
import type { SiteProfile } from './types';
import { profileOzon } from './ozon';
import { profile1688 } from './1688';
import { profileTaobao } from './taobao';
const PROFILES: SiteProfile[] = [profileOzon, profile1688, profileTaobao];
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, profile1688, profileTaobao };
export type { SiteProfile };
+162
View File
@@ -0,0 +1,162 @@
/**
* Ozon 商品页采集配置
*
* 选择器已在真实页面实测(reference/ozon1.html、ozon2.html,2026-08-15):
* - webProductHeading → <h1> 标题
* - webGallery → 主图(<img srcset>,wc50/wc100 缩略图)
* - webAspects → SKU 变体(颜色/尺码选择器)
* - webShortCharacteristics / webDetailedCharacteristics → 参数表("关于商品"区)
* - webPrice → 价格(DOM 结构复杂,价格主路径走 data-state)
*
* ★ 主采集路径是 structured(ozon-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: [
// 实测:变体选择器在 webAspects(webDetailSKU 其实是"复制 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',
],
},
],
// 实测 CDN(ir.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: '' },
],
};
+143
View File
@@ -0,0 +1,143 @@
/**
* 淘宝 / 天猫采集配置
*
* 选择器全部来自真实页面实测(2026-08-11,两个商品页各跑一轮反向探测):
* 天猫 detail.tmall.com/item.htm?id=960057430812
* 淘宝 item.taobao.com/item.htm?id=1060253247160
* 两站 DOM 完全一致(同一套前端),一份 profile 覆盖。
*
* 类名是 CSS Modules 的 `语义前缀--哈希` 形式,哈希每次构建都变,
* 所以一律用 `[class*="前缀--"]` 前缀匹配。
*
* 结尾那个 `--` 不能省——它把父容器和子元素区分开:
* `generalParamsInfoItem--` 不会误命中 `generalParamsInfoItemTitle--`。
*
* 实测证据见 docs/extension/selectors-taobao.md
*/
import type { SiteProfile } from './types';
export const profileTaobao: SiteProfile = {
id: 'taobao',
name: '淘宝/天猫',
urlPatterns: [
/^https:\/\/item\.taobao\.com\/item\.htm/,
/^https:\/\/detail\.tmall\.com\/item\.htm/,
],
extractItemId: (url) => url.match(/[?&]id=(\d+)/)?.[1] ?? null,
// 页面上没有 <h1>,别再拿它探活
readySelectors: [
'[class*="mainTitle--"]',
'[class*="picGallery--"]',
'#picGalleryEle',
],
readyTimeoutMs: 10_000,
// 阿里系 CDN 规则与 1688 相同
defaultSrcProps: ['data-lazyload-src', 'data-src', 'currentSrc', 'src'],
refererOrigin: 'https://www.taobao.com',
textRules: [
{
kind: 'title',
// mainTitle-- 是纯文本节点(探测里 imgs=0),最干净
// ItemTitle-- / MainTitle-- 是外层容器,带图标,作兜底
// 注意:属性选择器区分大小写,三个都得写
selectors: [
'[class*="mainTitle--"]',
'[class*="MainTitle--"]',
'[class*="ItemTitle--"]',
],
extract: 'first',
required: true,
},
{
kind: 'price',
// highlightPrice-- 是当前实际售价,两站一致
// priceWrap-- 是外层,会把"优惠前¥36.8"一起带进来,只作兜底
selectors: [
'[class*="highlightPrice--"]',
'[class*="priceWrap--"]',
],
extract: 'first',
},
{
kind: 'params',
// generalParamsInfoItem-- 每项含 Title(键) + SubTitle(值)
selectors: ['[class*="generalParamsInfoItem--"]'],
extract: 'table',
tableKeySelector: '[class*="ParamsInfoItemTitle--"]',
tableValueSelector: '[class*="ParamsInfoItemSubTitle--"]',
},
// desc 故意不采:详情容器 detailInfo-- 里混着用户评价、参数、图文详情,
// join 出来是一坨无法使用的字符串。1688/淘宝的中文文案对 Ozon 价值也低
// (见 docs/extension/1688-taobao-implementation.md 采集优先级)。
],
imageGroups: [
{
key: 'main',
name: '主图',
type: 'img',
// picGallery-- 内含大图 + 缩略图,同一张图的两种尺寸
// toOriginalUrl() 剥掉尺寸后缀后 dedupeKey 相同,会自动去重
selectors: [
'[class*="picGallery--"] img',
'#picGalleryEle img',
'[class*="thumbnailPic--"]',
],
// 不设 minWidth:缩略图 naturalWidth 只有 60 左右,
// 按 200 过滤会把主图全误杀(原图靠 toOriginalUrl 还原)
},
{
key: 'sku',
name: 'SKU图片',
type: 'img',
// ★ 与 1688 不同:淘宝 SKU 是真实 <img>,不是 CSS 背景图
// 探测证据:valueItem-- n=22 imgs=22(每项恰含一张 img)
// 所以这里不能用 srcProps: ['backgroundImage']
selectors: [
'[class*="valueItem--"]',
'[class*="valueItemImgWrap--"]',
],
nameSelectors: ['[class*="valueItemText--"]'],
minWidth: 20,
minHeight: 20,
},
{
key: 'detail',
name: '详情图',
type: 'img',
// 图文详情是懒加载的,需用户点开「图文详情」tab 或滚到底
selectors: [
'[class*="tabDetailWrap--"] img',
'[class*="detailInfo--"] img',
],
// detailInfo-- 同时包着「用户评价」区,买家晒单图能有 400-800px,
// 光靠 minWidth 滤不掉。这些图带水印、质量差,不能采
excludeWithin: [
'[class*="Comment--"]',
'[class*="comments--"]',
'[class*="userInfo--"]',
'[class*="rate"]',
// 本店推荐:详情区底部的推荐卡片流(RecommendInfo-- 容器 / data-spm="recommends" /
// recommend-- 卡片区 / cardPic-- 卡片图盒),不是本商品的详情图,不能采
'[class*="RecommendInfo--"]',
'[data-spm="recommends"]',
'[class*="recommend--"]',
'[class*="cardPic--"]',
],
minWidth: 300,
minHeight: 100,
},
{
key: 'video',
name: '视频',
type: 'video',
selectors: ['[class*="picGallery--"] video', 'video'],
},
],
};
@@ -0,0 +1,79 @@
/**
* Site Profile - 平台采集配置(声明式)
*
* 与 extension-v1 同一套抽象,新增 Ozon 需要的文本类型:
* selling_point(卖点 / About this item)、brand(品牌)。
*
* 采集引擎(collector/)完全通用,加一个新平台只需新增一个 profile。
*/
export type TextKind =
| 'title'
| 'price'
| 'params'
| 'selling_point'
| 'desc'
| 'brand'
| 'sales'
| 'shop';
export type ImageGroupKey = 'main' | 'sku' | 'detail' | 'video' | 'upload';
export type SrcProp =
| 'data-lazyload-src'
| 'data-src'
| 'srcset'
| 'currentSrc'
| 'src'
| 'backgroundImage';
export interface TextRule {
kind: TextKind;
/** 多套选择器,逐个尝试直到命中 */
selectors: string[];
/**
* extract 模式:
* join - 所有命中节点的文本拼接(标题被拆多个 span 时用)
* first - 只取第一个命中节点
* table - 行式键值表:selectors 命中行,tableKey/ValueSelector 在行内取键值
* cells - 兄弟键值对:selectors 直接命中「键」节点,值取它的下一个兄弟元素
* (适配 antd Descriptions 的 th/td 结构、dl 的 dt/dd 结构)
*/
extract: 'join' | 'first' | 'table' | 'cells';
/** 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;
}
@@ -0,0 +1,79 @@
/**
* 服务端设置(上传/生成用):后端地址 + Bearer Token + 水印选项,持久化到 chrome.storage.local。
* v2.1 并入 ozon-seller-kit 后新增「商品上报」配置:reportEnabled / reportBaseUrl / studioBaseUrl。
*/
/** 生成图水印:服务端在 AI 出图后、落盘前合成(与生图模型无关)。默认复刻 ozonSeller。 */
export interface WatermarkSettings {
enabled: boolean;
type: 'image' | 'text';
text: string;
opacity: number; // 1-100(%)
}
export interface BackendSettings {
baseUrl: string;
token: string;
watermark: WatermarkSettings;
/** 商品上报(ozon-seller-kit 后台):开关 + 后台地址 + 试算页地址 */
reportEnabled: boolean;
reportBaseUrl: string;
studioBaseUrl: string;
}
const KEY = 'suite_backend_settings';
export const DEFAULT_BASE_URL = 'http://127.0.0.1:8800';
export const DEFAULT_REPORT_BASE_URL = 'http://127.0.0.1:8800';
export const DEFAULT_STUDIO_BASE_URL = 'http://localhost:8900';
const DEFAULT: BackendSettings = {
baseUrl: DEFAULT_BASE_URL,
token: '',
watermark: { enabled: false, type: 'image', text: 'Panda Store', opacity: 30 },
reportEnabled: true,
reportBaseUrl: DEFAULT_REPORT_BASE_URL,
studioBaseUrl: DEFAULT_STUDIO_BASE_URL,
};
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置)。
* v2.1 并入 OSK 后生图服务端与上报同源(8800),ISS 的 3300 存档自动迁移。 */
const MIGRATE: Record<string, string> = {
'http://127.0.0.1:3300': DEFAULT_BASE_URL,
'http://localhost:3300': DEFAULT_BASE_URL,
'http://127.0.0.1:8810': DEFAULT_BASE_URL,
'http://127.0.0.1:7000': DEFAULT_BASE_URL,
'http://localhost:7000': DEFAULT_BASE_URL,
'http://127.0.0.1:7200': DEFAULT_BASE_URL,
'http://localhost:7200': DEFAULT_BASE_URL,
};
export async function loadSettings(): Promise<BackendSettings> {
const r = await chrome.storage.local.get(KEY);
const saved = r[KEY] ?? {};
const baseUrl = MIGRATE[saved.baseUrl] ?? saved.baseUrl ?? DEFAULT.baseUrl;
// 水印子对象深合并:老版本存储里没有 watermark,避免整对象覆盖丢默认值
const s: BackendSettings = {
token: '',
...saved,
baseUrl,
watermark: { ...DEFAULT.watermark, ...(saved.watermark ?? {}) },
// 上报配置为 v2.1 新增字段:老存档缺失时兜底默认值
reportEnabled: saved.reportEnabled ?? DEFAULT.reportEnabled,
reportBaseUrl: saved.reportBaseUrl ?? DEFAULT.reportBaseUrl,
studioBaseUrl: saved.studioBaseUrl ?? DEFAULT.studioBaseUrl,
};
if (baseUrl !== saved.baseUrl) await chrome.storage.local.set({ [KEY]: s }); // 迁移结果写回
return s;
}
export async function saveSettings(s: BackendSettings): Promise<void> {
// localhost 会被 Chrome 解析为 IPv6 ::1,若该端口被系统服务(如 macOS AirPlay)占用会 403,
// 统一改写为 IPv4 的 127.0.0.1
s = {
...s,
baseUrl: s.baseUrl.replace('//localhost:', '//127.0.0.1:'),
reportBaseUrl: s.reportBaseUrl.replace('//localhost:', '//127.0.0.1:'),
};
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"]
}
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
name: '电商套图工作台',
description: '采集 Ozon / 1688 / 淘宝 / 天猫 商品信息与图片,一键生成电商套图并导出',
permissions: [
'storage',
'sidePanel',
'activeTab',
'scripting', // 执行 content script 函数需要
'downloads' // 导出采集图片 / 套图 ZIP 到本地
],
host_permissions: [
// Ozon 商品页 + 图片 CDN
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
'https://*.ozonusercontent.com/*',
// 1688 / 淘宝 / 天猫 + 阿里 CDN
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
'https://h5api.m.taobao.com/*',
'https://h5api.m.tmall.com/*',
'https://*.alicdn.com/*',
// 1688 详情数据 CDN(description.detailUrl)
'https://itemcdn.tmall.com/*',
// 本机后端(上传 / 生成套图用);生产换成你的公网域名
'http://127.0.0.1:3300/*',
'http://localhost:3300/*',
// ozon-seller-kit 后台(商品上报 8800)+ 试算页(8900)
'http://127.0.0.1:8800/*',
'http://localhost:8800/*',
'http://127.0.0.1:8900/*',
'http://localhost:8900/*'
],
action: {
default_title: '电商套图工作台'
},
// 页内悬浮面板用 iframe 加载 sidepanel.html,必须声明为 web accessible
web_accessible_resources: [{
resources: ['sidepanel.html'],
matches: [
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
}]
},
modules: ['react']
});
-23
View File
@@ -1,23 +0,0 @@
"""鉴权路由。"""
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)
+206
View File
@@ -0,0 +1,206 @@
"""导出采集图片:把勾选的采集图/生成图打包成 ZIP 下载到本地。
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
采集图片默认按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传)。生成结果可
请求扁平导出,所有图片直接放在商品根目录;文件名沿用采集 key(main-001 等)+ SKU 规格名。
"""
from __future__ import annotations
import io
import mimetypes
import re
import tempfile
import zipfile
from collections.abc import Iterator
from urllib.parse import quote
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field
from api.proxy import guess_referer
from config import get_settings
from services.storage import (
ResourceDownloadTimeoutError,
ResourceSizeLimitError,
UnsafeRemoteUrlError,
download_bytes,
local_path,
read_local_bytes,
)
router = APIRouter(prefix="/api", tags=["export"])
_EXT_BY_CTYPE = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/bmp": ".bmp",
}
class ExportImageItem(BaseModel):
url: str
groupName: str = "主图"
variantName: str | None = None
key: str = "" # 采集 key,如 main-001 / sku-002 / upload-001
class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem] = Field(min_length=1)
flat: bool = Field(default=False, description="是否忽略图片分组,直接写入 ZIP 顶层商品目录")
def _clean(name: str) -> str:
"""清洗文件夹/文件名非法字符(与插件 cleanFilename 同规则,Windows 兼容)。"""
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", (name or "").strip())
s = re.sub(r"\s+", "_", s)
return s.strip(" .")[:80]
def _ext(url: str, ctype: str) -> str:
"""由 content-type(优先)或 URL 后缀决定扩展名。"""
ctype = ctype.split(";")[0].strip().lower()
if ctype in _EXT_BY_CTYPE:
return _EXT_BY_CTYPE[ctype]
if ctype.startswith("image/"):
return "." + ctype.split("/")[-1]
path = url.split("?")[0].lower()
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"):
if path.endswith(ext):
return ".jpg" if ext == ".jpeg" else ext
return ".jpg"
def _is_image(url: str, ctype: str) -> bool:
ctype = ctype.split(";")[0].strip().lower()
if ctype.startswith("image/"):
return True
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
def _download_disposition(filename: str) -> str:
"""生成兼容中文文件名的下载响应头。
Starlette 会按 Latin-1 编码 HTTP 头;直接将中文放进 filename 会抛 UnicodeEncodeError
并返回 500。filename 提供 ASCII 回退,filename* 使用 RFC 5987 的 UTF-8 百分号编码。
"""
fallback = re.sub(r"[^\x20-\x7e]", "_", filename) or "image"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
async def _download(url: str) -> tuple[bytes, str]:
"""本地 media 文件直读磁盘;远程 URL(源站 CDN / 七牛)带 Referer 下载。"""
path = local_path(url)
if path is not None:
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
return read_local_bytes(path), mime
return await download_bytes(url, referer=guess_referer(url))
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
"""以固定块流式返回临时 ZIP,响应结束(或中断)后关闭临时文件。"""
try:
while chunk := handle.read(chunk_size):
yield chunk
finally:
handle.close()
@router.post("/export/images")
async def export_images(req: ExportImagesRequest):
settings = get_settings()
if len(req.images) > settings.export_max_images:
raise HTTPException(status_code=413, detail=f"一次最多导出 {settings.export_max_images} 张图片,请分批导出")
root = _clean(req.title) or "采集图片"
# 小 ZIP 留在内存,大 ZIP 自动滚动到临时磁盘;响应阶段按块传输。
archive = tempfile.SpooledTemporaryFile(
max_size=settings.export_zip_memory_threshold_bytes,
mode="w+b",
)
used: set[str] = set()
total_source_bytes = 0
try:
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for img in req.images:
try:
data, ctype = await _download(img.url)
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许导出:{exc}") from exc
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"无法导出「{img.key or '图片'}」:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception: # noqa: BLE001
continue # 单张下载失败不中断整包
if not _is_image(img.url, ctype):
continue
total_source_bytes += len(data)
if total_source_bytes > settings.export_zip_max_bytes:
raise HTTPException(
status_code=413,
detail=f"导出图片总大小超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少勾选图片后重试",
)
ext = _ext(img.url, ctype)
base = _clean(img.key) or "image"
if img.variantName:
base += f"-{_clean(img.variantName)}"
filename = f"{base}{ext}"
if filename in used: # 同名加序号防覆盖
stem = filename[: -len(ext)]
n = 2
while f"{stem}-{n}{ext}" in used:
n += 1
filename = f"{stem}-{n}{ext}"
used.add(filename)
archive_path = f"{root}/{filename}" if req.flat else f"{root}/{_clean(img.groupName) or '图片'}/{filename}"
zf.writestr(archive_path, data)
if archive.tell() > settings.export_zip_max_bytes:
raise HTTPException(
status_code=413,
detail=f"导出 ZIP 超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少勾选图片后重试",
)
except Exception:
archive.close()
raise
archive.seek(0)
return StreamingResponse(
_stream_file(archive),
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="collect.zip"'},
)
@router.post("/export/image")
async def export_image(img: ExportImageItem):
"""代理下载单张图片,兼容源站防盗链与本地媒体路径。"""
try:
data, ctype = await _download(img.url)
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许下载:{exc}") from exc
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法下载:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=422, detail="图片下载失败") from exc
if not _is_image(img.url, ctype):
raise HTTPException(status_code=400, detail="该地址不是图片")
ext = _ext(img.url, ctype)
base = _clean(img.key) or "image"
if img.variantName:
base += f"-{_clean(img.variantName)}"
return Response(
content=data,
media_type=ctype,
headers={"Content-Disposition": _download_disposition(f"{base}{ext}")},
)
+2 -3
View File
@@ -1,14 +1,13 @@
"""汇率路由。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter
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)):
async def fx():
return await get_fx_rate()
@@ -2,19 +2,51 @@
from __future__ import annotations
import re
import logging
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
from pydantic import BaseModel, Field
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
from schemas.suite import SuiteOut, WatermarkOptions
from services.image_assets import WatermarkUpdate, get_image_asset_service
from services.storage import ImageValidationError, ResourceSizeLimitError, normalize_image_bytes
from services.jobs import create_image_job, get_job, list_product_jobs, suite_view
from config import get_settings
router = APIRouter(prefix="/api", tags=["collection"])
log = logging.getLogger("materials")
class ApplyWatermarkRequest(BaseModel):
"""对已落库图片批量应用水印;始终基于 original 重新合成。"""
asset_ids: list[str] = Field(min_length=1, max_length=50)
watermark: WatermarkOptions
class ApplyWatermarkResponse(BaseModel):
"""批量水印已进入后台队列;前端以 job_id 查询逐图进度。"""
job_id: str
total: int
class RemoveWatermarkResponse(BaseModel):
"""同步移除水印的逐项处理结果。"""
updated_ids: list[str]
failed: list[dict[str, str]]
class RemoveWatermarkRequest(BaseModel):
asset_ids: list[str] = Field(min_length=1, max_length=50)
def _parse_number(text: str | None) -> float | None:
@@ -114,9 +146,13 @@ async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Pro
async def create_materials(
req: MaterialsRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> MaterialsResponse:
if len(req.images) > get_settings().collection_max_images:
raise HTTPException(
status_code=413,
detail=f"一次最多采集 {get_settings().collection_max_images} 个素材,请分批上报",
)
product = await _get_or_create_product(db, req)
_apply_texts(product, req.texts)
@@ -178,9 +214,7 @@ async def create_materials(
async def process_product_assets(product_id: str) -> None:
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
from services.storage import get_storage
storage = get_storage()
image_assets = get_image_asset_service()
async with get_session_factory()() as db:
assets = (await db.scalars(
select(ProductAsset).where(
@@ -192,8 +226,7 @@ async def process_product_assets(product_id: str) -> None:
a.status = AssetStatus.downloading
await db.commit()
try:
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
a.stored_url = stored
await image_assets.save_original_from_url(a, a.source_url)
a.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
a.status = AssetStatus.failed
@@ -210,13 +243,31 @@ async def upload_material_bytes(
sort_order: int = Form(0),
type: str = Form("img"),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
data = await file.read()
limit = get_settings().image_upload_max_bytes
if file.size is not None and file.size > limit:
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
chunks: list[bytes] = []
total = 0
while chunk := await file.read(1024 * 1024):
total += len(chunk)
if total > limit:
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
chunks.append(chunk)
data = b"".join(chunks)
if type != "img":
raise HTTPException(status_code=400, detail="该接口目前仅支持上传图片素材")
try:
normalized = normalize_image_bytes(data, max_bytes=limit)
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ImageValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
asset = ProductAsset(
product_id=product.id,
group_key=group_key,
@@ -228,24 +279,32 @@ async def upload_material_bytes(
)
db.add(asset)
await db.flush()
# 直接转存字节
from services.storage import get_storage
storage = get_storage()
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
image_assets = get_image_asset_service()
try:
asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "")
await image_assets.save_original(
asset,
normalized.data,
normalized.content_type,
max_bytes=limit,
)
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}
return {
"asset_id": str(asset.id),
"status": asset.status.value,
"width": asset.width,
"height": asset.height,
}
@router.get("/products/{product_id}/fingerprints")
async def product_fingerprints(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
rows = (await db.scalars(
select(ProductAsset.dedupe_key).where(
@@ -260,8 +319,7 @@ async def product_fingerprints(
async def is_collected(
platform: str,
itemId: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
rows = (await db.execute(
select(Product).where(
@@ -270,3 +328,139 @@ async def is_collected(
)
)).scalars().all()
return {"collected": len(rows) > 0, "count": len(rows)}
@router.delete("/assets/{asset_id}")
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
"""删除一条素材记录及其 original/current 七牛对象。"""
asset = await db.get(ProductAsset, UUID(asset_id))
if asset is None:
raise HTTPException(status_code=404, detail="素材不存在")
try:
await get_image_asset_service().delete_asset_media(asset)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"图片存储删除失败,素材未删除:{exc}") from exc
product_id = asset.product_id
group_key = asset.group_key
await db.delete(asset)
# 同步修正 asset_counts(generated 计数由生图回写时累加)
product = await db.get(Product, product_id)
if product is not None:
counts = dict(product.asset_counts or {})
if group_key in counts:
counts[group_key] = max(0, int(counts.get(group_key) or 0) - 1)
product.asset_counts = counts
await db.commit()
return {"deleted": True}
@router.post("/assets/watermark", response_model=ApplyWatermarkResponse)
async def apply_assets_watermark(
req: ApplyWatermarkRequest,
db: AsyncSession = Depends(get_db),
) -> ApplyWatermarkResponse:
"""创建批量水印后台任务;Worker 从原图逐张合成并切换当前展示版。"""
if not req.watermark.enabled:
raise HTTPException(status_code=400, detail="请先开启水印")
try:
asset_ids = list(dict.fromkeys(UUID(asset_id) for asset_id in req.asset_ids))
except ValueError as exc:
raise HTTPException(status_code=400, detail="包含非法素材 ID") from exc
assets = (await db.scalars(select(ProductAsset).where(ProductAsset.id.in_(asset_ids)))).all()
by_id = {asset.id: asset for asset in assets}
missing = [str(asset_id) for asset_id in asset_ids if asset_id not in by_id]
if missing:
raise HTTPException(status_code=404, detail=f"素材不存在:{', '.join(missing[:3])}")
product_ids = {asset.product_id for asset in assets}
if len(product_ids) != 1:
raise HTTPException(status_code=400, detail="一次批量水印只能处理同一商品的素材")
product_id = str(next(iter(product_ids)))
task = await create_image_job(
product_id=product_id,
kind="watermark_batch",
# 水印在后台低优先级执行,不阻塞单张编辑和套图生成。
priority=-10,
provider="local",
model=None,
platform="ozon",
lang="",
ratio="",
style_set=0,
request_payload={"watermark": req.watermark.model_dump()},
item_payloads=[
{
"position": index,
"kind": "watermark",
"title": f"水印:{str(asset.id)[:8]}",
"asset_id": str(asset.id),
# 任务排队时冻结当前版本;若同图被其他操作更新,Worker 不会覆盖新版本。
"expected_current_storage_key": asset.current_storage_key,
"expected_current_url": asset.stored_url,
}
for index, asset in enumerate(by_id[asset_id] for asset_id in asset_ids)
],
)
return ApplyWatermarkResponse(job_id=str(task.id), total=task.total)
@router.get("/products/{product_id}/watermark-jobs", response_model=list[SuiteOut])
async def list_watermark_jobs(product_id: str, active_only: bool = False):
"""试算页刷新后恢复正在执行的批量水印任务。"""
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=("watermark_batch",))
views: list[SuiteOut] = []
for job in jobs:
result = await get_job(str(job.id))
if result is not None:
views.append(SuiteOut(**suite_view(*result)))
return views
@router.post("/assets/watermark/remove", response_model=RemoveWatermarkResponse)
async def remove_assets_watermark(
req: RemoveWatermarkRequest,
db: AsyncSession = Depends(get_db),
) -> RemoveWatermarkResponse:
"""批量移除水印:current 切回 original,并删除当前水印对象。"""
try:
asset_ids = list(dict.fromkeys(UUID(asset_id) for asset_id in req.asset_ids))
except ValueError as exc:
raise HTTPException(status_code=400, detail="包含非法素材 ID") from exc
assets = (await db.scalars(select(ProductAsset).where(ProductAsset.id.in_(asset_ids)))).all()
by_id = {asset.id: asset for asset in assets}
missing = [str(asset_id) for asset_id in asset_ids if asset_id not in by_id]
if missing:
raise HTTPException(status_code=404, detail=f"素材不存在:{', '.join(missing[:3])}")
image_assets = get_image_asset_service()
updated_ids: list[str] = []
failed: list[dict[str, str]] = []
obsolete: list[WatermarkUpdate] = []
for asset_id in asset_ids:
asset = by_id[asset_id]
if asset.type != "img":
failed.append({"asset_id": str(asset_id), "error": "仅支持图片素材"})
continue
if not asset.watermark_enabled:
continue
try:
obsolete.append(await image_assets.remove_watermark(asset))
updated_ids.append(str(asset_id))
except Exception as exc: # noqa: BLE001
failed.append({"asset_id": str(asset_id), "error": str(exc)[:200]})
await db.commit()
for update in obsolete:
try:
await image_assets.delete_key(update.obsolete_key)
except Exception: # noqa: BLE001
log.exception("水印已移除,但旧水印对象删除失败:%s", update.obsolete_key)
return RemoveWatermarkResponse(updated_ids=updated_ids, failed=failed)
+22 -15
View File
@@ -8,7 +8,6 @@ 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
@@ -22,8 +21,7 @@ async def list_products(
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),
db: AsyncSession = Depends(get_db)
):
stmt = select(Product)
if stage:
@@ -42,8 +40,7 @@ async def list_products(
@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),
db: AsyncSession = Depends(get_db)
):
product = await db.get(Product, UUID(product_id))
if product is None:
@@ -54,8 +51,7 @@ async def get_product(
@router.post("", response_model=ProductDetail)
async def create_product(
body: ProductUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
product = Product(stage=Stage.collected)
_apply_update(product, body)
@@ -68,8 +64,7 @@ async def create_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),
db: AsyncSession = Depends(get_db)
):
"""复制商品为新变体:继承标题/描述/属性/型号名称/计价,重置货号与图片。"""
src = await db.get(Product, UUID(product_id))
@@ -118,8 +113,7 @@ async def copy_product(
async def update_product(
product_id: str,
body: ProductUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
product = await db.get(Product, UUID(product_id))
if product is None:
@@ -134,13 +128,24 @@ async def update_product(
async def delete_product(
product_id: str,
hard: bool = False,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
if hard:
# 先阻止后台 Worker 回写新生成素材,再删除已有图片与商品记录。
from services.jobs import cancel_product_jobs
await cancel_product_jobs(str(product.id))
assets = (await db.scalars(
select(ProductAsset).where(ProductAsset.product_id == product.id)
)).all()
try:
from services.image_assets import get_image_asset_service
await get_image_asset_service().delete_product_media(str(product.id), assets)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"图片存储删除失败,商品未删除:{exc}") from exc
await db.delete(product)
else:
product.stage = Stage.archived
@@ -151,8 +156,7 @@ async def delete_product(
@router.get("/{product_id}/assets")
async def list_assets(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
rows = (await db.scalars(
select(ProductAsset)
@@ -168,6 +172,9 @@ async def list_assets(
"type": a.type,
"source_url": a.source_url,
"stored_url": a.stored_url,
"original_url": a.original_url,
"watermark_enabled": a.watermark_enabled,
"watermark_config": a.watermark_config,
"status": a.status.value,
"width": a.width,
"height": a.height,
+65
View File
@@ -0,0 +1,65 @@
"""图片代理:绕过源站防盗链,供前端 <img> 预览与生图参考使用。
平移自 image-suite-studio(/api/proxy-image?url=... 形式,docs/v2.1/api.md §7)。
"""
from __future__ import annotations
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query, Response
from services.storage import (
ResourceDownloadTimeoutError,
ResourceSizeLimitError,
UnsafeRemoteUrlError,
download_bytes,
)
router = APIRouter(prefix="/api", tags=["proxy"])
# 域名片段 → 防盗链所需 Referer
_REFERER_BY_DOMAIN: list[tuple[str, str]] = [
("alicdn.com", "https://www.taobao.com"),
("taobao.com", "https://www.taobao.com"),
("tmall.com", "https://www.tmall.com"),
("1688.com", "https://www.1688.com"),
("ozon.ru", "https://www.ozon.ru"),
("ozon.kz", "https://www.ozon.ru"),
("ozon.by", "https://www.ozon.ru"),
("ozonusercontent.com", "https://www.ozon.ru"),
]
def guess_referer(url: str) -> str | None:
host = (urlparse(url).hostname or "").lower()
for frag, referer in _REFERER_BY_DOMAIN:
if frag in host:
return referer
return None
@router.get("/proxy-image")
async def proxy_image(url: str = Query(..., description="源站图片 URL")):
scheme = urlparse(url).scheme
if scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
try:
data, ctype = await download_bytes(url, referer=guess_referer(url))
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许代理:{exc}") from exc
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法代理:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
if not ctype.startswith("image/"):
ctype = "image/jpeg"
return Response(
content=data,
media_type=ctype,
headers={
"Cache-Control": "public, max-age=86400",
"Access-Control-Allow-Origin": "*",
},
)
+380
View File
@@ -0,0 +1,380 @@
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 AI 生图。
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studio(services/planner|generator|
watermark + services/prompts),任务持久化到数据库,由独立 Worker 执行。
生成图回调回写 product_assets(group_key='generated');product_id 缺省时只落盘不回写。
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
"""
from __future__ import annotations
import io
import re
import tempfile
import uuid
import zipfile
from collections.abc import Iterator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from config import get_settings
from schemas.suite import (
PLATFORM_SPECS,
SUPPORTED_TYPES,
ImageEditSingleRequest,
ImageEditTaskResponse,
PlanItemOut,
PlanResponse,
SuiteCreateResponse,
SuiteGenerateRequest,
SuiteOut,
SuitePlanRequest,
api_model,
resolve_provider,
)
from services.generator import GENERATORS
from services.jobs import (
ITEM_DONE,
cancel_job,
create_image_job,
get_job,
list_product_jobs,
retry_failed_items,
retry_failed_item,
suite_view,
)
from services.planner import generate_plan
from services.prompts import type_name
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes
from services.suite_service import texts_to_raw, validate_model
router = APIRouter(prefix="/api", tags=["suite"])
_DIMENSION_PARAM_RE = re.compile(
r"尺寸|长宽高|长度|宽度|高度|厚度|深度|直径|dimension|length|width|height|depth|thickness|diameter",
re.IGNORECASE,
)
def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict:
"""将已填写的尺寸方向放入提示词上下文;未填写方向绝不传给绘制提示词。"""
if dimensions is None:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
known_dimensions = {
key: value for key, value in {
"length": dimensions.length,
"width": dimensions.width,
"height": dimensions.height,
}.items() if value is not None and value > 0
}
if not known_dimensions:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
enriched = dict(raw)
# 表单尺寸是尺寸标注图的唯一事实来源。原始商品参数常含完整 L×W×H,
# 若继续带进 prompt,模型会把未填写的方向也画出来。
params = raw.get("params") or []
if isinstance(params, list):
enriched["params"] = [
pair for pair in params
if isinstance(pair, dict) and not _DIMENSION_PARAM_RE.search(str(pair.get("key") or ""))
]
enriched["dimensions"] = {**known_dimensions, "unit": dimensions.unit}
return enriched
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
try:
while chunk := handle.read(chunk_size):
yield chunk
finally:
handle.close()
# ── 出图方案规划 ──────────────────────────────────────────────────────────
@router.post("/suite/plan", response_model=PlanResponse)
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
product_info = _with_dimensions(texts_to_raw(req.texts), req.dimensions)
if not product_info.get("title"):
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
try:
result = await generate_plan(
product_info, req.sku_variants, req.image_stats, req.platform,
requirements=req.requirements,
)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"规划失败: {exc}") from exc
return PlanResponse(summary=result["summary"], items=[PlanItemOut(**i) for i in result["items"]])
# ── 一键生成 ─────────────────────────────────────────────────────────────
@router.post("/suite/generate", response_model=SuiteCreateResponse)
async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
if not req.images:
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
# 生成任务列表:方案优先;无方案时按 types(空则默认四种)
if req.plan:
jobs: list[dict] = []
for item in req.plan:
if item.count <= 0:
continue
if item.kind not in SUPPORTED_TYPES:
raise HTTPException(status_code=400, detail=f"方案项「{item.title}」的图类型不支持: {item.kind}")
# 同一项多张 → 展开为多任务,第二张起在标题上加序号
for n in range(item.count):
jobs.append({
"kind": item.kind,
"title": item.title if item.count == 1 else f"{item.title}{n + 1}",
"detail": item.detail,
"prompt_hint": item.prompt_hint,
"variant_name": item.variant_name,
})
if not jobs:
raise HTTPException(status_code=400, detail="方案中所有项的数量都是 0")
else:
types = req.types or ["white_bg", "key_features", "lifestyle", "multi_scene"]
bad = [t for t in types if t not in SUPPORTED_TYPES]
if bad:
raise HTTPException(status_code=400, detail=f"不支持的图类型: {bad}")
jobs = [{"kind": t, "title": type_name(t), "detail": "", "prompt_hint": "", "variant_name": None} for t in types]
if req.platform not in PLATFORM_SPECS:
raise HTTPException(status_code=400, detail=f"不支持的目标平台: {req.platform}(ozon | wb | cn)")
spec = PLATFORM_SPECS[req.platform]
settings = get_settings()
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi、gpt-image-2@pcore → pcore)
provider_name = resolve_provider(req.model, None, settings.image_provider)
validate_model(provider_name, req.model)
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
product_id = (req.product_id or "").strip() or None
if product_id:
try:
uuid.UUID(product_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
task = await create_image_job(
product_id=product_id,
kind="image_suite",
priority=0,
provider=provider_name,
model=model,
platform=req.platform,
lang=spec["lang"],
ratio=spec["ratio"],
style_set=req.style_set,
request_payload={
"context": _with_dimensions(texts_to_raw(req.texts), req.dimensions),
"style_prompt": req.style_prompt,
"requirements": req.requirements,
"watermark": req.watermark.model_dump() if req.watermark else None,
"append": bool(product_id),
# main 组优先;variant_name 精确匹配由 Worker 复用原有选图规则。
"ref_images": [
{"url": image.url, "group_key": image.group_key, "variant_name": image.variant_name}
for image in sorted(req.images, key=lambda image: 0 if image.group_key == "main" else 1)
],
},
item_payloads=[{**job, "position": index} for index, job in enumerate(jobs)],
)
return SuiteCreateResponse(suite_id=str(task.id))
# ── 任务查询 / 结果导出 ──────────────────────────────────────────────────
@router.get("/suites/{suite_id}", response_model=SuiteOut)
async def get_suite(suite_id: str):
result = await get_job(suite_id)
if result is None:
raise HTTPException(status_code=404, detail="任务不存在")
return SuiteOut(**suite_view(*result))
@router.get("/products/{product_id}/suites", response_model=list[SuiteOut])
async def list_suites(product_id: str, active_only: bool = False):
"""试算页恢复商品的未完成任务;后续任务中心可复用。"""
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=("image_suite", "image_edit"))
views: list[SuiteOut] = []
for job in jobs:
result = await get_job(str(job.id))
if result is not None:
views.append(SuiteOut(**suite_view(*result)))
return views
@router.post("/suites/{suite_id}/cancel", response_model=SuiteOut)
async def cancel_suite(suite_id: str):
task = await cancel_job(suite_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
result = await get_job(str(task.id))
if result is None:
raise HTTPException(status_code=404, detail="任务不存在")
return SuiteOut(**suite_view(*result))
@router.post("/suites/{suite_id}/retry", response_model=SuiteOut)
async def retry_suite(suite_id: str):
task = await retry_failed_items(suite_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
result = await get_job(str(task.id))
if result is None:
raise HTTPException(status_code=404, detail="任务不存在")
return SuiteOut(**suite_view(*result))
@router.post("/suites/{suite_id}/items/{item_id}/retry", response_model=SuiteOut)
async def retry_suite_item(suite_id: str, item_id: str):
task = await retry_failed_item(suite_id, item_id)
if task is None:
raise HTTPException(status_code=404, detail="任务或子项不存在")
result = await get_job(str(task.id))
if result is None:
raise HTTPException(status_code=404, detail="任务不存在")
return SuiteOut(**suite_view(*result))
@router.get("/suites/{suite_id}/zip")
async def download_suite_zip(suite_id: str):
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
result = await get_job(suite_id)
if result is None:
raise HTTPException(status_code=404, detail="任务不存在")
task, task_items = result
settings = get_settings()
images = [image for image in task_items if image.status == ITEM_DONE and image.result_url]
if len(images) > settings.export_max_images:
raise HTTPException(status_code=413, detail=f"该任务有 {len(images)} 张图片,单次最多导出 {settings.export_max_images} 张")
archive = tempfile.SpooledTemporaryFile(
max_size=settings.export_zip_memory_threshold_bytes,
mode="w+b",
)
total_source_bytes = 0
try:
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
seen: set[str] = set()
for i, img in enumerate(images):
path = local_path(img.result_url or "")
try:
if path is not None:
data, suffix = read_local_bytes(path), path.suffix or ".jpg"
else: # 七牛等远程存储:直接拉取公开 URL
data, _ = await download_bytes(img.result_url or "")
suffix = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"无法导出「{img.name}」:{exc}") from exc
except Exception: # noqa: BLE001
continue
total_source_bytes += len(data)
if total_source_bytes > settings.export_zip_max_bytes:
raise HTTPException(
status_code=413,
detail=f"导出图片总大小超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
)
payload = img.payload or {}
filename = payload.get("title") or payload.get("kind") or "AI 生图"
if filename in seen: # 同类型多张时加序号防覆盖
filename = f"{filename}-{i + 1}"
seen.add(filename)
zf.writestr(f"{filename}{suffix}", data)
if archive.tell() > settings.export_zip_max_bytes:
raise HTTPException(
status_code=413,
detail=f"导出 ZIP 超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
)
except Exception:
archive.close()
raise
archive.seek(0)
return StreamingResponse(
_stream_file(archive),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
)
# ── 单张 AI 生图(V2.1 新增,ISS 无对应实现)──────────────────────────────
@router.post("/suite/image-edit", response_model=ImageEditTaskResponse)
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
settings = get_settings()
provider_name = resolve_provider(req.model, None, settings.image_provider)
if provider_name not in GENERATORS:
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
validate_model(provider_name, req.model)
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
product_id = (req.product_id or "").strip() or None
if product_id:
try:
uuid.UUID(product_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
if not ref_urls:
raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
if req.mode == "outpaint":
if provider_name != "pcore":
raise HTTPException(status_code=400, detail="仅扩图当前仅支持 Pcore gpt-image-2")
if len(ref_urls) != 1:
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
if req.generate_size_chart:
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
is_size_chart = bool(req.generate_size_chart)
task = await create_image_job(
product_id=product_id,
kind="image_edit",
priority=10,
provider=provider_name,
model=model,
platform="ozon",
lang=spec["lang"],
ratio=spec["ratio"],
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
style_set=0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5,
request_payload={
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
"style_prompt": None,
"requirements": req.prompt,
"operation": req.mode,
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
"requirements_exclusive": bool(req.mode == "edit" and not is_size_chart and req.prompt.strip()),
"watermark": req.watermark.model_dump() if req.watermark else None,
"append": bool(req.append and product_id),
"after_asset_id": req.after_asset_id,
"ref_images": [{"url": url, "group_key": "main", "variant_name": None} for url in ref_urls],
},
item_payloads=[{
"position": 0,
"kind": "size_chart" if is_size_chart else "custom",
"title": "尺寸标注图" if is_size_chart else ("仅扩图" if req.mode == "outpaint" else "AI生图"),
"detail": "",
"prompt_hint": "",
"variant_name": None,
"prompt_extra": {"title": "", "detail": "", "prompt_hint": ""},
}],
)
return ImageEditTaskResponse(suite_id=str(task.id))
+140
View File
@@ -0,0 +1,140 @@
"""商品图片轮播视频:复用后台任务状态机,FFmpeg 在独立 Worker 中执行。"""
from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import select
from db import get_session_factory
from models import Product, ProductAsset
from models.enums import AssetStatus
from schemas.video import VideoCreateRequest, VideoCreateResponse, VideoDeleteResponse, VideoJobOut
from services.jobs import create_image_job, delete_job, get_job, list_product_jobs, retry_failed_items, video_job_view
from services.storage import get_storage
from services.storage import local_path
from services.video_renderer import slide_seconds_for
router = APIRouter(prefix="/api", tags=["video"])
VIDEO_JOB_KIND = "slideshow_video"
def _job_out(result) -> VideoJobOut:
return VideoJobOut(**video_job_view(*result))
@router.post("/products/{product_id}/video-jobs", response_model=VideoCreateResponse)
async def create_video_job(product_id: str, req: VideoCreateRequest) -> VideoCreateResponse:
try:
product_uuid = uuid.UUID(product_id)
asset_ids = [uuid.UUID(asset_id) for asset_id in req.asset_ids]
except ValueError as exc:
raise HTTPException(status_code=400, detail="商品或图片 ID 不合法") from exc
async with get_session_factory()() as db:
product = await db.get(Product, product_uuid)
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == product_uuid,
ProductAsset.id.in_(asset_ids),
ProductAsset.type == "img",
ProductAsset.status == AssetStatus.uploaded,
)
)).all()
by_id = {asset.id: asset for asset in assets}
ordered = [by_id.get(asset_id) for asset_id in asset_ids]
if any(asset is None or not asset.stored_url for asset in ordered):
raise HTTPException(status_code=400, detail="所选图片必须属于当前商品且已完成转存")
seconds = slide_seconds_for(len(ordered))
task = await create_image_job(
product_id=product_id,
kind=VIDEO_JOB_KIND,
priority=5,
provider="ffmpeg",
model=None,
platform="wb_ozon",
lang="ru",
ratio="3:4",
style_set=0,
request_payload={
"preset": "wb_ozon",
"slide_seconds": seconds,
"sources": [
{"asset_id": str(asset.id), "url": asset.stored_url, "storage_key": asset.current_storage_key}
for asset in ordered if asset is not None
],
},
item_payloads=[{"title": "商品轮播视频", "kind": VIDEO_JOB_KIND}],
)
return VideoCreateResponse(job_id=str(task.id))
@router.get("/products/{product_id}/video-jobs", response_model=list[VideoJobOut])
async def list_video_jobs(product_id: str, active_only: bool = False):
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=(VIDEO_JOB_KIND,))
views: list[VideoJobOut] = []
for job in jobs:
result = await get_job(str(job.id))
if result is not None:
views.append(_job_out(result))
return views
@router.get("/video-jobs/{job_id}", response_model=VideoJobOut)
async def get_video_job(job_id: str):
result = await get_job(job_id)
if result is None or result[0].kind != VIDEO_JOB_KIND:
raise HTTPException(status_code=404, detail="视频任务不存在")
return _job_out(result)
@router.post("/video-jobs/{job_id}/cancel", response_model=VideoDeleteResponse)
async def cancel_video_job(job_id: str) -> VideoDeleteResponse:
# 视频任务不像生图任务需要保留诊断历史:取消后立即从页面和数据库消失。
return await _purge_video_job(job_id)
@router.post("/video-jobs/{job_id}/retry", response_model=VideoJobOut)
async def retry_video_job(job_id: str):
job = await retry_failed_items(job_id)
result = await get_job(str(job.id)) if job and job.kind == VIDEO_JOB_KIND else None
if result is None:
raise HTTPException(status_code=404, detail="视频任务不存在")
return _job_out(result)
async def _purge_video_job(job_id: str) -> VideoDeleteResponse:
result = await get_job(job_id)
if result is None or result[0].kind != VIDEO_JOB_KIND:
raise HTTPException(status_code=404, detail="视频任务不存在")
job, items = result
# 先删该任务专属目录;即使 FFmpeg 正在运行,Worker 下一次检查也会发现任务不存在并终止。
if job.product_id:
await get_storage().delete_prefix(f"products/{job.product_id}/videos/{job.id}/")
if not await delete_job(job_id):
raise HTTPException(status_code=404, detail="视频任务不存在")
return VideoDeleteResponse()
@router.delete("/video-jobs/{job_id}", response_model=VideoDeleteResponse)
async def delete_video_job(job_id: str) -> VideoDeleteResponse:
"""删除完成/失败视频及其 MP4;任务记录不保留。"""
return await _purge_video_job(job_id)
@router.get("/video-jobs/{job_id}/download")
async def download_video(job_id: str):
result = await get_job(job_id)
if result is None or result[0].kind != VIDEO_JOB_KIND or not result[1] or not result[1][0].result_url:
raise HTTPException(status_code=404, detail="视频文件不存在")
url = result[1][0].result_url
path = local_path(url)
filename = f"product-video-{job_id}.mp4"
if path is not None and Path(path).is_file():
return FileResponse(path, media_type="video/mp4", filename=filename)
return RedirectResponse(url, status_code=307)
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+69 -4
View File
@@ -34,10 +34,9 @@ class Settings(BaseSettings):
# 本地过渡用 SQLite;上线切 PostgreSQL:postgresql+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 天)
# 店铺凭证 AES-GCM 加密密钥(shops/categories/publish 冻结链路使用;
# 鉴权/账户体系已按 V2.1 决策移除,后续引入账户时一并重做)
secret_key: str = ""
# ── V2:七牛(图片存储)──
qiniu_access_key: str = ""
@@ -50,6 +49,72 @@ class Settings(BaseSettings):
# ── V2:对外地址(插件/前端回写、生成图回调)──
app_base_url: str = "http://127.0.0.1:8800"
# ── 图片与导出资源上限(字节)──
# 单张图片在后端需要解码/加水印,限制单文件可避免一次请求耗尽内存。
image_upload_max_bytes: int = 20 * 1024 * 1024
image_download_max_bytes: int = 20 * 1024 * 1024
# 图片解码前依据真实宽高限制像素,避免超大图耗尽 Pillow/水印处理内存。
image_max_pixels: int = 40_000_000
# 远程图片下载防护:连接/读写/总耗时分别受控;重定向逐跳校验目标地址。
image_download_connect_timeout: float = 10.0
image_download_read_timeout: float = 30.0
image_download_total_timeout: float = 60.0
image_download_max_redirects: int = 3
collection_max_images: int = 100
export_max_images: int = 50
export_zip_max_bytes: int = 200 * 1024 * 1024
# ZIP 超过该值自动落临时磁盘,不继续占用进程内存。
export_zip_memory_threshold_bytes: int = 2 * 1024 * 1024
# ── 商品轮播视频(FFmpeg)──
# Ubuntu 默认安装包会提供同名命令;容器或特殊路径可在 .env 覆盖。
video_ffmpeg_path: str = "ffmpeg"
video_ffprobe_path: str = "ffprobe"
video_width: int = 810
video_height: int = 1080
video_fps: int = 30
video_transition_seconds: float = 0.35
video_crf: int = 22
video_max_rate: str = "3M"
video_buffer_size: str = "6M"
video_max_bytes: int = 45 * 1024 * 1024
video_max_duration_seconds: int = 180
video_render_timeout_seconds: int = 120
background_job_lease_seconds: int = 300
# ── V2.1 套图生图(provider 路由与 image-suite-studio 一致)──
image_provider: str = "doubao" # 未知模型时的兜底 provider:doubao | tongyi | rightapi
request_timeout: int = 300 # 单张生图请求超时(秒)
poll_max_wait: int = 600 # 异步任务轮询上限(秒)
# 水印徽章源图(图片水印用,可 .env 覆盖)
watermark_image_path: str = str(Path(__file__).resolve().parents[1] / "assets" / "watermark.jpg")
# 豆包 / 火山方舟 Seedream
ark_api_key: str = ""
ark_base_url: str = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
ark_image_model: str = "doubao-seedream-4-5-251128"
# 通义 / DashScope(key 复用上方 dashscope_api_key;dashscope_base_http_api_url 是业务空间专用,与此处无关)
dashscope_model: str = "wan2.7-image-pro"
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
# RightAPI(OpenAI 兼容中转:gpt-image / nano-banana 系列)
rightapi_api_key: str = ""
rightapi_base_url: str = "https://rightapi.ai/draw"
rightapi_image_model: str = "gpt-image-2"
rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
# Pcore(OpenAI 兼容中转:pcore.ai,异步图片任务;模型值带 @pcore 后缀路由到此)
pcore_api_key: str = ""
pcore_base_url: str = "https://pcore.ai"
pcore_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
pcore_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
# DeepSeek 出图方案规划器(key 复用上方 deepseek_api_key;文案生成的模型走 config/models.yaml,互不影响)
deepseek_base_url: str = "https://api.deepseek.com/v1"
deepseek_model: str = "deepseek-v4-flash"
@property
def cors_origin_list(self) -> list[str]:
if not self.cors_origins.strip():
+4 -23
View File
@@ -1,35 +1,16 @@
"""JWT 鉴权 + 店铺凭证 AES-GCM 加解密。"""
"""店铺凭证 AES-GCM 加解密(shops/categories/publish 冻结链路使用)。
鉴权(JWT/APP_TOKEN)已按 V2.1 决策移除,后续加账户体系时再引入。
"""
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()
-26
View File
@@ -1,26 +0,0 @@
"""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}
View File
View File
@@ -1,4 +1,5 @@
"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
from uuid import UUID
@@ -9,9 +10,8 @@ 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
from legacy.models import Shop
from legacy.services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/categories", tags=["categories"])
@@ -35,8 +35,7 @@ def _unwrap(result: dict) -> dict:
@router.post("/tree")
async def category_tree(
body: ShopRef,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
client = await _client(body.shop_id, db)
try:
@@ -56,8 +55,7 @@ class AttributeQuery(BaseModel):
async def category_attributes(
category_id: int,
body: AttributeQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
client = await _client(body.shop_id, db)
try:
@@ -88,8 +86,7 @@ class ValueQuery(BaseModel):
async def attribute_values(
attribute_id: int,
body: ValueQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
client = await _client(body.shop_id, db)
try:
@@ -1,4 +1,5 @@
"""发布端点:提交 ImportProductsV3 + 后台轮询回填。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
import asyncio
@@ -12,11 +13,11 @@ 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 import Product
from legacy.models import PublishTask, Shop
from models.enums import PublishStatus, Stage
from services.ozon_client import OzonClient, OzonAPIError
from services.publish import build_import_item, validate_ready
from legacy.services.ozon_client import OzonClient, OzonAPIError
from legacy.services.publish import build_import_item, validate_ready
router = APIRouter(prefix="/api", tags=["publish"])
@@ -34,8 +35,7 @@ async def publish_product(
product_id: str,
body: PublishRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
product = await db.get(Product, UUID(product_id))
if product is None:
@@ -140,8 +140,7 @@ async def _poll(task_id: str) -> None:
@router.get("/publish/{task_id}")
async def get_publish_task(
task_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
task = await db.get(PublishTask, UUID(task_id))
if task is None:
@@ -162,8 +161,7 @@ async def get_publish_task(
@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),
db: AsyncSession = Depends(get_db)
):
rows = (await db.scalars(
select(PublishTask)
@@ -1,4 +1,5 @@
"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
from datetime import datetime, timezone
@@ -10,11 +11,10 @@ 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 legacy.models import Shop
from models.enums import ShopStatus
from schemas.shop import ShopCreate, ShopListItem, ShopUpdate
from services.ozon_client import OzonClient, OzonAPIError
from legacy.schemas.shop import ShopCreate, ShopListItem, ShopUpdate
from legacy.services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/shops", tags=["shops"])
@@ -25,8 +25,7 @@ def _mask(client_id: str) -> str:
@router.get("", response_model=list[ShopListItem])
async def list_shops(
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
rows = (await db.scalars(select(Shop).order_by(Shop.created_at))).all()
items = []
@@ -43,8 +42,7 @@ async def list_shops(
@router.post("", response_model=ShopListItem)
async def create_shop(
body: ShopCreate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
shop = Shop(
name=body.name,
@@ -65,8 +63,7 @@ async def create_shop(
async def update_shop(
shop_id: str,
body: ShopUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
@@ -89,8 +86,7 @@ async def update_shop(
@router.delete("/{shop_id}")
async def delete_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
@@ -103,8 +99,7 @@ async def delete_shop(
@router.post("/{shop_id}/test")
async def test_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
+12
View File
@@ -0,0 +1,12 @@
"""legacy 冻结模型导出(Ozon 直传链路;表结构随 legacy api import 链注册到 Base.metadata)。"""
from legacy.models.category import AttributeValue, CategoryAttribute, CategoryTree
from legacy.models.publish_task import PublishTask
from legacy.models.shop import Shop
__all__ = [
"Shop",
"PublishTask",
"CategoryTree",
"CategoryAttribute",
"AttributeValue",
]
@@ -1,4 +1,5 @@
"""Ozon 类目字典缓存(可重建,不作为业务真源)。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
from datetime import datetime
@@ -1,4 +1,5 @@
"""发布任务:一次 ImportProductsV3 请求与轮询结果。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
import uuid
@@ -1,4 +1,5 @@
"""Ozon 店铺(Client-Id / Api-Key 加密落库)。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
import uuid
View File
@@ -1,4 +1,5 @@
"""店铺(Ozon 凭证)请求/响应模型。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
from datetime import datetime
View File
@@ -1,4 +1,5 @@
"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
import httpx
@@ -1,4 +1,5 @@
"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。"""
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
from __future__ import annotations
from models import Product
+15 -7
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from api import ai, auth, categories, collection, fx, image, ozon, products, publish, shops
from api import ai, export, fx, image, materials, products, proxy, suite, video
from config import get_settings
from db import get_engine
@@ -26,16 +26,24 @@ if settings.cors_origin_list:
allow_headers=["*"],
)
# 业务路由
app.include_router(auth.router)
app.include_router(collection.router)
# 业务路由(活跃:采集上报 / 商品库 / 套图生图 / 辅助)
app.include_router(materials.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)
# V2.1 套图生图(试算页 04 区块)/ 导出 / 图片代理
app.include_router(suite.router)
app.include_router(export.router)
app.include_router(proxy.router)
app.include_router(video.router)
# ⚠️ 冻结链路(Ozon API 直传):代码移至 legacy/,接口保留但不再投入
from legacy.api import categories, ozon, publish, shops # noqa: E402
app.include_router(shops.router)
app.include_router(categories.router)
app.include_router(publish.router)
app.include_router(ozon.router)
@@ -0,0 +1,44 @@
"""add asset image lifecycle fields
Revision ID: a41e8c7f4b20
Revises: 658b0503f71c
Create Date: 2026-08-30 09:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a41e8c7f4b20"
down_revision = "658b0503f71c"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("product_assets", sa.Column("original_url", sa.Text(), nullable=True))
op.add_column("product_assets", sa.Column("original_storage_key", sa.String(length=1024), nullable=True))
op.add_column("product_assets", sa.Column("current_storage_key", sa.String(length=1024), nullable=True))
op.add_column("product_assets", sa.Column("watermark_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("product_assets", sa.Column("watermark_config", sa.JSON(), nullable=True))
# 兼容旧数据:采集图优先以已转存文件为原图;生成图若此前已保存 source_url,则它是无水印原图。
op.execute(
"""
UPDATE product_assets
SET original_url = CASE
WHEN group_key = 'generated' AND source_url IS NOT NULL AND source_url <> '' THEN source_url
ELSE COALESCE(stored_url, source_url)
END
"""
)
def downgrade() -> None:
op.drop_column("product_assets", "watermark_config")
op.drop_column("product_assets", "watermark_enabled")
op.drop_column("product_assets", "current_storage_key")
op.drop_column("product_assets", "original_storage_key")
op.drop_column("product_assets", "original_url")
@@ -0,0 +1,101 @@
"""add persistent background jobs
Revision ID: b5d2c9a3e1f7
Revises: a41e8c7f4b20
Create Date: 2026-08-30 12:00:00
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b5d2c9a3e1f7"
down_revision = "a41e8c7f4b20"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 开发态 FastAPI 会在 startup 以 Base.metadata.create_all 兜底;若它先于 Alembic
# 启动,表已完整存在,此处只补迁移版本记录,避免 create_table 冲突。
inspector = sa.inspect(op.get_bind())
if "background_jobs" in inspector.get_table_names():
# 开发态若先由 metadata.create_all 建表,仍补上后来新增的索引。
existing_indexes = {
index["name"] for index in inspector.get_indexes("background_job_items")
}
if "ix_background_job_items_updated_at" not in existing_indexes:
op.create_index("ix_background_job_items_updated_at", "background_job_items", ["updated_at"])
return
op.create_table(
"background_jobs",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("product_id", sa.Uuid(), nullable=True),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=24), nullable=False, server_default="queued"),
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
sa.Column("provider", sa.String(length=32), nullable=False, server_default=""),
sa.Column("model", sa.String(length=128), nullable=True),
sa.Column("platform", sa.String(length=16), nullable=False, server_default="ozon"),
sa.Column("lang", sa.String(length=16), nullable=False, server_default="ru"),
sa.Column("ratio", sa.String(length=16), nullable=False, server_default="3:4"),
sa.Column("style_set", sa.Integer(), nullable=False, server_default="1"),
sa.Column("request_payload", sa.JSON(), nullable=False),
sa.Column("total", sa.Integer(), nullable=False, server_default="0"),
sa.Column("completed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("cancel_requested", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_background_jobs_product_id", "background_jobs", ["product_id"])
op.create_index("ix_background_jobs_kind", "background_jobs", ["kind"])
op.create_index("ix_background_jobs_status", "background_jobs", ["status"])
op.create_index("ix_background_jobs_priority", "background_jobs", ["priority"])
op.create_index("ix_background_jobs_cancel_requested", "background_jobs", ["cancel_requested"])
op.create_index("ix_background_jobs_created_at", "background_jobs", ["created_at"])
op.create_index("ix_background_jobs_updated_at", "background_jobs", ["updated_at"])
op.create_table(
"background_job_items",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("job_id", sa.Uuid(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("status", sa.String(length=24), nullable=False, server_default="queued"),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("provider_task_id", sa.String(length=512), nullable=True),
sa.Column("provider_task_kind", sa.String(length=64), nullable=True),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
sa.Column("result_url", sa.Text(), nullable=True),
sa.Column("source_url", sa.Text(), nullable=True),
sa.Column("watermark_config", sa.JSON(), nullable=True),
sa.Column("generated_asset_id", sa.Uuid(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("lease_owner", sa.String(length=128), nullable=True),
sa.Column("lease_expires_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("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.ForeignKeyConstraint(["job_id"], ["background_jobs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_background_job_items_job_id", "background_job_items", ["job_id"])
op.create_index("ix_background_job_items_status", "background_job_items", ["status"])
op.create_index("ix_background_job_items_provider_task_id", "background_job_items", ["provider_task_id"])
op.create_index("ix_background_job_items_next_run_at", "background_job_items", ["next_run_at"])
op.create_index("ix_background_job_items_lease_expires_at", "background_job_items", ["lease_expires_at"])
op.create_index("ix_background_job_items_updated_at", "background_job_items", ["updated_at"])
def downgrade() -> None:
op.drop_index("ix_background_job_items_updated_at", table_name="background_job_items")
op.drop_table("background_job_items")
op.drop_table("background_jobs")
View File
+8 -9
View File
@@ -1,18 +1,17 @@
"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。"""
"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。
冻结链路的模型已移至 legacy/models(shops/categories/publish 相关),
由 legacy.api 路由 import 链注册到同一 Base.metadata。
"""
from models.asset import ProductAsset
from models.category import AttributeValue, CategoryAttribute, CategoryTree
from models.job import BackgroundJob, BackgroundJobItem
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",
"BackgroundJob",
"BackgroundJobItem",
]
+11 -3
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import AssetStatus
from models.types import JSONType
class ProductAsset(Base):
@@ -22,8 +23,15 @@ class ProductAsset(Base):
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
source_url: Mapped[str] = mapped_column(Text, default="") # 采集源站地址;生成图旧数据可能存无水印 URL
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 当前展示版 URL(兼容旧接口)
# 图片生命周期:original 始终无水印;当前版用于页面预览、下载、导出。
# 未加水印时 original 与 current 指向同一对象;不保留历史水印版。
original_url: Mapped[str | None] = mapped_column(Text, nullable=True)
original_storage_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
current_storage_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
watermark_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
watermark_config: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
status: Mapped[AssetStatus] = mapped_column(
Enum(AssetStatus, native_enum=False, length=16), default=AssetStatus.pending, index=True
)
+78
View File
@@ -0,0 +1,78 @@
"""持久化后台任务:AI 套图、单张编辑共用的父任务与逐图子任务。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.types import JSONType
class BackgroundJob(Base):
"""一个用户可见的后台操作:套图、单张编辑或批量水印。"""
__tablename__ = "background_jobs"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 商品删除后保留任务诊断记录,但 Worker 不会再把结果回写到不存在的商品。
product_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="SET NULL"), nullable=True, index=True
)
kind: Mapped[str] = mapped_column(String(32), index=True) # image_suite | image_edit | watermark_batch
status: Mapped[str] = mapped_column(String(24), default="queued", index=True)
priority: Mapped[int] = mapped_column(Integer, default=0, index=True)
provider: Mapped[str] = mapped_column(String(32), default="")
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
platform: Mapped[str] = mapped_column(String(16), default="ozon")
lang: Mapped[str] = mapped_column(String(16), default="ru")
ratio: Mapped[str] = mapped_column(String(16), default="3:4")
style_set: Mapped[int] = mapped_column(Integer, default=1)
# 原始请求的不可变快照:商品上下文、参考图池、风格、要求、水印等。
request_payload: Mapped[dict] = mapped_column(JSONType, default=dict)
total: Mapped[int] = mapped_column(Integer, default=0)
completed: Mapped[int] = mapped_column(Integer, default=0)
failed: Mapped[int] = mapped_column(Integer, default=0)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
)
class BackgroundJobItem(Base):
"""任务中的一个可调度工作单元:套图的一张图或单张编辑。"""
__tablename__ = "background_job_items"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
job_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("background_jobs.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(24), default="queued", index=True)
payload: Mapped[dict] = mapped_column(JSONType, default=dict)
# 供应商异步任务 ID 与类型(pcore 还需 edits/generations);重启后继续轮询,不重新提交。
provider_task_id: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
provider_task_kind: Mapped[str | None] = mapped_column(String(64), nullable=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, default=3)
result_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
watermark_config: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
generated_asset_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
lease_owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
)
+1 -1
View File
@@ -1,4 +1,4 @@
"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。"""
"""用户表(预留多用户;V2.1 已移除鉴权,后续加账户体系时启用)。"""
from __future__ import annotations
import uuid
+3
View File
@@ -14,3 +14,6 @@ alembic>=1.13.0
PyJWT>=2.8.0
cryptography>=42.0.0
qiniu>=7.13.0
# V2.1:套图生成(水印合成)
pillow>=10.0.0
-14
View File
@@ -1,14 +0,0 @@
"""鉴权请求/响应模型。"""
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
+2
View File
@@ -18,6 +18,8 @@ class ProductListItem(BaseModel):
currency_code: str = "RUB"
source_platform: str | None = None
source_url: str | None = None
# 采集箱显示中文标题:优先 raw.title_zh,兼容早期采集写入的 raw.title。
raw: dict | None = None
asset_counts: dict | None = None
ozon_product_id: int | None = None
created_at: datetime
+203
View File
@@ -0,0 +1,203 @@
"""套图规划 / 一键生成 / 单张 AI 生图 契约(studio/src/services/suite.ts 同源,见 docs/v2.1/api.md)。"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
SUPPORTED_TYPES = [
"white_bg", "key_features", "selling_pt", "material",
"lifestyle", "multi_scene", "ecommerce_detail",
"size_chart", "sku_collection", "custom",
]
# 通义(DashScope)生图模型白名单
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro", "wan2.6-image", "wan2.6-t2i"]
# RightAPI 生图模型白名单(gpt-image 系列 + Google nano-banana 系列,同一中转)
RIGHTAPI_MODELS = [
"gpt-image-2",
"gpt-image-2-vip",
"nano-banana",
"nano-banana-2",
"nano-banana-2-lite",
"nano-banana-pro",
]
# Pcore 生图模型白名单(pcore.ai 中转;同一模型名与 RightAPI 重名,用 @pcore 后缀区分路由)
PCORE_MODELS = ["gpt-image-2"]
# 豆包 Seedream 走火山方舟;模型由 ARK_IMAGE_MODEL 指定(不在下拉白名单内也允许直连豆包)
DOUBAO_MODELS = ["doubao-seedream-4-5-251128"]
# 模型 → provider 推断表:前端只传模型名,服务端据此路由(模型名优先于 provider 字段)
# 同名模型走多家中转时,前端传「api_model@provider」形式(如 gpt-image-2@pcore)
MODEL_PROVIDERS: dict[str, str] = {
**{m: "tongyi" for m in TONGYI_MODELS},
**{m: "rightapi" for m in RIGHTAPI_MODELS},
**{m: "doubao" for m in DOUBAO_MODELS},
**{f"{m}@pcore": "pcore" for m in PCORE_MODELS},
}
def api_model(model: str | None) -> str | None:
"""前端模型值 → 发给供应商 API 的模型名:'gpt-image-2@pcore' → 'gpt-image-2'。"""
return model.split("@", 1)[0] if model and "@" in model else model
def resolve_provider(model: str | None, requested: str | None, default: str) -> str:
"""已知模型名直接定位 provider;未知模型回退到请求指定的 provider 或默认值。"""
if model and model in MODEL_PROVIDERS:
return MODEL_PROVIDERS[model]
return requested or default
# 目标平台 → 文案语言 + 图片比例(平台决定规格,不再单独选语言)
PLATFORM_SPECS: dict[str, dict] = {
"ozon": {"lang": "ru", "ratio": "3:4", "label": "Ozon"},
"wb": {"lang": "ru", "ratio": "3:4", "label": "Wildberries"},
"cn": {"lang": "zh", "ratio": "1:1", "label": "中文(国内平台)"},
}
class TextMaterial(BaseModel):
kind: str = Field(..., description="title | title_ru | params | selling_point | desc | price | brand | sales | shop")
content: str = ""
pairs: list[dict] | None = None # [{key, value}]
class WatermarkOptions(BaseModel):
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。右下角,默认文字 Panda Store。"""
enabled: bool = Field(default=False, description="是否开启水印")
type: Literal["image", "text"] = Field(default="image", description="图片水印 | 文字水印")
text: str = Field(default="Panda Store", description="文字水印内容")
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
class ProductDimensions(BaseModel):
"""商品长宽高;尺寸图仅标注实际提供的方向。"""
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
unit: Literal["mm", "cm"] = Field(default="mm", description="尺寸单位")
class PlanItem(BaseModel):
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
title: str = Field(..., description="方案标题,如「主图·粉色」")
detail: str = Field(default="", description="这张图展示什么(中文)")
prompt_hint: str = Field(default="", description="构图提示(英文,进生图 prompt)")
count: int = Field(default=1, ge=0, le=5)
variant_name: str | None = Field(default=None, description="绑定的 SKU 规格名")
class SuitePlanRequest(BaseModel):
"""POST /api/suite/plan:DeepSeek 根据商品信息生成出图方案。"""
product_id: str | None = Field(default=None, description="便于日志与上下文定位,可空")
texts: list[TextMaterial] = Field(default_factory=list)
sku_variants: list[str] = Field(default_factory=list, description="带图的 SKU 规格名")
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}")
platform: str = Field(default="ozon")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于规划尺寸标注图")
class PlanItemOut(BaseModel):
kind: str
title: str
detail: str = ""
prompt_hint: str = ""
count: int = 1
variant_name: str | None = None
class PlanResponse(BaseModel):
summary: str = ""
items: list[PlanItemOut]
class GenerateImageItem(BaseModel):
url: str = Field(..., description="勾选的参考底图 URL(stored_url 或源站原图)")
group_key: str = Field(default="main", description="main | sku | detail | upload")
variant_name: str | None = Field(default=None, description="SKU 规格名(方案绑定用)")
class SuiteGenerateRequest(BaseModel):
"""POST /api/suite/generate:提交套图任务,返回 suite_id 后前端轮询。"""
product_id: str | None = Field(default=None, description="用于生成图回写素材(generated 组),可空")
texts: list[TextMaterial] = Field(default_factory=list)
images: list[GenerateImageItem] = Field(default_factory=list, description="勾选的参考底图")
style_set: int = Field(default=1, ge=1, le=5)
style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,强制约束,覆盖其他设定)")
plan: list[PlanItem] | None = Field(default=None, description="出图方案(优先于 types)")
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
platform: str = Field(default="ozon")
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)")
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
class SuiteCreateResponse(BaseModel):
suite_id: str
class SuiteImageOut(BaseModel):
item_id: str
type_id: str
name: str
url: str
status: str
error: str | None = None
class SuiteOut(BaseModel):
id: str
status: str
kind: str = "image_suite"
style_set: int
platform: str
lang: str
ratio: str
provider: str
model: str | None = None
total: int = 0 # 计划总张数(进度分母;images 是逐张追加,过程中 length < total)
completed: int = 0
failed: int = 0
cancel_requested: bool = False
created_at: datetime | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
images: list[SuiteImageOut]
error: str | None = None
class ImageEditSingleRequest(BaseModel):
"""POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。"""
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
mode: Literal["edit", "outpaint"] = Field(
default="edit",
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
)
model: str = Field(default="nano-banana-2")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field(
default=None,
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
)
watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传")
generate_size_chart: bool = Field(default=False, description="是否按尺寸标注图生成")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
class ImageEditTaskResponse(BaseModel):
"""单张编辑已进入统一图片任务队列;用 GET /api/suites/{suite_id} 查询结果。"""
suite_id: str
+56
View File
@@ -0,0 +1,56 @@
"""轮播商品视频的请求与任务响应契约。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class VideoCreateRequest(BaseModel):
"""按传入顺序把 2–5 张当前商品图片合成为无音轨轮播视频。"""
asset_ids: list[str] = Field(min_length=2, max_length=5)
@field_validator("asset_ids")
@classmethod
def asset_ids_must_be_unique(cls, values: list[str]) -> list[str]:
if len(set(values)) != len(values):
raise ValueError("合成视频不能重复选择同一张图片")
return values
class VideoCreateResponse(BaseModel):
job_id: str
class VideoDeleteResponse(BaseModel):
deleted: bool = True
class VideoResultOut(BaseModel):
item_id: str
status: str
url: str | None = None
error: str | None = None
class VideoJobOut(BaseModel):
id: str
kind: str = "slideshow_video"
status: str
total: int = 1
completed: int = 0
failed: int = 0
cancel_requested: bool = False
preset: str = "wb_ozon"
source_asset_ids: list[str] = Field(default_factory=list)
slide_seconds: float = 0.0
duration_seconds: float | None = None
width: int = 810
height: int = 1080
file_size_bytes: int | None = None
created_at: datetime | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
result: VideoResultOut | None = None
error: str | None = None
@@ -0,0 +1,69 @@
"""为历史图片素材补齐真实宽高,不改写已有图片文件。"""
from __future__ import annotations
import argparse
import asyncio
import mimetypes
import sys
from pathlib import Path
from sqlalchemy import or_, select
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from db import get_session_factory
from config import get_settings
from models import ProductAsset
from services.storage import local_path, normalize_image_bytes, read_local_bytes
async def backfill(dry_run: bool = False) -> tuple[int, list[str]]:
"""返回成功回填数和最多十条失败摘要。"""
updated = 0
failures: list[str] = []
async with get_session_factory()() as db:
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.type == "img",
or_(ProductAsset.width.is_(None), ProductAsset.height.is_(None)),
)
)).all()
for asset in assets:
url = asset.original_url or asset.stored_url
path = local_path(url or "")
if path is None:
if len(failures) < 10:
failures.append(f"{asset.id}: 本地原图不存在,跳过")
continue
try:
data = read_local_bytes(path)
image = normalize_image_bytes(
data,
max_bytes=max(len(data), get_settings().image_download_max_bytes),
)
asset.width = image.width
asset.height = image.height
updated += 1
except Exception as exc: # noqa: BLE001
if len(failures) < 10:
failures.append(f"{asset.id}: {exc}")
if not dry_run:
await db.commit()
return updated, failures
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="只检查,不写数据库")
args = parser.parse_args()
updated, failures = asyncio.run(backfill(dry_run=args.dry_run))
mode = "将回填" if args.dry_run else "已回填"
print(f"{mode} {updated} 条图片素材的宽高")
for failure in failures:
print(f"跳过:{failure}")
if __name__ == "__main__":
main()

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