feat: 优化图片资源删除管理,生图任务列表管理

This commit is contained in:
2026-08-30 14:50:33 +08:00
parent 4ee667142b
commit 957289f717
46 changed files with 4180 additions and 524 deletions
+10
View File
@@ -35,6 +35,16 @@ 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
# COLLECTION_MAX_IMAGES=100
# EXPORT_MAX_IMAGES=50
# EXPORT_ZIP_MAX_BYTES=209715200
# EXPORT_ZIP_MEMORY_THRESHOLD_BYTES=2097152
# ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)──
# 豆包 Seedream(火山方舟)
ARK_API_KEY=
+19 -11
View File
@@ -1,8 +1,8 @@
# 工作交接说明(HANDOFF)
> 更新:2026-08-29 晚
> 更新:2026-08-30
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`image-suite.md`](./image-suite.md)(生图)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.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)(后端结构)
---
@@ -32,7 +32,8 @@ extensions/collector 扩展采集(Ozon/1688/淘宝/天猫)
pnpm -C extensions/collector build # 产物 .output/chrome-mv3
```
- **start.command 新增**:启动时自动构建采集插件,并起后台监听(3s 轮询 `src/**` + `wxt.config.ts`,改动自动重建;BSD find 语法,无新依赖)。Ctrl+C 会连同监听进程一起清理。
- **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` 有全部键位说明。
@@ -48,7 +49,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
- **默认生图模型 = `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(日限);生图全局串行(`generator.py` 的 `asyncio.Lock`),远碰不到 RPM。pcore 账户余额 $5,按张扣费。
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。
## 4. AI 规划张数策略(`planner.py`)
@@ -64,12 +65,16 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|---|---|
| 试算页 | 五区块页面、计价、俄文文案、采集图片/出图方案/生成结果、登记导出 |
| 采集对接 | ISS 扩展并入 `extensions/collector/`,「上报商品」入库 + 自动打开试算页 |
| 生图服务端 | planner/generator/prompts/watermark/tasks,套图 + 单张 AI 生图 + 水印 + ZIP 导出 |
| 生图服务端 | 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` |
| **本轮:生图任务与轮询** | 单张 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 自动构建+监听插件 |
@@ -80,7 +85,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
## 6. ⚠️ 未提交改动清单(当前最大事项)
分支 `feat-2.1.0`,工作区有 19 个文件改动 + 1 个新目录,**建议按模块分 3-4 个 commit**:
分支 `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`
@@ -88,7 +93,8 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
`studio/src/pages/trial/{TrialSuitePanel,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. **交接文档**:`docs/v2.1/HANDOFF.md`
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`(构建产物,勿提交)。
@@ -105,9 +111,9 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
| # | 坑 | 对策 |
|---|---|---|
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 server |
| 2 | 套图任务在内存(`services/tasks.py`) | server 重启丢任务状态(前端已提示重新生成);已落盘图片不丢(在「生图记录」区) |
| 3 | 生图全局串行(`generator.py` 的 `asyncio.Lock`) | 一次只跑一个生成队列,测试时别并发提交 |
| 4 | 单张 image-edit 是同步接口 | 慢模型单张 1-5 分钟,前端 axios 超时 120s 可能先断(后端仍在跑,图会出现在生图记录) |
| 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` |
@@ -116,6 +122,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
| 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. 关键文件速查
@@ -125,7 +132,8 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
| 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 图片与生图(8 列网格/悬浮按钮/图名) | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{suite_service,generator,planner,prompts/,tasks,watermark}.py` |
| 04 图片与生图(7 列网格/悬浮按钮/图名) | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{jobs,job_worker,suite_service,generator,planner,prompts/,watermark,image_assets}.py` |
| 图片原图/水印/删除回收 | `TrialSuitePanel.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` |
+9 -6
View File
@@ -1,7 +1,7 @@
# Ozon Seller Kit V2.1 方案总览(转向:放弃 API 直传,聚焦试算工作流)
> 状态:方案定稿 + 分阶段实施(Phase A 前端先行,进行中)
> 最后更新:2026-08-26
> 状态:主链路已落地,持续打磨;后台任务中心第一、二阶段已完成
> 最后更新:2026-08-30
> 定位:本文是 V2.1 全部设计文档的入口与决策总表。先读本文,再按需读分册。
> 前置:`docs/v2/`(V2 方案)。V2.1 是对 V2 主链路的一次「降本转向」,不推翻 V2 的基础设施决策(数据库/存储/鉴权/工作台化)。
@@ -90,7 +90,7 @@ V2(原计划) 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) |
| 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 |
@@ -111,6 +111,8 @@ V2(原计划) V2.1(现在)
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
| [`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) | 后端代码结构盘点:文件/接口/服务树形图、数据流、状态标注(在用/冻结/残留) | 改后端前先读 |
@@ -122,8 +124,9 @@ V2(原计划) V2.1(现在)
|---|---|---|
| **Phase A** | docs/v2.1 方案文档 + 试算页前端(路由/入口/5 区块/服务层契约) | ✅ 完成 |
| **Phase C** | 采集对接:ISS 扩展并入 `extensions/collector/` + 「上报商品」开关 + 自动打开试算页(方案三,见 collect.md) | ✅ 完成 |
| **Phase B** | 服务端:从 ISS server 平移 planner/generator/prompts/watermark/tasks(/api/suite/*、/api/suites/*、/api/export/images、/api/proxy-image),打通试算页一键生成与单张 AI 生图 | 待开工 |
| **Phase D** | 打磨:批量 CSV 服务端化、生成图自动回写素材、水印增强、ISS-only 产品模式构建配置 | 待开工 |
| **Phase B** | 服务端:planner/generator/prompts/watermark、套图/单图接口、图片回写与导出;已切为持久化任务 + 独立 Worker | ✅ 完成 |
| **Phase D** | 打磨:批量 CSV 服务端化、图片生命周期、水印增强、ISS-only 产品模式构建配置 | 🚧 部分完成 |
| **Phase E** | 后台任务中心第三阶段:当前商品任务抽屉已完成;全局面板、任务事件、调度公平性与幂等性增强待开发 | 🚧 进行中,见 [`background-jobs.md`](./background-jobs.md) |
每个阶段结束都可独立运行:Phase A 结束时,试算页的计价/文案/素材上传/入库立即可用(对接已有接口),套图相关按钮点击会提示「服务端接口未实现」。
@@ -142,7 +145,7 @@ V2(原计划) V2.1(现在)
| 风险 | 级别 | 对策 |
|---|---|---|
| image-suite-studio 服务端是无状态内存任务表,重启丢任务 | 🟡 低 | 接受(与该项目建设一致):图片落盘 storage 不丢,任务状态丢了重新生成即可;文档明示 |
| 模型生成耗时长、可能跨服务重启 | 🟡 中 | 本仓已用持久化父/子任务 + 独立 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/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 |
+23 -12
View File
@@ -10,7 +10,7 @@
| 接口 | 用途(试算页) |
|---|---|
| `GET /api/products/{id}`、`PATCH /api/products/{id}` | 商品详情 / 试算页自动保存(raw/pricing/copy/price/weight/... 全字段) |
| `GET /api/products/{id}/assets` | 素材列表(分组/规格/状态/stored_url) |
| `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}` |
@@ -54,22 +54,24 @@
```
**Res** `{ "suite_id": "…" }`
实现要点:方案按 count 展开逐张 job;内存任务表 + 全局串行队列;逐张 build_prompt(模型家族分册)→ 参考图解析(variant 精确匹配→main 第 1 张;≤2 张)→ 生图 → 水印 → 落 storage;生成图追加 `product_assets(group_key='generated')`。
实现要点:方案按 count 展开为持久化逐图子任务;独立 Worker 领取子项并在完成后回写进度。Pcore、RightAPI 与通义万相的第三方任务 ID 会先写入数据库,重启后继续轮询同一个模型任务;完成后再水印、落 storage、追加 `product_assets(group_key='generated')`。
## 4. 任务查询 `GET /api/suites/{id}`(Phase B)
**Res**
```jsonc
{
"id": "…", "status": "pending|running|done|partial|failed",
"id": "…", "status": "queued|running|done|partial|failed|cancelled",
"style_set": 1, "platform": "ozon", "lang": "俄文", "ratio": "3:4", "provider": "rightapi",
"total": 8, // 计划总张数(images 逐张追加,过程中 length < total)
"total": 8, "completed": 3, "failed": 1,
"images": [ { "type_id": "white_bg", "name": "白底主图-01.png", "url": "/media/suites/…png",
"status": "ok|failed", "error": null } ],
"status": "queued|waiting_provider|running|done|failed|cancelled", "error": null } ],
"error": null
}
```
前端 3s 轮询;`done/partial/failed` 终态;后端重启后 404,前端提示重新生成。
前端采用“本次请求结束后再延迟下一次”的递归轮询(正常 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)
@@ -87,12 +89,19 @@
"append": true // true:结果追加 product_assets(generated)
}
```
**Res** `{ "url": "/media/suites/….png", "asset_id": "uuid|null" }`
**Res** `{ "suite_id": "…" }`
同步接口(单张 5s-5min,前端 axios 超时 120s+;gpt 系列偶发超时由前端重试提示兜底)。
实现要点:复用 §3 的 provider 层与 prompt 家族;参考图 = 传入单图;Ozon 规格(俄文 3:4)。
接口仅创建并提交统一图片任务,立即返回;前端通过 `GET /api/suites/{suite_id}` 查询状态和 `images[0].url`。完成后若 `append=true`,结果会回写为 `product_assets(group_key='generated')`。单张编辑优先级高于套图,提交模型后同样持久化第三方任务 ID;参考图 = 传入单图;Ozon 规格(俄文 3:4)。
## 7. 导出与代理(Phase B / D)
## 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. 导出与代理(Phase B / D)
| 接口 | 方法 | Req | Res |
|---|---|---|---|
@@ -100,11 +109,13 @@
| `/api/proxy-image?url=` | GET | 源站图片 URL | 图片字节(带站点 Referer 代下,绕防盗链) |
| `/api/export/trial-csv` | GET | query:`stage?` | csv blob(16 列,见 trial-page.md §7;Phase D 服务端化,替代前端拼装) |
## 8. 采集相关(Phase C 使用,均已实现)
图片资源上限:默认单张上传/远程下载 20 MB;一次导出最多 50 张,ZIP 内源图片总量与成品 ZIP 均最多 200 MB。超过限制返回 HTTP `413` 及可读错误提示;ZIP 超过 2 MB 自动写入临时磁盘,并以分块响应下载。
## 9. 采集相关(Phase C 使用,均已实现)
- `POST /api/materials`:插件上传采集结果;**响应含 `product_id`**(插件用于打开 `/trial/{id}`)。
- `GET /api/collected?platform=&itemId=`:采集前查重。
## 9. 冻结接口(不投入,保留代码)
## 10. 冻结接口(不投入,保留代码)
`/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。
+12 -9
View File
@@ -30,10 +30,11 @@ server/
│ │ ├── GET·POST /api/products 商品列表 / 新建
│ │ ├── GET·PATCH·DELETE /api/products/{id} 详情 / 自动保存 / 删除(软删 archived | 硬删)
│ │ └── POST /api/products/{id}/copy 复制为新变体(重置货号/图片防冲突)
│ ├── suite.py 【326 ✅核心·Phase B】
│ ├── suite.py 【✅核心·Phase B】
│ │ ├── POST /api/suite/plan DeepSeek 出图方案规划(同步,数秒级)
│ │ ├── POST /api/suite/generate 一键生成:方案展开→内存任务→串行生图→回写 generated 素材
│ │ ├── GET /api/suites/{id} 任务状态轮询(前端 3s 一次)
│ │ ├── 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规格 文件夹结构)
@@ -47,8 +48,8 @@ server/
│ └── ozon.py 【5 🧊冻结】空占位(远期直传重启时用)
│
├── services/ ──────────────────────── 业务逻辑层
│ ├── generator.py 【494 ✅核心】三大生图 provider(doubao 火山 / tongyi 通义 / rightapi 中转)
│ │ + asyncio.Lock 全局串行队列 + run_suite 任务执行 + 参考图解析
│ ├── 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 丢弃绑定 / 截断修复)
@@ -59,7 +60,9 @@ server/
│ │ ├── gpt.py gpt-image-2「edits 保真」语义:以参考图为准,禁止按文案重造商品
│ │ ├── google.py nano-banana「主体保持」语义:一句话钉死主体 + 自然语言指令
│ │ └── doubao.py 豆包(复用 alibaba)
│ ├── tasks.py 【70 ✅】套图内存任务注册表(⚠️ server 重启丢任务状态;已落盘图片不丢)
│ ├── 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(俄文文案模型白名单:快/省、质量两档)
@@ -141,7 +144,7 @@ extensions/collector 采集
→ 试算页 /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
│ → tasks 内存任务 → generator(prompts 家族 → provider API → watermark → storage)
│ → background_jobs / items → 独立 Worker → generator(prompts 家族 → provider API → watermark → storage)
│ → 每张成功回写 product_assets(generated) → 前端轮询 GET /api/suites/{id} + 刷新素材
└─ 05 登记(localStorage)→ 导出 CSV / 组合码(纯前端)
```
@@ -150,8 +153,8 @@ extensions/collector 采集
| 项 | 说明 |
|---|---|
| 套图任务在内存 | `tasks.py` 进程内注册表:server 重启丢任务状态(前端提示重新生成);已落盘图片不丢 |
| 生图全局串行 | `generator.py` 的 `asyncio.Lock`:一次只跑一个生成队列(防中转限流,ISS 同款) |
| 生图任务依赖独立 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(豆包,可选) |
+281
View File
@@ -0,0 +1,281 @@
# 后台任务中心:设计、实施状态与路线图
> 更新:2026-08-30
> 适用范围:套图生成、单张 AI 生图 / AI 编辑、批量添加水印,以及未来的耗时导出和批处理。
> 阅读顺序:接手相关开发前,先读本文,再读 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) 与 [`api.md`](./api.md)。
## 1. 结论与边界
“单图弹窗等待”、“套图生成”和“批量水印”本质都是耗时、可失败、需要恢复的后台操作,不应分别实现异步逻辑。项目统一采用:
```text
Web API
└─ 在数据库创建父任务 + 子项,立即返回任务 ID
独立 Worker
└─ 领取一个子项 → 调模型 / 合成水印 / 保存图片 → 回写进度与结果
试算页任务入口(当前)/ 任务中心(后续)
└─ 查询状态、恢复进度、取消、重试、打开结果
```
当前是本地单机、SQLite 架构,**不引入 Redis、Celery 或分布式队列**。数据库驱动的独立 Worker 已能覆盖断开弹窗、刷新页面、Web 服务重启、模型长耗时和失败重试,复杂度与收益最平衡。未来扩展到多机器或多实例时,再迁移 PostgreSQL 并评估 Redis / Celery / RQ 等队列。
本文中的“任务中心”指统一的数据模型、调度和用户可见的任务体验;不意味着每一种任务各有一套后台实现。
---
## 2. 产品体验
### 2.1 目标体验
单张“AI 生图 / AI 编辑”弹窗只负责配置,不负责等待:
1. 用户填写要求、选择模型,点击“提交任务”。
2. 页面立即提示“已加入任务队列”,弹窗关闭;用户可继续编辑商品或离开当前页面。
3. 页面提供“处理中(N)”入口,任务完成后图片自动出现在“生图记录”。
4. 套图与批量水印同样进入任务中心,可查看进度、失败原因、取消、重试和结果。
单张 AI 生图和 AI 编辑保留不同入口、提示词与来源图,但后端都属于 `image_edit` 单图生成任务;区别只在任务快照和标题。
### 2.2 统一状态
| 用户看到的状态 | 父任务 / 子项状态 | 含义 |
|---|---|---|
| 排队中 | `queued` | 等待 Worker 领取。 |
| 处理中 | `running` | 正在生成、下载或合成水印。 |
| 等待模型结果 | 子项 `waiting_provider` | 已提交给第三方模型,Worker 后续轮询同一个模型任务。 |
| 已完成 | `done` | 所有子项成功。 |
| 部分完成 | `partial` | 至少一个成功,且有失败或取消项。 |
| 已失败 | `failed` | 没有成功项,失败原因可查看与重试。 |
| 已取消 | `cancelled` | 用户取消;后续结果不会回写商品。 |
### 2.3 当前已呈现的界面
- 单图弹窗提交成功后关闭,生成进度由“生成结果”区域继续跟踪。
- 套图在“生成结果”区域展示排队、进行、完成、取消、失败重试。
- 水印设置浮层展示本次批量水印进度、取消、全部失败项重试和单图重试。
- 试算页右下角“录入商品”上方有固定“任务 / 处理中(N)”入口;点击打开当前商品的右侧任务抽屉,统一显示套图、单图与水印任务。
- 两类任务均会在刷新试算页后恢复跟踪。
**尚未完成的是应用级全局“任务中心”**:当前抽屉只展示当前商品,且生成结果区和水印设置浮层仍保留上下文状态提示。见第 9 节第三阶段。
---
## 3. 统一数据模型
### 3.1 已实现表
#### `background_jobs`(父任务)
一条用户可感知的后台操作,关键字段包括:
| 字段 | 用途 |
|---|---|
| `id`、`product_id`、`kind` | 任务身份、归属商品和任务类型。商品删除后 `product_id` 置空,避免任务反向复活商品。 |
| `status`、`priority`、`cancel_requested` | 调度与取消控制。 |
| `provider`、`model`、`platform`、`lang`、`ratio`、`style_set` | 生图任务的可恢复执行上下文。 |
| `request_payload` | 不可变请求快照:商品上下文、参考图、提示词、风格、水印配置等。 |
| `total`、`completed`、`failed`、`error` | 整体进度和概要错误。 |
| `created_at`、`started_at`、`completed_at`、`updated_at` | 任务历史、排序和诊断。 |
#### `background_job_items`(子项)
可独立领取、失败和重试的最小单元:
| 场景 | 父任务 | 子项 |
|---|---|---|
| 套图生成 | 一次套图请求 | 一张计划生成图 |
| 单张 AI 生图 / 编辑 | 一次编辑请求 | 唯一的一张图 |
| 批量添加水印 | 一次批量操作 | 一张商品素材 |
子项保存 `payload`、状态、尝试次数、租约、结果 URL、生成回写素材 ID、错误、下次执行时间,以及第三方 `provider_task_id` / `provider_task_kind`。
### 3.2 任务类型
| 类型 | 当前状态 | 说明 |
|---|---|---|
| `image_suite` | 已实施 | 套图按“每张图一个子项”执行。 |
| `image_edit` | 已实施 | 单张 AI 生图 / AI 编辑。 |
| `watermark_batch` | 已实施 | 已选素材逐张生成当前水印版。 |
| `export_zip` / 批量下载 | 待开发 | 可复用同一父/子任务模型处理长时间 ZIP。 |
| 孤儿文件清理 | 待开发 | 后台扫描并回收存储异常残留。 |
### 3.3 可选诊断表(未实施)
未来新增 `background_job_events`,记录“提交模型”“获取 provider task ID”“轮询失败”“租约恢复”“用户取消”“人工重试”等事件。它不参与调度,作用是排障、审计和任务中心时间线;当前诊断信息主要存于子项错误文本和服务日志。
---
## 4. 执行、恢复与一致性
### 4.1 Worker 执行流程(已实施)
`start.command` 会额外启动 `python server/worker.py`。Worker 通过数据库租约领取一个符合条件的子项:
```text
queued / waiting_provider
→ Worker 写入 running + 租约
→ 执行一次推进
├─ 模型未完成:持久化 provider_task_id → waiting_provider → 延迟下次轮询
├─ 成功:保存图片 / 回写素材 → done
├─ 可诊断错误:failed
└─ 已取消或商品不存在:cancelled
→ 从所有子项重新计算父任务进度与终态
```
Worker 异常退出时,过期租约会重新回到队列;Web 服务重启不影响已提交的数据库任务。
### 4.2 第三方模型恢复(已实施范围)
- **Pcore、RightAPI、通义万相**:提交后先持久化 `provider_task_id`,Worker 重启后继续轮询同一模型任务,不重复扣费。
- **豆包、同步千问等同步接口**:供应商不提供可恢复的外部任务 ID。若进程恰好在请求期间中断,只能重试,无法保证供应商侧幂等。
- **仍待完善的极小窗口**:若“已向供应商提交”与“成功写入 `provider_task_id`”之间进程崩溃,当前无法完全避免重复提交。第三阶段应引入供应商幂等键;不支持幂等键时,把子项标为“待人工确认”,不盲目重复扣费。
### 4.3 图片生命周期配合(已实施)
批量水印任务创建时会冻结:
- `asset_id`;
- 当时的 `current_storage_key` / 当前图片 URL;
- 水印配置快照。
Worker 始终基于素材 `original` 无水印版本合成新的 `current` 水印版。执行时若素材已删除或 current 版本已经变化,子项失败而不会用旧任务覆盖新图片;单项重试会重新冻结该素材的最新 current 版本。数据库成功切换到新 current 后,才尝试删除旧水印对象。
详细的原图、当前版、删除回收规则见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md)。
### 4.4 删除与取消(已实施)
删除商品前会取消该商品所有未完成任务。Worker 在落盘和回写素材前再次检查取消标志与商品是否存在;若商品或素材已删除,只丢弃/清理本次临时结果,绝不“复活”商品或素材记录。
---
## 5. 调度与优先级
### 当前实现
当前为一个独立 Worker 进程,按子项粒度领取,排序为优先级优先、再按创建时间:
| 类型 | 当前优先级 | 含义 |
|---|---:|---|
| `image_edit` | `10` | 用户临时单图编辑优先。 |
| `image_suite` | `0` | 常规套图。 |
| `watermark_batch` | `-10` | 低优先级,不应优先于模型生成。 |
这已避免“十张套图作为整体长期占住任务”的问题:套图也是逐张领取,单图编辑可插队。
### 后续目标(未实施)
AI 生成与水印不应长期共用单线程吞吐:
1. AI 按供应商设置串行或小并发及速率限制;单图编辑优先。
2. 批量水印使用独立逻辑队列 / Worker 池,建议并发约 2,不阻塞模型任务。
3. 调度增加公平性:连续处理 2–3 个高优先级单图后,给套图一个执行机会,避免套图饿死。
在 SQLite 单机阶段,这可先通过两个启动参数不同的 Worker 和类型过滤实现;迁移 PostgreSQL 后再做跨进程可靠并发领取。
---
## 6. API 与前端契约
### 当前接口
| 接口 | 返回 / 用途 |
|---|---|
| `POST /api/suite/generate` | 返回 `{ suite_id }`,创建 `image_suite`。 |
| `POST /api/suite/image-edit` | 返回 `{ suite_id }`,创建 `image_edit`。 |
| `POST /api/assets/watermark` | 返回 `{ job_id, total }`,创建 `watermark_batch`。 |
| `GET /api/suites/{id}` | 通用任务详情、父任务进度、逐项状态和错误。 |
| `GET /api/products/{id}/suites?active_only=true` | 恢复活动的套图 / 单图生成任务。 |
| `GET /api/products/{id}/watermark-jobs?active_only=true` | 恢复活动的水印任务。 |
| `POST /api/suites/{id}/cancel` | 取消任务中尚未完成的子项。 |
| `POST /api/suites/{id}/retry` | 重试全部失败子项。 |
| `POST /api/suites/{id}/items/{item_id}/retry` | 仅重试指定失败子项。 |
`GET /api/suites/{id}` 是历史命名;它已经承载水印任务。全局任务中心落地时应新增语义更通用的 `GET /api/background-jobs/{id}`(保留旧接口兼容)。
### 当前前端轮询
前端使用递归 `setTimeout`,在上一轮请求完成后才安排下一轮;网络失败采用指数退避并使用 `AbortController` 取消页面卸载或任务切换后的请求。浏览器会保存最近任务 ID,刷新后还会从服务端查询商品活动任务,因此不依赖单次页面生命周期。
---
## 7. 已完成阶段
| 阶段 | 内容 | 状态 |
|---|---|---|
| 第一阶段:AI 任务可靠性 | 任务表与迁移;独立 Worker;套图与单图编辑迁入持久化任务;provider task ID 持久化;取消、失败重试、刷新恢复。 | ✅ 已完成 |
| 第二阶段:批量水印复用 | `watermark_batch` 逐素材子项;后台合成和版本切换;整体进度、取消、全部失败重试、单项重试、刷新恢复。 | ✅ 已完成 |
| 数据安全配合 | 商品删除取消任务;写入前二次检查;水印任务冻结素材 current 版本,拒绝旧任务覆盖新状态。 | ✅ 已完成 |
| 第三阶段:前端任务中心(第一步) | 当前商品固定任务入口、右侧任务抽屉、任务进度、取消、失败重试、跳转生成结果。 | ✅ 已完成 |
已验证:Python 编译、前端 TypeScript/生产构建;模拟存储下的“创建水印任务 → Worker 完成 → 素材 current 回写”以及“单个失败项按最新版本重试”。真实模型与七牛仍应按第 10 节进行人工回归。
---
## 8. 当前限制
- 任务中心 UI 尚未独立成页面或全局抽屉,任务入口仍分散在生成结果和水印设置中。
- `background_job_events` 尚未建立,复杂问题主要依赖日志与错误摘要。
- 当前一个 Worker 统一领取 AI 与水印任务,优先级有效,但没有独立并发池和公平调度。
- 移除水印仍是短时同步接口;大量素材场景可迁入 `watermark_batch` 同一框架。
- SQLite 适合本机单用户,不适合多机器或高并发抢占。
- 同步供应商无法做到提交中的完全幂等恢复;异步供应商的“提交后未保存 provider ID”窗口尚未用幂等键闭合。
- 历史任务和失败临时文件尚未设置保留期与定期清理策略。
---
## 9. 待开发阶段
### 第三阶段:前端任务中心后续完善
1. **已完成**:在试算页右下角“录入商品”上方提供“任务 / 处理中(N)”入口,打开当前商品任务抽屉。
2. **已完成**:抽屉合并套图、单图编辑、水印任务,展示逐图失败、取消、重试和“查看结果”。
3. 待开发:按类型、状态筛选,展示更细的 `waiting_provider` 时间线与预计耗时。
4. 待开发:新增通用列表接口,例如 `GET /api/background-jobs?product_id=&status=&kind=`;旧 `/suites/*` 继续兼容。
5. 待开发:单图弹窗提交后提供“查看任务”快捷入口,并逐步移除弹窗自身的轮询职责。
### 第四阶段:可靠性、调度与诊断
1. 新增 `background_job_events` 与结构化日志,记录供应商、模型、耗时、错误类别和重试。
2. 引入供应商幂等键或“待人工确认”状态,关闭提交模型后的重复扣费窗口。
3. 拆分 AI 与水印的逻辑 Worker 池,配置并发、供应商限流和公平调度。
4. 增加任务历史保留期、失败临时文件清理和七牛孤儿对象扫描。
5. 把移除水印、耗时 ZIP 导出、批量下载逐步迁入任务中心。
### 第五阶段:多实例基础设施
前提是产品确实需要多用户、多机器或更高吞吐:
1. SQLite 迁移 PostgreSQL。
2. 采用 PostgreSQL 行级锁 / `SKIP LOCKED` 强化子项领取。
3. 再评估 Redis + Celery、RQ 或专业队列;不要在本地单机阶段提前引入。
---
## 10. 回归与运维清单
每次修改任务系统后,至少验证:
1. `./start.command` 会同时启动 Web、前端和 Worker;手动启动 server 时必须另开 `python server/worker.py`。
2. 单图编辑提交后可关闭弹窗;刷新试算页后仍能看到任务和最终图片。
3. 套图多张任务中插入单图编辑,单图应优先领取,套图不丢进度。
4. 批量水印中删除商品或单个素材,Worker 不得回写/复活资源。
5. 改水印任务执行期间再次修改同一素材,旧任务必须失败或跳过,不能覆盖新 current。
6. Pcore / 通义等异步模型在重启 Worker 后继续轮询原 `provider_task_id`,不再次提交。
7. 失败项的全部重试与单项重试都能恢复状态;单项水印重试使用最新素材版本。
8. 真实存储环境检查旧水印对象仅在新 current 数据库提交后删除;失败时不会丢失原图。
## 11. 代码入口
| 位置 | 责任 |
|---|---|
| `server/models/job.py` | 父任务、子项 ORM 模型。 |
| `server/migrations/versions/b5d2c9a3e1f7_add_background_jobs.py` | 任务表迁移。 |
| `server/services/jobs.py` | 创建、领取、租约恢复、状态聚合、取消与重试。 |
| `server/services/job_worker.py` | 执行生成和批量水印子项。 |
| `server/worker.py`、`start.command` | 独立 Worker 命令与启动编排。 |
| `server/api/suite.py` | 套图、单图编辑和通用任务查询接口。 |
| `server/api/materials.py` | 批量水印创建和水印任务列表。 |
| `studio/src/pages/trial/TrialSuitePanel.tsx` | 试算页任务恢复、轮询、取消与重试入口。 |
| `studio/src/pages/trial/AiImageGenModal.tsx` | 单图任务配置与提交入口。 |
+127
View File
@@ -0,0 +1,127 @@
# 图片资产生命周期
> 状态:已实施(2026-08-30)
> 适用范围:采集图、手动上传图、AI 套图、单张 AI 图生图,以及图片水印。
## 1. 核心模型
每个 `product_assets` 图片素材只维护两个逻辑版本:
```text
original:无水印原图,始终保留
current:页面预览、单图下载、ZIP 导出使用的当前版本
```
| 状态 | original | current | 七牛对象数 |
|---|---|---|---|
| 未加水印 | 同一个对象 | 指向 original | 1 |
| 已加水印 | 无水印对象 | 指向水印对象 | 2 |
系统**不保留历史水印版**。修改水印时,先上传新的水印版并切换 current,再删除旧水印版。
采集图、上传图、AI 图的区别只在 original 的来源:采集源站、用户上传文件、模型输出。后续水印、下载、删除均走同一套流程。
## 2. 数据字段
`product_assets` 继续保留 `stored_url` 兼容既有前端接口;其语义为 current URL。新增字段:
| 字段 | 含义 |
|---|---|
| `original_url` | 无水印原图公开 URL |
| `original_storage_key` | 无水印原图对象 Key |
| `current_storage_key` | 当前展示对象 Key |
| `watermark_enabled` | current 是否为水印版 |
| `watermark_config` | 当前水印配置快照 |
删除对象必须使用 `storage_key`,不应由 URL 反推;`storage_key_from_url()` 仅用于兼容旧数据迁移。
迁移:`a41e8c7f4b20_add_asset_image_lifecycle.py`。`start.command` 会在启动服务前执行 `alembic upgrade head`。
## 3. 对象路径
新图片统一使用:
```text
products/{product_id}/assets/{asset_id}/original.{ext}
products/{product_id}/assets/{asset_id}/watermark-{uuid}.{ext}
```
路径前缀不仅便于单图精准删除,也允许删除商品时按 `products/{product_id}/` 兜底清理所有对象,包括异常中断留下的孤儿文件。
## 4. 创建与水印流程
### 采集、上传、无水印 AI 图
```text
写入 original
→ current 指向 original
→ watermark_enabled = false
```
### 添加或修改水印
```text
读取 original
→ 合成新水印图
→ 上传新 current
→ 提交数据库切换 current
→ 删除旧 current(水印版)
```
因此不会叠加水印。AI 生图参考图也优先使用 `original_url`,避免把水印带进下一次生成。
### 移除水印
```text
current 切回 original
→ 提交数据库
→ 删除旧水印对象
```
## 5. 删除流程
### 删除单张素材
```text
删除 products/{product_id}/assets/{asset_id}/ 前缀
→ 删除旧格式路径中的 original/current 对象(兼容迁移前数据)
→ 删除 product_assets 记录
```
若对象存储删除失败,接口返回失败并保留数据库素材记录,避免出现“数据库已删、文件未删”的静默状态。
### 硬删除商品
```text
查询商品所有素材
→ 清理每个素材目录和旧格式对象
→ 清理 products/{product_id}/ 前缀(孤儿兜底)
→ 删除商品,数据库级联删除素材记录
```
归档商品只改变商品状态,不删除图片。
## 6. 服务职责
| 模块 | 职责 |
|---|---|
| `services/image_assets.py` | original/current 生命周期、加/移水印、迁入正式路径、单图/整商品回收 |
| `services/storage.py` | 本地/七牛上传、下载、按 Key 删除、按前缀删除、URL→Key 兼容解析 |
| `services/watermark.py` | 纯图片字节合成;不操作数据库或对象存储 |
| API 路由 | 参数校验、数据库提交、调用生命周期服务 |
## 7. 前端入口
试算页“采集图片”和“生成结果”均可打开水印设置:
- 为已选采集图添加水印;
- 为已选生成图添加水印;
- 移除已选采集图或生成图水印;
- 新的一键生图和单张 AI 生图按当前设置自动加水印。
预览、下载与 ZIP 导出始终使用 `stored_url`(current);AI 生成参考图使用 `original_url`。
## 8. 旧数据兼容
旧素材首次加/移水印时会迁入新的商品/素材路径,并补齐 original/current 字段。迁移前已经产生但数据库无记录的旧七牛对象无法可靠识别;后续可按运营需要增加七牛前缀扫描与人工确认清理工具。
+6 -5
View File
@@ -47,8 +47,8 @@ POST /api/suite/generate
`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 词)。
- **任务模型**:进程内内存任务表 + `asyncio.Lock` 全局串行队列(防中转限流);逐张生成 → Pillow 水印 → 按 PNG 魔数定扩展名落 `storage`。**重启丢任务、图片不丢**(与 ISS 一致,接受)。
- **前端轮询**:`GET /api/suites/{id}` 每 3s;`done/partial/failed` 终态;连续 3 次失败停止跟踪;超预算(5min/张+10min)提示。
- **任务模型**:数据库父任务 + 逐图子任务,独立 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 表)
@@ -72,14 +72,15 @@ POST /api/suite/generate
```
采集图/生成图单元格 → 「AI生图」按钮
→ 弹窗:左侧原图预览;右侧要求文本域(必填)+ 模型下拉(同上 10 个)+ 生成按钮
→ 弹窗:左侧原图预览;右侧要求文本域(必填)+ 模型下拉(同上 10 个)+ 提交任务按钮
→ POST /api/suite/image-edit { product_id, image_url, prompt, model, append: true }
→ 返回 { url },弹窗内展示结果,可下载/打开原图
→ 立即返回 { suite_id },弹窗轮询 GET /api/suites/{suite_id}
→ 终态后展示 images[0].url,可下载/打开原图
→ 服务端把结果追加为 product_assets(group_key='generated', variant_name=null)
```
- 与「智能修图」页(`/ai-image`,wanx 注解编辑)并存:本入口是**轻量单张再生成**(选模型 + 一句话要求),不做标注/遮罩。
- 服务端实现 = 统一模型路由的单张调用(复用 §5 provider 层 + 参考图即传入的单图 + prompt = 用户要求 + Ozon 平台规格),生成后落 storage 并回写素材;`append=false` 时不回写。
- 服务端实现 = 复用套图的持久化任务和模型路由(参考图即传入的单图 + prompt = 用户要求 + Ozon 平台规格),生成后落 storage 并回写素材;`append=false` 时不回写。提交接口不等待模型完成,用户可关闭弹窗继续工作。
## 8. 展示与防盗链
+167
View File
@@ -0,0 +1,167 @@
# 前后端优化审查清单
> 审查日期: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:统一图片格式、尺寸和元数据校验
- 现状:存储层主要依据 URL 后缀或 Content-Type 推断格式,未统一校验文件大小、像素、实际 MIME 类型和方向信息。
- 涉及:`server/services/storage.py`、`server/api/materials.py`。
- 建议:上传/下载后用图片库校验真实格式,限制像素与文件大小,按需要压缩、纠正 EXIF 方向并记录宽高。
---
## 3. 服务端:安全与资源保护
### P0:限制图片代理和导出 URL,防止 SSRF
- 现状:图片代理、单图下载、ZIP 导出会请求客户端传入的 HTTP(S) URL,并跟随重定向。
- 涉及:`server/api/proxy.py`、`server/api/export.py`、`server/services/storage.py`。
- 风险:若服务可被非本机访问,可能被用于读取内网地址、云元数据地址,或下载超大文件。
- 建议:解析并拒绝回环、内网、链路本地与保留 IP;每次重定向后再次校验;设置域名白名单/允许列表、最大响应体、连接与总耗时上限。
### 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,400 行,采集图和生成图卡片、悬浮操作、选择、下载等逻辑有重复。
- 建议:拆分为素材网格、结果网格、图片动作层、预览下载、水印设置、套图轮询等子组件/hooks。
- 收益:降低后续 UI 修改遗漏风险,便于复用和测试。
### P2:图片多时采用懒加载/虚拟化
- 现状:图片网格会一次性创建全部 Ant Design Image 与预览节点。
- 建议:大量历史生成图时采用分页、虚拟列表或按需加载;缩略图使用合适尺寸和 `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 契约、索引、可观测性**:依据真实使用量逐步完善。
+69 -11
View File
@@ -10,14 +10,17 @@ from __future__ import annotations
import io
import mimetypes
import re
import tempfile
import zipfile
from collections.abc import Iterator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, Field
from api.proxy import guess_referer
from services.storage import download_bytes, local_path
from config import get_settings
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes
router = APIRouter(prefix="/api", tags=["export"])
@@ -39,7 +42,7 @@ class ExportImageItem(BaseModel):
class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem]
images: list[ExportImageItem] = Field(min_length=1)
def _clean(name: str) -> str:
@@ -75,27 +78,51 @@ async def _download(url: str) -> tuple[bytes, str]:
path = local_path(url)
if path is not None:
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
return path.read_bytes(), mime
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):
if not req.images:
raise HTTPException(status_code=400, detail="没有可导出的图片")
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 "采集图片"
buf = io.BytesIO()
# 小 ZIP 留在内存,大 ZIP 自动滚动到临时磁盘;响应阶段按块传输。
archive = tempfile.SpooledTemporaryFile(
max_size=settings.export_zip_memory_threshold_bytes,
mode="w+b",
)
used: set[str] = set()
total_source_bytes = 0
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
try:
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for img in req.images:
try:
data, ctype = await _download(img.url)
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"无法导出「{img.key or '图片'}」:{exc}") from exc
except Exception: # noqa: BLE001
continue # 单张失败不中断整包
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"
@@ -112,10 +139,41 @@ async def export_images(req: ExportImagesRequest):
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
buf.seek(0)
archive.seek(0)
return StreamingResponse(
buf,
_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 ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法下载:{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": f'attachment; filename="{base}{ext}"'},
)
+164 -18
View File
@@ -2,9 +2,11 @@
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
@@ -12,8 +14,38 @@ from db import get_db, get_session_factory
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.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:
@@ -115,6 +147,11 @@ async def create_materials(
background: BackgroundTasks,
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)
@@ -176,9 +213,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(
@@ -190,8 +225,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
@@ -213,7 +247,17 @@ async def upload_material_bytes(
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)
asset = ProductAsset(
product_id=product.id,
group_key=group_key,
@@ -225,11 +269,10 @@ 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, data, file.content_type or "")
asset.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
asset.status = AssetStatus.failed
@@ -269,20 +312,16 @@ async def is_collected(
@router.delete("/assets/{asset_id}")
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
"""删除一条素材记录(DB 行 + 本地文件;七牛等远程存储仅删引用)。"""
from services.storage import local_path
"""删除一条素材记录及其 original/current 七牛对象。"""
asset = await db.get(ProductAsset, UUID(asset_id))
if asset is None:
raise HTTPException(status_code=404, detail="素材不存在")
if asset.stored_url:
path = local_path(asset.stored_url)
if path is not None and path.is_file():
try:
path.unlink()
except OSError: # noqa: BLE001
pass # 文件删除失败不阻断记录删除
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
@@ -298,3 +337,110 @@ async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
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)
+15
View File
@@ -134,6 +134,18 @@ async def delete_product(
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
@@ -160,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,
+3 -1
View File
@@ -8,7 +8,7 @@ from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query, Response
from services.storage import download_bytes
from services.storage import ResourceSizeLimitError, download_bytes
router = APIRouter(prefix="/api", tags=["proxy"])
@@ -40,6 +40,8 @@ async def proxy_image(url: str = Query(..., description="源站图片 URL")):
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
try:
data, ctype = await download_bytes(url, referer=guess_referer(url))
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法代理:{exc}") from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
if not ctype.startswith("image/"):
+179 -107
View File
@@ -1,7 +1,7 @@
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 AI 生图。
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studio(services/planner|generator|
tasks|watermark + services/prompts),任务存进程内内存表(重启即失效)。
watermark + services/prompts),任务持久化到数据库,由独立 Worker 执行。
生成图回调回写 product_assets(group_key='generated');product_id 缺省时只落盘不回写。
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
@@ -9,11 +9,11 @@ tasks|watermark + services/prompts),任务存进程内内存表(重启即
from __future__ import annotations
import io
import tempfile
import uuid
import zipfile
from functools import partial
from fastapi import APIRouter, BackgroundTasks, HTTPException
from collections.abc import Iterator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from config import get_settings
@@ -21,7 +21,7 @@ from schemas.suite import (
PLATFORM_SPECS,
SUPPORTED_TYPES,
ImageEditSingleRequest,
ImageEditSingleResponse,
ImageEditTaskResponse,
PlanItemOut,
PlanResponse,
SuiteCreateResponse,
@@ -31,16 +31,34 @@ from schemas.suite import (
api_model,
resolve_provider,
)
from services.generator import _image_size, GENERATORS, run_suite
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 build_context, build_prompt, type_name
from services.storage import download_bytes, get_storage, local_path
from services.suite_service import append_generated_asset, texts_to_raw, validate_model
from services.tasks import IMG_OK, TaskImage, create_task, get_task
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)
@@ -64,7 +82,7 @@ async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
# ── 一键生成 ─────────────────────────────────────────────────────────────
@router.post("/suite/generate", response_model=SuiteCreateResponse)
async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks) -> SuiteCreateResponse:
async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
if not req.images:
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
@@ -111,94 +129,146 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
task = create_task(
status="pending",
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,
style_prompt=req.style_prompt,
requirements=req.requirements,
provider=provider_name,
model=model,
total=len(jobs),
context=texts_to_raw(req.texts),
plan=jobs,
watermark=req.watermark.model_dump() if req.watermark else None,
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
ref_images=[
{
"url": i.url,
"group_key": i.group_key,
"variant_name": i.variant_name,
}
for i in sorted(req.images, key=lambda x: 0 if x.group_key == "main" else 1)
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)],
)
# 每张成功即回写 generated 组;未关联商品时仅落存储不回写。
# partial 绑定 product_id:run_suite 回调只传 image,签名须为 (image)
background.add_task(
run_suite,
task,
partial(append_generated_asset, product_id) if product_id else None,
)
return SuiteCreateResponse(suite_id=task.id)
return SuiteCreateResponse(suite_id=str(task.id))
# ── 任务查询 / 结果导出 ──────────────────────────────────────────────────
@router.get("/suites/{suite_id}", response_model=SuiteOut)
async def get_suite(suite_id: str):
task = get_task(suite_id)
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="任务不存在(服务可能已重启),请重新生成")
return SuiteOut(
id=task.id,
status=task.status,
style_set=task.style_set,
platform=task.platform,
lang=task.lang,
ratio=task.ratio,
provider=task.provider,
model=task.model,
total=task.total,
images=[
{"type_id": i.type_id, "name": i.name, "url": i.url, "status": i.status, "error": i.error}
for i in task.images
],
error=task.error,
)
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(中文文件名)。"""
task = get_task(suite_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启)")
result = await get_job(suite_id)
if result is None:
raise HTTPException(status_code=404, detail="任务不存在")
task, task_items = result
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
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([i for i in task.images if i.status == IMG_OK]):
path = local_path(img.url or "")
for i, img in enumerate(images):
path = local_path(img.result_url or "")
try:
if path is not None:
data, suffix = path.read_bytes(), path.suffix or ".jpg"
else:
try: # 七牛等远程存储:直接拉取公开 URL
data, _ = await download_bytes(img.url or "")
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
suffix = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
filename = img.name or img.type_id
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)
buf.seek(0)
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(
buf,
_stream_file(archive),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.zip\""},
)
@@ -206,9 +276,9 @@ async def download_suite_zip(suite_id: str):
# ── 单张 AI 生图(V2.1 新增,ISS 无对应实现)──────────────────────────────
@router.post("/suite/image-edit", response_model=ImageEditSingleResponse)
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleResponse:
"""轻量单张再生成:原图 + 一句话要求 + 模型选择。同步接口(单张可长达数分钟)。"""
@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:
@@ -217,39 +287,41 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
is_wan = provider_name == "tongyi" and model.lower().startswith("wan")
# 复用套图的 prompt 家族:custom 类型装配「用户要求」为最高优先级约束,
# 商品外观仍由参考图锁定(保真语义与一键生成一致)。
ctx = build_context({"title": "AI 精修"}, fallback_name="product")
prompt = build_prompt(
provider_name, model, "custom", ctx,
style_set=5, lang=spec["lang"],
extra={"title": "AI 精修", "detail": req.prompt, "prompt_hint": ""},
requirements=req.prompt,
)
product_id = (req.product_id or "").strip() or None
if product_id:
try:
generator = GENERATORS[provider_name]
data = await generator(prompt, [req.image_url], size=_image_size(provider_name, spec["ratio"], is_wan=is_wan, model=model), model=model)
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
uuid.UUID(product_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
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"
url = await get_storage().save_bytes(data, f"suites/single/{uuid.uuid4().hex}{ext}", ctype)
asset_id: str | None = None
if req.append and req.product_id:
try:
asset_id = await append_generated_asset(
req.product_id,
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
after_asset_id=req.after_asset_id,
# 单图编辑优先级高于套图;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": ""},
}],
)
except Exception: # noqa: BLE001
pass # 回写失败不影响结果返回,图片已在任务网格可见
return ImageEditSingleResponse(url=url, asset_id=asset_id)
return ImageEditTaskResponse(suite_id=str(task.id))
+10
View File
@@ -49,6 +49,16 @@ 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
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
# ── V2.1 套图生图(provider 路由与 image-suite-studio 一致)──
image_provider: str = "doubao" # 未知模型时的兜底 provider:doubao | tongyi | rightapi
request_timeout: int = 300 # 单张生图请求超时(秒)
@@ -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,92 @@
"""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 冲突。
if "background_jobs" in sa.inspect(op.get_bind()).get_table_names():
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"])
def downgrade() -> None:
op.drop_table("background_job_items")
op.drop_table("background_jobs")
+3
View File
@@ -4,6 +4,7 @@
由 legacy.api 路由 import 链注册到同一 Base.metadata。
"""
from models.asset import ProductAsset
from models.job import BackgroundJob, BackgroundJobItem
from models.product import Product
from models.user import User
@@ -11,4 +12,6 @@ __all__ = [
"User",
"Product",
"ProductAsset",
"BackgroundJob",
"BackgroundJobItem",
]
+11 -3
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import AssetStatus
from models.types import JSONType
class ProductAsset(Base):
@@ -22,8 +23,15 @@ class ProductAsset(Base):
variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # SKU 规格名
sort_order: Mapped[int] = mapped_column(Integer, default=0)
type: Mapped[str] = mapped_column(String(8), default="img") # img / video
source_url: Mapped[str] = mapped_column(Text, default="")
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地路径或七牛公网 URL
source_url: Mapped[str] = mapped_column(Text, default="") # 采集源站地址;生成图旧数据可能存无水印 URL
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 当前展示版 URL(兼容旧接口)
# 图片生命周期:original 始终无水印;当前版用于页面预览、下载、导出。
# 未加水印时 original 与 current 指向同一对象;不保留历史水印版。
original_url: Mapped[str | None] = mapped_column(Text, nullable=True)
original_storage_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
current_storage_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
watermark_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
watermark_config: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
status: Mapped[AssetStatus] = mapped_column(
Enum(AssetStatus, native_enum=False, length=16), default=AssetStatus.pending, index=True
)
+78
View File
@@ -0,0 +1,78 @@
"""持久化后台任务:AI 套图、单张编辑共用的父任务与逐图子任务。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.types import JSONType
class BackgroundJob(Base):
"""一个用户可见的后台操作:套图、单张编辑或批量水印。"""
__tablename__ = "background_jobs"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 商品删除后保留任务诊断记录,但 Worker 不会再把结果回写到不存在的商品。
product_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="SET NULL"), nullable=True, index=True
)
kind: Mapped[str] = mapped_column(String(32), index=True) # image_suite | image_edit | watermark_batch
status: Mapped[str] = mapped_column(String(24), default="queued", index=True)
priority: Mapped[int] = mapped_column(Integer, default=0, index=True)
provider: Mapped[str] = mapped_column(String(32), default="")
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
platform: Mapped[str] = mapped_column(String(16), default="ozon")
lang: Mapped[str] = mapped_column(String(16), default="ru")
ratio: Mapped[str] = mapped_column(String(16), default="3:4")
style_set: Mapped[int] = mapped_column(Integer, default=1)
# 原始请求的不可变快照:商品上下文、参考图池、风格、要求、水印等。
request_payload: Mapped[dict] = mapped_column(JSONType, default=dict)
total: Mapped[int] = mapped_column(Integer, default=0)
completed: Mapped[int] = mapped_column(Integer, default=0)
failed: Mapped[int] = mapped_column(Integer, default=0)
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
)
class BackgroundJobItem(Base):
"""任务中的一个可调度工作单元:套图的一张图或单张编辑。"""
__tablename__ = "background_job_items"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
job_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("background_jobs.id", ondelete="CASCADE"), index=True
)
position: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(24), default="queued", index=True)
payload: Mapped[dict] = mapped_column(JSONType, default=dict)
# 供应商异步任务 ID 与类型(pcore 还需 edits/generations);重启后继续轮询,不重新提交。
provider_task_id: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
provider_task_kind: Mapped[str | None] = mapped_column(String(64), nullable=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, default=3)
result_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
watermark_config: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
generated_asset_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
lease_owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
)
+2
View File
@@ -18,6 +18,8 @@ class ProductListItem(BaseModel):
currency_code: str = "RUB"
source_platform: str | None = None
source_url: str | None = None
# 采集箱显示中文标题:优先 raw.title_zh,兼容早期采集写入的 raw.title。
raw: dict | None = None
asset_counts: dict | None = None
ozon_product_id: int | None = None
created_at: datetime
+16 -5
View File
@@ -1,6 +1,7 @@
"""套图规划 / 一键生成 / 单张 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
@@ -61,7 +62,7 @@ PLATFORM_SPECS: dict[str, dict] = {
class TextMaterial(BaseModel):
kind: str = Field(..., description="title | params | selling_point | desc | price | brand | sales | shop")
kind: str = Field(..., description="title | title_ru | params | selling_point | desc | price | brand | sales | shop")
content: str = ""
pairs: list[dict] | None = None # [{key, value}]
@@ -135,6 +136,7 @@ class SuiteCreateResponse(BaseModel):
class SuiteImageOut(BaseModel):
item_id: str
type_id: str
name: str
url: str
@@ -145,6 +147,7 @@ class SuiteImageOut(BaseModel):
class SuiteOut(BaseModel):
id: str
status: str
kind: str = "image_suite"
style_set: int
platform: str
lang: str
@@ -152,6 +155,12 @@ class SuiteOut(BaseModel):
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
@@ -160,15 +169,17 @@ 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(..., min_length=1, description="用户要求(必填,进生图指令)")
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 ImageEditSingleResponse(BaseModel):
url: str
asset_id: str | None = None
class ImageEditTaskResponse(BaseModel):
"""单张编辑已进入统一图片任务队列;用 GET /api/suites/{suite_id} 查询结果。"""
suite_id: str
+196 -5
View File
@@ -13,6 +13,7 @@ import base64
import logging
import mimetypes
import re
from dataclasses import dataclass
from typing import Awaitable, Callable
import httpx
@@ -30,7 +31,7 @@ from services.tasks import (
TaskImage,
)
from services.watermark import apply_watermark
from services.storage import download_bytes, get_storage, local_path
from services.storage import download_bytes, get_storage, local_path, read_local_bytes
log = logging.getLogger("suite.generator")
@@ -125,7 +126,7 @@ async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]:
path = local_path(url)
if path is not None:
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
return path.read_bytes(), mime
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))
@@ -288,6 +289,18 @@ async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin:
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)。
@@ -411,6 +424,19 @@ async def _pcore_poll_task(client: httpx.AsyncClient, headers: dict, base: str,
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)。
@@ -482,6 +508,164 @@ async def generate_pcore(prompt: str, ref_images: list[str], size: str = "1536x2
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,
@@ -524,12 +708,12 @@ def _refs_for_job(images: list[dict], job: dict) -> list[str]:
_GEN_LOCK = asyncio.Lock()
async def _store_image_bytes(task: Task, index: int, data: bytes) -> str:
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}/{index}{ext}", ctype)
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:
@@ -565,16 +749,23 @@ async def run_suite(task: Task, on_image_ok: OnImageOk | None = None) -> None:
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=job, style_prompt=task.style_prompt, requirements=task.requirements,
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
+167
View File
@@ -0,0 +1,167 @@
"""商品图片生命周期:原图、当前水印版与存储回收的唯一入口。
每个图片素材只维护两个逻辑版本:original(无水印)与 current(页面展示/导出)。
未加水印时两者指向同一个对象;重加水印会先上传新 current,再切换引用并删除旧 current,
不保留历史水印版。
"""
from __future__ import annotations
import mimetypes
import uuid
from dataclasses import dataclass
from api.proxy import guess_referer
from models import ProductAsset
from services.storage import download_bytes, get_storage, image_extension, local_path, 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 = "") -> str:
"""写入该素材唯一的无水印原图,并将 current 同步到原图。"""
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)
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()
+212
View File
@@ -0,0 +1,212 @@
"""独立后台 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
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_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch":
await _process_watermark_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")
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)
if claim is None:
if once:
return
await asyncio.sleep(idle_seconds)
continue
await process_item(claim)
if once:
return
+470
View File
@@ -0,0 +1,470 @@
"""数据库驱动的后台任务仓库与调度状态机。
已承载图片生成与批量水印;表结构刻意通用,后续可复用到 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,再安排下一次查询,是避免重启重复扣费的关键提交点。"""
async with get_session_factory()() as db:
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
if item is None:
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 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
],
}
+5 -11
View File
@@ -7,7 +7,7 @@
from __future__ import annotations
from .common import (
STYLE_SETS, TEXT_RENDER, requirements_block, resolve_style, selling_point_lines,
STYLE_SETS, TEXT_RENDER, params_line_for_output, requirements_block, resolve_style, selling_point_lines,
)
# ── 公共组件(主体参考语义专用)────────────────────────────────────────────
@@ -85,7 +85,7 @@ def _prompt_multi_scene(ctx: dict, style: dict, lang: str) -> str:
def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
params = ctx["params_line"]
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']}); "
@@ -95,7 +95,7 @@ def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
)
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
dims = ctx["params_line"]
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, "
@@ -115,15 +115,9 @@ def _prompt_sku_collection(ctx: dict, style: dict, lang: str) -> str:
def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
hint = (extra.get("prompt_hint") or "").strip()
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
bg = f" {style['bg']} as environment." if style.get("bg") else ""
composed = (
f"E-commerce marketing image for product \"{ctx['title']}\""
+ (f" — {purpose}" if purpose else "")
+ (f": {detail}" if detail 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}"
+28 -9
View File
@@ -12,27 +12,27 @@ import re
STYLE_SETS: dict[int, dict] = {
1: {
"name": "北欧极简",
"tone": "北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净",
"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": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
"tone": "Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition",
"bg": "",
},
3: {
"name": "高级感深色",
"tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
"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": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
"tone": "Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere",
"bg": "",
},
5: {
"name": "纯净棚拍",
"tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
"tone": "Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent",
"bg": "",
},
}
@@ -70,6 +70,9 @@ TEXT_RENDER = {
"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."
),
}
@@ -86,8 +89,8 @@ def requirements_block(requirements: str | None) -> str:
"""用户强制要求块:最高优先级、置于提示词最前、覆盖冲突指令(原文保留不翻译)。"""
if requirements and requirements.strip():
return (
"STRICT REQUIREMENTS (highest priority, must be followed exactly, "
"override any conflicting instruction): "
"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(".")
+ "."
)
@@ -110,7 +113,8 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
raw: {title, desc, price, params: [{key, value}], sellingPoints}
"""
title = _clean_title(raw.get("title") or fallback_name or "product")
# 试算页会同时上传中文采集标题和已回填的俄文标题;后者用于图内可见文案。
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)
# 卖点:优先显式卖点文本;否则从参数表里挑短而有信息量的键值对
@@ -138,6 +142,12 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
"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 "",
}
@@ -147,7 +157,16 @@ def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
if not sps:
return ""
key = "zh" if lang == "zh" else "en"
return "; ".join(s[key] for s in sps if s.get(key))
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:
+5 -9
View File
@@ -9,7 +9,7 @@
"""
from __future__ import annotations
from .common import TEXT_RENDER, requirements_block, resolve_style, selling_point_lines
from .common import TEXT_RENDER, params_line_for_output, requirements_block, resolve_style, selling_point_lines
_SUBJECT_LOCK = (
"SUBJECT LOCK (highest priority): the product in the first image is the subject. "
@@ -32,8 +32,8 @@ _REMINDER = (
def _anchor(ctx: dict) -> str:
"""商品文字锚定:仅供识别,明确以图为准(同 gpt 模块,避免文字反噬商品)。"""
line = f"Context (identification only): the product is \"{ctx['title']}\""
if ctx.get("params_line"):
line += f" ({ctx['params_line']})"
if ctx.get("params_line_non_cjk"):
line += f" ({ctx['params_line_non_cjk']})"
return line + ". The image, not this text, defines the product's appearance."
@@ -88,7 +88,7 @@ def _task_multi_scene(ctx: dict, lang: str) -> str:
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
params = ctx["params_line"]
params = params_line_for_output(ctx, lang)
return (
"Compose a square detail-page hero section: top half a hero banner with the unchanged product "
"at a 3/4 angle; bottom half a clean spec card with 3 feature rows and line icons"
@@ -97,7 +97,7 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
)
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = ctx["params_line"]
dims = params_line_for_output(ctx, lang)
return (
"Create a size chart: the unchanged product in clean front and side views on a light background, "
"thin measurement annotation lines (arrows) marking length, width and height with values beside "
@@ -135,11 +135,7 @@ def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict
hint = (extra.get("prompt_hint") or "").strip()
if type_id == "custom":
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
task = "Create an e-commerce marketing image featuring the product from the first image"
task += f" — {purpose}" if purpose else ""
task += f": {detail}" if detail else ""
task += "."
wants_text = True
else:
+5 -9
View File
@@ -15,7 +15,7 @@
"""
from __future__ import annotations
from .common import TEXT_RENDER, requirements_block, resolve_style, selling_point_lines
from .common import TEXT_RENDER, params_line_for_output, requirements_block, resolve_style, selling_point_lines
# 保真锁:商品由 Image 1 唯一定义,其余指令一律不得触碰商品本体
_PRESERVE = (
@@ -55,8 +55,8 @@ def _anchor(ctx: dict) -> str:
文字锚定用于帮模型认出"是哪件商品",而不是"长什么样"。
"""
line = f"CONTEXT (identification only): the product is \"{ctx['title']}\""
if ctx.get("params_line"):
line += f" ({ctx['params_line']})"
if ctx.get("params_line_non_cjk"):
line += f" ({ctx['params_line_non_cjk']})"
return (
line
+ ". Image 1 — not this text — defines the product's appearance; "
@@ -119,7 +119,7 @@ def _task_multi_scene(ctx: dict, lang: str) -> str:
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
sp = selling_point_lines(ctx, lang) or ctx["title"]
params = ctx["params_line"]
params = params_line_for_output(ctx, lang)
return (
"TASK: Detail-page hero section on a square canvas. Top half: hero banner with the unchanged "
"product at a 3/4 angle. Bottom half: clean spec card with 3 feature rows and line icons"
@@ -128,7 +128,7 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
)
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = ctx["params_line"]
dims = params_line_for_output(ctx, lang)
return (
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
"background; overlay thin technical annotation lines (arrows) marking length, width and height "
@@ -167,11 +167,7 @@ def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict
hint = (extra.get("prompt_hint") or "").strip()
if type_id == "custom":
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
task = "TASK: Create an e-commerce marketing image featuring the product from Image 1"
task += f" — {purpose}" if purpose else ""
task += f": {detail}" if detail else ""
task += "."
wants_text = True
else:
+140 -21
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import mimetypes
import shutil
import uuid
from pathlib import Path
@@ -13,11 +14,16 @@ from config import get_settings
_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media"
def _ext_from_url(url: str) -> str:
ext = mimetypes.guess_extension(url.split("?")[0].lower()) or ".jpg"
if ext == ".jpe":
ext = ".jpg"
return ext
class ResourceSizeLimitError(ValueError):
"""上传、下载或本地读取超过单文件上限。"""
def __init__(self, limit_bytes: int, actual_bytes: int | None = None) -> None:
self.limit_bytes = limit_bytes
self.actual_bytes = actual_bytes
detail = f"图片文件超过大小上限(最多 {limit_bytes // 1024 // 1024} MB)"
if actual_bytes is not None:
detail += f",当前约 {actual_bytes / 1024 / 1024:.1f} MB"
super().__init__(detail)
def _ext_from_content_type(content_type: str) -> str:
@@ -34,40 +40,109 @@ def _ext_from_content_type(content_type: str) -> str:
return mapping.get(ctype, ".jpg")
async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]:
"""下载远程字节。返回 (bytes, content_type)。"""
def image_extension(data: bytes, content_type: str = "") -> str:
"""按真实图片字节优先推断扩展名,避免 provider 错报 Content-Type。"""
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
if data[:3] == b"GIF":
return ".gif"
return _ext_from_content_type(content_type)
async def download_bytes(
url: str,
referer: str | None = None,
timeout: float = 60.0,
max_bytes: int | None = None,
) -> tuple[bytes, str]:
"""流式下载远程图片并限制体积。返回 (bytes, content_type)。"""
limit = max_bytes or get_settings().image_download_max_bytes
if limit <= 0:
raise ValueError("图片下载大小上限必须大于 0")
headers = {"Referer": referer} if referer else {}
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
async with client.stream("GET", url, headers=headers) as resp:
resp.raise_for_status()
length = resp.headers.get("content-length")
if length:
try:
declared_size = int(length)
except ValueError:
declared_size = None
if declared_size is not None and declared_size > limit:
raise ResourceSizeLimitError(limit, declared_size)
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > limit:
raise ResourceSizeLimitError(limit, total)
chunks.append(chunk)
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
return resp.content, ctype
return b"".join(chunks), ctype
def read_local_bytes(path: Path, max_bytes: int | None = None) -> bytes:
"""受单文件上限保护地读取本地媒体文件。"""
limit = max_bytes or get_settings().image_download_max_bytes
size = path.stat().st_size
if size > limit:
raise ResourceSizeLimitError(limit, size)
return path.read_bytes()
class LocalStorage:
"""开发期:落 data/media/,由 FastAPI /media 静态托管,返回 app_base_url 可访问 URL。"""
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
data, _ = await download_bytes(url, referer)
key = self._write(data, key_prefix, url)
return self.public_url(key)
data, ctype = await download_bytes(url, referer)
key = f"{key_prefix.rstrip('/') + '/' if key_prefix else ''}{uuid.uuid4().hex}{image_extension(data, ctype)}"
return await self.save_bytes(data, key, ctype)
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
key = self._write(data, "", key)
return self.public_url(key)
return self.public_url(self._write(data, key, content_type))
def _write(self, data: bytes, key_prefix: str, hint: str) -> str:
def _write(self, data: bytes, key: str, content_type: str) -> str:
_LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
ext = _ext_from_url(hint) if hint and not hint.startswith("data:") else ".jpg"
key = f"{key_prefix + '/' if key_prefix else ''}{uuid.uuid4().hex}{ext}"
(_LOCAL_ROOT / key).parent.mkdir(parents=True, exist_ok=True)
(_LOCAL_ROOT / key).write_bytes(data)
return key
safe_key = key.strip().lstrip("/")
if not safe_key:
safe_key = uuid.uuid4().hex
if not Path(safe_key).suffix:
safe_key += image_extension(data, content_type)
path = (_LOCAL_ROOT / safe_key).resolve()
if _LOCAL_ROOT.resolve() not in path.parents:
raise ValueError("非法存储 key")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return safe_key
def public_url(self, key: str) -> str:
settings = get_settings()
return f"{settings.app_base_url.rstrip('/')}/media/{key}"
async def delete(self, key: str) -> None:
path = (_LOCAL_ROOT / key.strip().lstrip("/")).resolve()
if _LOCAL_ROOT.resolve() not in path.parents:
raise ValueError("非法存储 key")
if path.is_file():
path.unlink()
async def delete_prefix(self, prefix: str) -> int:
root = (_LOCAL_ROOT / prefix.strip().strip("/")).resolve()
if _LOCAL_ROOT.resolve() not in root.parents and root != _LOCAL_ROOT.resolve():
raise ValueError("非法存储前缀")
if not root.exists():
return 0
if root.is_file():
root.unlink()
return 1
count = sum(1 for path in root.rglob("*") if path.is_file())
shutil.rmtree(root)
return count
class QiniuStorage:
"""生产:上传七牛,返回绑定域名公网 URL(Ozon 可拉取)。"""
@@ -97,6 +172,33 @@ class QiniuStorage:
settings = get_settings()
return f"{settings.qiniu_domain.rstrip('/')}/{key}"
async def delete(self, key: str) -> None:
import qiniu
auth, settings = self._client()
_, info = qiniu.BucketManager(auth).delete(settings.qiniu_bucket, key)
if info.status_code not in (200, 612): # 612:对象已不存在,视为删除完成
raise RuntimeError(f"七牛删除失败:{info.error or info.text_body or info.status_code}")
async def delete_prefix(self, prefix: str) -> int:
import qiniu
auth, settings = self._client()
manager = qiniu.BucketManager(auth)
marker: str | None = None
deleted = 0
while True:
result, eof, info = manager.list(settings.qiniu_bucket, prefix=prefix, marker=marker, limit=1000)
if info.status_code != 200:
raise RuntimeError(f"七牛列举失败:{info.error or info.text_body or info.status_code}")
items = (result or {}).get("items") or []
for item in items:
await self.delete(item["key"])
deleted += 1
marker = (result or {}).get("marker")
if eof or not marker:
return deleted
def get_storage():
settings = get_settings()
@@ -105,6 +207,19 @@ def get_storage():
return LocalStorage()
def storage_key_from_url(url: str) -> str | None:
"""将本项目生成的公开 URL 还原为对象 key;第三方源站 URL 返回 None。"""
if not url:
return None
settings = get_settings()
if "/media/" in url:
return url.split("/media/", 1)[1].split("?", 1)[0]
domain = settings.qiniu_domain.rstrip("/")
if domain and url.startswith(f"{domain}/"):
return url[len(domain) + 1:].split("?", 1)[0]
return None
def local_path(stored_url_or_key: str) -> Path | None:
"""stored_url(http…/media/xxx)或 media key → 本地文件路径。
@@ -113,5 +228,9 @@ def local_path(stored_url_or_key: str) -> Path | None:
s = stored_url_or_key or ""
if "/media/" in s:
s = s.split("/media/", 1)[1]
p = _LOCAL_ROOT / s
root = _LOCAL_ROOT.resolve()
p = (root / s.split("?", 1)[0]).resolve()
# URL / key 可能来自历史数据,不能让 ../../ 逃出本地媒体目录。
if root not in p.parents:
return None
return p if p.is_file() else None
+23 -3
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import uuid
from schemas.suite import TextMaterial
from services.image_assets import get_image_asset_service
from services.tasks import TaskImage
@@ -16,6 +17,8 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
for t in texts:
if t.kind == "title" and t.content:
raw["title"] = t.content
elif t.kind == "title_ru" and t.content:
raw["title_ru"] = t.content
elif t.kind == "price" and t.content:
raw["price"] = t.content
elif t.kind == "brand" and t.content:
@@ -67,6 +70,9 @@ async def append_generated_asset(
pid = uuid.UUID(product_id)
async with get_session_factory()() as db:
product = await db.get(Product, pid)
if product is None:
raise RuntimeError("商品已删除,生成结果不再回写")
sort_order: int | None = None
if after_asset_id:
try:
@@ -104,16 +110,30 @@ async def append_generated_asset(
sort_order=sort_order,
type="img",
source_url="",
stored_url=image.url,
status="uploaded",
)
db.add(asset)
await db.flush()
product = await db.get(Product, pid)
if product is not None:
image_assets = get_image_asset_service()
original_url = image.source_url or image.url
await image_assets.adopt_generated_versions(
asset,
original_url=original_url,
current_url=image.url,
watermark_config=image.watermark_config,
)
counts = dict(product.asset_counts or {})
counts["generated"] = int(counts.get("generated") or 0) + 1
product.asset_counts = counts
await db.commit()
# task 的 suites/ 临时对象已迁入正式素材路径,提交成功后立即回收。
try:
await image_assets.delete_temporary_urls([image.source_url, image.url])
except Exception: # noqa: BLE001
# 当前素材已可用;临时对象残留可由删除商品时的前缀清理兜底。
pass
image.source_url = asset.original_url or ""
image.url = asset.stored_url or ""
return str(asset.id)
+2
View File
@@ -29,6 +29,8 @@ class TaskImage:
name: str
status: str = IMG_PENDING # 循环里先建后跑,成功改 ok、失败显式改 failed
url: str = ""
source_url: str = "" # 水印版对应的无水印原图;未加水印时为空
watermark_config: dict | None = None
error: str | None = None
+1 -1
View File
@@ -21,7 +21,7 @@ log = logging.getLogger("suite.watermark")
# 尺寸比例(与 ozonSeller app.js 常量一致)
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
TEXT_SCALE = 0.06 # 文字字号 / 图宽
TEXT_SCALE = 0.048 # 文字字号 / 图宽(原 0.06 缩小 20%)
TEXT_MIN_SIZE = 12
STROKE_ALPHA = 0.55
STROKE_RATIO = 1 / 8 # 描边宽 / 字号
+20
View File
@@ -0,0 +1,20 @@
"""命令入口:`python -m worker`,与 Web 进程独立运行。"""
from __future__ import annotations
import argparse
import asyncio
import logging
from services.job_worker import run_worker
def main() -> None:
parser = argparse.ArgumentParser(description="Ozon Seller Kit background worker")
parser.add_argument("--once", action="store_true", help="最多处理一个已到期子项后退出(用于诊断/测试)")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
asyncio.run(run_worker(once=args.once))
if __name__ == "__main__":
main()
+26
View File
@@ -24,6 +24,12 @@ if [[ ! -f .env ]]; then
exit 1
fi
# 数据结构变更由 Alembic 管理;必须先迁移,再启动会读取新字段的服务端。
if ! alembic upgrade head; then
echo "⚠️ 数据库迁移失败,停止启动以避免新旧代码与表结构不一致"
exit 1
fi
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
COLLECTOR_WATCH_PID=""
@@ -64,7 +70,13 @@ echo
# ---- 同步并启动前端(studio:Vite 开发服务器,/api 代理到后端 8800)----
STUDIO_PID=""
WORKER_SUPERVISOR_PID=""
cleanup() {
if [[ -n "$WORKER_SUPERVISOR_PID" ]] && kill -0 "$WORKER_SUPERVISOR_PID" 2>/dev/null; then
# 先终止 Worker 子进程,再结束守护循环;避免退出 start.command 后残留处理器。
pkill -P "$WORKER_SUPERVISOR_PID" 2>/dev/null
kill "$WORKER_SUPERVISOR_PID" 2>/dev/null
fi
if [[ -n "$STUDIO_PID" ]] && kill -0 "$STUDIO_PID" 2>/dev/null; then
kill "$STUDIO_PID" 2>/dev/null
sleep 0.2
@@ -79,6 +91,20 @@ cleanup() {
}
trap cleanup EXIT
# ---- 持久化后台任务 Worker(与 uvicorn 独立,服务重启不会中断正在轮询的模型任务)----
# Worker 因配置/网络等未捕获错误退出时,2 秒后自动拉起;否则任务会长期停在「排队中」。
supervise_worker() {
while true; do
python server/worker.py
WORKER_EXIT_CODE=$?
echo "⚠️ 后台任务 Worker 已退出(code: $WORKER_EXIT_CODE),2 秒后自动重启…"
sleep 2
done
}
supervise_worker &
WORKER_SUPERVISOR_PID=$!
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
if ! command -v pnpm >/dev/null 2>&1; then
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
+9
View File
@@ -33,3 +33,12 @@ body {
#root {
min-height: 100vh;
}
/* 图库预览的图片大多为 3:4;翻页按钮压在图片两侧,方便就近翻页。 */
.ant-image-preview .ant-image-preview-switch-prev {
inset-inline-start: max(16px, calc(50% - min(35vw, 52.5vh) + 80px)) !important;
}
.ant-image-preview .ant-image-preview-switch-next {
inset-inline-end: max(16px, calc(50% - min(35vw, 52.5vh) + 80px)) !important;
}
@@ -97,12 +97,19 @@ export default function CollectionPage() {
else load();
};
/** 采集箱优先展示可编辑的中文标题,兼容早期采集数据;没有中文才回退俄文商品名。 */
const displayProductName = (item: ProductListItem) => {
const raw = item.raw ?? {};
const chineseTitle = String(raw.title_zh ?? raw.title ?? '').trim();
return chineseTitle || item.name?.trim() || '(未命名)';
};
const columns: ColumnsType<ProductListItem> = [
{
title: '商品名',
dataIndex: 'name',
render: (v, r) => (
<a onClick={() => navigate(`/trial/${r.id}`)}>{v || '(未命名)'}</a>
render: (_v, r) => (
<a onClick={() => navigate(`/trial/${r.id}`)}>{displayProductName(r)}</a>
),
},
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
+142 -27
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, imageEditSingle } from '@/services/suite';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, WatermarkPayload } from '@/services/suite';
const { Text } = Typography;
@@ -13,35 +14,121 @@ interface Props {
onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void;
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
onSubmitted?: (suiteId: string) => void;
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
watermark?: WatermarkPayload;
}
/**
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated }: Props) {
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState('');
const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState('');
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const activeTaskRef = useRef<string | null>(null);
// 每次打开重置(保留模型选择,方便连续精修)
const stopPolling = () => {
pollRunRef.current += 1;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current?.abort();
pollAbortRef.current = null;
};
// 每次打开重置(保留模型选择,方便连续精修);后台任务运行时不重置,以免丢失跟踪。
useEffect(() => {
if (open) {
if (open && !activeTaskRef.current) {
setPrompt('');
setResultUrl('');
setGenerating(false);
setTaskId('');
setTaskError('');
}
}, [open]);
const onGenerate = async () => {
if (!source) return;
if (!prompt.trim()) {
message.warning('请先输入生图要求');
useEffect(() => () => stopPolling(), []);
const pollTask = (suiteId: string) => {
stopPolling();
const runId = ++pollRunRef.current;
const controller = new AbortController();
pollAbortRef.current = controller;
activeTaskRef.current = suiteId;
const startedAt = Date.now();
let failures = 0;
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current = null;
activeTaskRef.current = null;
setGenerating(false);
setTaskId('');
};
const poll = async () => {
if (runId !== pollRunRef.current || controller.signal.aborted) return;
try {
const suite = await getSuite(suiteId, { signal: controller.signal });
if (runId !== pollRunRef.current) return;
failures = 0;
if (['done', 'partial', 'failed', 'cancelled'].includes(suite.status)) {
finish();
const result = suite.images.find((image) => image.status === 'done' && image.url);
if (result) {
setResultUrl(result.url);
setTaskError(suite.error || '');
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(result.url);
} else if (suite.status === 'cancelled') {
setTaskError('任务已取消');
message.info('任务已取消');
} else {
const detail = suite.error || '生成失败,未返回图片';
setTaskError(detail);
message.error(detail);
}
return;
}
// 单张任务最长可达 5 分钟;超时只提示,继续跟踪,避免后台完成但前端丢结果。
if (Date.now() - startedAt > 6 * 60_000) {
setTaskError('任务耗时较长,仍在后台生成;可关闭弹窗,结果完成后会自动回写。');
}
schedule(3000);
} catch (e) {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
failures += 1;
if (failures >= 3) {
finish();
const detail = '连续查询任务进度失败;后台任务可能仍在执行,稍后可在「生成结果」查看。';
setTaskError(detail);
message.error(detail);
return;
}
// 失败后指数退避:3 秒、6 秒、12 秒;下一次只在本次请求完成后才会发出。
schedule(Math.min(3000 * (2 ** failures), 12_000));
}
};
schedule(0);
};
const onGenerate = async () => {
if (!source) return;
setGenerating(true);
setResultUrl('');
setTaskError('');
try {
const r = await imageEditSingle({
product_id: productId,
@@ -50,17 +137,38 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
model,
append: true,
after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined,
});
setResultUrl(r.url);
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(r.url);
setTaskId(r.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
if (onSubmitted) {
onSubmitted(r.suite_id);
onClose();
} else {
// 独立使用该组件时仍保留自身轮询能力。
pollTask(r.suite_id);
}
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setTaskError(apiErrorMessage(e));
setGenerating(false);
}
};
const onCancelTask = async () => {
if (!taskId) return;
try {
await cancelSuite(taskId);
stopPolling();
activeTaskRef.current = null;
setGenerating(false);
setTaskId('');
setTaskError('任务已取消;已完成的图片会保留在生成结果中。');
} catch (e) {
message.error(apiErrorMessage(e));
}
};
return (
<>
{open && (
@@ -88,15 +196,12 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<Text type="secondary" style={{ fontSize: 12, maxWidth: 360 }} ellipsis={{ tooltip: source?.name }}>
{source?.name}
</Text>
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Text type="secondary" style={{ fontSize: 12 }}>原图</Text>
<Image
src={source?.url}
alt="原图"
@@ -108,12 +213,13 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={'输入生图要求,例如:把背景换成纯白色摄影棚,保留商品细节与比例;增加柔和阴影'}
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
disabled={generating}
/>
<Space style={{ marginTop: 12 }} wrap>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select
style={{ width: 260 }}
style={{ flex: 1, minWidth: 0 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
@@ -128,17 +234,26 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
</div>
)}
/>
<Button type="primary" loading={generating} onClick={onGenerate}>
生成
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
提交任务
</Button>
<Button onClick={onClose}>关闭</Button>
</Space>
</div>
</div>
<Alert
style={{ marginTop: 16 }}
type="info"
type={taskError && !generating ? 'error' : 'info'}
showIcon
message="生成结果会自动追加到「生成图」分组;GPT 系列单张 1-5 分钟,请耐心等待"
message={
generating
? `后台任务生成中${taskId ? `(${taskId.slice(0, 8)})` : ''},可关闭弹窗继续操作`
: taskError || '生成结果会自动追加到「生成结果」;GPT 系列单张通常需要 1-5 分钟'
}
/>
{generating && taskId && (
<Button danger type="link" size="small" onClick={() => void onCancelTask()} style={{ marginTop: 4, paddingLeft: 0 }}>
取消此任务
</Button>
)}
</div>
</div>
{resultUrl && (
+27 -17
View File
@@ -5,7 +5,7 @@
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
*/
import { useCallback, useEffect, useState } from 'react';
import { Button, Input, message, Modal, Popconfirm, Table, Typography } from 'antd';
import { Button, Input, message, Modal, Popconfirm, Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CopyOutlined, DownloadOutlined, FileTextOutlined, PlusOutlined, RestOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
@@ -224,6 +224,26 @@ export default function TrialExportPanel({ product }: Props) {
dataIndex: field,
render: (v: unknown) => <Text style={{ fontSize: 13 }}>{fmt(v)}</Text>,
});
const renderLink = (url: string, maxWidth: number) => (
<Tooltip title={url}>
<a
href={url.startsWith('http') ? url : `https://${url}`}
target="_blank"
rel="noreferrer"
style={{
display: 'inline-block',
maxWidth,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
verticalAlign: 'bottom',
fontSize: 12,
}}
>
{url}
</a>
</Tooltip>
);
const base: ColumnsType<RegisterRecord> = [
{
title: '操作',
@@ -265,28 +285,17 @@ export default function TrialExportPanel({ product }: Props) {
title: 'Ozon地址',
dataIndex: 'sku',
width: 160,
render: (sku: string) =>
sku ? (
<a href={`https://www.ozon.ru/product/${sku}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
{`https://www.ozon.ru/product/${sku}`}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
),
render: (sku: string) => {
const url = sku ? `https://www.ozon.ru/product/${sku}` : '';
return url ? renderLink(url, 128) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>;
},
},
{
title: '采买地址',
dataIndex: 'purchaseUrl',
width: 180,
ellipsis: true,
render: (v: string) =>
v ? (
<a href={v.startsWith('http') ? v : `https://${v}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }} title={v}>
{v}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
),
v ? renderLink(v, 148) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>,
},
];
return showOptional ? [...base, ...optional, ...rest, ...optional2, ...links] : [...base, ...rest, ...links];
@@ -306,6 +315,7 @@ export default function TrialExportPanel({ product }: Props) {
zIndex: 100,
background: COLOR_SECONDARY,
height: 40,
width: 124,
paddingInline: 16,
boxShadow: '0 4px 12px rgba(16,185,129,0.4)',
}
+39 -3
View File
@@ -4,15 +4,17 @@
* 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。
*/
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { Card, Menu, Space, Spin, Typography, message } from 'antd';
import { getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
import { Link, useNavigate, useParams } from 'react-router';
import { DeleteOutlined } from '@ant-design/icons';
import { Button, Card, Menu, Popconfirm, Space, Spin, Typography, message } from 'antd';
import { deleteProduct, getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage';
import TrialInfoPanel from './TrialInfoPanel';
import TrialPricingPanel from './TrialPricingPanel';
import TrialSuitePanel from './TrialSuitePanel';
import TrialExportPanel from './TrialExportPanel';
import TrialTaskCenter from './TrialTaskCenter';
import CopyPanel from '../product/CopyPanel';
const { Title, Text } = Typography;
@@ -29,9 +31,11 @@ const SECTIONS = [
export default function TrialPage() {
const { id } = useParams();
const navigate = useNavigate();
const [product, setProduct] = useState<ProductDetail | null>(null);
const [assets, setAssets] = useState<ProductAsset[]>([]);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [active, setActive] = useState(SECTIONS[0].id);
// 静默刷新:不置 loading。置 loading 会让整页退回全屏 Spin、卸载 TrialSuitePanel,
@@ -82,6 +86,21 @@ export default function TrialPage() {
[id, product],
);
const deleteCurrentProduct = async () => {
if (!id) return;
setDeleting(true);
try {
// hard=true 会同时删除 original / 当前水印版等商品图片资源。
await deleteProduct(id, true);
message.success('商品及其图片资源已删除');
navigate('/collection', { replace: true });
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setDeleting(false);
}
};
if (!product) {
return (
<div style={{ padding: 80, textAlign: 'center' }}>
@@ -106,7 +125,21 @@ export default function TrialPage() {
{STAGE_LABEL[product.stage] || product.stage}
</span>
</Space>
<Space size={6}>
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
<Popconfirm
title="永久删除商品?"
description="将同时删除采集图、AI 图片和水印版本,删除后无法恢复。"
okText="永久删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={deleteCurrentProduct}
>
<Button type="text" danger size="small" icon={<DeleteOutlined />} loading={deleting}>
删除商品
</Button>
</Popconfirm>
</Space>
</div>
<Space size={16} style={{ marginTop: 4 }}>
{product.name && product.name !== titleZh && (
@@ -143,6 +176,9 @@ export default function TrialPage() {
</div>
</div>
{/* 固定操作坞:任务位于「录入商品」正上方,任务详情在右侧抽屉中统一管理。 */}
<TrialTaskCenter productId={product.id} onRefreshAssets={load} />
{/* 右侧区域导航 */}
<div style={{ width: 120, flexShrink: 0 }}>
<div style={{ position: 'sticky', top: 80 }}>
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
/** 当前商品的后台任务入口:固定操作坞 + 右侧任务抽屉。 */
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
Alert, Badge, Button, Card, Drawer, Empty, Popconfirm, Progress, Space, Tag, Typography, message,
} from 'antd';
import {
BgColorsOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
LoadingOutlined, PictureOutlined, ReloadOutlined, StopOutlined,
} from '@ant-design/icons';
import {
cancelSuite, listProductSuites, listProductWatermarkJobs, retrySuite, retrySuiteItem,
type SuiteImageInfo, type SuiteInfo,
} from '@/services/suite';
import { apiErrorMessage } from '@/services/api';
const { Text } = Typography;
const ACTIVE_STATUSES = new Set(['queued', 'pending', 'running']);
interface Props {
productId: string;
onRefreshAssets: () => void;
}
function taskTitle(task: SuiteInfo) {
if (task.kind === 'watermark_batch') return '批量添加水印';
if (task.kind === 'image_edit') return 'AI 生图 / 编辑';
return '电商套图生成';
}
function taskIcon(task: SuiteInfo) {
return task.kind === 'watermark_batch' ? <BgColorsOutlined /> : <PictureOutlined />;
}
function isActive(task: SuiteInfo) {
return ACTIVE_STATUSES.has(task.status);
}
function isWaitingProvider(task: SuiteInfo) {
return task.images.some((image) => image.status === 'waiting_provider');
}
function statusMeta(task: SuiteInfo): { label: string; color: string; icon?: ReactNode } {
if (isWaitingProvider(task)) return { label: '等待模型结果', color: 'processing', icon: <ClockCircleOutlined /> };
if (task.status === 'queued' || task.status === 'pending') return { label: '排队中', color: 'default', icon: <ClockCircleOutlined /> };
if (task.status === 'running') return { label: '处理中', color: 'processing', icon: <LoadingOutlined spin /> };
if (task.status === 'done') return { label: '已完成', color: 'success', icon: <CheckCircleOutlined /> };
if (task.status === 'partial') return { label: '部分完成', color: 'warning' };
if (task.status === 'cancelled') return { label: '已取消', color: 'default' };
return { label: '已失败', color: 'error', icon: <CloseCircleOutlined /> };
}
function formatTime(value?: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
}).format(date);
}
function TaskCard({ task, onCancel, onRetry, onRetryItem, onOpenResult }: {
task: SuiteInfo;
onCancel: (task: SuiteInfo) => void;
onRetry: (task: SuiteInfo) => void;
onRetryItem: (task: SuiteInfo, item: SuiteImageInfo) => void;
onOpenResult: () => void;
}) {
const meta = statusMeta(task);
const failedItems = task.images.filter((image) => image.status === 'failed');
const total = task.total ?? task.images.length;
const completed = task.completed ?? task.images.filter((image) => image.status === 'done').length;
const canOpenResult = ['image_suite', 'image_edit'].includes(task.kind || '') && completed > 0;
return (
<Card
size="small"
style={{ marginBottom: 6, background: isActive(task) ? '#f6faff' : undefined }}
styles={{ body: { padding: '10px 12px' } }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<span style={{ color: task.kind === 'watermark_batch' ? '#722ed1' : '#1677ff', paddingTop: 3 }}>
{taskIcon(task)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<Text strong ellipsis={{ tooltip: taskTitle(task) }}>{taskTitle(task)}</Text>
<Tag color={meta.color} icon={meta.icon} style={{ marginInlineEnd: 0, flexShrink: 0 }}>{meta.label}</Tag>
</div>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 2 }}>
{task.kind === 'watermark_batch'
? `为 ${total} 张图片添加水印`
: task.kind === 'image_edit'
? '单张图片生成任务'
: `套图生成 · ${total} 张`}
{task.created_at ? ` · ${formatTime(task.created_at)}` : ''}
</Text>
{isActive(task) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
<Progress
percent={total ? Math.round((completed / total) * 100) : 0}
size="small"
status="active"
format={() => `${completed}/${total}`}
style={{ flex: 1, minWidth: 0 }}
/>
<Popconfirm
title="取消任务"
description="未完成的子项将停止;已经完成的结果会保留。"
okText="取消任务"
cancelText="继续处理"
okButtonProps={{ danger: true }}
onConfirm={() => onCancel(task)}
>
<Button size="small" danger icon={<StopOutlined />}>取消</Button>
</Popconfirm>
</div>
)}
{task.error && (
<Text type="danger" ellipsis={{ tooltip: task.error }} style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
{task.error}
</Text>
)}
{failedItems.slice(0, 3).map((item) => (
<div key={item.item_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
<Text type="danger" ellipsis={{ tooltip: item.error || item.name }} style={{ flex: 1, fontSize: 12 }}>
{item.name}:{item.error || '处理失败'}
</Text>
<Button size="small" onClick={() => onRetryItem(task, item)}>重试</Button>
</div>
))}
{failedItems.length > 3 && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>另有 {failedItems.length - 3} 项失败</Text>
)}
<Space size={6} style={{ marginTop: 6 }} wrap>
{['partial', 'failed'].includes(task.status) && (
<Button size="small" icon={<ReloadOutlined />} onClick={() => onRetry(task)}>重试失败项</Button>
)}
{canOpenResult && (
<Button size="small" type="link" onClick={onOpenResult}>查看结果</Button>
)}
</Space>
</div>
</div>
</Card>
);
}
export default function TrialTaskCenter({ productId, onRefreshAssets }: Props) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [tasks, setTasks] = useState<SuiteInfo[]>([]);
const [loadError, setLoadError] = useState('');
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeIdsRef = useRef<Set<string>>(new Set());
const initializedRef = useRef(false);
const stopPolling = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
abortRef.current?.abort();
abortRef.current = null;
}, []);
const loadTasks = useCallback(async (signal?: AbortSignal) => {
const [imageTasks, watermarkTasks] = await Promise.all([
listProductSuites(productId, false, { signal }),
listProductWatermarkJobs(productId, false, { signal }),
]);
const byId = new Map<string, SuiteInfo>();
[...imageTasks, ...watermarkTasks].forEach((task) => byId.set(task.id, task));
const next = Array.from(byId.values()).sort((a, b) =>
(b.created_at || '').localeCompare(a.created_at || ''),
);
const nextActive = new Set(next.filter(isActive).map((task) => task.id));
if (initializedRef.current && [...activeIdsRef.current].some((id) => !nextActive.has(id))) {
onRefreshAssets();
}
initializedRef.current = true;
activeIdsRef.current = nextActive;
setTasks(next);
setLoadError('');
}, [onRefreshAssets, productId]);
useEffect(() => {
stopPolling();
initializedRef.current = false;
activeIdsRef.current = new Set();
let disposed = false;
let failures = 0;
const run = async () => {
if (disposed) return;
const controller = new AbortController();
abortRef.current = controller;
try {
await loadTasks(controller.signal);
failures = 0;
setLoading(false);
} catch (error) {
if (!controller.signal.aborted) {
failures += 1;
setLoadError(failures >= 3 ? '任务状态暂时无法刷新,请稍后重试' : '');
setLoading(false);
}
} finally {
if (abortRef.current === controller) abortRef.current = null;
if (!disposed) timerRef.current = setTimeout(() => void run(), failures ? Math.min(3000 * (2 ** failures), 15_000) : 3000);
}
};
void run();
return () => {
disposed = true;
stopPolling();
};
}, [loadTasks, productId, stopPolling]);
const activeTasks = useMemo(() => tasks.filter(isActive), [tasks]);
const recentTasks = useMemo(() => tasks.filter((task) => !isActive(task)).slice(0, 12), [tasks]);
const refreshNow = async () => {
setLoading(true);
try {
await loadTasks();
} catch (error) {
setLoadError(apiErrorMessage(error));
} finally {
setLoading(false);
}
};
const handleCancel = async (task: SuiteInfo) => {
try {
await cancelSuite(task.id);
message.info('任务已取消;已完成的结果会保留');
await refreshNow();
} catch (error) {
message.error(apiErrorMessage(error));
}
};
const handleRetry = async (task: SuiteInfo) => {
try {
await retrySuite(task.id);
message.info('失败项已重新加入队列');
await refreshNow();
} catch (error) {
message.error(apiErrorMessage(error));
}
};
const handleRetryItem = async (task: SuiteInfo, item: SuiteImageInfo) => {
try {
await retrySuiteItem(task.id, item.item_id);
message.info('该项已重新加入队列');
await refreshNow();
} catch (error) {
message.error(apiErrorMessage(error));
}
};
const openResult = () => {
setOpen(false);
window.setTimeout(() => document.getElementById('section-result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
};
return (
<>
<div style={{ position: 'fixed', right: 24, bottom: 72, zIndex: 100, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<Badge count={activeTasks.length} overflowCount={99} offset={[-2, 2]}>
<Button
icon={activeTasks.length > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />}
onClick={() => setOpen(true)}
style={{ width: 124, height: 40, borderColor: activeTasks.length ? '#91caff' : undefined, color: activeTasks.length ? '#1677ff' : undefined, boxShadow: '0 3px 10px rgba(0,0,0,0.12)' }}
>
{activeTasks.length ? `处理中(${activeTasks.length})` : '任务'}
</Button>
</Badge>
</div>
<Drawer
title={`当前商品任务${activeTasks.length ? ` · 处理中 ${activeTasks.length}` : ''}`}
open={open}
onClose={() => setOpen(false)}
width={460}
extra={<Button size="small" icon={<ReloadOutlined />} loading={loading} onClick={() => void refreshNow()}>刷新</Button>}
>
{loadError && <Alert type="warning" showIcon message={loadError} style={{ marginBottom: 12 }} />}
<Text strong>处理中{activeTasks.length ? `(${activeTasks.length})` : ''}</Text>
<div style={{ marginTop: 8 }}>
{activeTasks.length ? activeTasks.map((task) => (
<TaskCard key={task.id} task={task} onCancel={handleCancel} onRetry={handleRetry} onRetryItem={handleRetryItem} onOpenResult={openResult} />
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前没有处理中的任务" />}
</div>
{recentTasks.length > 0 && (
<>
<Text strong style={{ display: 'block', marginTop: 18, marginBottom: 8 }}>最近任务</Text>
{recentTasks.map((task) => (
<TaskCard key={task.id} task={task} onCancel={handleCancel} onRetry={handleRetry} onRetryItem={handleRetryItem} onOpenResult={openResult} />
))}
</>
)}
</Drawer>
</>
);
}
+6
View File
@@ -9,6 +9,8 @@ export interface ProductListItem {
currency_code: string;
source_platform: string | null;
source_url: string | null;
/** 采集中文标题保存在 raw.title_zh;raw.title 为早期数据兼容字段。 */
raw?: Record<string, unknown> | null;
asset_counts: Record<string, number> | null;
ozon_product_id: number | null;
created_at: string;
@@ -63,6 +65,10 @@ export interface ProductAsset {
type: string;
source_url: string;
stored_url: string | null;
/** 无水印原图;AI 参考图应优先使用该地址。 */
original_url?: string | null;
watermark_enabled?: boolean;
watermark_config?: Record<string, unknown> | null;
status: 'pending' | 'downloading' | 'uploaded' | 'failed';
width: number | null;
height: number | null;
+76 -11
View File
@@ -5,6 +5,7 @@
*/
import { api } from './api';
import apiClient from './api';
import type { AxiosRequestConfig } from 'axios';
/** 套图类型白名单(与服务端 prompts/common.py 对齐) */
export const SUITE_TYPE_OPTIONS = [
@@ -42,11 +43,11 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) =
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
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: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出' },
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' },
{ value: 2, label: '清新明亮', prompt: 'Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition' },
{ value: 3, label: '高级感深色', prompt: 'Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood' },
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere' },
{ value: 5, label: '纯净棚拍', prompt: 'Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent' },
] as const;
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
@@ -115,6 +116,7 @@ export interface SuiteGeneratePayload {
}
export interface SuiteImageInfo {
item_id: string;
type_id: string;
name: string;
url: string;
@@ -124,13 +126,20 @@ export interface SuiteImageInfo {
export interface SuiteInfo {
id: string;
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
status: 'queued' | 'pending' | 'running' | 'done' | 'partial' | 'failed' | 'cancelled';
kind?: 'image_suite' | 'image_edit' | string;
style_set: number;
platform: string;
lang: string;
ratio: string;
provider?: string;
total?: number;
completed?: number;
failed?: number;
cancel_requested?: boolean;
created_at?: string | null;
started_at?: string | null;
completed_at?: string | null;
images: SuiteImageInfo[];
error?: string | null;
}
@@ -146,8 +155,26 @@ export function generateSuite(payload: SuiteGeneratePayload) {
}
/** ★ 查询套图任务状态(轮询用) */
export function getSuite(suiteId: string) {
return api.get<SuiteInfo>(`/suites/${suiteId}`);
export function getSuite(suiteId: string, config?: AxiosRequestConfig) {
return api.get<SuiteInfo>(`/suites/${suiteId}`, config);
}
/** 当前商品的后台图片任务,用于刷新页面后恢复进度。 */
export function listProductSuites(productId: string, activeOnly = false, config?: AxiosRequestConfig) {
return api.get<SuiteInfo[]>(`/products/${productId}/suites`, { ...config, params: { active_only: activeOnly } });
}
export function cancelSuite(suiteId: string) {
return api.post<SuiteInfo>(`/suites/${suiteId}/cancel`);
}
export function retrySuite(suiteId: string) {
return api.post<SuiteInfo>(`/suites/${suiteId}/retry`);
}
/** 重试任务中单个失败图片;批量水印可据此只补偿失败项。 */
export function retrySuiteItem(suiteId: string, itemId: string) {
return api.post<SuiteInfo>(`/suites/${suiteId}/items/${itemId}/retry`);
}
/** ★ 下载生成结果 ZIP(blob) */
@@ -159,16 +186,18 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
export interface ImageEditSinglePayload {
product_id?: string;
image_url: string;
prompt: string;
prompt?: string;
model: string;
append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
after_asset_id?: string;
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
watermark?: WatermarkPayload;
}
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
export function imageEditSingle(payload: ImageEditSinglePayload) {
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
return api.post<{ suite_id: string }>('/suite/image-edit', payload);
}
/** 删除一条素材(DB 记录 + 本地文件),用于删除已生成的图片 */
@@ -176,6 +205,36 @@ export function deleteAsset(assetId: string) {
return api.delete<{ deleted: boolean }>(`/assets/${assetId}`);
}
export interface ApplyWatermarkPayload {
asset_ids: string[];
watermark: WatermarkPayload;
}
export interface ApplyWatermarkResponse {
job_id: string;
total: number;
}
export interface RemoveWatermarkResponse {
updated_ids: string[];
failed: Array<{ asset_id: string; error: string }>;
}
/** 将水印任务加入后台队列;Worker 始终从保留的原图重做,避免重复叠加。 */
export function applyWatermarkToAssets(payload: ApplyWatermarkPayload) {
return api.post<ApplyWatermarkResponse>('/assets/watermark', payload);
}
/** 取消已选图片的水印,服务端切回保留的无水印原图。 */
export function removeWatermarkFromAssets(assetIds: string[]) {
return api.post<RemoveWatermarkResponse>('/assets/watermark/remove', { asset_ids: assetIds });
}
/** 当前商品的批量水印任务,用于刷新页面后恢复进度。 */
export function listProductWatermarkJobs(productId: string, activeOnly = false, config?: AxiosRequestConfig) {
return api.get<SuiteInfo[]>(`/products/${productId}/watermark-jobs`, { ...config, params: { active_only: activeOnly } });
}
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
@@ -187,6 +246,12 @@ export async function exportImages(payload: ExportImagesPayload): Promise<Blob>
return res.data as Blob;
}
/** 单张图片下载:服务端代理源站图片,避免防盗链与跨域导致浏览器无法保存。 */
export async function exportImage(payload: ExportImagesPayload['images'][number]): Promise<Blob> {
const res = await apiClient.post('/export/image', payload, { responseType: 'blob' });
return res.data as Blob;
}
/** 上传补充参考图到商品素材(已有接口:POST /api/materials/bytes) */
export async function uploadProductAsset(
productId: string,
+1 -1
View File
@@ -6,7 +6,7 @@ import type { WatermarkState } from '@/types/image';
export const WATERMARK_SCALE = 0.15; // 图片水印直径占原图宽度的比例
export const WATERMARK_MARGIN = 10; // 默认位置距右下角的间距(原图像素)
export const WATERMARK_TEXT_SCALE = 0.051; // 文字水印字号占原图宽度的比例(原 0.06 的 85%)
export const WATERMARK_TEXT_SCALE = 0.0408; // 文字水印字号占原图宽度的比例(原 0.051 缩小 20%)
interface Size {
width: number;