Compare commits
12 Commits
main
...
feat-2.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| d7247ca2da | |||
| 8b54f7ed1e | |||
| 2a667b4f3b | |||
| 957289f717 | |||
| 4ee667142b | |||
| a77ec26a02 | |||
| dc6d38c128 | |||
| 2835914fd8 | |||
| efe3474deb | |||
| bb8f235e34 | |||
| 7e1192f2ef | |||
| ac4ab22ac2 |
@@ -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 鉴权范围配置
|
||||
@@ -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 整理(远期重启直传时用) |
|
||||
|
||||
@@ -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 改动:浏览器实测 + 必要时截图确认
|
||||
@@ -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/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 |
|
||||
@@ -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)。
|
||||
@@ -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` 逐字段对齐(联调已验证) |
|
||||
@@ -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` | 单图任务配置与提交入口。 |
|
||||
@@ -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 即可独立 |
|
||||
@@ -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 字段。迁移前已经产生但数据库无记录的旧七牛对象无法可靠识别;后续可按运营需要增加七牛前缀扫描与人工确认清理工具。
|
||||
|
||||
@@ -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)。
|
||||
@@ -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 的价格?
|
||||
@@ -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;它们会直接影响表结构、导出格式和后续上品方式。
|
||||
@@ -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 契约、索引、可观测性**:依据真实使用量逐步完善。
|
||||
@@ -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)、`lineMultiplier` 划线价倍数%(默认100,0-500)、
|
||||
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。
|
||||
|
||||
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
|
||||
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
|
||||
|
||||
**物流费**(`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,
|
||||
"lineMultiplier": 100, "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`、页头信息、采集箱「试算」入口 |
|
||||
@@ -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') {
|
||||
const headers: Record<string, string> = {};
|
||||
if (msg.referer) headers['Referer'] = msg.referer;
|
||||
fetch(msg.url, {
|
||||
headers,
|
||||
credentials: msg.credentials ? 'include' : 'omit',
|
||||
})
|
||||
.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>
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+4411
File diff suppressed because it is too large
Load Diff
@@ -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全部断言通过 ✅');
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,168 @@
|
||||
/**
|
||||
* 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 }>;
|
||||
}
|
||||
|
||||
/** 深度查找指定键(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);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 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' });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,95 @@
|
||||
/**
|
||||
* 1688 采集编排(平台文件):
|
||||
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
|
||||
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
|
||||
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
|
||||
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
|
||||
* 详情图改为滚动加载后由 DOM 采集补齐。
|
||||
*/
|
||||
import { autoScrollToBottom, 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 { 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;
|
||||
|
||||
// ① 桥读 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';
|
||||
}
|
||||
|
||||
// ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
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 (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Ozon 采集编排(平台文件):四路径合并(来自 extension-v2 生产逻辑)。
|
||||
* ① SSR widget state(DOM data-state 属性,白名单)★主路径
|
||||
* ② JSON-LD(schema.org/Product)
|
||||
* ③ 站内页 JSON API(entrypoint-api.bx)
|
||||
* ④ DOM data-widget 选择器兜底 + 详情图补充
|
||||
*/
|
||||
import { 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 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,49 @@
|
||||
/**
|
||||
* 淘宝/天猫 采集编排(平台文件):
|
||||
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
|
||||
* (isolated world 读不到 window 变量,v1 直读是无效的)
|
||||
* ② DOM 兜底 + 补充
|
||||
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
|
||||
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
|
||||
*/
|
||||
import { autoScrollToBottom, 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 { 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 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';
|
||||
}
|
||||
|
||||
// ② DOM 兜底 + 补充
|
||||
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
|
||||
await autoScrollToBottom();
|
||||
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 (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('详情图需 DOM 补充:请滚动到页面底部后重新采集');
|
||||
}
|
||||
|
||||
return {
|
||||
platform: profile.id,
|
||||
itemId: data.item.itemId,
|
||||
url: location.href,
|
||||
texts,
|
||||
images,
|
||||
scannedAt: Date.now(),
|
||||
stats,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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: '' },
|
||||
],
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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://*.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']
|
||||
});
|
||||
@@ -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)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""导出采集图片:把勾选的采集图/生成图打包成 ZIP 下载到本地。
|
||||
|
||||
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
|
||||
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
|
||||
ZIP 内部按分组名建子文件夹(主图 / 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
group = _clean(img.groupName) or "图片"
|
||||
zf.writestr(f"{root}/{group}/{filename}", 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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 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 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"])
|
||||
|
||||
|
||||
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 = texts_to_raw(req.texts)
|
||||
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": texts_to_raw(req.texts),
|
||||
"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
|
||||
|
||||
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
||||
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"],
|
||||
style_set=5,
|
||||
request_payload={
|
||||
"context": {"title": "AI 精修"},
|
||||
"style_prompt": None,
|
||||
"requirements": req.prompt,
|
||||
"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": req.image_url, "group_key": "main", "variant_name": None}],
|
||||
},
|
||||
item_payloads=[{
|
||||
"position": 0,
|
||||
"kind": "custom",
|
||||
"title": "AI生图",
|
||||
"detail": "",
|
||||
"prompt_hint": "",
|
||||
"variant_name": None,
|
||||
"prompt_extra": {"title": "", "detail": "", "prompt_hint": ""},
|
||||
}],
|
||||
)
|
||||
return ImageEditTaskResponse(suite_id=str(task.id))
|
||||
@@ -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 |
@@ -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
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -1,4 +1,5 @@
|
||||
"""店铺(Ozon 凭证)请求/响应模型。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。"""
|
||||
"""用户表(预留多用户;V2.1 已移除鉴权,后续加账户体系时启用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""套图规划 / 一键生成 / 单张 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 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="生图要求(最高优先级,规划方案必须遵循)")
|
||||
|
||||
|
||||
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="生成图水印(服务端后处理合成);关闭时不传")
|
||||
|
||||
|
||||
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 生图(试算页每张图的「AI 生图」入口)。"""
|
||||
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
|
||||
image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)")
|
||||
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
|
||||
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="单张生图完成后的服务端水印;关闭时不传")
|
||||
|
||||
|
||||
class ImageEditTaskResponse(BaseModel):
|
||||
"""单张编辑已进入统一图片任务队列;用 GET /api/suites/{suite_id} 查询结果。"""
|
||||
|
||||
suite_id: str
|
||||
@@ -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()
|
||||
@@ -0,0 +1,793 @@
|
||||
"""套图生成服务:图像 provider(豆包 Seedream / 通义万相 / RightAPI 中转)+ 任务执行器。
|
||||
|
||||
平移自 image-suite-studio/services/generator.py,适配点:
|
||||
- 存储走本仓 services/storage 抽象(本地 data/media 兜底 / 七牛),save_bytes 直接返回可访问 URL;
|
||||
- 参考图解析复用 storage.local_path 与 storage.download_bytes(防盗链 Referer 由 api/proxy.guess_referer 提供);
|
||||
- run_suite 新增可选 on_image_ok 回调:每张成功即通知调用方落库(product_assets/generated),
|
||||
本模块不接触数据库,保持「套图引擎不依赖商品模型」的独立产品化裁剪能力。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
from services.prompts import build_context, build_prompt, type_name
|
||||
from services.tasks import (
|
||||
IMG_FAILED,
|
||||
IMG_OK,
|
||||
TASK_DONE,
|
||||
TASK_FAILED,
|
||||
TASK_PARTIAL,
|
||||
TASK_RUNNING,
|
||||
Task,
|
||||
TaskImage,
|
||||
)
|
||||
from services.watermark import apply_watermark
|
||||
from services.storage import download_bytes, get_storage, local_path, read_local_bytes
|
||||
|
||||
log = logging.getLogger("suite.generator")
|
||||
|
||||
# 单张成功回调:(image) -> None;由 API 层注入用于回写商品素材
|
||||
OnImageOk = Callable[[TaskImage], Awaitable[None]]
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
"""带 HTTP 状态码的 API 错误(用于区分可重试的网关/限流错误)。"""
|
||||
|
||||
def __init__(self, message: str, status: int = 0):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
_HTML_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _raise_api_error(resp, provider: str):
|
||||
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
|
||||
if resp.is_success:
|
||||
return
|
||||
text = resp.text or ""
|
||||
if "<html" in text[:300].lower() or text.lstrip()[:15].lower().startswith("<!doctype"):
|
||||
# Cloudflare/网关错误页:取 <title> 作摘要,避免整段 HTML 进错误信息
|
||||
m = _HTML_TITLE_RE.search(text)
|
||||
detail = (re.sub(r"\s+", " ", m.group(1)).strip() if m else "") or "网关返回 HTML 错误页(上游/CDN 故障)"
|
||||
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail}", resp.status_code)
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception: # noqa: BLE001
|
||||
raise ApiError(f"{provider} API HTTP {resp.status_code} — {text[:200]}", resp.status_code)
|
||||
# 兼容两种错误形状:顶层 {code, message}(豆包/通义)与嵌套 {error: {code, message}}(new_api 系中转)
|
||||
err = body.get("error") if isinstance(body.get("error"), dict) else body
|
||||
detail = f"{err.get('code', '')}: {err.get('message', '')}".strip(': ')
|
||||
raise ApiError(f"{provider} API HTTP {resp.status_code} — {detail or '无错误详情'}", resp.status_code)
|
||||
|
||||
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
|
||||
TYPE_REF_INDEX = {
|
||||
"material": 1,
|
||||
}
|
||||
DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + 背面/细节 1 张)
|
||||
|
||||
|
||||
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
|
||||
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
|
||||
if provider == "doubao":
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
if provider == "rightapi":
|
||||
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
|
||||
return "1536x2048" if ratio == "3:4" else "2048x2048"
|
||||
if provider == "pcore":
|
||||
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
|
||||
return "1024x1536" if ratio == "3:4" else "1024x1024"
|
||||
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
|
||||
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
|
||||
if model.startswith("wan2.6"):
|
||||
return "1152*1536" if ratio == "3:4" else "1440*1440"
|
||||
if ratio == "3:4":
|
||||
return "1536*2048" if is_wan else "768*1024"
|
||||
return "2048*2048" if is_wan else "1024*1024"
|
||||
|
||||
_DOUBAO_ANTI_AI = (
|
||||
"authentic real-world photography, natural imperfections, genuine texture, "
|
||||
"no synthetic look, no CGI quality, no heavy post-processing"
|
||||
)
|
||||
|
||||
DEFAULT_NEGATIVE_PROMPT = (
|
||||
"AI-generated look, artificial, CGI quality, 3D render, synthetic texture, "
|
||||
"plastic skin, mannequin-like, too perfect, oversaturated, HDR, heavy vignette, "
|
||||
"low resolution, blurry, deformed, bad anatomy, overexposed, underexposed, grainy, "
|
||||
"watermark, text distortion, bad typography, overlapping text, cheap look, cartoon"
|
||||
)
|
||||
|
||||
|
||||
# ── 参考图解析 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _bytes_to_data_uri(data: bytes, mime: str) -> str:
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
||||
|
||||
|
||||
async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]:
|
||||
"""参考图 URL → (bytes, mime)。本地 media 文件直读磁盘;远程 URL 带 Referer 下载。
|
||||
|
||||
生图 API 的服务器无法访问 127.0.0.1,代理 URL 也不能直接透传,
|
||||
所以统一在本地解析成原始字节再进请求体(data URI 或 multipart)。
|
||||
"""
|
||||
if url.startswith("data:"):
|
||||
head, _, b64 = url.partition(",")
|
||||
mime = head[5:].split(";", 1)[0] or "image/jpeg"
|
||||
return base64.b64decode(b64), mime
|
||||
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
|
||||
if url.startswith(("http://", "https://")):
|
||||
from api.proxy import guess_referer
|
||||
data, ctype = await download_bytes(url, referer=guess_referer(url))
|
||||
if not ctype.startswith("image/"):
|
||||
ctype = "image/jpeg"
|
||||
return data, ctype
|
||||
raise FileNotFoundError(f"无法解析参考图: {url}")
|
||||
|
||||
|
||||
async def _resolve_ref(url: str) -> str:
|
||||
data, mime = await _resolve_ref_bytes(url)
|
||||
return _bytes_to_data_uri(data, mime)
|
||||
|
||||
|
||||
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
|
||||
|
||||
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
||||
s = get_settings()
|
||||
if not s.ark_api_key:
|
||||
raise RuntimeError("未配置 ARK_API_KEY(.env)")
|
||||
body = {
|
||||
"model": model or s.ark_image_model,
|
||||
"prompt": prompt.rstrip(". ") + ". " + _DOUBAO_ANTI_AI,
|
||||
"size": size,
|
||||
"response_format": "url",
|
||||
"watermark": False,
|
||||
"n": 1,
|
||||
}
|
||||
if ref_images:
|
||||
body["image"] = [await _resolve_ref(u) for u in ref_images]
|
||||
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
|
||||
resp = await client.post(
|
||||
s.ark_base_url,
|
||||
headers={"Authorization": f"Bearer {s.ark_api_key}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
_raise_api_error(resp, "豆包")
|
||||
img_url = resp.json()["data"][0]["url"]
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
|
||||
|
||||
# ── Provider:通义万相 / 千问(DashScope)────────────────────────────────
|
||||
|
||||
def _is_wan_model(model: str) -> bool:
|
||||
return model.lower().startswith("wan")
|
||||
|
||||
|
||||
def _is_t2i_model(model: str) -> bool:
|
||||
"""纯文生图模型(如 wan2.6-t2i):不接受参考图,商品一致性只能靠文案描述。"""
|
||||
return "t2i" in model.lower()
|
||||
|
||||
|
||||
async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, max_wait: int) -> str:
|
||||
poll_url = "https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id
|
||||
elapsed, interval = 0, 3
|
||||
while elapsed < max_wait:
|
||||
resp = await client.get(poll_url, headers={"Authorization": f"Bearer {key}"}, timeout=30)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
status = result.get("output", {}).get("task_status", "")
|
||||
if status == "SUCCEEDED":
|
||||
choices = result["output"].get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", [])
|
||||
if content:
|
||||
return content[0].get("image", "")
|
||||
results = result["output"].get("results", [])
|
||||
if results:
|
||||
return results[0].get("url") or results[0].get("b64_image", "")
|
||||
raise RuntimeError(f"通义任务成功但无结果: {result}")
|
||||
if status in ("FAILED", "UNKNOWN"):
|
||||
raise RuntimeError(f"通义任务失败: {result}")
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
interval = min(interval + 2, 10)
|
||||
raise TimeoutError(f"通义异步任务超时 ({max_wait}s): task_id={task_id}")
|
||||
|
||||
|
||||
async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048", model: str | None = None) -> bytes:
|
||||
s = get_settings()
|
||||
if not s.dashscope_api_key:
|
||||
raise RuntimeError("未配置 DASHSCOPE_API_KEY(.env)")
|
||||
model = model or s.dashscope_model
|
||||
is_wan = _is_wan_model(model)
|
||||
url = s.dashscope_base_url or (
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"
|
||||
if is_wan
|
||||
else "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
)
|
||||
|
||||
# t2i 模型不接受参考图:content 只有文本,商品一致性依赖 prompt 里的标题/卖点描述
|
||||
content: list[dict] = []
|
||||
if not _is_t2i_model(model):
|
||||
content = [{"image": await _resolve_ref(u)} for u in ref_images]
|
||||
content.append({"text": prompt})
|
||||
|
||||
params = {"size": size, "n": 1, "watermark": False}
|
||||
if not is_wan:
|
||||
params["prompt_extend"] = False
|
||||
params["negative_prompt"] = DEFAULT_NEGATIVE_PROMPT[:500]
|
||||
|
||||
headers = {"Authorization": f"Bearer {s.dashscope_api_key}", "Content-Type": "application/json"}
|
||||
if is_wan:
|
||||
headers["X-DashScope-Async"] = "enable"
|
||||
|
||||
body = {"model": model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params}
|
||||
|
||||
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
|
||||
resp = await client.post(url, headers=headers, json=body)
|
||||
_raise_api_error(resp, "通义")
|
||||
data = resp.json()
|
||||
if is_wan:
|
||||
task_id = data.get("output", {}).get("task_id", "")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"通义万象未返回 task_id: {data}")
|
||||
img_url = await _tongyi_poll_task(client, s.dashscope_api_key, task_id, s.poll_max_wait)
|
||||
if img_url.startswith("data:") or len(img_url) > 500:
|
||||
return base64.b64decode(img_url.split(",", 1)[-1] if "," in img_url else img_url)
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
img_url = data["output"]["choices"][0]["message"]["content"][0]["image"]
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
|
||||
|
||||
# ── Provider:RightAPI(gpt-image / nano-banana,OpenAI 兼容中转)──────────
|
||||
|
||||
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429)
|
||||
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin: str,
|
||||
task_id: str, max_wait: int) -> dict:
|
||||
"""轮询站点级任务查询接口 GET /v1/tasks/{task_id}(不带 /draw 前缀)。
|
||||
|
||||
实测要点:
|
||||
- 完成响应**没有** status:"completed" 字段,完成判定 = 响应里出现 data;
|
||||
- progress 基本不动(0~2),不能当进度条依据;
|
||||
- 失败态 = status 为 failed / error / cancelled。
|
||||
"""
|
||||
poll_url = f"{origin}/v1/tasks/{task_id}"
|
||||
elapsed, interval = 0, 3
|
||||
while elapsed < max_wait:
|
||||
resp = await client.get(poll_url, headers=headers, timeout=30)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
result = resp.json()
|
||||
status = result.get("status", "")
|
||||
if status in ("failed", "error", "cancelled"):
|
||||
err = result.get("error") or {}
|
||||
raise RuntimeError(f"RightAPI 任务失败: {err.get('message') or result}")
|
||||
if "data" in result:
|
||||
return result
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
interval = min(interval + 2, 10)
|
||||
raise TimeoutError(f"RightAPI 异步任务超时 ({max_wait}s): task_id={task_id}")
|
||||
|
||||
|
||||
async def _rightapi_poll_task_once(client: httpx.AsyncClient, headers: dict, origin: str, task_id: str) -> dict | None:
|
||||
"""持久化 Worker 的单次查询版本;未完成返回 None,不占住 Worker 等待。"""
|
||||
response = await client.get(f"{origin}/v1/tasks/{task_id}", headers=headers, timeout=30)
|
||||
_raise_api_error(response, "RightAPI")
|
||||
result = response.json()
|
||||
status = result.get("status", "")
|
||||
if status in ("failed", "error", "cancelled"):
|
||||
error = result.get("error") or {}
|
||||
raise RuntimeError(f"RightAPI 任务失败: {error.get('message') or result}")
|
||||
return result if "data" in result else None
|
||||
|
||||
|
||||
def _rightapi_extract_image(result: dict) -> tuple[str | None, str | None]:
|
||||
"""从轮询完成结果里取 (kind, payload):kind ∈ url | b64,未取到返回 (None, None)。
|
||||
|
||||
完成形状为 Images 协议:{"created":..., "data":[{"url": "..."}]}(实测只见 url)。
|
||||
"""
|
||||
data = result.get("data") or []
|
||||
if data:
|
||||
item = data[0] or {}
|
||||
url = item.get("url") or ""
|
||||
if url:
|
||||
return ("url", url)
|
||||
b64 = item.get("b64_json") or ""
|
||||
if b64:
|
||||
return ("b64", b64)
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
|
||||
"""RightAPI 各模型:统一走 /v1/images/generations(异步)。
|
||||
|
||||
官方协议(2026-07 起统一异步):
|
||||
- POST /draw/v1/images/generations,请求体固定带 async:true,参考图放 image 数组(data-URI);
|
||||
- 返回 task_id 后轮询 GET /v1/tasks/{task_id}(站点级,不带 /draw);
|
||||
- 参数只有 model/prompt/n/size/imageSize/image/async;不传 quality/output_format/input_fidelity。
|
||||
参考图沿用现选图逻辑(≤2 张,image 数组)。单张 1-5 分钟,轮询上限 poll_max_wait 兜底。
|
||||
"""
|
||||
base = s.rightapi_base_url.rstrip("/")
|
||||
# 任务查询是站点级接口,不带 /draw:从 base 里拆出 origin(https://rightapi.ai/draw → https://rightapi.ai)
|
||||
origin = base.split("/draw", 1)[0].rstrip("/") or base
|
||||
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"async": True,
|
||||
}
|
||||
if ref_images:
|
||||
body["image"] = [await _resolve_ref(u) for u in ref_images]
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
submitted = resp.json()
|
||||
task_id = submitted.get("task_id") or ""
|
||||
if task_id:
|
||||
result = await _rightapi_poll_task(client, headers, origin, task_id, s.poll_max_wait)
|
||||
else:
|
||||
# 极端兜底:个别中转可能同步返回 data(文档不保证,但防御处理)
|
||||
result = submitted
|
||||
|
||||
kind, payload = _rightapi_extract_image(result)
|
||||
if kind == "b64" and payload:
|
||||
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
|
||||
if kind == "url" and payload:
|
||||
dl = await client.get(payload, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
raise RuntimeError(f"RightAPI 任务完成但没有图片数据: {result}")
|
||||
|
||||
|
||||
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
||||
"""带重试的 RightAPI 入口:429/5xx/超时按递增间隔重试。
|
||||
|
||||
实测该中转对同 key 连续请求有分钟级冷却(成功一张后紧接着的请求会被网关秒拒 502),
|
||||
60s → 120s → 240s 的退避基本能等到窗口放开。
|
||||
"""
|
||||
s = get_settings()
|
||||
if not s.rightapi_api_key:
|
||||
raise RuntimeError("未配置 RIGHTAPI_API_KEY(.env)")
|
||||
model = model or s.rightapi_image_model
|
||||
attempts = max(1, s.rightapi_max_retries)
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return await _rightapi_request(s, prompt, ref_images, size, model)
|
||||
except ApiError as exc:
|
||||
last_exc = exc
|
||||
if exc.status not in RETRYABLE_STATUS:
|
||||
raise # 参数错误等不可重试,立即失败
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
last_exc = exc # 网络抖动/超时可重试
|
||||
if i == attempts - 1:
|
||||
break
|
||||
wait = s.rightapi_retry_wait * (2 ** i)
|
||||
log.warning("RightAPI 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait)
|
||||
await asyncio.sleep(wait)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
|
||||
# ── Provider:Pcore(pcore.ai,OpenAI 兼容异步图片协议)──────────────────
|
||||
|
||||
async def _pcore_poll_task(client: httpx.AsyncClient, headers: dict, base: str,
|
||||
kind: str, task_id: str, max_wait: int) -> dict:
|
||||
"""轮询 Pcore 任务:GET {base}/v1/images/{kind}/{task_id},kind ∈ generations | edits。
|
||||
|
||||
状态机:queued / in_progress → 继续;completed / succeeded → 返回;
|
||||
failed / error / cancelled → 抛错。
|
||||
"""
|
||||
poll_url = f"{base}/v1/images/{kind}/{task_id}"
|
||||
elapsed, interval = 0, 3
|
||||
while elapsed < max_wait:
|
||||
resp = await client.get(poll_url, headers=headers, timeout=30)
|
||||
_raise_api_error(resp, "Pcore")
|
||||
result = resp.json()
|
||||
status = result.get("status", "")
|
||||
if status in ("failed", "error", "cancelled"):
|
||||
err = result.get("error") or {}
|
||||
raise RuntimeError(f"Pcore 任务失败: {err.get('message') or result}")
|
||||
if status in ("completed", "succeeded"):
|
||||
return result
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
interval = min(interval + 2, 10)
|
||||
raise TimeoutError(f"Pcore 异步任务超时 ({max_wait}s): task_id={task_id}")
|
||||
|
||||
|
||||
async def _pcore_poll_task_once(client: httpx.AsyncClient, headers: dict, base: str,
|
||||
kind: str, task_id: str) -> dict | None:
|
||||
"""持久化 Worker 的单次查询版本;未完成返回 None。"""
|
||||
response = await client.get(f"{base}/v1/images/{kind}/{task_id}", headers=headers, timeout=30)
|
||||
_raise_api_error(response, "Pcore")
|
||||
result = response.json()
|
||||
status = result.get("status", "")
|
||||
if status in ("failed", "error", "cancelled"):
|
||||
error = result.get("error") or {}
|
||||
raise RuntimeError(f"Pcore 任务失败: {error.get('message') or result}")
|
||||
return result if status in ("completed", "succeeded") else None
|
||||
|
||||
|
||||
async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
|
||||
"""Pcore:无参考图走 /v1/images/generations,带参考图走 /v1/images/edits(JSON,data-URI)。
|
||||
|
||||
两者均为异步:提交返回任务 id → 轮询对应资源路径 → 完成后 data[0].url 取图。
|
||||
完成形状与 RightAPI 同为 Images 协议,复用 _rightapi_extract_image。
|
||||
"""
|
||||
base = s.pcore_base_url.rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {s.pcore_api_key}"}
|
||||
|
||||
kind = "edits" if ref_images else "generations"
|
||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||
if ref_images:
|
||||
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required)
|
||||
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/{kind}",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
_raise_api_error(resp, "Pcore")
|
||||
submitted = resp.json()
|
||||
# new_api 系中转常见「HTTP 200 + 错误体」,需主动检查 error 字段
|
||||
if isinstance(submitted, dict) and submitted.get("error"):
|
||||
err = submitted["error"] or {}
|
||||
raise RuntimeError(f"Pcore 提交失败: {err.get('message') or submitted}")
|
||||
task_id = submitted.get("task_id") or submitted.get("id") or ""
|
||||
if not task_id:
|
||||
raise RuntimeError(f"Pcore 提交未返回任务 id: {submitted}")
|
||||
result = await _pcore_poll_task(client, headers, base, kind, task_id, s.poll_max_wait)
|
||||
|
||||
img_kind, payload = _rightapi_extract_image(result)
|
||||
if img_kind == "b64" and payload:
|
||||
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
|
||||
if img_kind == "url" and payload:
|
||||
dl = await client.get(payload, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
raise RuntimeError(f"Pcore 任务完成但没有图片数据: {result}")
|
||||
|
||||
|
||||
async def generate_pcore(prompt: str, ref_images: list[str], size: str = "1536x2048", model: str | None = None) -> bytes:
|
||||
"""带重试的 Pcore 入口:429/5xx/超时按递增间隔重试(与 RightAPI 同策略)。"""
|
||||
s = get_settings()
|
||||
if not s.pcore_api_key:
|
||||
raise RuntimeError("未配置 PCORE_API_KEY(.env)")
|
||||
model = model or "gpt-image-2"
|
||||
attempts = max(1, s.pcore_max_retries)
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return await _pcore_request(s, prompt, ref_images, size, model)
|
||||
except ApiError as exc:
|
||||
last_exc = exc
|
||||
if exc.status not in RETRYABLE_STATUS:
|
||||
raise
|
||||
# 通道不存在(模型未开通)重试无意义,立即失败
|
||||
if "no available channel" in str(exc).lower() or "model_not_found" in str(exc).lower():
|
||||
raise
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
last_exc = exc # 网络抖动/超时可重试
|
||||
if i == attempts - 1:
|
||||
break
|
||||
wait = s.pcore_retry_wait * (2 ** i)
|
||||
log.warning("Pcore 第 %d/%d 次请求失败(%s),%ds 后重试", i + 1, attempts, last_exc, wait)
|
||||
await asyncio.sleep(wait)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
|
||||
# ── 可恢复的逐步 provider 调用(持久化 Worker 使用)──────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationAdvance:
|
||||
"""一次 Worker 推进的结果:data 有值时已完成,否则持久化外部 task id 后稍后再查。"""
|
||||
|
||||
data: bytes | None = None
|
||||
provider_task_id: str | None = None
|
||||
provider_task_kind: str | None = None
|
||||
|
||||
|
||||
async def _download_provider_image(client: httpx.AsyncClient, result: dict, provider: str) -> bytes:
|
||||
kind, payload = _rightapi_extract_image(result)
|
||||
if kind == "b64" and payload:
|
||||
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
|
||||
if kind == "url" and payload:
|
||||
response = await client.get(payload, timeout=get_settings().request_timeout)
|
||||
_raise_api_error(response, provider)
|
||||
return response.content
|
||||
raise RuntimeError(f"{provider} 任务完成但没有图片数据: {result}")
|
||||
|
||||
|
||||
async def _poll_tongyi_once(client: httpx.AsyncClient, key: str, task_id: str) -> tuple[str, str | None]:
|
||||
response = await client.get(
|
||||
"https://dashscope.aliyuncs.com/api/v1/tasks/" + task_id,
|
||||
headers={"Authorization": f"Bearer {key}"}, timeout=30,
|
||||
)
|
||||
_raise_api_error(response, "通义")
|
||||
result = response.json()
|
||||
status = result.get("output", {}).get("task_status", "")
|
||||
if status in ("FAILED", "UNKNOWN"):
|
||||
raise RuntimeError(f"通义任务失败: {result}")
|
||||
if status != "SUCCEEDED":
|
||||
return "waiting", None
|
||||
choices = result.get("output", {}).get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", [])
|
||||
if content:
|
||||
return "done", content[0].get("image", "")
|
||||
results = result.get("output", {}).get("results", [])
|
||||
if results:
|
||||
return "done", results[0].get("url") or results[0].get("b64_image", "")
|
||||
raise RuntimeError(f"通义任务成功但无结果: {result}")
|
||||
|
||||
|
||||
async def advance_generation(
|
||||
provider: str,
|
||||
prompt: str,
|
||||
ref_images: list[str],
|
||||
*,
|
||||
size: str,
|
||||
model: str,
|
||||
provider_task_id: str | None = None,
|
||||
provider_task_kind: str | None = None,
|
||||
) -> GenerationAdvance:
|
||||
"""推进一次图片生成,不在本函数内无限轮询。
|
||||
|
||||
对异步供应商:首次仅提交并返回 task id;后续调用只轮询该 id。这样 Worker 重启后
|
||||
能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。
|
||||
"""
|
||||
settings = get_settings()
|
||||
if provider == "doubao":
|
||||
return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model))
|
||||
|
||||
if provider == "tongyi":
|
||||
if not settings.dashscope_api_key:
|
||||
raise RuntimeError("未配置 DASHSCOPE_API_KEY(.env)")
|
||||
is_wan = _is_wan_model(model)
|
||||
if not is_wan:
|
||||
return GenerationAdvance(data=await generate_tongyi(prompt, ref_images, size=size, model=model))
|
||||
headers = {"Authorization": f"Bearer {settings.dashscope_api_key}", "Content-Type": "application/json"}
|
||||
if provider_task_id:
|
||||
async with httpx.AsyncClient(timeout=60, verify=False) as client:
|
||||
state, payload = await _poll_tongyi_once(client, settings.dashscope_api_key, provider_task_id)
|
||||
if state == "waiting":
|
||||
return GenerationAdvance(provider_task_id=provider_task_id, provider_task_kind="tongyi_wan")
|
||||
if payload and (payload.startswith("data:") or len(payload) > 500):
|
||||
return GenerationAdvance(data=base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload))
|
||||
if not payload:
|
||||
raise RuntimeError("通义任务成功但图片地址为空")
|
||||
response = await client.get(payload, timeout=settings.request_timeout)
|
||||
_raise_api_error(response, "通义")
|
||||
return GenerationAdvance(data=response.content)
|
||||
url = settings.dashscope_base_url or "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"
|
||||
content: list[dict] = []
|
||||
if not _is_t2i_model(model):
|
||||
content = [{"image": await _resolve_ref(item)} for item in ref_images]
|
||||
content.append({"text": prompt})
|
||||
body = {
|
||||
"model": model,
|
||||
"input": {"messages": [{"role": "user", "content": content}]},
|
||||
"parameters": {"size": size, "n": 1, "watermark": False, "negative_prompt": DEFAULT_NEGATIVE_PROMPT[:500]},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=settings.request_timeout, verify=False) as client:
|
||||
response = await client.post(url, headers={**headers, "X-DashScope-Async": "enable"}, json=body)
|
||||
_raise_api_error(response, "通义")
|
||||
task_id = response.json().get("output", {}).get("task_id", "")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"通义万象未返回 task_id: {response.json()}")
|
||||
return GenerationAdvance(provider_task_id=task_id, provider_task_kind="tongyi_wan")
|
||||
|
||||
if provider == "rightapi":
|
||||
if not settings.rightapi_api_key:
|
||||
raise RuntimeError("未配置 RIGHTAPI_API_KEY(.env)")
|
||||
base = settings.rightapi_base_url.rstrip("/")
|
||||
origin = base.split("/draw", 1)[0].rstrip("/") or base
|
||||
headers = {"Authorization": f"Bearer {settings.rightapi_api_key}"}
|
||||
async with httpx.AsyncClient(timeout=max(settings.request_timeout, 600), verify=False) as client:
|
||||
if provider_task_id:
|
||||
result = await _rightapi_poll_task_once(client, headers, origin, provider_task_id)
|
||||
if result is None:
|
||||
return GenerationAdvance(provider_task_id=provider_task_id, provider_task_kind="rightapi")
|
||||
return GenerationAdvance(data=await _download_provider_image(client, result, "RightAPI"))
|
||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||
if ref_images:
|
||||
body["image"] = [await _resolve_ref(item) for item in ref_images]
|
||||
response = await client.post(
|
||||
f"{base}/v1/images/generations", headers={**headers, "Content-Type": "application/json"}, json=body,
|
||||
)
|
||||
_raise_api_error(response, "RightAPI")
|
||||
submitted = response.json()
|
||||
task_id = submitted.get("task_id") or ""
|
||||
if task_id:
|
||||
return GenerationAdvance(provider_task_id=task_id, provider_task_kind="rightapi")
|
||||
return GenerationAdvance(data=await _download_provider_image(client, submitted, "RightAPI"))
|
||||
|
||||
if provider == "pcore":
|
||||
if not settings.pcore_api_key:
|
||||
raise RuntimeError("未配置 PCORE_API_KEY(.env)")
|
||||
base = settings.pcore_base_url.rstrip("/")
|
||||
headers = {"Authorization": f"Bearer {settings.pcore_api_key}"}
|
||||
async with httpx.AsyncClient(timeout=max(settings.request_timeout, 600), verify=False) as client:
|
||||
if provider_task_id:
|
||||
kind = provider_task_kind or "edits"
|
||||
result = await _pcore_poll_task_once(client, headers, base, kind, provider_task_id)
|
||||
if result is None:
|
||||
return GenerationAdvance(provider_task_id=provider_task_id, provider_task_kind=kind)
|
||||
return GenerationAdvance(data=await _download_provider_image(client, result, "Pcore"))
|
||||
kind = "edits" if ref_images else "generations"
|
||||
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
|
||||
if ref_images:
|
||||
body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images]
|
||||
response = await client.post(
|
||||
f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body,
|
||||
)
|
||||
_raise_api_error(response, "Pcore")
|
||||
submitted = response.json()
|
||||
if isinstance(submitted, dict) and submitted.get("error"):
|
||||
error = submitted["error"] or {}
|
||||
raise RuntimeError(f"Pcore 提交失败: {error.get('message') or submitted}")
|
||||
task_id = submitted.get("task_id") or submitted.get("id") or ""
|
||||
if not task_id:
|
||||
raise RuntimeError(f"Pcore 提交未返回任务 id: {submitted}")
|
||||
return GenerationAdvance(provider_task_id=task_id, provider_task_kind=kind)
|
||||
|
||||
raise RuntimeError(f"未知 provider: {provider}")
|
||||
|
||||
|
||||
GENERATORS = {
|
||||
"doubao": generate_doubao,
|
||||
"tongyi": generate_tongyi,
|
||||
"rightapi": generate_rightapi,
|
||||
"pcore": generate_pcore,
|
||||
}
|
||||
|
||||
|
||||
# ── 任务执行器 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _order_refs(refs: list[str], type_id: str) -> list[str]:
|
||||
"""参考图槽位选择 + 截断:material 偏好第 2 张,其余用第 1 张。"""
|
||||
preferred = TYPE_REF_INDEX.get(type_id)
|
||||
if preferred is not None and len(refs) > preferred:
|
||||
refs = [refs[preferred]] + [r for i, r in enumerate(refs) if i != preferred]
|
||||
return refs[:DEFAULT_REF_COUNT]
|
||||
|
||||
|
||||
def _refs_for_job(images: list[dict], job: dict) -> list[str]:
|
||||
"""无状态路径:按方案项选参考图。
|
||||
|
||||
优先 variant_name 精确匹配(「主图·粉色」用粉色那张 SKU 图);
|
||||
匹配不到则回退 main 组第一张(再退到任意第一张)。
|
||||
"""
|
||||
variant = job.get("variant_name")
|
||||
if variant:
|
||||
matched = [i["url"] for i in images if i.get("variant_name") == variant]
|
||||
if matched:
|
||||
return matched[:DEFAULT_REF_COUNT]
|
||||
mains = [i["url"] for i in images if i.get("group_key") == "main"]
|
||||
others = [i["url"] for i in images if i.get("group_key") != "main"]
|
||||
pool = mains or others or [i["url"] for i in images]
|
||||
if not pool:
|
||||
raise RuntimeError("任务没有参考图")
|
||||
return _order_refs(pool, job.get("kind", ""))
|
||||
|
||||
|
||||
# 串行生成队列:所有用户共享同一批 API key,并发生成会触发中转限流
|
||||
# (rightapi 同 key 分钟级冷却);同一时间只跑一个任务,其余保持 pending 排队。
|
||||
_GEN_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
async def _store_image_bytes(task: Task, index: int, data: bytes, version: str = "result") -> str:
|
||||
"""生成图字节 → 存储落盘,返回可访问 URL。按 PNG 魔数定扩展名(部分中转不遵守 output_format)。"""
|
||||
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
ext = ".png" if is_png else ".jpg"
|
||||
ctype = "image/png" if is_png else "image/jpeg"
|
||||
return await get_storage().save_bytes(data, f"suites/{task.id}/{version}/{index}{ext}", ctype)
|
||||
|
||||
|
||||
async def run_suite(task: Task, on_image_ok: OnImageOk | None = None) -> None:
|
||||
"""后台执行套图任务:排队 → 逐张生成 → 落盘 → 更新内存状态;单张失败不中断。
|
||||
|
||||
on_image_ok:每张成功落盘后的回调(API 层用于实时回写 product_assets/generated),
|
||||
回调异常只记日志,不影响任务本身。
|
||||
"""
|
||||
settings = get_settings()
|
||||
provider_name = task.provider or settings.image_provider
|
||||
generator = GENERATORS.get(provider_name)
|
||||
if generator is None:
|
||||
task.status = TASK_FAILED
|
||||
task.error = f"未知 provider: {provider_name}"
|
||||
return
|
||||
|
||||
ctx = build_context(task.context or {}, fallback_name="")
|
||||
model = task.model or {
|
||||
"tongyi": settings.dashscope_model,
|
||||
"rightapi": settings.rightapi_image_model,
|
||||
}.get(provider_name, settings.ark_image_model)
|
||||
is_wan = provider_name == "tongyi" and _is_wan_model(model)
|
||||
size = _image_size(provider_name, task.ratio, is_wan=is_wan, model=model)
|
||||
jobs = [dict(j) for j in task.plan]
|
||||
|
||||
async with _GEN_LOCK:
|
||||
task.status = TASK_RUNNING
|
||||
ok, failed = 0, 0
|
||||
failures: list[str] = []
|
||||
for job in jobs:
|
||||
type_id = job["kind"]
|
||||
image = TaskImage(type_id=type_id, name=job.get("title") or type_name(type_id))
|
||||
task.images.append(image)
|
||||
try:
|
||||
# 提示词按模型家族分发:国产主体参考 / gpt edits 保真 / google 主体保持
|
||||
# 单张 AI 编辑可传入 prompt_extra:任务展示名保留「AI 生图」,
|
||||
# 但不把中文展示名拼进模型 prompt,避免生成图内出现中文。
|
||||
prompt_extra = job.get("prompt_extra") or job
|
||||
prompt = build_prompt(
|
||||
provider_name, model, type_id, ctx, task.style_set, task.lang,
|
||||
extra=prompt_extra, style_prompt=task.style_prompt, requirements=task.requirements,
|
||||
)
|
||||
refs = _refs_for_job(list(task.ref_images or []), job)
|
||||
data = await generator(prompt, refs, size=size, model=model)
|
||||
# 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图)
|
||||
wm = task.watermark or {}
|
||||
if wm.get("enabled"):
|
||||
image.source_url = await _store_image_bytes(task, len(task.images), data, version="original")
|
||||
data = apply_watermark(data, wm)
|
||||
image.url = await _store_image_bytes(task, len(task.images), data, version="watermarked")
|
||||
image.watermark_config = wm
|
||||
else:
|
||||
image.url = await _store_image_bytes(task, len(task.images), data)
|
||||
image.status = IMG_OK
|
||||
ok += 1
|
||||
if on_image_ok is not None and image.url:
|
||||
try:
|
||||
await on_image_ok(image)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("套图 %s 第 %d 张回写商品素材失败", task.id, len(task.images))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("套图 %s 类型 %s 生成失败", task.id, type_id)
|
||||
image.status = IMG_FAILED # 默认 pending,失败显式置 failed
|
||||
image.error = str(exc)[:500]
|
||||
failures.append(f"{job.get('title') or type_name(type_id)}:{str(exc)[:200]}")
|
||||
failed += 1
|
||||
|
||||
task.status = TASK_DONE if failed == 0 else (TASK_PARTIAL if ok > 0 else TASK_FAILED)
|
||||
if failed:
|
||||
uniq = list(dict.fromkeys(failures)) # 去重保序
|
||||
detail = ";".join(uniq[:6])
|
||||
if len(uniq) > 6:
|
||||
detail += f";…等共 {failed} 张失败"
|
||||
if ok == 0:
|
||||
task.error = f"全部生成失败。{detail}"
|
||||
else:
|
||||
task.error = f"部分生成失败({failed} 张)。{detail}"
|
||||
@@ -0,0 +1,193 @@
|
||||
"""商品图片生命周期:原图、当前水印版与存储回收的唯一入口。
|
||||
|
||||
每个图片素材只维护两个逻辑版本:original(无水印)与 current(页面展示/导出)。
|
||||
未加水印时两者指向同一个对象;重加水印会先上传新 current,再切换引用并删除旧 current,
|
||||
不保留历史水印版。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from api.proxy import guess_referer
|
||||
from config import get_settings
|
||||
from models import ProductAsset
|
||||
from services.storage import (
|
||||
download_bytes,
|
||||
get_storage,
|
||||
image_extension,
|
||||
local_path,
|
||||
normalize_image_bytes,
|
||||
read_local_bytes,
|
||||
storage_key_from_url,
|
||||
)
|
||||
from services.watermark import apply_watermark
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatermarkUpdate:
|
||||
"""水印切换后的旧对象;数据库提交成功后再回收。"""
|
||||
|
||||
obsolete_key: str | None = None
|
||||
|
||||
|
||||
class ImageAssetService:
|
||||
"""将 ProductAsset 与七牛/本地对象存储的生命周期收敛到同一服务。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.storage = get_storage()
|
||||
|
||||
@staticmethod
|
||||
def asset_prefix(asset: ProductAsset) -> str:
|
||||
return f"products/{asset.product_id}/assets/{asset.id}/"
|
||||
|
||||
@staticmethod
|
||||
def product_prefix(product_id: str) -> str:
|
||||
return f"products/{product_id}/"
|
||||
|
||||
def _key(self, asset: ProductAsset, name: str, data: bytes, content_type: str) -> str:
|
||||
return f"{self.asset_prefix(asset)}{name}{image_extension(data, content_type)}"
|
||||
|
||||
async def _read(self, url: str) -> tuple[bytes, str]:
|
||||
path = local_path(url)
|
||||
if path is not None:
|
||||
return read_local_bytes(path), mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return await download_bytes(url, referer=guess_referer(url))
|
||||
|
||||
async def save_original(
|
||||
self,
|
||||
asset: ProductAsset,
|
||||
data: bytes,
|
||||
content_type: str = "",
|
||||
max_bytes: int | None = None,
|
||||
) -> str:
|
||||
"""写入该素材唯一的无水印原图,并将 current 同步到原图。"""
|
||||
if asset.type == "img":
|
||||
normalized = normalize_image_bytes(data, max_bytes=max_bytes)
|
||||
data = normalized.data
|
||||
content_type = normalized.content_type
|
||||
asset.width = normalized.width
|
||||
asset.height = normalized.height
|
||||
key = self._key(asset, "original", data, content_type)
|
||||
url = await self.storage.save_bytes(data, key, content_type)
|
||||
asset.original_url = url
|
||||
asset.original_storage_key = key
|
||||
asset.stored_url = url
|
||||
asset.current_storage_key = key
|
||||
asset.watermark_enabled = False
|
||||
asset.watermark_config = None
|
||||
return url
|
||||
|
||||
async def save_original_from_url(self, asset: ProductAsset, url: str) -> str:
|
||||
data, content_type = await self._read(url)
|
||||
return await self.save_original(
|
||||
asset,
|
||||
data,
|
||||
content_type,
|
||||
max_bytes=get_settings().image_download_max_bytes,
|
||||
)
|
||||
|
||||
async def ensure_original(self, asset: ProductAsset) -> tuple[bytes, str]:
|
||||
"""为旧记录补齐原图字段;首次触碰旧素材时迁入新的商品/素材路径。"""
|
||||
original_url = asset.original_url or asset.stored_url or asset.source_url
|
||||
if not original_url:
|
||||
raise ValueError("图片原图地址为空")
|
||||
|
||||
# 新格式已在当前素材路径中,无需复制。
|
||||
if asset.original_storage_key and asset.original_url:
|
||||
return await self._read(asset.original_url)
|
||||
|
||||
data, content_type = await self._read(original_url)
|
||||
await self.save_original(asset, data, content_type)
|
||||
return data, content_type
|
||||
|
||||
async def apply_watermark(self, asset: ProductAsset, opts: dict) -> WatermarkUpdate:
|
||||
"""从 original 合成新水印版,调用方提交数据库后再删除返回的旧水印对象。"""
|
||||
previous_current_key = asset.current_storage_key or storage_key_from_url(asset.stored_url or "")
|
||||
data, content_type = await self.ensure_original(asset)
|
||||
watermarked = apply_watermark(data, opts)
|
||||
key = self._key(asset, f"watermark-{uuid.uuid4().hex}", watermarked, content_type)
|
||||
url = await self.storage.save_bytes(watermarked, key, content_type)
|
||||
|
||||
obsolete_key = previous_current_key
|
||||
# 未加水印时 current 就是 original,绝不能作为旧水印删除。
|
||||
if obsolete_key == asset.original_storage_key:
|
||||
obsolete_key = None
|
||||
|
||||
asset.stored_url = url
|
||||
asset.current_storage_key = key
|
||||
asset.watermark_enabled = True
|
||||
asset.watermark_config = opts
|
||||
return WatermarkUpdate(obsolete_key=obsolete_key)
|
||||
|
||||
async def adopt_generated_versions(
|
||||
self,
|
||||
asset: ProductAsset,
|
||||
original_url: str,
|
||||
current_url: str,
|
||||
watermark_config: dict | None = None,
|
||||
) -> None:
|
||||
"""将任务临时输出迁入正式的商品/素材路径,不让 suites 临时对象长期残留。"""
|
||||
await self.save_original_from_url(asset, original_url)
|
||||
if current_url == original_url:
|
||||
return
|
||||
data, content_type = await self._read(current_url)
|
||||
key = self._key(asset, f"watermark-{uuid.uuid4().hex}", data, content_type)
|
||||
asset.stored_url = await self.storage.save_bytes(data, key, content_type)
|
||||
asset.current_storage_key = key
|
||||
asset.watermark_enabled = True
|
||||
asset.watermark_config = watermark_config or {}
|
||||
|
||||
async def remove_watermark(self, asset: ProductAsset) -> WatermarkUpdate:
|
||||
"""切回无水印原图,调用方提交数据库后再删除旧水印对象。"""
|
||||
previous_current_key = asset.current_storage_key or storage_key_from_url(asset.stored_url or "")
|
||||
await self.ensure_original(asset)
|
||||
obsolete_key = previous_current_key
|
||||
asset.stored_url = asset.original_url
|
||||
asset.current_storage_key = asset.original_storage_key
|
||||
asset.watermark_enabled = False
|
||||
asset.watermark_config = None
|
||||
if obsolete_key == asset.original_storage_key:
|
||||
obsolete_key = None
|
||||
return WatermarkUpdate(obsolete_key=obsolete_key)
|
||||
|
||||
async def delete_key(self, key: str | None) -> None:
|
||||
if key:
|
||||
await self.storage.delete(key)
|
||||
|
||||
async def delete_temporary_urls(self, urls: list[str]) -> None:
|
||||
"""只回收 suites/ 临时输出,绝不删除作为参考图传入的正式商品素材。"""
|
||||
for url in set(urls):
|
||||
key = storage_key_from_url(url)
|
||||
if key and key.startswith("suites/"):
|
||||
await self.delete_key(key)
|
||||
|
||||
async def delete_asset_media(self, asset: ProductAsset) -> int:
|
||||
"""删除单个素材的当前格式目录,并兼容清理由旧数据留下的路径外对象。"""
|
||||
explicit_keys = {
|
||||
key for key in (
|
||||
asset.original_storage_key,
|
||||
asset.current_storage_key,
|
||||
storage_key_from_url(asset.original_url or ""),
|
||||
storage_key_from_url(asset.stored_url or ""),
|
||||
) if key
|
||||
}
|
||||
deleted = await self.storage.delete_prefix(self.asset_prefix(asset))
|
||||
for key in explicit_keys:
|
||||
if not key.startswith(self.asset_prefix(asset)):
|
||||
await self.delete_key(key)
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
async def delete_product_media(self, product_id: str, assets: list[ProductAsset]) -> int:
|
||||
"""删除商品下所有素材;先清理旧 key,再按商品前缀兜底清理孤儿对象。"""
|
||||
deleted = 0
|
||||
for asset in assets:
|
||||
deleted += await self.delete_asset_media(asset)
|
||||
deleted += await self.storage.delete_prefix(self.product_prefix(product_id))
|
||||
return deleted
|
||||
|
||||
|
||||
def get_image_asset_service() -> ImageAssetService:
|
||||
return ImageAssetService()
|
||||
@@ -0,0 +1,259 @@
|
||||
"""独立后台 Worker:按子项粒度执行持久化图片任务。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import uuid
|
||||
|
||||
from config import get_settings
|
||||
from db import get_session_factory
|
||||
from models import ProductAsset
|
||||
from services.image_assets import get_image_asset_service
|
||||
from services.generator import _image_size, _refs_for_job, advance_generation
|
||||
from services.jobs import (
|
||||
ClaimedItem,
|
||||
claim_next_item,
|
||||
job_can_write,
|
||||
mark_item_cancelled,
|
||||
mark_item_done,
|
||||
mark_item_failed,
|
||||
mark_waiting_provider,
|
||||
recover_expired_leases,
|
||||
)
|
||||
from services.prompts import build_context, build_prompt
|
||||
from services.storage import get_storage
|
||||
from services.suite_service import append_generated_asset
|
||||
from services.tasks import TaskImage
|
||||
from services.watermark import apply_watermark
|
||||
from services.video_renderer import VideoRenderCancelled, render_slideshow
|
||||
|
||||
log = logging.getLogger("background.worker")
|
||||
|
||||
|
||||
def _worker_owner() -> str:
|
||||
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
|
||||
|
||||
|
||||
def _image_meta(data: bytes) -> tuple[str, str]:
|
||||
return (".png", "image/png") if data[:8] == b"\x89PNG\r\n\x1a\n" else (".jpg", "image/jpeg")
|
||||
|
||||
|
||||
async def _store_image(job_id: str, position: int, data: bytes, version: str) -> str:
|
||||
suffix, content_type = _image_meta(data)
|
||||
return await get_storage().save_bytes(data, f"suites/{job_id}/{version}/{position}{suffix}", content_type)
|
||||
|
||||
|
||||
async def _cleanup_temporary(urls: list[str]) -> None:
|
||||
"""append 成功后由 image_assets 搬运并清理;取消时主动清理本次临时文件。"""
|
||||
if not urls:
|
||||
return
|
||||
try:
|
||||
from services.image_assets import get_image_asset_service
|
||||
await get_image_asset_service().delete_temporary_urls(urls)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("清理取消任务的临时图片失败")
|
||||
|
||||
|
||||
async def _process_watermark_item(claim: ClaimedItem) -> None:
|
||||
"""批量水印的一个素材子项:基于 original 重做 current,提交后回收旧水印对象。"""
|
||||
if not await job_can_write(claim.job_id):
|
||||
await mark_item_cancelled(claim.item_id)
|
||||
return
|
||||
payload = claim.item_payload
|
||||
raw_asset_id = payload.get("asset_id")
|
||||
try:
|
||||
asset_id = uuid.UUID(str(raw_asset_id))
|
||||
except (TypeError, ValueError) as exc:
|
||||
await mark_item_failed(claim.item_id, "水印任务缺少合法素材 ID")
|
||||
return
|
||||
|
||||
obsolete_key: str | None = None
|
||||
result_url = ""
|
||||
source_url = ""
|
||||
watermark_config: dict | None = None
|
||||
try:
|
||||
async with get_session_factory()() as db:
|
||||
asset = await db.get(ProductAsset, asset_id)
|
||||
if asset is None:
|
||||
raise RuntimeError("素材已删除")
|
||||
if asset.type != "img":
|
||||
raise RuntimeError("仅支持图片素材")
|
||||
|
||||
expected_key = payload.get("expected_current_storage_key")
|
||||
expected_url = payload.get("expected_current_url")
|
||||
if expected_key and asset.current_storage_key != expected_key:
|
||||
raise RuntimeError("素材在等待期间已被其他操作更新,请重新提交水印任务")
|
||||
if not expected_key and expected_url and asset.stored_url != expected_url:
|
||||
raise RuntimeError("素材在等待期间已被其他操作更新,请重新提交水印任务")
|
||||
|
||||
watermark = claim.request_payload.get("watermark") or {}
|
||||
if not watermark.get("enabled"):
|
||||
raise RuntimeError("水印设置已关闭")
|
||||
image_assets = get_image_asset_service()
|
||||
update = await image_assets.apply_watermark(asset, watermark)
|
||||
asset.error = None
|
||||
result_url = asset.stored_url or ""
|
||||
source_url = asset.original_url or ""
|
||||
watermark_config = asset.watermark_config
|
||||
obsolete_key = update.obsolete_key
|
||||
await db.commit()
|
||||
|
||||
# 数据库已切换到新版本后再删旧对象;删除失败不影响用户可用的新图片。
|
||||
if obsolete_key:
|
||||
try:
|
||||
await get_image_asset_service().delete_key(obsolete_key)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("水印已切换,但旧水印对象删除失败:%s", obsolete_key)
|
||||
await mark_item_done(
|
||||
claim.item_id, result_url=result_url, source_url=source_url or None,
|
||||
watermark_config=watermark_config, generated_asset_id=None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("后台水印任务 %s/%s 执行失败", claim.job_id, claim.item_id)
|
||||
await mark_item_failed(claim.item_id, str(exc))
|
||||
|
||||
|
||||
async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
|
||||
"""一个视频任务只有一个子项;输入 URL 在提交时冻结,避免后续选择状态影响成片。"""
|
||||
payload = claim.request_payload
|
||||
sources = list(payload.get("sources") or [])
|
||||
source_urls = [str(source.get("url") or "") for source in sources]
|
||||
if not 2 <= len(source_urls) <= 5 or any(not url for url in source_urls):
|
||||
await mark_item_failed(claim.item_id, "视频任务缺少 2–5 张可用图片")
|
||||
return
|
||||
try:
|
||||
async def should_cancel() -> bool:
|
||||
return not await job_can_write(claim.job_id)
|
||||
|
||||
rendered = await render_slideshow(source_urls, should_cancel=should_cancel)
|
||||
if not await job_can_write(claim.job_id):
|
||||
await mark_item_cancelled(claim.item_id)
|
||||
return
|
||||
key = f"products/{claim.product_id}/videos/{claim.job_id}/result.mp4"
|
||||
result_url = await get_storage().save_bytes(rendered.data, key, "video/mp4")
|
||||
async with get_session_factory()() as db:
|
||||
from models import BackgroundJobItem
|
||||
item = await db.get(BackgroundJobItem, uuid.UUID(claim.item_id))
|
||||
if item is not None:
|
||||
item.payload = {
|
||||
**dict(item.payload),
|
||||
"duration_seconds": rendered.duration_seconds,
|
||||
"width": rendered.width,
|
||||
"height": rendered.height,
|
||||
"file_size_bytes": len(rendered.data),
|
||||
}
|
||||
await db.commit()
|
||||
await mark_item_done(claim.item_id, result_url=result_url, source_url=None, watermark_config=None, generated_asset_id=None)
|
||||
except VideoRenderCancelled:
|
||||
await mark_item_cancelled(claim.item_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("后台轮播视频任务 %s/%s 执行失败", claim.job_id, claim.item_id)
|
||||
await mark_item_failed(claim.item_id, str(exc))
|
||||
|
||||
|
||||
async def process_item(claim: ClaimedItem) -> None:
|
||||
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
|
||||
if claim.job_kind == "watermark_batch":
|
||||
await _process_watermark_item(claim)
|
||||
return
|
||||
if claim.job_kind == "slideshow_video":
|
||||
await _process_slideshow_video_item(claim)
|
||||
return
|
||||
if claim.job_kind not in {"image_suite", "image_edit"}:
|
||||
await mark_item_failed(claim.item_id, f"Worker 暂不支持任务类型: {claim.job_kind}")
|
||||
return
|
||||
|
||||
settings = get_settings()
|
||||
payload = claim.request_payload
|
||||
job_item = claim.item_payload
|
||||
provider = claim.provider or settings.image_provider
|
||||
model = claim.model or {
|
||||
"tongyi": settings.dashscope_model,
|
||||
"rightapi": settings.rightapi_image_model,
|
||||
"pcore": "gpt-image-2",
|
||||
}.get(provider, settings.ark_image_model)
|
||||
try:
|
||||
context = build_context(payload.get("context") or {}, fallback_name="")
|
||||
prompt_extra = job_item.get("prompt_extra") or job_item
|
||||
prompt = build_prompt(
|
||||
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
|
||||
extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
|
||||
)
|
||||
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item)
|
||||
is_wan = provider == "tongyi" and model.lower().startswith("wan")
|
||||
result = await advance_generation(
|
||||
provider, prompt, refs,
|
||||
size=_image_size(provider, claim.ratio, is_wan=is_wan, model=model), model=model,
|
||||
provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind,
|
||||
)
|
||||
if result.data is None:
|
||||
if not result.provider_task_id or not result.provider_task_kind:
|
||||
raise RuntimeError("异步生成未返回可恢复的 provider task id")
|
||||
# 轮询供应商期间用户可能刚好取消任务。此时不能再写回
|
||||
# waiting_provider,否则父任务已取消、子项却会永久显示“进行中”。
|
||||
if not await job_can_write(claim.job_id):
|
||||
await mark_item_cancelled(claim.item_id)
|
||||
return
|
||||
await mark_waiting_provider(claim.item_id, result.provider_task_id, result.provider_task_kind)
|
||||
return
|
||||
|
||||
# 生成完成后仍检查取消/商品删除,避免在用户删除商品后把素材回写回来。
|
||||
if not await job_can_write(claim.job_id):
|
||||
await mark_item_cancelled(claim.item_id)
|
||||
return
|
||||
|
||||
watermark = payload.get("watermark") or {}
|
||||
position = int(job_item.get("position", 0))
|
||||
if watermark.get("enabled"):
|
||||
source_url = await _store_image(claim.job_id, position, result.data, "original")
|
||||
result_url = await _store_image(claim.job_id, position, apply_watermark(result.data, watermark), "watermarked")
|
||||
watermark_config = watermark
|
||||
else:
|
||||
source_url = ""
|
||||
result_url = await _store_image(claim.job_id, position, result.data, "result")
|
||||
watermark_config = None
|
||||
|
||||
asset_id: str | None = None
|
||||
if payload.get("append") and claim.product_id:
|
||||
image = TaskImage(
|
||||
type_id=job_item.get("kind", "custom"),
|
||||
name=job_item.get("title") or "AI 生图",
|
||||
url=result_url,
|
||||
source_url=source_url,
|
||||
watermark_config=watermark_config,
|
||||
)
|
||||
try:
|
||||
asset_id = await append_generated_asset(
|
||||
claim.product_id, image, after_asset_id=payload.get("after_asset_id"),
|
||||
)
|
||||
except Exception:
|
||||
# 回写失败时保留 suites 临时文件和失败状态,避免用户看不到实际结果而又无法诊断。
|
||||
raise
|
||||
result_url, source_url, watermark_config = image.url, image.source_url, image.watermark_config
|
||||
|
||||
await mark_item_done(
|
||||
claim.item_id, result_url=result_url, source_url=source_url or None,
|
||||
watermark_config=watermark_config, generated_asset_id=asset_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
|
||||
await mark_item_failed(claim.item_id, str(exc))
|
||||
|
||||
|
||||
async def run_worker(*, once: bool = False, idle_seconds: float = 0.75) -> None:
|
||||
owner = _worker_owner()
|
||||
recovered = await recover_expired_leases()
|
||||
if recovered:
|
||||
log.warning("恢复了 %d 个过期后台任务租约", recovered)
|
||||
log.info("后台 Worker 已启动:%s", owner)
|
||||
while True:
|
||||
claim = await claim_next_item(owner, lease_seconds=get_settings().background_job_lease_seconds)
|
||||
if claim is None:
|
||||
if once:
|
||||
return
|
||||
await asyncio.sleep(idle_seconds)
|
||||
continue
|
||||
await process_item(claim)
|
||||
if once:
|
||||
return
|
||||
@@ -0,0 +1,522 @@
|
||||
"""数据库驱动的后台任务仓库与调度状态机。
|
||||
|
||||
已承载图片生成与批量水印;表结构刻意通用,后续可复用到 ZIP 导出等耗时操作。
|
||||
Worker 不依赖 FastAPI 进程;第三方 provider task id 保存在子项中,重启后继续轮询。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import case, func, or_, select, update
|
||||
|
||||
from db import get_session_factory
|
||||
from models import BackgroundJob, BackgroundJobItem
|
||||
|
||||
JOB_QUEUED = "queued"
|
||||
JOB_RUNNING = "running"
|
||||
JOB_DONE = "done"
|
||||
JOB_PARTIAL = "partial"
|
||||
JOB_FAILED = "failed"
|
||||
JOB_CANCELLED = "cancelled"
|
||||
|
||||
ITEM_QUEUED = "queued"
|
||||
ITEM_RUNNING = "running"
|
||||
ITEM_WAITING_PROVIDER = "waiting_provider"
|
||||
ITEM_DONE = "done"
|
||||
ITEM_FAILED = "failed"
|
||||
ITEM_CANCELLED = "cancelled"
|
||||
|
||||
TERMINAL_ITEM_STATUSES = (ITEM_DONE, ITEM_FAILED, ITEM_CANCELLED)
|
||||
TERMINAL_JOB_STATUSES = (JOB_DONE, JOB_PARTIAL, JOB_FAILED, JOB_CANCELLED)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimedItem:
|
||||
job_id: str
|
||||
item_id: str
|
||||
job_kind: str
|
||||
product_id: str | None
|
||||
provider: str
|
||||
model: str | None
|
||||
platform: str
|
||||
lang: str
|
||||
ratio: str
|
||||
style_set: int
|
||||
request_payload: dict[str, Any]
|
||||
item_payload: dict[str, Any]
|
||||
provider_task_id: str | None
|
||||
provider_task_kind: str | None
|
||||
|
||||
|
||||
async def create_image_job(
|
||||
*,
|
||||
product_id: str | None,
|
||||
kind: str,
|
||||
priority: int,
|
||||
provider: str,
|
||||
model: str | None,
|
||||
platform: str,
|
||||
lang: str,
|
||||
ratio: str,
|
||||
style_set: int,
|
||||
request_payload: dict[str, Any],
|
||||
item_payloads: list[dict[str, Any]],
|
||||
) -> BackgroundJob:
|
||||
"""一次事务写入父任务和逐图子项,API 成功返回即意味着任务可由 Worker 恢复。"""
|
||||
parsed_product_id = uuid.UUID(product_id) if product_id else None
|
||||
job = BackgroundJob(
|
||||
product_id=parsed_product_id,
|
||||
kind=kind,
|
||||
status=JOB_QUEUED,
|
||||
priority=priority,
|
||||
provider=provider,
|
||||
model=model,
|
||||
platform=platform,
|
||||
lang=lang,
|
||||
ratio=ratio,
|
||||
style_set=style_set,
|
||||
request_payload=request_payload,
|
||||
total=len(item_payloads),
|
||||
)
|
||||
async with get_session_factory()() as db:
|
||||
db.add(job)
|
||||
await db.flush()
|
||||
db.add_all([
|
||||
BackgroundJobItem(job_id=job.id, position=index, payload=payload, status=ITEM_QUEUED)
|
||||
for index, payload in enumerate(item_payloads)
|
||||
])
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
async def _refresh_job_status(db, job: BackgroundJob) -> None:
|
||||
"""从子项计算父任务进度与终态;调用方已在同一事务中修改子项。"""
|
||||
rows = (await db.execute(
|
||||
select(BackgroundJobItem.status, func.count(BackgroundJobItem.id))
|
||||
.where(BackgroundJobItem.job_id == job.id)
|
||||
.group_by(BackgroundJobItem.status)
|
||||
)).all()
|
||||
counts = {status: int(count) for status, count in rows}
|
||||
done = counts.get(ITEM_DONE, 0)
|
||||
failed = counts.get(ITEM_FAILED, 0)
|
||||
cancelled = counts.get(ITEM_CANCELLED, 0)
|
||||
terminal = done + failed + cancelled
|
||||
job.completed = done
|
||||
job.failed = failed
|
||||
|
||||
if terminal < job.total:
|
||||
job.status = JOB_RUNNING
|
||||
return
|
||||
job.completed_at = utcnow()
|
||||
if done == 0 and cancelled == job.total:
|
||||
job.status = JOB_CANCELLED
|
||||
elif failed == 0 and cancelled == 0:
|
||||
job.status = JOB_DONE
|
||||
elif done > 0:
|
||||
job.status = JOB_PARTIAL
|
||||
elif job.cancel_requested and cancelled > 0:
|
||||
job.status = JOB_CANCELLED
|
||||
else:
|
||||
job.status = JOB_FAILED
|
||||
|
||||
errors = (await db.scalars(
|
||||
select(BackgroundJobItem.error)
|
||||
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status == ITEM_FAILED)
|
||||
.order_by(BackgroundJobItem.position)
|
||||
)).all()
|
||||
if errors:
|
||||
brief = [str(error)[:200] for error in errors if error]
|
||||
job.error = ";".join(brief[:6]) or "任务失败"
|
||||
|
||||
|
||||
async def recover_expired_leases() -> int:
|
||||
"""把上个 Worker 遗留的工作单元重新放回队列;provider id 不会被清空。"""
|
||||
now = utcnow()
|
||||
async with get_session_factory()() as db:
|
||||
result = await db.execute(
|
||||
update(BackgroundJobItem)
|
||||
.where(
|
||||
BackgroundJobItem.status == ITEM_RUNNING,
|
||||
BackgroundJobItem.lease_expires_at.is_not(None),
|
||||
BackgroundJobItem.lease_expires_at < now,
|
||||
)
|
||||
.values(status=ITEM_QUEUED, lease_owner=None, lease_expires_at=None, next_run_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def claim_next_item(owner: str, lease_seconds: int = 90) -> ClaimedItem | None:
|
||||
"""领取一个到期子项。单机 SQLite Worker 使用租约,未来迁移 PostgreSQL 后可直接强化为 SKIP LOCKED。"""
|
||||
now = utcnow()
|
||||
async with get_session_factory()() as db:
|
||||
# 先回收过期租约;即便 Worker 非正常退出,也不会永久卡住。
|
||||
await db.execute(
|
||||
update(BackgroundJobItem)
|
||||
.where(
|
||||
BackgroundJobItem.status == ITEM_RUNNING,
|
||||
BackgroundJobItem.lease_expires_at.is_not(None),
|
||||
BackgroundJobItem.lease_expires_at < now,
|
||||
)
|
||||
.values(status=ITEM_QUEUED, lease_owner=None, lease_expires_at=None, next_run_at=now)
|
||||
)
|
||||
query = (
|
||||
select(BackgroundJobItem, BackgroundJob)
|
||||
.join(BackgroundJob, BackgroundJob.id == BackgroundJobItem.job_id)
|
||||
.where(
|
||||
BackgroundJob.cancel_requested.is_(False),
|
||||
BackgroundJob.status.not_in(TERMINAL_JOB_STATUSES),
|
||||
BackgroundJobItem.status.in_((ITEM_QUEUED, ITEM_WAITING_PROVIDER)),
|
||||
or_(BackgroundJobItem.next_run_at.is_(None), BackgroundJobItem.next_run_at <= now),
|
||||
)
|
||||
.order_by(BackgroundJob.priority.desc(), BackgroundJob.created_at.asc(), BackgroundJobItem.position.asc())
|
||||
.limit(1)
|
||||
)
|
||||
row = (await db.execute(query)).first()
|
||||
if row is None:
|
||||
await db.commit()
|
||||
return None
|
||||
item, job = row
|
||||
item.status = ITEM_RUNNING
|
||||
if not item.provider_task_id:
|
||||
item.attempts += 1
|
||||
item.lease_owner = owner
|
||||
item.lease_expires_at = now + timedelta(seconds=lease_seconds)
|
||||
item.next_run_at = None
|
||||
item.started_at = item.started_at or now
|
||||
job.status = JOB_RUNNING
|
||||
job.started_at = job.started_at or now
|
||||
await db.commit()
|
||||
return ClaimedItem(
|
||||
job_id=str(job.id), item_id=str(item.id), job_kind=job.kind,
|
||||
product_id=str(job.product_id) if job.product_id else None,
|
||||
provider=job.provider, model=job.model, platform=job.platform,
|
||||
lang=job.lang, ratio=job.ratio, style_set=job.style_set,
|
||||
request_payload=dict(job.request_payload or {}), item_payload=dict(item.payload or {}),
|
||||
provider_task_id=item.provider_task_id, provider_task_kind=item.provider_task_kind,
|
||||
)
|
||||
|
||||
|
||||
async def mark_waiting_provider(item_id: str, provider_task_id: str, provider_task_kind: str, delay_seconds: int = 3) -> None:
|
||||
"""持久化外部任务 ID 并安排下一次查询。
|
||||
|
||||
取消可能恰好发生在 Worker 轮询供应商的网络请求期间。这里在同一事务内
|
||||
再次检查父任务,避免已取消任务被写回 waiting_provider 后永远不再被领取。
|
||||
"""
|
||||
async with get_session_factory()() as db:
|
||||
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
|
||||
if item is None:
|
||||
return
|
||||
job = await db.get(BackgroundJob, item.job_id)
|
||||
if job is None or job.cancel_requested:
|
||||
item.status = ITEM_CANCELLED
|
||||
item.lease_owner = None
|
||||
item.lease_expires_at = None
|
||||
item.next_run_at = None
|
||||
item.completed_at = utcnow()
|
||||
if job is not None:
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
return
|
||||
item.provider_task_id = provider_task_id
|
||||
item.provider_task_kind = provider_task_kind
|
||||
item.status = ITEM_WAITING_PROVIDER
|
||||
item.lease_owner = None
|
||||
item.lease_expires_at = None
|
||||
item.next_run_at = utcnow() + timedelta(seconds=delay_seconds)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_item_done(
|
||||
item_id: str,
|
||||
*,
|
||||
result_url: str,
|
||||
source_url: str | None,
|
||||
watermark_config: dict | None,
|
||||
generated_asset_id: str | None,
|
||||
) -> None:
|
||||
async with get_session_factory()() as db:
|
||||
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
|
||||
if item is None:
|
||||
return
|
||||
job = await db.get(BackgroundJob, item.job_id)
|
||||
item.status = ITEM_DONE
|
||||
item.result_url = result_url
|
||||
item.source_url = source_url
|
||||
item.watermark_config = watermark_config
|
||||
item.generated_asset_id = uuid.UUID(generated_asset_id) if generated_asset_id else None
|
||||
item.error = None
|
||||
item.lease_owner = None
|
||||
item.lease_expires_at = None
|
||||
item.completed_at = utcnow()
|
||||
if job is not None:
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_item_failed(item_id: str, error: str) -> None:
|
||||
async with get_session_factory()() as db:
|
||||
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
|
||||
if item is None:
|
||||
return
|
||||
job = await db.get(BackgroundJob, item.job_id)
|
||||
item.status = ITEM_FAILED
|
||||
item.error = error[:500]
|
||||
item.lease_owner = None
|
||||
item.lease_expires_at = None
|
||||
item.completed_at = utcnow()
|
||||
if job is not None:
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def job_can_write(job_id: str) -> bool:
|
||||
"""结果落盘前的最后一道确认:取消或商品被硬删除时禁止回写素材。"""
|
||||
async with get_session_factory()() as db:
|
||||
job = await db.get(BackgroundJob, uuid.UUID(job_id))
|
||||
if job is None or job.cancel_requested:
|
||||
return False
|
||||
if job.product_id is None:
|
||||
return True
|
||||
# 仅用主键查询避免导入 Product 形成循环;不存在即不允许生成结果复活商品。
|
||||
from models import Product
|
||||
return await db.get(Product, job.product_id) is not None
|
||||
|
||||
|
||||
async def mark_item_cancelled(item_id: str) -> None:
|
||||
async with get_session_factory()() as db:
|
||||
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
|
||||
if item is None:
|
||||
return
|
||||
job = await db.get(BackgroundJob, item.job_id)
|
||||
item.status = ITEM_CANCELLED
|
||||
item.lease_owner = None
|
||||
item.lease_expires_at = None
|
||||
item.completed_at = utcnow()
|
||||
if job is not None:
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def cancel_job(job_id: str) -> BackgroundJob | None:
|
||||
async with get_session_factory()() as db:
|
||||
job = await db.get(BackgroundJob, uuid.UUID(job_id))
|
||||
if job is None:
|
||||
return None
|
||||
if job.status in TERMINAL_JOB_STATUSES:
|
||||
return job
|
||||
job.cancel_requested = True
|
||||
await db.execute(
|
||||
update(BackgroundJobItem)
|
||||
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status.in_((ITEM_QUEUED, ITEM_WAITING_PROVIDER)))
|
||||
.values(status=ITEM_CANCELLED, completed_at=utcnow(), lease_owner=None, lease_expires_at=None)
|
||||
)
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
async def delete_job(job_id: str) -> bool:
|
||||
"""硬删除一个后台任务及其子项;调用方须先处理对应的存储对象。"""
|
||||
try:
|
||||
parsed = uuid.UUID(job_id)
|
||||
except ValueError:
|
||||
return False
|
||||
async with get_session_factory()() as db:
|
||||
job = await db.get(BackgroundJob, parsed)
|
||||
if job is None:
|
||||
return False
|
||||
await db.delete(job)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def cancel_product_jobs(product_id: str) -> int:
|
||||
"""硬删除商品前取消其未完成任务,Worker 随后只会丢弃结果而不会回写素材。"""
|
||||
try:
|
||||
parsed = uuid.UUID(product_id)
|
||||
except ValueError:
|
||||
return 0
|
||||
async with get_session_factory()() as db:
|
||||
jobs = (await db.scalars(
|
||||
select(BackgroundJob).where(
|
||||
BackgroundJob.product_id == parsed,
|
||||
BackgroundJob.status.not_in(TERMINAL_JOB_STATUSES),
|
||||
)
|
||||
)).all()
|
||||
now = utcnow()
|
||||
for job in jobs:
|
||||
job.cancel_requested = True
|
||||
await db.execute(
|
||||
update(BackgroundJobItem)
|
||||
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status.in_((ITEM_QUEUED, ITEM_WAITING_PROVIDER)))
|
||||
.values(status=ITEM_CANCELLED, completed_at=now, lease_owner=None, lease_expires_at=None)
|
||||
)
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
return len(jobs)
|
||||
|
||||
|
||||
async def retry_failed_items(job_id: str) -> BackgroundJob | None:
|
||||
"""仅把失败子项重新排队;外部失败任务不可复用,清空 provider id 后重新提交。"""
|
||||
async with get_session_factory()() as db:
|
||||
job = await db.get(BackgroundJob, uuid.UUID(job_id))
|
||||
if job is None:
|
||||
return None
|
||||
now = utcnow()
|
||||
result = await db.execute(
|
||||
update(BackgroundJobItem)
|
||||
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status == ITEM_FAILED)
|
||||
.values(
|
||||
status=ITEM_QUEUED, error=None, provider_task_id=None, provider_task_kind=None,
|
||||
result_url=None, source_url=None, watermark_config=None, generated_asset_id=None,
|
||||
next_run_at=now, lease_owner=None, lease_expires_at=None, completed_at=None,
|
||||
)
|
||||
)
|
||||
if not result.rowcount:
|
||||
return job
|
||||
job.cancel_requested = False
|
||||
job.status = JOB_QUEUED
|
||||
job.completed_at = None
|
||||
job.error = None
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
async def retry_failed_item(job_id: str, item_id: str) -> BackgroundJob | None:
|
||||
"""只重试一个失败子项,供批量水印等任务逐图补偿。"""
|
||||
try:
|
||||
parsed_job = uuid.UUID(job_id)
|
||||
parsed_item = uuid.UUID(item_id)
|
||||
except ValueError:
|
||||
return None
|
||||
async with get_session_factory()() as db:
|
||||
job = await db.get(BackgroundJob, parsed_job)
|
||||
item = await db.get(BackgroundJobItem, parsed_item)
|
||||
if job is None or item is None or item.job_id != job.id:
|
||||
return None
|
||||
if item.status != ITEM_FAILED:
|
||||
return job
|
||||
# 水印任务等待期间,素材可能被用户再次处理过。逐项重试时改为以
|
||||
# 最新 current 版本为基线,避免把已经更新的图片误判为永久失败。
|
||||
if job.kind == "watermark_batch":
|
||||
raw_asset_id = (item.payload or {}).get("asset_id")
|
||||
try:
|
||||
from models import ProductAsset
|
||||
asset = await db.get(ProductAsset, uuid.UUID(str(raw_asset_id)))
|
||||
except (TypeError, ValueError):
|
||||
asset = None
|
||||
if asset is not None:
|
||||
payload = dict(item.payload or {})
|
||||
payload["expected_current_storage_key"] = asset.current_storage_key
|
||||
payload["expected_current_url"] = asset.stored_url
|
||||
item.payload = payload
|
||||
item.status = ITEM_QUEUED
|
||||
item.error = None
|
||||
item.provider_task_id = None
|
||||
item.provider_task_kind = None
|
||||
item.result_url = None
|
||||
item.source_url = None
|
||||
item.watermark_config = None
|
||||
item.generated_asset_id = None
|
||||
item.next_run_at = utcnow()
|
||||
item.lease_owner = None
|
||||
item.lease_expires_at = None
|
||||
item.completed_at = None
|
||||
job.cancel_requested = False
|
||||
job.status = JOB_QUEUED
|
||||
job.completed_at = None
|
||||
job.error = None
|
||||
await _refresh_job_status(db, job)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
async def get_job(job_id: str) -> tuple[BackgroundJob, list[BackgroundJobItem]] | None:
|
||||
try:
|
||||
parsed = uuid.UUID(job_id)
|
||||
except ValueError:
|
||||
return None
|
||||
async with get_session_factory()() as db:
|
||||
job = await db.get(BackgroundJob, parsed)
|
||||
if job is None:
|
||||
return None
|
||||
items = (await db.scalars(
|
||||
select(BackgroundJobItem).where(BackgroundJobItem.job_id == job.id).order_by(BackgroundJobItem.position)
|
||||
)).all()
|
||||
return job, items
|
||||
|
||||
|
||||
async def list_product_jobs(
|
||||
product_id: str,
|
||||
active_only: bool = False,
|
||||
limit: int = 20,
|
||||
kinds: tuple[str, ...] | None = None,
|
||||
) -> list[BackgroundJob]:
|
||||
try:
|
||||
parsed = uuid.UUID(product_id)
|
||||
except ValueError:
|
||||
return []
|
||||
async with get_session_factory()() as db:
|
||||
query = select(BackgroundJob).where(BackgroundJob.product_id == parsed)
|
||||
if kinds:
|
||||
query = query.where(BackgroundJob.kind.in_(kinds))
|
||||
if active_only:
|
||||
query = query.where(BackgroundJob.status.not_in(TERMINAL_JOB_STATUSES))
|
||||
return (await db.scalars(query.order_by(BackgroundJob.created_at.desc()).limit(limit))).all()
|
||||
|
||||
|
||||
def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str, Any]:
|
||||
"""兼容现有 /suites/{id} 契约,同时增加任务类型、取消状态与子项状态。"""
|
||||
return {
|
||||
"id": str(job.id), "status": job.status, "kind": job.kind,
|
||||
"style_set": job.style_set, "platform": job.platform, "lang": job.lang,
|
||||
"ratio": job.ratio, "provider": job.provider, "model": job.model,
|
||||
"total": job.total, "completed": job.completed, "failed": job.failed,
|
||||
"cancel_requested": job.cancel_requested, "error": job.error,
|
||||
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
|
||||
"images": [
|
||||
{
|
||||
"type_id": (item.payload or {}).get("kind", "custom"),
|
||||
"item_id": str(item.id),
|
||||
"name": (item.payload or {}).get("title", "AI 生图"),
|
||||
"url": item.result_url or "", "status": item.status,
|
||||
"error": item.error,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def video_job_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str, Any]:
|
||||
"""轮播视频的轻量任务视图;仍复用 BackgroundJob 的恢复/取消/重试状态机。"""
|
||||
payload = dict(job.request_payload or {})
|
||||
item = items[0] if items else None
|
||||
item_payload = dict(item.payload or {}) if item else {}
|
||||
return {
|
||||
"id": str(job.id), "kind": job.kind, "status": job.status,
|
||||
"total": job.total, "completed": job.completed, "failed": job.failed,
|
||||
"cancel_requested": job.cancel_requested, "preset": payload.get("preset", "wb_ozon"),
|
||||
"source_asset_ids": [str(source.get("asset_id")) for source in payload.get("sources", [])],
|
||||
"slide_seconds": payload.get("slide_seconds", 0),
|
||||
"duration_seconds": item_payload.get("duration_seconds"),
|
||||
"width": item_payload.get("width", 810), "height": item_payload.get("height", 1080),
|
||||
"file_size_bytes": item_payload.get("file_size_bytes"),
|
||||
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
|
||||
"result": None if item is None else {
|
||||
"item_id": str(item.id), "status": item.status, "url": item.result_url, "error": item.error,
|
||||
},
|
||||
"error": job.error,
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"""出图方案规划器:DeepSeek 根据采集的商品信息生成套图方案。
|
||||
|
||||
方案每项 = 一类图(标题 + 说明 + 生图提示 + 张数 + 可选 SKU 绑定),
|
||||
生成时按方案逐张出图;参考图可按 variant_name 精确绑定到对应 SKU 图。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
|
||||
log = logging.getLogger("suite.planner")
|
||||
|
||||
# 规划器可选用的图类型(与 prompt.py 的 builder 对应)
|
||||
ALLOWED_KINDS = [
|
||||
"white_bg", "key_features", "selling_pt", "material",
|
||||
"lifestyle", "multi_scene", "ecommerce_detail",
|
||||
"size_chart", "sku_collection", "custom",
|
||||
]
|
||||
|
||||
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
|
||||
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
|
||||
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
|
||||
PLAN_SAFETY_MAX = 15
|
||||
PLAN_REQUIREMENTS_MAX = 24
|
||||
MAX_VARIANT_MAIN_IMAGES = 2
|
||||
|
||||
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
|
||||
# 因此均视作同一类覆盖,而不是强迫模型只使用 material。
|
||||
SCENE_KINDS = {"lifestyle", "multi_scene"}
|
||||
DETAIL_KINDS = {"material", "key_features", "selling_pt", "ecommerce_detail"}
|
||||
_DIMENSION_PATTERN = re.compile(
|
||||
r"尺寸|长宽高|直径|厚度|(?:长|宽|高)\s*[::×x*]?\s*\d+|\d+(?:\.\d+)?\s*(?:厘米|毫米|cm|mm|inch|in)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _total_budget(requirements: str | None) -> int:
|
||||
"""从生图要求提取「N 张」作为总张数预算;未指定时用默认安全上限。"""
|
||||
if requirements:
|
||||
m = re.search(r"(\d+)\s*张", requirements)
|
||||
if m:
|
||||
return min(max(int(m.group(1)), 1), PLAN_REQUIREMENTS_MAX)
|
||||
return PLAN_SAFETY_MAX
|
||||
|
||||
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商套图的出图方案。
|
||||
|
||||
## 输出硬性约束(违反即失败)
|
||||
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
|
||||
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
|
||||
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
|
||||
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
|
||||
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
|
||||
|
||||
## 规划规则
|
||||
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
|
||||
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
|
||||
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
|
||||
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
|
||||
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 1 张;无可靠尺寸参数时不要虚构尺寸。
|
||||
6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 1 张,优先级低于上述四类。
|
||||
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
|
||||
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
|
||||
|
||||
## 输出示例(紧凑单行)
|
||||
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
|
||||
|
||||
|
||||
def _system_prompt_with_requirements(requirements: str | None) -> str:
|
||||
"""把生图要求作为最高优先级约束注入 system prompt(置于规划规则之前)。
|
||||
|
||||
不仅声明优先级,还明确要求把要求落地到每个方案项的 prompt_hint,
|
||||
避免模型只把要求当作背景信息而不影响输出。
|
||||
"""
|
||||
if not (requirements and requirements.strip()):
|
||||
return SYSTEM_PROMPT
|
||||
marker = "\n## 输出硬性约束"
|
||||
idx = SYSTEM_PROMPT.find(marker)
|
||||
if idx < 0:
|
||||
return SYSTEM_PROMPT
|
||||
req = requirements.strip()
|
||||
block = (
|
||||
"\n## 生图要求(最高优先级,硬性约束,覆盖下方所有规划规则与约束)\n"
|
||||
+ req
|
||||
+ "\n\n"
|
||||
+ "规划方案时,必须把上述生图要求落地到每一项:\n"
|
||||
+ "1. 每个方案项的 prompt_hint 必须融入上述要求的关键约束(如要求纯黑背景,则每个 prompt_hint 都要写明 black background);\n"
|
||||
+ "2. title / detail 措辞不得与上述要求矛盾;\n"
|
||||
+ "3. 任何规划规则与上述要求冲突时,一律以本生图要求为准。\n"
|
||||
)
|
||||
return SYSTEM_PROMPT[:idx] + block + SYSTEM_PROMPT[idx:]
|
||||
|
||||
|
||||
def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
"""清洗模型输出:kind 白名单、count 钳制、variant 必须真实存在。"""
|
||||
items: list[dict] = []
|
||||
for it in raw_items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
kind = str(it.get("kind") or "custom")
|
||||
if kind not in ALLOWED_KINDS:
|
||||
kind = "custom"
|
||||
# title 缺失不再丢弃条目:从 detail / kind 兜底合成,避免整份方案被判空
|
||||
title = str(it.get("title") or "").strip()[:20] or str(it.get("detail") or "").strip()[:12] or kind
|
||||
try:
|
||||
count = max(0, min(3, int(it.get("count", 1))))
|
||||
except (TypeError, ValueError):
|
||||
count = 1
|
||||
variant = str(it.get("variant_name") or "").strip() or None
|
||||
if variant and variant not in sku_variants:
|
||||
variant = None # 幻觉规格:丢弃绑定,回退主图
|
||||
items.append({
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"detail": str(it.get("detail") or "").strip()[:80],
|
||||
"prompt_hint": str(it.get("prompt_hint") or "").strip()[:300],
|
||||
"count": count,
|
||||
"variant_name": variant,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def _has_dimensions(product_info: dict) -> bool:
|
||||
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
|
||||
for param in product_info.get("params") or []:
|
||||
if not isinstance(param, dict):
|
||||
continue
|
||||
text = f"{param.get('key') or ''} {param.get('value') or ''}"
|
||||
if _DIMENSION_PATTERN.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _requires_every_sku(requirements: str | None) -> bool:
|
||||
"""用户显式要求逐 SKU 出图时,保留其优先级,不施加代表款上限。"""
|
||||
if not requirements:
|
||||
return False
|
||||
return bool(re.search(
|
||||
r"(?:每个|全部|所有|逐个|分别).{0,10}(?:sku|SKU|规格|款式|颜色|变体)",
|
||||
requirements,
|
||||
))
|
||||
|
||||
|
||||
def _coverage_group(kind: str) -> str:
|
||||
if kind == "white_bg":
|
||||
return "main"
|
||||
if kind in SCENE_KINDS:
|
||||
return "scene"
|
||||
if kind in DETAIL_KINDS:
|
||||
return "detail"
|
||||
if kind == "size_chart":
|
||||
return "size"
|
||||
return "other"
|
||||
|
||||
|
||||
def _total(items: list[dict]) -> int:
|
||||
return sum(item["count"] for item in items)
|
||||
|
||||
|
||||
def _group_total(items: list[dict], group: str) -> int:
|
||||
return sum(item["count"] for item in items if _coverage_group(item["kind"]) == group)
|
||||
|
||||
|
||||
def _remove_one(items: list[dict], predicate) -> bool:
|
||||
"""从后往前删除一个生成槽位,优先保留模型较早规划的构图。"""
|
||||
for index in range(len(items) - 1, -1, -1):
|
||||
if not predicate(items[index]):
|
||||
continue
|
||||
if items[index]["count"] > 1:
|
||||
items[index]["count"] -= 1
|
||||
else:
|
||||
items.pop(index)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _make_room(items: list[dict], budget: int, minimums: dict[str, int]) -> bool:
|
||||
"""为必需图类腾出一张,先去除非核心图或某类的冗余张数。"""
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for index, item in enumerate(items):
|
||||
group = _coverage_group(item["kind"])
|
||||
if _group_total(items, group) <= minimums.get(group, 0):
|
||||
continue
|
||||
# other 最低优先;同类冗余时从后面的项开始删。
|
||||
priority = 0 if group == "other" else 1
|
||||
candidates.append((priority, -index))
|
||||
if not candidates:
|
||||
return False
|
||||
_, neg_index = min(candidates)
|
||||
index = -neg_index
|
||||
item = items[index]
|
||||
if item["count"] > 1:
|
||||
item["count"] -= 1
|
||||
else:
|
||||
items.pop(index)
|
||||
return True
|
||||
|
||||
|
||||
def _balanced_items(
|
||||
items: list[dict],
|
||||
sku_variants: list[str],
|
||||
product_info: dict,
|
||||
requirements: str | None,
|
||||
budget: int,
|
||||
) -> list[dict]:
|
||||
"""将 LLM 方案约束为信息均衡的商品套图。
|
||||
|
||||
提示词能改善规划倾向,但不能保证每次输出。这里把 SKU 主图总量限制为
|
||||
代表款数量,并在预算允许时补齐核心图类,避免 SKU 多时挤掉场景/细节/尺寸图。
|
||||
"""
|
||||
balanced = [dict(item) for item in items if item["count"] > 0]
|
||||
|
||||
if not _requires_every_sku(requirements):
|
||||
max_mains = 1 if len(sku_variants) <= 1 else MAX_VARIANT_MAIN_IMAGES
|
||||
while sum(item["count"] for item in balanced if item["kind"] == "white_bg") > max_mains:
|
||||
_remove_one(balanced, lambda item: item["kind"] == "white_bg")
|
||||
|
||||
required: list[tuple[str, dict]] = [
|
||||
("main", {
|
||||
"kind": "white_bg", "title": "白底主图", "detail": "商品白底主视觉",
|
||||
"prompt_hint": "front view on pure white background", "count": 1,
|
||||
"variant_name": sku_variants[0] if sku_variants else None,
|
||||
}),
|
||||
("scene", {
|
||||
"kind": "lifestyle", "title": "使用场景", "detail": "展示商品实际使用场景",
|
||||
"prompt_hint": "product in a natural everyday use setting", "count": 1,
|
||||
"variant_name": None,
|
||||
}),
|
||||
("detail", {
|
||||
"kind": "material", "title": "细节展示", "detail": "突出材质与结构细节",
|
||||
"prompt_hint": "close-up of authentic material and construction details", "count": 1,
|
||||
"variant_name": None,
|
||||
}),
|
||||
]
|
||||
if _has_dimensions(product_info):
|
||||
required.append(("size", {
|
||||
"kind": "size_chart", "title": "尺寸标注", "detail": "展示已采集的商品尺寸",
|
||||
"prompt_hint": "clear size chart using known product dimensions", "count": 1,
|
||||
"variant_name": None,
|
||||
}))
|
||||
|
||||
# 用户明确要求较小张数时,优先保留前面的核心图类;不突破其指定预算。
|
||||
required = required[:budget]
|
||||
minimums = {group: 1 for group, _ in required}
|
||||
for group, fallback in required:
|
||||
if _group_total(balanced, group) > 0:
|
||||
continue
|
||||
while _total(balanced) >= budget and _make_room(balanced, budget, minimums):
|
||||
pass
|
||||
if _total(balanced) < budget:
|
||||
balanced.append(fallback)
|
||||
|
||||
while _total(balanced) > budget and _make_room(balanced, budget, minimums):
|
||||
pass
|
||||
return balanced
|
||||
|
||||
|
||||
def _repair_truncated(s: str) -> dict | None:
|
||||
"""截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。
|
||||
|
||||
适用于「items 数组中途被 max_tokens 截断」的场景——截断点在完整对象边界,
|
||||
此前的字符串必然已闭合,简单计数配平即可。
|
||||
"""
|
||||
for cut in (m.end() for m in reversed(list(re.finditer(r'\}', s)))):
|
||||
cand = s[:cut].rstrip().rstrip(',')
|
||||
opens: list[str] = []
|
||||
for ch in cand:
|
||||
if ch in '{[':
|
||||
opens.append(ch)
|
||||
elif ch == '}' and opens and opens[-1] == '{':
|
||||
opens.pop()
|
||||
elif ch == ']' and opens and opens[-1] == '[':
|
||||
opens.pop()
|
||||
suffix = ''.join('}' if o == '{' else ']' for o in reversed(opens))
|
||||
try:
|
||||
data = json.loads(cand + suffix)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _extract_json(text: str) -> dict:
|
||||
"""从模型输出提取 JSON:剥离思考块/markdown 围栏,截断时尝试修复。"""
|
||||
s = (text or '').strip()
|
||||
# 剥离思考块(思考型模型会把推理过程放进 <think>)
|
||||
s = re.sub(r'<think>.*?</think>', '', s, flags=re.S).strip()
|
||||
# 剥离 markdown 代码围栏
|
||||
m = re.search(r'```(?:json)?\s*(.*?)```', s, flags=re.S)
|
||||
if m:
|
||||
s = m.group(1).strip()
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
start = s.find('{')
|
||||
if start >= 0:
|
||||
repaired = _repair_truncated(s[start:])
|
||||
if repaired is not None:
|
||||
log.warning("规划器输出疑似被截断,已自动截断修复(可能丢失末尾部分方案项)")
|
||||
return repaired
|
||||
raise ValueError("模型输出无法解析为 JSON")
|
||||
|
||||
|
||||
async def generate_plan(
|
||||
product_info: dict,
|
||||
sku_variants: list[str],
|
||||
image_stats: dict,
|
||||
platform: str,
|
||||
requirements: str | None = None,
|
||||
) -> dict:
|
||||
"""调用 DeepSeek 生成方案。返回 {summary, items}。
|
||||
|
||||
requirements:生图要求,最高优先级注入 system prompt,规划方案必须遵循。
|
||||
"""
|
||||
s = get_settings()
|
||||
if not s.deepseek_api_key:
|
||||
raise RuntimeError("未配置 DEEPSEEK_API_KEY(.env)")
|
||||
|
||||
user_payload: dict = {
|
||||
"商品信息": product_info, # {title, desc, params:[{key,value}], sellingPoints, price}
|
||||
"SKU规格": sku_variants, # 带图的 SKU 规格名(variant_name 只能从中选)
|
||||
"图片统计": image_stats, # {main: n, sku: n, detail: n}
|
||||
"目标平台": platform, # ozon/wb/cn(决定图内文案语言)
|
||||
}
|
||||
# 生图要求同时在 user 侧强调(与 system prompt 双重约束),确保模型真正遵循
|
||||
if requirements and requirements.strip():
|
||||
user_payload["生图要求(最高优先级,必须体现在每个方案项中)"] = requirements.strip()
|
||||
user_content = json.dumps(user_payload, ensure_ascii=False)
|
||||
|
||||
last_error: RuntimeError | ValueError | None = None
|
||||
for attempt in range(2): # 解析失败/空方案自动重试一次
|
||||
async with httpx.AsyncClient(timeout=90, verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{s.deepseek_base_url.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {s.deepseek_api_key}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"model": s.deepseek_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": _system_prompt_with_requirements(requirements)},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 8000,
|
||||
# deepseek-v4 是思维链模型:不禁用 thinking 会把 max_tokens 烧在推理上,
|
||||
# 正文为空 → 只能拿 reasoning_content 兜底解析 → 频发「解析失败/未返回有效方案项」
|
||||
"thinking": {"type": "disabled"},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
message = body["choices"][0]["message"]
|
||||
finish_reason = body["choices"][0].get("finish_reason", "")
|
||||
usage = body.get("usage") or {}
|
||||
log.info(
|
||||
"规划器 token 用量: prompt=%s completion=%s finish=%s",
|
||||
usage.get("prompt_tokens", "?"), usage.get("completion_tokens", "?"), finish_reason,
|
||||
)
|
||||
content = message.get("content") or ""
|
||||
# 思考型输出:content 为空时从 reasoning_content 里捞
|
||||
if not content.strip() and message.get("reasoning_content"):
|
||||
content = message["reasoning_content"]
|
||||
|
||||
try:
|
||||
data = _extract_json(content)
|
||||
except ValueError as exc:
|
||||
last_error = exc
|
||||
log.error(
|
||||
"规划器第 %d 次输出解析失败 finish_reason=%s content[:200]=%s",
|
||||
attempt + 1, finish_reason, content[:200],
|
||||
)
|
||||
continue
|
||||
|
||||
items = _normalize_items(data.get("items") or [], sku_variants)
|
||||
if items:
|
||||
# 先压缩多 SKU 的重复主图、补齐核心视觉类别,再执行总量保护。
|
||||
# 生图要求指定「N 张」时按 N 放宽上限,并始终不突破该预算。
|
||||
budget = _total_budget(requirements)
|
||||
items = _balanced_items(items, sku_variants, product_info, requirements, budget)
|
||||
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
|
||||
|
||||
last_error = RuntimeError("规划器未返回有效方案项")
|
||||
log.warning(
|
||||
"规划器第 %d 次返回无有效方案项 finish_reason=%s content[:200]=%s",
|
||||
attempt + 1, finish_reason, content[:200],
|
||||
)
|
||||
|
||||
raise last_error or RuntimeError("规划器未返回有效方案项")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""套图提示词引擎:按模型家族分发,各家族独立封装。
|
||||
|
||||
不同家族的生图语义差异极大,共用一套提示词会导致语义错配
|
||||
(gpt-image-2 按文字重造商品即由此而来),故按家族各自成册:
|
||||
|
||||
alibaba 通义 wan*/qwen*(DashScope)—— 主体参考语义
|
||||
doubao 豆包 Seedream(火山方舟)—— 主体参考语义,与通义共用装配
|
||||
gpt gpt-image-2 / gpt-image-2-vip(RightAPI)—— /v1/images/edits 编辑语义
|
||||
google nano-banana 系列(RightAPI)—— 原生主体保持语义
|
||||
|
||||
路由规则:provider 为主;rightapi 内再按模型名细分 gpt / google。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import alibaba, doubao, google, gpt
|
||||
from .common import build_context, type_name
|
||||
|
||||
_MODULE_BY_FAMILY = {
|
||||
"alibaba": alibaba,
|
||||
"doubao": doubao,
|
||||
"gpt": gpt,
|
||||
"google": google,
|
||||
}
|
||||
|
||||
|
||||
def prompt_family(provider: str, model: str | None) -> str:
|
||||
"""(provider, model) → 提示词家族名。"""
|
||||
if provider in ("rightapi", "pcore"):
|
||||
if (model or "").lower().startswith("nano-banana"):
|
||||
return "google"
|
||||
return "gpt" # gpt-image-* 及未知中转模型默认按 edits 语义处理
|
||||
if provider == "tongyi":
|
||||
return "alibaba"
|
||||
return "doubao" # doubao 及默认 provider
|
||||
|
||||
|
||||
def build_prompt(provider: str, model: str | None, type_id: str, ctx: dict, style_set: int,
|
||||
lang: str, extra: dict | None = None, style_prompt: str | None = None,
|
||||
requirements: str | None = None) -> str:
|
||||
"""按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
|
||||
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
|
||||
return module.build_prompt(
|
||||
type_id, ctx, style_set, lang,
|
||||
extra=extra, style_prompt=style_prompt, requirements=requirements,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""阿里通义(wan* 万相 / qwen* 千问)提示词:国产"主体参考"语义。
|
||||
|
||||
生图 API 把参考图当商品锚(subject reference)、prompt 当场景描述,
|
||||
风格词/文字商品描述不会反噬商品本体,负面清单也可以安全写入 prompt。
|
||||
豆包(doubao.py)与此语义一致,直接复用本模块装配。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import (
|
||||
STYLE_SETS, TEXT_RENDER, params_line_for_output, requirements_block, resolve_style, selling_point_lines,
|
||||
)
|
||||
|
||||
# ── 公共组件(主体参考语义专用)────────────────────────────────────────────
|
||||
|
||||
QUALITY = (
|
||||
"Shot on Sony A7R V with 85mm lens at f/2.0, ultra-detailed, photorealistic, "
|
||||
"8K commercial image quality, professional retouching."
|
||||
)
|
||||
|
||||
PRODUCT_REF_LOCK = (
|
||||
"CRITICAL: The product must look EXACTLY the same as in the reference image — "
|
||||
"identical silhouette, proportions, colors, print pattern, stitching and every design detail. "
|
||||
"Only the background, camera angle, lighting and styling may change. "
|
||||
"Do not redesign, add or remove any element of the product."
|
||||
)
|
||||
|
||||
DEFAULT_NEGATIVE_INTENT = (
|
||||
"no AI-generated look, no CGI quality, no plastic appearance, no watermark, "
|
||||
"no distorted text, no deformed product, no extra limbs, no blurry areas"
|
||||
)
|
||||
|
||||
# ── 各图类型 Prompt ───────────────────────────────────────────────────────
|
||||
|
||||
def _prompt_white_bg(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"E-commerce main product image on pure white background (RGB 255,255,255), "
|
||||
f"product \"{ctx['title']}\" centered and filling about 85% of the frame, "
|
||||
f"front view, even shadowless studio lighting with a faint natural contact shadow, "
|
||||
f"{style['tone']}. No text, no props, no background elements. {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_key_features(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
return (
|
||||
f"E-commerce key-features infographic for product \"{ctx['title']}\", square layout: "
|
||||
f"product on the left two-thirds ({style['bg']}), right column lists 3 feature callouts "
|
||||
f"with minimal line icons, thin leader lines pointing to product details. "
|
||||
f"Feature callouts: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_selling_pt(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang, 1) or ctx["title"]
|
||||
return (
|
||||
f"Single-selling-point e-commerce poster for product \"{ctx['title']}\": "
|
||||
f"hero product close-up at dynamic angle ({style['bg']}), one large bold headline "
|
||||
f"about \"{sp}\", generous negative space, one small magnified detail circle "
|
||||
f"highlighting material or craft. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_material(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Macro material close-up of product \"{ctx['title']}\": extreme detail shot revealing "
|
||||
f"fabric weave / surface texture / stitching / finish, shallow depth of field, "
|
||||
f"raking light across the surface, {style['tone']}. Small caption label in corner. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_lifestyle(ctx: dict, style: dict, lang: str) -> str:
|
||||
bg = f" ({style['bg']})" if style.get("bg") else ""
|
||||
return (
|
||||
f"Lifestyle in-context scene for product \"{ctx['title']}\": the product is naturally "
|
||||
f"used / placed in a real environment{bg}, realistic human-scale surroundings, "
|
||||
f"soft daylight, authentic candid mood, product remains the clear visual focus. "
|
||||
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_multi_scene(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang)
|
||||
return (
|
||||
f"Triptych multi-scene e-commerce image for product \"{ctx['title']}\": three vertical panels "
|
||||
f"separated by thin gutters, each panel shows the SAME product in a different usage scene "
|
||||
f"(e.g. home interior / outdoor street / office desk), consistent color grading across panels. "
|
||||
f"Panel captions: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
|
||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||
params = params_line_for_output(ctx, lang)
|
||||
return (
|
||||
f"E-commerce detail-page hero section for product \"{ctx['title']}\", square layout: "
|
||||
f"top half is a hero banner with the product at a 3/4 angle ({style['bg']}); "
|
||||
f"bottom half is a clean spec card listing 3 feature rows with line icons"
|
||||
+ (f" (specs: {params})" if params else "")
|
||||
+ f" and one highlighted row: {sp}. {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
|
||||
dims = params_line_for_output(ctx, lang)
|
||||
return (
|
||||
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
|
||||
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
|
||||
f"measurement values rendered next to each line"
|
||||
+ (f" (known specs: {dims})" if dims else "")
|
||||
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
|
||||
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_sku_collection(ctx: dict, style: dict, lang: str) -> str:
|
||||
return (
|
||||
f"Colorway collection image for product \"{ctx['title']}\": the SAME product in all its color/variant "
|
||||
f"options arranged in a neat equal grid (2-4 items per row), each colorway with a small label chip below it, "
|
||||
f"consistent lighting and scale across all items, clean e-commerce presentation. "
|
||||
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
)
|
||||
|
||||
def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
bg = f" {style['bg']} as environment." if style.get("bg") else ""
|
||||
# title/detail 是规划器给前端展示的中文文案;只使用约定为英文的 prompt_hint,避免它们变成图中文字。
|
||||
composed = f"E-commerce marketing image for product \"{ctx['title']}\"."
|
||||
if hint:
|
||||
composed += f" Composition: {hint}."
|
||||
return f"{composed}{bg} {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||
|
||||
_PROMPT_BUILDERS = {
|
||||
"white_bg": _prompt_white_bg,
|
||||
"key_features": _prompt_key_features,
|
||||
"selling_pt": _prompt_selling_pt,
|
||||
"material": _prompt_material,
|
||||
"lifestyle": _prompt_lifestyle,
|
||||
"multi_scene": _prompt_multi_scene,
|
||||
"ecommerce_detail": _prompt_ecommerce_detail,
|
||||
"size_chart": _prompt_size_chart,
|
||||
"sku_collection": _prompt_sku_collection,
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
|
||||
style_prompt: str | None = None, requirements: str | None = None) -> str:
|
||||
"""构造指定图类型的完整生图 prompt(主体参考语义)。
|
||||
|
||||
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
|
||||
预设类型也会把 prompt_hint 作为构图补充注入。
|
||||
style_prompt: 用户改写的风格提示词,覆盖 style_set 内置模板(tone/bg 整体替换)。
|
||||
requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面,
|
||||
声明覆盖一切冲突指令,用户可在此输入强制要求。
|
||||
"""
|
||||
style = resolve_style(style_set, style_prompt)
|
||||
extra = extra or {}
|
||||
if type_id == "custom":
|
||||
prompt = _prompt_custom(ctx, style, lang, extra)
|
||||
else:
|
||||
builder = _PROMPT_BUILDERS.get(type_id)
|
||||
if builder is None:
|
||||
raise ValueError(f"未知图类型: {type_id}")
|
||||
prompt = builder(ctx, style, lang)
|
||||
hint = (extra.get("prompt_hint") or "").strip()
|
||||
if hint:
|
||||
prompt = prompt.rstrip(".") + f". Additional composition guidance: {hint}."
|
||||
req = requirements_block(requirements)
|
||||
if req:
|
||||
prompt = f"{req} {prompt}"
|
||||
return prompt + ". " + DEFAULT_NEGATIVE_INTENT
|
||||
@@ -0,0 +1,173 @@
|
||||
"""提示词公共层:与模型家族无关的商品上下文、风格模板、图类型名与文案组件。
|
||||
|
||||
各家族模块(alibaba / doubao / gpt / google)只负责"如何对模型说话",
|
||||
商品信息提炼与风格体系统一在这里维护,避免多处漂移。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应;提示词可被用户在插件里改写覆盖)───
|
||||
|
||||
STYLE_SETS: dict[int, dict] = {
|
||||
1: {
|
||||
"name": "北欧极简",
|
||||
"tone": "Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition",
|
||||
"bg": "",
|
||||
},
|
||||
2: {
|
||||
"name": "清新明亮",
|
||||
"tone": "Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition",
|
||||
"bg": "",
|
||||
},
|
||||
3: {
|
||||
"name": "高级感深色",
|
||||
"tone": "Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood",
|
||||
"bg": "",
|
||||
},
|
||||
4: {
|
||||
"name": "暖调生活",
|
||||
"tone": "Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere",
|
||||
"bg": "",
|
||||
},
|
||||
5: {
|
||||
"name": "纯净棚拍",
|
||||
"tone": "Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent",
|
||||
"bg": "",
|
||||
},
|
||||
}
|
||||
|
||||
# ── 图类型中文名(导出文件名用)───────────────────────────────────────────
|
||||
|
||||
TYPE_NAMES_ZH: dict[str, str] = {
|
||||
"white_bg": "白底主图",
|
||||
"key_features": "核心卖点图",
|
||||
"selling_pt": "卖点图",
|
||||
"material": "材质图",
|
||||
"lifestyle": "场景展示图",
|
||||
"multi_scene": "多场景拼图",
|
||||
"ecommerce_detail": "电商详情图",
|
||||
"size_chart": "尺寸标注图",
|
||||
"sku_collection": "SKU合集图",
|
||||
"custom": "创意图",
|
||||
}
|
||||
|
||||
# ── 图内营销文案渲染规范(各家族共用;语言由平台决定)──────────────────────
|
||||
|
||||
TEXT_RENDER = {
|
||||
"zh": (
|
||||
"Render concise Chinese marketing text inside the image: main headline max 8 Chinese characters, "
|
||||
"sub-lines max 12 characters each, font is modern clean sans-serif (Source Han Sans style), "
|
||||
"high legibility, tasteful typography layout, colors harmonized with the composition. "
|
||||
"No spelling errors, no garbled characters."
|
||||
),
|
||||
"en": (
|
||||
"Render concise English marketing text inside the image: headline max 5 words, "
|
||||
"sub-lines max 8 words each, Helvetica Neue style sans-serif, high legibility, "
|
||||
"tasteful typography layout, colors harmonized with the composition. No spelling errors."
|
||||
),
|
||||
"ru": (
|
||||
"Render concise Russian marketing text inside the image: headline max 4 words, "
|
||||
"sub-lines max 6 words each, modern clean sans-serif (Inter / PT Sans style), "
|
||||
"proper Cyrillic typography, high legibility, tasteful layout, colors harmonized with the composition. "
|
||||
"Translate any Chinese source facts into Russian before rendering. All newly generated visible text "
|
||||
"must be Russian Cyrillic only: never render Chinese, Hanzi, CJK characters, or Chinese phrases. "
|
||||
"The sole exception is Chinese text physically printed on the reference product itself, which must remain unchanged. "
|
||||
"No spelling errors, no mixed latin/cyrillic gibberish."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def resolve_style(style_set: int, style_prompt: str | None = None) -> dict:
|
||||
"""用户改写的风格提示词整体覆盖内置模板(tone/bg 整体替换)。"""
|
||||
if style_prompt and style_prompt.strip():
|
||||
return {"name": "custom", "tone": style_prompt.strip(), "bg": ""}
|
||||
return STYLE_SETS.get(style_set, STYLE_SETS[1])
|
||||
|
||||
|
||||
def requirements_block(requirements: str | None) -> str:
|
||||
"""用户强制要求块:最高优先级、置于提示词最前、覆盖冲突指令(原文保留不翻译)。"""
|
||||
if requirements and requirements.strip():
|
||||
return (
|
||||
"STRICT REQUIREMENTS (highest priority for visual intent; interpret any Chinese source instruction "
|
||||
"by meaning only and never copy its Hanzi into newly generated text): "
|
||||
+ requirements.strip().rstrip(".")
|
||||
+ "."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
# ── 商品上下文提炼 ────────────────────────────────────────────────────────
|
||||
|
||||
def _shorten(text: str, n: int) -> str:
|
||||
text = re.sub(r"\s+", " ", (text or "")).strip()
|
||||
return text[:n]
|
||||
|
||||
def _clean_title(title: str) -> str:
|
||||
"""去掉常见堆砌词,让标题更可读。"""
|
||||
t = _shorten(title, 60)
|
||||
return re.sub(r"[【【】】\\[\\]|/]", " ", t).strip()
|
||||
|
||||
def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -> dict:
|
||||
"""从采集数据提炼生图上下文:标题、描述行、卖点列表、参数行。
|
||||
|
||||
raw: {title, desc, price, params: [{key, value}], sellingPoints}
|
||||
"""
|
||||
# 试算页会同时上传中文采集标题和已回填的俄文标题;后者用于图内可见文案。
|
||||
title = _clean_title(raw.get("title_ru") or raw.get("title") or fallback_name or "product")
|
||||
desc = _shorten(raw.get("desc") or fallback_desc or "", 200)
|
||||
|
||||
# 卖点:优先显式卖点文本;否则从参数表里挑短而有信息量的键值对
|
||||
selling_points: list[dict] = []
|
||||
sp_text = raw.get("sellingPoints") or ""
|
||||
if sp_text:
|
||||
for chunk in re.split(r"[;;\n·]+|(?<!\d)\.(?!\d)", sp_text):
|
||||
c = _shorten(chunk, 20)
|
||||
if c and len(selling_points) < 5:
|
||||
selling_points.append({"zh": c, "en": c})
|
||||
if not selling_points:
|
||||
for p in (raw.get("params") or [])[:12]:
|
||||
k, v = _shorten(p.get("key", ""), 10), _shorten(str(p.get("value", "")), 16)
|
||||
if k and v and k.lower() not in {"货号", "sku", "isbn", "上架时间"}:
|
||||
selling_points.append({"zh": f"{k} {v}", "en": f"{k} {v}"})
|
||||
if len(selling_points) >= 5:
|
||||
break
|
||||
|
||||
params_line = "; ".join(
|
||||
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
|
||||
)
|
||||
return {
|
||||
"title": title,
|
||||
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
|
||||
"desc": desc,
|
||||
"selling_points": selling_points[:3],
|
||||
"params_line": params_line,
|
||||
# 俄文图片里不能直接渲染中文参数;保留不含汉字的规格(型号、数字、单位)供尺寸图使用。
|
||||
"params_line_non_cjk": "; ".join(
|
||||
f"{p.get('key')}: {p.get('value')}"
|
||||
for p in (raw.get("params") or [])[:8]
|
||||
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
|
||||
),
|
||||
"price": raw.get("price") or "",
|
||||
}
|
||||
|
||||
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
|
||||
"""卖点列表 → 单行文案(图内 callout/标题用),无卖点返回空串。"""
|
||||
sps = ctx["selling_points"][:max_n]
|
||||
if not sps:
|
||||
return ""
|
||||
key = "zh" if lang == "zh" else "en"
|
||||
values = [s[key] for s in sps if s.get(key)]
|
||||
# 中文卖点是模型理解商品的来源,不应成为俄文图片中的逐字文案。
|
||||
if lang == "ru":
|
||||
values = [v for v in values if not re.search(r"[\u3400-\u9fff]", v)]
|
||||
return "; ".join(values)
|
||||
|
||||
|
||||
def params_line_for_output(ctx: dict, lang: str) -> str:
|
||||
"""返回可进入图内规格文案的参数;俄文模式剔除含汉字的原始采集参数。"""
|
||||
return ctx.get("params_line") if lang == "zh" else ctx.get("params_line_non_cjk", "")
|
||||
|
||||
|
||||
def type_name(type_id: str) -> str:
|
||||
return TYPE_NAMES_ZH.get(type_id, type_id)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user