feat: 优化图片资源删除管理,生图任务列表管理
This commit is contained in:
@@ -35,6 +35,16 @@ STORAGE_BACKEND=local
|
|||||||
# ── V2:对外地址(插件/前端回写、生成图回调)──
|
# ── V2:对外地址(插件/前端回写、生成图回调)──
|
||||||
APP_BASE_URL=http://127.0.0.1:8800
|
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 按模型名自动路由)──
|
# ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)──
|
||||||
# 豆包 Seedream(火山方舟)
|
# 豆包 Seedream(火山方舟)
|
||||||
ARK_API_KEY=
|
ARK_API_KEY=
|
||||||
|
|||||||
+19
-11
@@ -1,8 +1,8 @@
|
|||||||
# 工作交接说明(HANDOFF)
|
# 工作交接说明(HANDOFF)
|
||||||
|
|
||||||
> 更新:2026-08-29 晚
|
> 更新:2026-08-30
|
||||||
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
|
> 读者:下一个接手工作的 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
|
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` 正常接管。
|
- **运行态提醒**:交接时 server 是上个会话用 nohup 起的后台进程(日志 `/tmp/osk-server.log`),原 start.command 终端已被停掉。新会话建议:`kill $(lsof -ti :8800)` 后用 `./start.command` 正常接管。
|
||||||
- **`uvicorn --reload` 不监控 `.env`**:改 `.env` 后必须手动重启 server(Kill 8800 端口进程再起)。
|
- **`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` 有全部键位说明。
|
- 密钥在根目录 `.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`)。
|
- **默认生图模型 = `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`。
|
- **同名模型多中转的路由约定**:前端模型 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」类错误不重试直接失败。
|
- **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`)
|
## 4. AI 规划张数策略(`planner.py`)
|
||||||
|
|
||||||
@@ -64,12 +65,16 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 试算页 | 五区块页面、计价、俄文文案、采集图片/出图方案/生成结果、登记导出 |
|
| 试算页 | 五区块页面、计价、俄文文案、采集图片/出图方案/生成结果、登记导出 |
|
||||||
| 采集对接 | ISS 扩展并入 `extensions/collector/`,「上报商品」入库 + 自动打开试算页 |
|
| 采集对接 | ISS 扩展并入 `extensions/collector/`,「上报商品」入库 + 自动打开试算页 |
|
||||||
| 生图服务端 | planner/generator/prompts/watermark/tasks,套图 + 单张 AI 生图 + 水印 + ZIP 导出 |
|
| 生图服务端 | planner/generator/prompts/watermark/jobs + 独立 Worker,套图 + 单张 AI 生图 + 水印 + ZIP 导出 |
|
||||||
| **本轮:Pcore 接入** | 新 provider 全链路(生成/编辑/轮询/重试/错误处理),`@pcore` 路由约定,下拉带供应商名,默认模型切 Pcore |
|
| **本轮:Pcore 接入** | 新 provider 全链路(生成/编辑/轮询/重试/错误处理),`@pcore` 路由约定,下拉带供应商名,默认模型切 Pcore |
|
||||||
| **本轮:规划器修复** | thinking 禁用 + 重试 + 张数策略(AI 自主,建议 8 张左右,生图要求可指定) |
|
| **本轮:规划器修复** | thinking 禁用 + 重试 + 张数策略(AI 自主,建议 8 张左右,生图要求可指定) |
|
||||||
| **本轮:采集箱** | 勾选列 + 全选/取消全选 + 批量删除(硬删、Popconfirm 二次确认、删空页自动回退) |
|
| **本轮:采集箱** | 勾选列 + 全选/取消全选 + 批量删除(硬删、Popconfirm 二次确认、删空页自动回退) |
|
||||||
| **本轮:采集图片区改版** | 8 列网格;AI 生图按钮半透明悬浮图片右下角;图名显示在图片下方(取 URL 末段);SKU 名并入图名行(不再悬浮遮挡勾选钮);勾选钮在右上角;默认全部不勾选 |
|
| **本轮:采集图片区改版** | 8 列网格;AI 生图按钮半透明悬浮图片右下角;图名显示在图片下方(取 URL 末段);SKU 名并入图名行(不再悬浮遮挡勾选钮);勾选钮在右上角;默认全部不勾选 |
|
||||||
| **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 POST /api/export/images,ZIP 内分「套图结果/单张生图」文件夹);「单张 AI 生图」小节改名「生图记录」(落库历史,刷新后保留) |
|
| **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 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` |
|
| **本轮:关键 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 自动构建+监听插件 |
|
| **本轮:杂项** | 采买地址「打开地址」改图标按钮(与复制图标同款);start.command 自动构建+监听插件 |
|
||||||
|
|
||||||
@@ -80,7 +85,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
|||||||
|
|
||||||
## 6. ⚠️ 未提交改动清单(当前最大事项)
|
## 6. ⚠️ 未提交改动清单(当前最大事项)
|
||||||
|
|
||||||
分支 `feat-2.1.0`,工作区有 19 个文件改动 + 1 个新目录,**建议按模块分 3-4 个 commit**:
|
分支 `feat-2.1.0`,工作区仍有多项未提交改动,**建议按模块分 4-5 个 commit**:
|
||||||
|
|
||||||
1. **Pcore 生图接入 + 规划器修复 + 张数策略**
|
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`
|
`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`
|
`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 插件自动构建**
|
3. **web v1 样式修复(CDN 固化)+ start.command 插件自动构建**
|
||||||
`web/ozonSeller.html`、`web/vendor/tailwind.js`(新目录)、`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`(构建产物,勿提交)。
|
提交前:`git checkout -- studio/tsconfig.app.tsbuildinfo`(构建产物,勿提交)。
|
||||||
|
|
||||||
@@ -105,9 +111,9 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
|||||||
| # | 坑 | 对策 |
|
| # | 坑 | 对策 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 server |
|
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 server |
|
||||||
| 2 | 套图任务在内存(`services/tasks.py`) | server 重启丢任务状态(前端已提示重新生成);已落盘图片不丢(在「生图记录」区) |
|
| 2 | 生图任务依赖独立 Worker | `start.command` 会启动;若手动运行 uvicorn,必须另开 `python server/worker.py`,否则任务会一直排队 |
|
||||||
| 3 | 生图全局串行(`generator.py` 的 `asyncio.Lock`) | 一次只跑一个生成队列,测试时别并发提交 |
|
| 3 | SQLite 仅适合单机 Worker | 当前通过租约恢复异常退出的任务;未来多实例前先迁移 PostgreSQL,再做多 Worker 竞争领取 |
|
||||||
| 4 | 单张 image-edit 是同步接口 | 慢模型单张 1-5 分钟,前端 axios 超时 120s 可能先断(后端仍在跑,图会出现在生图记录) |
|
| 4 | 外部模型任务可恢复范围不同 | Pcore/RightAPI/通义万相保存 provider task id 后可继续轮询;豆包/同步千问没有外部任务 id,进程恰在请求中断时只能重试 |
|
||||||
| 5 | **axios 实例勿设全局 Content-Type** | axios 1.x 遇「默认 JSON 头 + FormData」会把表单转 JSON 发送(`formDataToJSON`),multipart 接口全 422。已从 `api.ts` 移除;对象请求体 axios 会自动补 JSON 头 |
|
| 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/` 改引用 |
|
| 6 | `web/` 样式依赖公网 CDN | tailwind 已固化 `web/vendor/tailwind.js`;font-awesome/jsdelivr 若也断,同样方式下载到 `web/vendor/` 改引用 |
|
||||||
| 7 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
|
| 7 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
|
||||||
@@ -116,6 +122,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
|||||||
| 10 | AI 规划/生图花钱 | pcore 按 $ 扣费、RightCode 按张计费;联调时 plan 张数设 1-2、优先低成本模型 |
|
| 10 | AI 规划/生图花钱 | pcore 按 $ 扣费、RightCode 按张计费;联调时 plan 张数设 1-2、优先低成本模型 |
|
||||||
| 11 | TrialPage `load()` 勿再置 loading | 置 loading = 整页 Spin + 卸载面板 = 杀生图轮询/丢状态(已修,保持静默刷新) |
|
| 11 | TrialPage `load()` 勿再置 loading | 置 loading = 整页 Spin + 卸载面板 = 杀生图轮询/丢状态(已修,保持静默刷新) |
|
||||||
| 12 | 浏览器自动化调试怪癖(如 ZCode IAB) | 截图偶发超时(重试一次多半仍失败,改用几何测量/计算样式验证);文件上传需用 DataTransfer+input change 事件模拟;改代码后 React 事件偶发失灵 → reload 页面即恢复 |
|
| 12 | 浏览器自动化调试怪癖(如 ZCode IAB) | 截图偶发超时(重试一次多半仍失败,改用几何测量/计算样式验证);文件上传需用 DataTransfer+input change 事件模拟;改代码后 React 事件偶发失灵 → reload 页面即恢复 |
|
||||||
|
| 13 | 图片上传/导出存在大小限制 | 默认单张 20 MB、采集 100 张、导出 50 张、ZIP 200 MB;超限接口返回 413。需要调整时改 `.env` 的 `IMAGE_*` / `EXPORT_*` 配置并重启服务 |
|
||||||
|
|
||||||
## 9. 关键文件速查
|
## 9. 关键文件速查
|
||||||
|
|
||||||
@@ -125,7 +132,8 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
|||||||
| 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` |
|
| 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` |
|
||||||
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
|
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
|
||||||
| 03 俄文文案 | `pages/product/CopyPanel.tsx`(共用) | `api/ai.py` + `services/deepseek.py` + `prompts/copy_ru.py` |
|
| 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 等) |
|
| 生图供应商路由 | `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`) | — |
|
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — |
|
||||||
| 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` |
|
| 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` |
|
||||||
|
|||||||
+9
-6
@@ -1,7 +1,7 @@
|
|||||||
# Ozon Seller Kit V2.1 方案总览(转向:放弃 API 直传,聚焦试算工作流)
|
# Ozon Seller Kit V2.1 方案总览(转向:放弃 API 直传,聚焦试算工作流)
|
||||||
|
|
||||||
> 状态:方案定稿 + 分阶段实施(Phase A 前端先行,进行中)
|
> 状态:主链路已落地,持续打磨;后台任务中心第一、二阶段已完成
|
||||||
> 最后更新:2026-08-26
|
> 最后更新:2026-08-30
|
||||||
> 定位:本文是 V2.1 全部设计文档的入口与决策总表。先读本文,再按需读分册。
|
> 定位:本文是 V2.1 全部设计文档的入口与决策总表。先读本文,再按需读分册。
|
||||||
> 前置:`docs/v2/`(V2 方案)。V2.1 是对 V2 主链路的一次「降本转向」,不推翻 V2 的基础设施决策(数据库/存储/鉴权/工作台化)。
|
> 前置:`docs/v2/`(V2 方案)。V2.1 是对 V2 主链路的一次「降本转向」,不推翻 V2 的基础设施决策(数据库/存储/鉴权/工作台化)。
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ V2(原计划) V2.1(现在)
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 商品试算页(5 区块) | studio 前端(**Phase A,本次**) | [`trial-page.md`](./trial-page.md) |
|
| 商品试算页(5 区块) | studio 前端(**Phase A,本次**) | [`trial-page.md`](./trial-page.md) |
|
||||||
| 套图规划/一键生成/单张 AI 生图 前端 | studio 前端(**Phase A,本次**) | [`image-suite.md`](./image-suite.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) |
|
| 采集对齐 image-suite-studio + 自动打开试算页 | extension-v2(Phase C) | [`collect.md`](./collect.md) |
|
||||||
| 批量 CSV 服务端导出(可选优化) | server(Phase D) | [`api.md`](./api.md) §7 |
|
| 批量 CSV 服务端导出(可选优化) | server(Phase D) | [`api.md`](./api.md) §7 |
|
||||||
|
|
||||||
@@ -111,6 +111,8 @@ V2(原计划) V2.1(现在)
|
|||||||
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
|
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
|
||||||
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
|
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
|
||||||
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
|
| [`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 契约(前端服务层已按此开发) | 前后端联调时读 |
|
| [`api.md`](./api.md) | 前后端 REST 契约(前端服务层已按此开发) | 前后端联调时读 |
|
||||||
| [`backend-structure.md`](./backend-structure.md) | 后端代码结构盘点:文件/接口/服务树形图、数据流、状态标注(在用/冻结/残留) | 改后端前先读 |
|
| [`backend-structure.md`](./backend-structure.md) | 后端代码结构盘点:文件/接口/服务树形图、数据流、状态标注(在用/冻结/残留) | 改后端前先读 |
|
||||||
|
|
||||||
@@ -122,8 +124,9 @@ V2(原计划) V2.1(现在)
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Phase A** | docs/v2.1 方案文档 + 试算页前端(路由/入口/5 区块/服务层契约) | ✅ 完成 |
|
| **Phase A** | docs/v2.1 方案文档 + 试算页前端(路由/入口/5 区块/服务层契约) | ✅ 完成 |
|
||||||
| **Phase C** | 采集对接:ISS 扩展并入 `extensions/collector/` + 「上报商品」开关 + 自动打开试算页(方案三,见 collect.md) | ✅ 完成 |
|
| **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 B** | 服务端:planner/generator/prompts/watermark、套图/单图接口、图片回写与导出;已切为持久化任务 + 独立 Worker | ✅ 完成 |
|
||||||
| **Phase D** | 打磨:批量 CSV 服务端化、生成图自动回写素材、水印增强、ISS-only 产品模式构建配置 | 待开工 |
|
| **Phase D** | 打磨:批量 CSV 服务端化、图片生命周期、水印增强、ISS-only 产品模式构建配置 | 🚧 部分完成 |
|
||||||
|
| **Phase E** | 后台任务中心第三阶段:当前商品任务抽屉已完成;全局面板、任务事件、调度公平性与幂等性增强待开发 | 🚧 进行中,见 [`background-jobs.md`](./background-jobs.md) |
|
||||||
|
|
||||||
每个阶段结束都可独立运行:Phase A 结束时,试算页的计价/文案/素材上传/入库立即可用(对接已有接口),套图相关按钮点击会提示「服务端接口未实现」。
|
每个阶段结束都可独立运行: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` 兜底 |
|
| 采集图防盗链(Ozon/阿里 CDN)在 studio 页面直接展示可能失败 | 🟡 中 | 素材展示优先 `stored_url`(服务端已转存);Phase B 补 `/api/proxy-image` 兜底 |
|
||||||
| 前端先行的契约与服务端实现不一致导致返工 | 🟡 中 | api.md 即契约真源,服务端按文档实现;字段命名对齐 image-suite-studio 已验证的结构 |
|
| 前端先行的契约与服务端实现不一致导致返工 | 🟡 中 | api.md 即契约真源,服务端按文档实现;字段命名对齐 image-suite-studio 已验证的结构 |
|
||||||
| CSV 人工上品仍是手工活 | 🟢 低 | 组合码 + CSV 已是 web/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 |
|
| CSV 人工上品仍是手工活 | 🟢 低 | 组合码 + CSV 已是 web/ 验证过的效率水平;后续可再评估半自动(表格粘贴)方案 |
|
||||||
|
|||||||
+23
-12
@@ -10,7 +10,7 @@
|
|||||||
| 接口 | 用途(试算页) |
|
| 接口 | 用途(试算页) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/products/{id}`、`PATCH /api/products/{id}` | 商品详情 / 试算页自动保存(raw/pricing/copy/price/weight/... 全字段) |
|
| `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 / 组合码导出的数据源 |
|
| `GET /api/products?stage&q&page&page_size` | 批量 CSV / 组合码导出的数据源 |
|
||||||
| `POST /api/materials/bytes` | 试算页「上传图片」(FormData:`product_id, group_key=upload, file`)→ `{asset_id, status}` |
|
| `POST /api/materials/bytes` | 试算页「上传图片」(FormData:`product_id, group_key=upload, file`)→ `{asset_id, status}` |
|
||||||
| `GET /api/fx` | 汇率 `{rate, source, ts}` |
|
| `GET /api/fx` | 汇率 `{rate, source, ts}` |
|
||||||
@@ -54,22 +54,24 @@
|
|||||||
```
|
```
|
||||||
**Res** `{ "suite_id": "…" }`
|
**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)
|
## 4. 任务查询 `GET /api/suites/{id}`(Phase B)
|
||||||
|
|
||||||
**Res**
|
**Res**
|
||||||
```jsonc
|
```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",
|
"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",
|
"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
|
"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)
|
## 5. 结果导出 `GET /api/suites/{id}/zip`(Phase B)
|
||||||
|
|
||||||
@@ -87,12 +89,19 @@
|
|||||||
"append": true // true:结果追加 product_assets(generated)
|
"append": true // true:结果追加 product_assets(generated)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
**Res** `{ "url": "/media/suites/….png", "asset_id": "uuid|null" }`
|
**Res** `{ "suite_id": "…" }`
|
||||||
|
|
||||||
同步接口(单张 5s-5min,前端 axios 超时 120s+;gpt 系列偶发超时由前端重试提示兜底)。
|
接口仅创建并提交统一图片任务,立即返回;前端通过 `GET /api/suites/{suite_id}` 查询状态和 `images[0].url`。完成后若 `append=true`,结果会回写为 `product_assets(group_key='generated')`。单张编辑优先级高于套图,提交模型后同样持久化第三方任务 ID;参考图 = 传入单图;Ozon 规格(俄文 3:4)。
|
||||||
实现要点:复用 §3 的 provider 层与 prompt 家族;参考图 = 传入单图;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 |
|
| 接口 | 方法 | Req | Res |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -100,11 +109,13 @@
|
|||||||
| `/api/proxy-image?url=` | GET | 源站图片 URL | 图片字节(带站点 Referer 代下,绕防盗链) |
|
| `/api/proxy-image?url=` | GET | 源站图片 URL | 图片字节(带站点 Referer 代下,绕防盗链) |
|
||||||
| `/api/export/trial-csv` | GET | query:`stage?` | csv blob(16 列,见 trial-page.md §7;Phase D 服务端化,替代前端拼装) |
|
| `/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}`)。
|
- `POST /api/materials`:插件上传采集结果;**响应含 `product_id`**(插件用于打开 `/trial/{id}`)。
|
||||||
- `GET /api/collected?platform=&itemId=`:采集前查重。
|
- `GET /api/collected?platform=&itemId=`:采集前查重。
|
||||||
|
|
||||||
## 9. 冻结接口(不投入,保留代码)
|
## 10. 冻结接口(不投入,保留代码)
|
||||||
|
|
||||||
`/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。
|
`/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ server/
|
|||||||
│ │ ├── GET·POST /api/products 商品列表 / 新建
|
│ │ ├── GET·POST /api/products 商品列表 / 新建
|
||||||
│ │ ├── GET·PATCH·DELETE /api/products/{id} 详情 / 自动保存 / 删除(软删 archived | 硬删)
|
│ │ ├── GET·PATCH·DELETE /api/products/{id} 详情 / 自动保存 / 删除(软删 archived | 硬删)
|
||||||
│ │ └── POST /api/products/{id}/copy 复制为新变体(重置货号/图片防冲突)
|
│ │ └── POST /api/products/{id}/copy 复制为新变体(重置货号/图片防冲突)
|
||||||
│ ├── suite.py 【326 ✅核心·Phase B】
|
│ ├── suite.py 【✅核心·Phase B】
|
||||||
│ │ ├── POST /api/suite/plan DeepSeek 出图方案规划(同步,数秒级)
|
│ │ ├── POST /api/suite/plan DeepSeek 出图方案规划(同步,数秒级)
|
||||||
│ │ ├── POST /api/suite/generate 一键生成:方案展开→内存任务→串行生图→回写 generated 素材
|
│ │ ├── POST /api/suite/generate 一键生成:方案展开→持久化子任务→Worker 生图→回写 generated 素材
|
||||||
│ │ ├── GET /api/suites/{id} 任务状态轮询(前端 3s 一次)
|
│ │ ├── GET /api/suites/{id} 任务状态轮询(前端 3s 一次)/ cancel / retry
|
||||||
|
│ │ ├── GET /api/products/{id}/suites 恢复商品活动任务
|
||||||
│ │ ├── GET /api/suites/{id}/zip 生成结果打包下载(中文文件名)
|
│ │ ├── GET /api/suites/{id}/zip 生成结果打包下载(中文文件名)
|
||||||
│ │ └── POST /api/suite/image-edit 单张 AI 生图(试算页每图按钮,append=true 回写素材)
|
│ │ └── POST /api/suite/image-edit 单张 AI 生图(试算页每图按钮,append=true 回写素材)
|
||||||
│ ├── export.py 【121 ✅】POST /api/export/images 采集图片打包 ZIP(标题/分组/SKU规格 文件夹结构)
|
│ ├── export.py 【121 ✅】POST /api/export/images 采集图片打包 ZIP(标题/分组/SKU规格 文件夹结构)
|
||||||
@@ -47,8 +48,8 @@ server/
|
|||||||
│ └── ozon.py 【5 🧊冻结】空占位(远期直传重启时用)
|
│ └── ozon.py 【5 🧊冻结】空占位(远期直传重启时用)
|
||||||
│
|
│
|
||||||
├── services/ ──────────────────────── 业务逻辑层
|
├── services/ ──────────────────────── 业务逻辑层
|
||||||
│ ├── generator.py 【494 ✅核心】三大生图 provider(doubao 火山 / tongyi 通义 / rightapi 中转)
|
│ ├── generator.py 【✅核心】生图 provider(doubao / tongyi / rightapi / pcore)
|
||||||
│ │ + asyncio.Lock 全局串行队列 + run_suite 任务执行 + 参考图解析
|
│ │ + 可恢复的 submit/poll 推进器 + 参考图解析
|
||||||
│ │ (variant 精确匹配 SKU 图 → 回退 main 首张;≤2 张转 data-URI)
|
│ │ (variant 精确匹配 SKU 图 → 回退 main 首张;≤2 张转 data-URI)
|
||||||
│ ├── planner.py 【227 ✅核心】DeepSeek 出图方案规划器
|
│ ├── planner.py 【227 ✅核心】DeepSeek 出图方案规划器
|
||||||
│ │ (单行 JSON 解析 / kind 白名单 / count 钳 0-3 / 幻觉 SKU 丢弃绑定 / 截断修复)
|
│ │ (单行 JSON 解析 / kind 白名单 / count 钳 0-3 / 幻觉 SKU 丢弃绑定 / 截断修复)
|
||||||
@@ -59,7 +60,9 @@ server/
|
|||||||
│ │ ├── gpt.py gpt-image-2「edits 保真」语义:以参考图为准,禁止按文案重造商品
|
│ │ ├── gpt.py gpt-image-2「edits 保真」语义:以参考图为准,禁止按文案重造商品
|
||||||
│ │ ├── google.py nano-banana「主体保持」语义:一句话钉死主体 + 自然语言指令
|
│ │ ├── google.py nano-banana「主体保持」语义:一句话钉死主体 + 自然语言指令
|
||||||
│ │ └── doubao.py 豆包(复用 alibaba)
|
│ │ └── doubao.py 豆包(复用 alibaba)
|
||||||
│ ├── tasks.py 【70 ✅】套图内存任务注册表(⚠️ server 重启丢任务状态;已落盘图片不丢)
|
│ ├── jobs.py 【✅核心】持久化任务仓库、租约领取、取消/失败重试、任务视图
|
||||||
|
│ ├── job_worker.py【✅核心】独立 Worker:逐图提交/轮询外部模型、落图与回写素材
|
||||||
|
│ ├── tasks.py 【兼容】旧内存任务执行器,活跃 API 不再使用
|
||||||
│ ├── watermark.py 【122 ✅】Pillow 水印合成(图片徽章/文字,右下角,失败回退原图)
|
│ ├── watermark.py 【122 ✅】Pillow 水印合成(图片徽章/文字,右下角,失败回退原图)
|
||||||
│ ├── deepseek.py 【182 ✅】DeepSeek chat 封装(俄文文案 + 出图规划共用;JSON 容错解析)
|
│ ├── deepseek.py 【182 ✅】DeepSeek chat 封装(俄文文案 + 出图规划共用;JSON 容错解析)
|
||||||
│ ├── models_catalog.py【84 ✅】读 config/models.yaml(俄文文案模型白名单:快/省、质量两档)
|
│ ├── models_catalog.py【84 ✅】读 config/models.yaml(俄文文案模型白名单:快/省、质量两档)
|
||||||
@@ -141,7 +144,7 @@ extensions/collector 采集
|
|||||||
→ 试算页 /trial/{id}(studio)
|
→ 试算页 /trial/{id}(studio)
|
||||||
├─ 01/02/03 编辑 → PATCH /api/products/{id} 自动保存(raw/pricing/copy JSON)
|
├─ 01/02/03 编辑 → PATCH /api/products/{id} 自动保存(raw/pricing/copy JSON)
|
||||||
├─ 04 生图:POST /api/suite/plan(planner+deepseek)→ POST /api/suite/generate
|
├─ 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} + 刷新素材
|
│ → 每张成功回写 product_assets(generated) → 前端轮询 GET /api/suites/{id} + 刷新素材
|
||||||
└─ 05 登记(localStorage)→ 导出 CSV / 组合码(纯前端)
|
└─ 05 登记(localStorage)→ 导出 CSV / 组合码(纯前端)
|
||||||
```
|
```
|
||||||
@@ -150,8 +153,8 @@ extensions/collector 采集
|
|||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 套图任务在内存 | `tasks.py` 进程内注册表:server 重启丢任务状态(前端提示重新生成);已落盘图片不丢 |
|
| 生图任务依赖独立 Worker | `start.command` 已自动启动 `python server/worker.py`;手动启动 uvicorn 时也必须启动 Worker |
|
||||||
| 生图全局串行 | `generator.py` 的 `asyncio.Lock`:一次只跑一个生成队列(防中转限流,ISS 同款) |
|
| 外部任务恢复 | Pcore、RightAPI、通义万相的 provider task id 会保存到数据库,重启后继续轮询原任务;同步 provider 无外部 id 时只能重试 |
|
||||||
| 全部接口无鉴权 | V2.1 决策:先功能后鉴权;重新引入时从 `deps.py` + 路由依赖层加 |
|
| 全部接口无鉴权 | V2.1 决策:先功能后鉴权;重新引入时从 `deps.py` + 路由依赖层加 |
|
||||||
| 残留可删 | `schemas/auth.py`(无引用);`api/ozon.py`(空占位,随冻结链路保留) |
|
| 残留可删 | `schemas/auth.py`(无引用);`api/ozon.py`(空占位,随冻结链路保留) |
|
||||||
| 密钥 | `.env`:DEEPSEEK_API_KEY(规划+文案)、RIGHTAPI_API_KEY(gpt/nano 生图)、DASHSCOPE_API_KEY(通义)、ARK_API_KEY(豆包,可选) |
|
| 密钥 | `.env`:DEEPSEEK_API_KEY(规划+文案)、RIGHTAPI_API_KEY(gpt/nano 生图)、DASHSCOPE_API_KEY(通义)、ARK_API_KEY(豆包,可选) |
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# 后台任务中心:设计、实施状态与路线图
|
||||||
|
|
||||||
|
> 更新:2026-08-30
|
||||||
|
> 适用范围:套图生成、单张 AI 生图 / AI 编辑、批量添加水印,以及未来的耗时导出和批处理。
|
||||||
|
> 阅读顺序:接手相关开发前,先读本文,再读 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) 与 [`api.md`](./api.md)。
|
||||||
|
|
||||||
|
## 1. 结论与边界
|
||||||
|
|
||||||
|
“单图弹窗等待”、“套图生成”和“批量水印”本质都是耗时、可失败、需要恢复的后台操作,不应分别实现异步逻辑。项目统一采用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web API
|
||||||
|
└─ 在数据库创建父任务 + 子项,立即返回任务 ID
|
||||||
|
|
||||||
|
独立 Worker
|
||||||
|
└─ 领取一个子项 → 调模型 / 合成水印 / 保存图片 → 回写进度与结果
|
||||||
|
|
||||||
|
试算页任务入口(当前)/ 任务中心(后续)
|
||||||
|
└─ 查询状态、恢复进度、取消、重试、打开结果
|
||||||
|
```
|
||||||
|
|
||||||
|
当前是本地单机、SQLite 架构,**不引入 Redis、Celery 或分布式队列**。数据库驱动的独立 Worker 已能覆盖断开弹窗、刷新页面、Web 服务重启、模型长耗时和失败重试,复杂度与收益最平衡。未来扩展到多机器或多实例时,再迁移 PostgreSQL 并评估 Redis / Celery / RQ 等队列。
|
||||||
|
|
||||||
|
本文中的“任务中心”指统一的数据模型、调度和用户可见的任务体验;不意味着每一种任务各有一套后台实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 产品体验
|
||||||
|
|
||||||
|
### 2.1 目标体验
|
||||||
|
|
||||||
|
单张“AI 生图 / AI 编辑”弹窗只负责配置,不负责等待:
|
||||||
|
|
||||||
|
1. 用户填写要求、选择模型,点击“提交任务”。
|
||||||
|
2. 页面立即提示“已加入任务队列”,弹窗关闭;用户可继续编辑商品或离开当前页面。
|
||||||
|
3. 页面提供“处理中(N)”入口,任务完成后图片自动出现在“生图记录”。
|
||||||
|
4. 套图与批量水印同样进入任务中心,可查看进度、失败原因、取消、重试和结果。
|
||||||
|
|
||||||
|
单张 AI 生图和 AI 编辑保留不同入口、提示词与来源图,但后端都属于 `image_edit` 单图生成任务;区别只在任务快照和标题。
|
||||||
|
|
||||||
|
### 2.2 统一状态
|
||||||
|
|
||||||
|
| 用户看到的状态 | 父任务 / 子项状态 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 排队中 | `queued` | 等待 Worker 领取。 |
|
||||||
|
| 处理中 | `running` | 正在生成、下载或合成水印。 |
|
||||||
|
| 等待模型结果 | 子项 `waiting_provider` | 已提交给第三方模型,Worker 后续轮询同一个模型任务。 |
|
||||||
|
| 已完成 | `done` | 所有子项成功。 |
|
||||||
|
| 部分完成 | `partial` | 至少一个成功,且有失败或取消项。 |
|
||||||
|
| 已失败 | `failed` | 没有成功项,失败原因可查看与重试。 |
|
||||||
|
| 已取消 | `cancelled` | 用户取消;后续结果不会回写商品。 |
|
||||||
|
|
||||||
|
### 2.3 当前已呈现的界面
|
||||||
|
|
||||||
|
- 单图弹窗提交成功后关闭,生成进度由“生成结果”区域继续跟踪。
|
||||||
|
- 套图在“生成结果”区域展示排队、进行、完成、取消、失败重试。
|
||||||
|
- 水印设置浮层展示本次批量水印进度、取消、全部失败项重试和单图重试。
|
||||||
|
- 试算页右下角“录入商品”上方有固定“任务 / 处理中(N)”入口;点击打开当前商品的右侧任务抽屉,统一显示套图、单图与水印任务。
|
||||||
|
- 两类任务均会在刷新试算页后恢复跟踪。
|
||||||
|
|
||||||
|
**尚未完成的是应用级全局“任务中心”**:当前抽屉只展示当前商品,且生成结果区和水印设置浮层仍保留上下文状态提示。见第 9 节第三阶段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 统一数据模型
|
||||||
|
|
||||||
|
### 3.1 已实现表
|
||||||
|
|
||||||
|
#### `background_jobs`(父任务)
|
||||||
|
|
||||||
|
一条用户可感知的后台操作,关键字段包括:
|
||||||
|
|
||||||
|
| 字段 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `id`、`product_id`、`kind` | 任务身份、归属商品和任务类型。商品删除后 `product_id` 置空,避免任务反向复活商品。 |
|
||||||
|
| `status`、`priority`、`cancel_requested` | 调度与取消控制。 |
|
||||||
|
| `provider`、`model`、`platform`、`lang`、`ratio`、`style_set` | 生图任务的可恢复执行上下文。 |
|
||||||
|
| `request_payload` | 不可变请求快照:商品上下文、参考图、提示词、风格、水印配置等。 |
|
||||||
|
| `total`、`completed`、`failed`、`error` | 整体进度和概要错误。 |
|
||||||
|
| `created_at`、`started_at`、`completed_at`、`updated_at` | 任务历史、排序和诊断。 |
|
||||||
|
|
||||||
|
#### `background_job_items`(子项)
|
||||||
|
|
||||||
|
可独立领取、失败和重试的最小单元:
|
||||||
|
|
||||||
|
| 场景 | 父任务 | 子项 |
|
||||||
|
|---|---|---|
|
||||||
|
| 套图生成 | 一次套图请求 | 一张计划生成图 |
|
||||||
|
| 单张 AI 生图 / 编辑 | 一次编辑请求 | 唯一的一张图 |
|
||||||
|
| 批量添加水印 | 一次批量操作 | 一张商品素材 |
|
||||||
|
|
||||||
|
子项保存 `payload`、状态、尝试次数、租约、结果 URL、生成回写素材 ID、错误、下次执行时间,以及第三方 `provider_task_id` / `provider_task_kind`。
|
||||||
|
|
||||||
|
### 3.2 任务类型
|
||||||
|
|
||||||
|
| 类型 | 当前状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `image_suite` | 已实施 | 套图按“每张图一个子项”执行。 |
|
||||||
|
| `image_edit` | 已实施 | 单张 AI 生图 / AI 编辑。 |
|
||||||
|
| `watermark_batch` | 已实施 | 已选素材逐张生成当前水印版。 |
|
||||||
|
| `export_zip` / 批量下载 | 待开发 | 可复用同一父/子任务模型处理长时间 ZIP。 |
|
||||||
|
| 孤儿文件清理 | 待开发 | 后台扫描并回收存储异常残留。 |
|
||||||
|
|
||||||
|
### 3.3 可选诊断表(未实施)
|
||||||
|
|
||||||
|
未来新增 `background_job_events`,记录“提交模型”“获取 provider task ID”“轮询失败”“租约恢复”“用户取消”“人工重试”等事件。它不参与调度,作用是排障、审计和任务中心时间线;当前诊断信息主要存于子项错误文本和服务日志。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 执行、恢复与一致性
|
||||||
|
|
||||||
|
### 4.1 Worker 执行流程(已实施)
|
||||||
|
|
||||||
|
`start.command` 会额外启动 `python server/worker.py`。Worker 通过数据库租约领取一个符合条件的子项:
|
||||||
|
|
||||||
|
```text
|
||||||
|
queued / waiting_provider
|
||||||
|
→ Worker 写入 running + 租约
|
||||||
|
→ 执行一次推进
|
||||||
|
├─ 模型未完成:持久化 provider_task_id → waiting_provider → 延迟下次轮询
|
||||||
|
├─ 成功:保存图片 / 回写素材 → done
|
||||||
|
├─ 可诊断错误:failed
|
||||||
|
└─ 已取消或商品不存在:cancelled
|
||||||
|
→ 从所有子项重新计算父任务进度与终态
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker 异常退出时,过期租约会重新回到队列;Web 服务重启不影响已提交的数据库任务。
|
||||||
|
|
||||||
|
### 4.2 第三方模型恢复(已实施范围)
|
||||||
|
|
||||||
|
- **Pcore、RightAPI、通义万相**:提交后先持久化 `provider_task_id`,Worker 重启后继续轮询同一模型任务,不重复扣费。
|
||||||
|
- **豆包、同步千问等同步接口**:供应商不提供可恢复的外部任务 ID。若进程恰好在请求期间中断,只能重试,无法保证供应商侧幂等。
|
||||||
|
- **仍待完善的极小窗口**:若“已向供应商提交”与“成功写入 `provider_task_id`”之间进程崩溃,当前无法完全避免重复提交。第三阶段应引入供应商幂等键;不支持幂等键时,把子项标为“待人工确认”,不盲目重复扣费。
|
||||||
|
|
||||||
|
### 4.3 图片生命周期配合(已实施)
|
||||||
|
|
||||||
|
批量水印任务创建时会冻结:
|
||||||
|
|
||||||
|
- `asset_id`;
|
||||||
|
- 当时的 `current_storage_key` / 当前图片 URL;
|
||||||
|
- 水印配置快照。
|
||||||
|
|
||||||
|
Worker 始终基于素材 `original` 无水印版本合成新的 `current` 水印版。执行时若素材已删除或 current 版本已经变化,子项失败而不会用旧任务覆盖新图片;单项重试会重新冻结该素材的最新 current 版本。数据库成功切换到新 current 后,才尝试删除旧水印对象。
|
||||||
|
|
||||||
|
详细的原图、当前版、删除回收规则见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md)。
|
||||||
|
|
||||||
|
### 4.4 删除与取消(已实施)
|
||||||
|
|
||||||
|
删除商品前会取消该商品所有未完成任务。Worker 在落盘和回写素材前再次检查取消标志与商品是否存在;若商品或素材已删除,只丢弃/清理本次临时结果,绝不“复活”商品或素材记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 调度与优先级
|
||||||
|
|
||||||
|
### 当前实现
|
||||||
|
|
||||||
|
当前为一个独立 Worker 进程,按子项粒度领取,排序为优先级优先、再按创建时间:
|
||||||
|
|
||||||
|
| 类型 | 当前优先级 | 含义 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `image_edit` | `10` | 用户临时单图编辑优先。 |
|
||||||
|
| `image_suite` | `0` | 常规套图。 |
|
||||||
|
| `watermark_batch` | `-10` | 低优先级,不应优先于模型生成。 |
|
||||||
|
|
||||||
|
这已避免“十张套图作为整体长期占住任务”的问题:套图也是逐张领取,单图编辑可插队。
|
||||||
|
|
||||||
|
### 后续目标(未实施)
|
||||||
|
|
||||||
|
AI 生成与水印不应长期共用单线程吞吐:
|
||||||
|
|
||||||
|
1. AI 按供应商设置串行或小并发及速率限制;单图编辑优先。
|
||||||
|
2. 批量水印使用独立逻辑队列 / Worker 池,建议并发约 2,不阻塞模型任务。
|
||||||
|
3. 调度增加公平性:连续处理 2–3 个高优先级单图后,给套图一个执行机会,避免套图饿死。
|
||||||
|
|
||||||
|
在 SQLite 单机阶段,这可先通过两个启动参数不同的 Worker 和类型过滤实现;迁移 PostgreSQL 后再做跨进程可靠并发领取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 与前端契约
|
||||||
|
|
||||||
|
### 当前接口
|
||||||
|
|
||||||
|
| 接口 | 返回 / 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/suite/generate` | 返回 `{ suite_id }`,创建 `image_suite`。 |
|
||||||
|
| `POST /api/suite/image-edit` | 返回 `{ suite_id }`,创建 `image_edit`。 |
|
||||||
|
| `POST /api/assets/watermark` | 返回 `{ job_id, total }`,创建 `watermark_batch`。 |
|
||||||
|
| `GET /api/suites/{id}` | 通用任务详情、父任务进度、逐项状态和错误。 |
|
||||||
|
| `GET /api/products/{id}/suites?active_only=true` | 恢复活动的套图 / 单图生成任务。 |
|
||||||
|
| `GET /api/products/{id}/watermark-jobs?active_only=true` | 恢复活动的水印任务。 |
|
||||||
|
| `POST /api/suites/{id}/cancel` | 取消任务中尚未完成的子项。 |
|
||||||
|
| `POST /api/suites/{id}/retry` | 重试全部失败子项。 |
|
||||||
|
| `POST /api/suites/{id}/items/{item_id}/retry` | 仅重试指定失败子项。 |
|
||||||
|
|
||||||
|
`GET /api/suites/{id}` 是历史命名;它已经承载水印任务。全局任务中心落地时应新增语义更通用的 `GET /api/background-jobs/{id}`(保留旧接口兼容)。
|
||||||
|
|
||||||
|
### 当前前端轮询
|
||||||
|
|
||||||
|
前端使用递归 `setTimeout`,在上一轮请求完成后才安排下一轮;网络失败采用指数退避并使用 `AbortController` 取消页面卸载或任务切换后的请求。浏览器会保存最近任务 ID,刷新后还会从服务端查询商品活动任务,因此不依赖单次页面生命周期。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 已完成阶段
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 第一阶段:AI 任务可靠性 | 任务表与迁移;独立 Worker;套图与单图编辑迁入持久化任务;provider task ID 持久化;取消、失败重试、刷新恢复。 | ✅ 已完成 |
|
||||||
|
| 第二阶段:批量水印复用 | `watermark_batch` 逐素材子项;后台合成和版本切换;整体进度、取消、全部失败重试、单项重试、刷新恢复。 | ✅ 已完成 |
|
||||||
|
| 数据安全配合 | 商品删除取消任务;写入前二次检查;水印任务冻结素材 current 版本,拒绝旧任务覆盖新状态。 | ✅ 已完成 |
|
||||||
|
| 第三阶段:前端任务中心(第一步) | 当前商品固定任务入口、右侧任务抽屉、任务进度、取消、失败重试、跳转生成结果。 | ✅ 已完成 |
|
||||||
|
|
||||||
|
已验证:Python 编译、前端 TypeScript/生产构建;模拟存储下的“创建水印任务 → Worker 完成 → 素材 current 回写”以及“单个失败项按最新版本重试”。真实模型与七牛仍应按第 10 节进行人工回归。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 当前限制
|
||||||
|
|
||||||
|
- 任务中心 UI 尚未独立成页面或全局抽屉,任务入口仍分散在生成结果和水印设置中。
|
||||||
|
- `background_job_events` 尚未建立,复杂问题主要依赖日志与错误摘要。
|
||||||
|
- 当前一个 Worker 统一领取 AI 与水印任务,优先级有效,但没有独立并发池和公平调度。
|
||||||
|
- 移除水印仍是短时同步接口;大量素材场景可迁入 `watermark_batch` 同一框架。
|
||||||
|
- SQLite 适合本机单用户,不适合多机器或高并发抢占。
|
||||||
|
- 同步供应商无法做到提交中的完全幂等恢复;异步供应商的“提交后未保存 provider ID”窗口尚未用幂等键闭合。
|
||||||
|
- 历史任务和失败临时文件尚未设置保留期与定期清理策略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 待开发阶段
|
||||||
|
|
||||||
|
### 第三阶段:前端任务中心后续完善
|
||||||
|
|
||||||
|
1. **已完成**:在试算页右下角“录入商品”上方提供“任务 / 处理中(N)”入口,打开当前商品任务抽屉。
|
||||||
|
2. **已完成**:抽屉合并套图、单图编辑、水印任务,展示逐图失败、取消、重试和“查看结果”。
|
||||||
|
3. 待开发:按类型、状态筛选,展示更细的 `waiting_provider` 时间线与预计耗时。
|
||||||
|
4. 待开发:新增通用列表接口,例如 `GET /api/background-jobs?product_id=&status=&kind=`;旧 `/suites/*` 继续兼容。
|
||||||
|
5. 待开发:单图弹窗提交后提供“查看任务”快捷入口,并逐步移除弹窗自身的轮询职责。
|
||||||
|
|
||||||
|
### 第四阶段:可靠性、调度与诊断
|
||||||
|
|
||||||
|
1. 新增 `background_job_events` 与结构化日志,记录供应商、模型、耗时、错误类别和重试。
|
||||||
|
2. 引入供应商幂等键或“待人工确认”状态,关闭提交模型后的重复扣费窗口。
|
||||||
|
3. 拆分 AI 与水印的逻辑 Worker 池,配置并发、供应商限流和公平调度。
|
||||||
|
4. 增加任务历史保留期、失败临时文件清理和七牛孤儿对象扫描。
|
||||||
|
5. 把移除水印、耗时 ZIP 导出、批量下载逐步迁入任务中心。
|
||||||
|
|
||||||
|
### 第五阶段:多实例基础设施
|
||||||
|
|
||||||
|
前提是产品确实需要多用户、多机器或更高吞吐:
|
||||||
|
|
||||||
|
1. SQLite 迁移 PostgreSQL。
|
||||||
|
2. 采用 PostgreSQL 行级锁 / `SKIP LOCKED` 强化子项领取。
|
||||||
|
3. 再评估 Redis + Celery、RQ 或专业队列;不要在本地单机阶段提前引入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 回归与运维清单
|
||||||
|
|
||||||
|
每次修改任务系统后,至少验证:
|
||||||
|
|
||||||
|
1. `./start.command` 会同时启动 Web、前端和 Worker;手动启动 server 时必须另开 `python server/worker.py`。
|
||||||
|
2. 单图编辑提交后可关闭弹窗;刷新试算页后仍能看到任务和最终图片。
|
||||||
|
3. 套图多张任务中插入单图编辑,单图应优先领取,套图不丢进度。
|
||||||
|
4. 批量水印中删除商品或单个素材,Worker 不得回写/复活资源。
|
||||||
|
5. 改水印任务执行期间再次修改同一素材,旧任务必须失败或跳过,不能覆盖新 current。
|
||||||
|
6. Pcore / 通义等异步模型在重启 Worker 后继续轮询原 `provider_task_id`,不再次提交。
|
||||||
|
7. 失败项的全部重试与单项重试都能恢复状态;单项水印重试使用最新素材版本。
|
||||||
|
8. 真实存储环境检查旧水印对象仅在新 current 数据库提交后删除;失败时不会丢失原图。
|
||||||
|
|
||||||
|
## 11. 代码入口
|
||||||
|
|
||||||
|
| 位置 | 责任 |
|
||||||
|
|---|---|
|
||||||
|
| `server/models/job.py` | 父任务、子项 ORM 模型。 |
|
||||||
|
| `server/migrations/versions/b5d2c9a3e1f7_add_background_jobs.py` | 任务表迁移。 |
|
||||||
|
| `server/services/jobs.py` | 创建、领取、租约恢复、状态聚合、取消与重试。 |
|
||||||
|
| `server/services/job_worker.py` | 执行生成和批量水印子项。 |
|
||||||
|
| `server/worker.py`、`start.command` | 独立 Worker 命令与启动编排。 |
|
||||||
|
| `server/api/suite.py` | 套图、单图编辑和通用任务查询接口。 |
|
||||||
|
| `server/api/materials.py` | 批量水印创建和水印任务列表。 |
|
||||||
|
| `studio/src/pages/trial/TrialSuitePanel.tsx` | 试算页任务恢复、轮询、取消与重试入口。 |
|
||||||
|
| `studio/src/pages/trial/AiImageGenModal.tsx` | 单图任务配置与提交入口。 |
|
||||||
@@ -0,0 +1,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 字段。迁移前已经产生但数据库无记录的旧七牛对象无法可靠识别;后续可按运营需要增加七牛前缀扫描与人工确认清理工具。
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ POST /api/suite/generate
|
|||||||
`title(title_zh||title) / price / brand / sales / shop / params(含尺寸、重量覆盖回参数表) / selling_point / desc`。
|
`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 进请求。
|
- **参考图解析**(服务端,ISS 逻辑):variant_name 精确匹配 SKU 图 → 回退 main 组第 1 张;material 偏好第 2 张;最多 2 张;本地 storage 直读,远程 URL 带站点 Referer 下载,转 data-URI 进请求。
|
||||||
- **提示词**:按模型家族分册(alibaba 主体参考语义 / gpt edits 保真语义 / google 主体保持),共用 10 种图类型 builder + 商品上下文(参数表提炼卖点)+ 5 套风格 + `requirements` 置顶强制约束 + 图内文案按语言规范(俄文 ≤4 词)。
|
- **提示词**:按模型家族分册(alibaba 主体参考语义 / gpt edits 保真语义 / google 主体保持),共用 10 种图类型 builder + 商品上下文(参数表提炼卖点)+ 5 套风格 + `requirements` 置顶强制约束 + 图内文案按语言规范(俄文 ≤4 词)。
|
||||||
- **任务模型**:进程内内存任务表 + `asyncio.Lock` 全局串行队列(防中转限流);逐张生成 → Pillow 水印 → 按 PNG 魔数定扩展名落 `storage`。**重启丢任务、图片不丢**(与 ISS 一致,接受)。
|
- **任务模型**:数据库父任务 + 逐图子任务,独立 Worker 按子项领取;逐张生成 → Pillow 水印 → 按 PNG 魔数定扩展名落 `storage`。Pcore、RightAPI、通义万相的外部任务 ID 会持久化,Worker/Web 重启后继续查询原任务,不重复提交。详见 [`background-jobs.md`](./background-jobs.md)。
|
||||||
- **前端轮询**:`GET /api/suites/{id}` 每 3s;`done/partial/failed` 终态;连续 3 次失败停止跟踪;超预算(5min/张+10min)提示。
|
- **前端轮询**:请求结束后再延迟下一次(正常 3s),因此不会发生异步 `setInterval` 重叠;在途请求由 `AbortController` 管理,切换任务/卸载时取消。请求失败最多重试 3 次,并按 6s、12s 指数退避;当前任务 ID 存入浏览器会话,刷新时也会查询商品活动任务。`done/partial/failed/cancelled` 为终态;超预算(5min/张+10min)提示。
|
||||||
|
|
||||||
## 5. 模型路由(与 ISS 一致,服务端 MODEL_PROVIDERS 表)
|
## 5. 模型路由(与 ISS 一致,服务端 MODEL_PROVIDERS 表)
|
||||||
|
|
||||||
@@ -72,14 +72,15 @@ POST /api/suite/generate
|
|||||||
|
|
||||||
```
|
```
|
||||||
采集图/生成图单元格 → 「AI生图」按钮
|
采集图/生成图单元格 → 「AI生图」按钮
|
||||||
→ 弹窗:左侧原图预览;右侧要求文本域(必填)+ 模型下拉(同上 10 个)+ 生成按钮
|
→ 弹窗:左侧原图预览;右侧要求文本域(必填)+ 模型下拉(同上 10 个)+ 提交任务按钮
|
||||||
→ POST /api/suite/image-edit { product_id, image_url, prompt, model, append: true }
|
→ 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)
|
→ 服务端把结果追加为 product_assets(group_key='generated', variant_name=null)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 与「智能修图」页(`/ai-image`,wanx 注解编辑)并存:本入口是**轻量单张再生成**(选模型 + 一句话要求),不做标注/遮罩。
|
- 与「智能修图」页(`/ai-image`,wanx 注解编辑)并存:本入口是**轻量单张再生成**(选模型 + 一句话要求),不做标注/遮罩。
|
||||||
- 服务端实现 = 统一模型路由的单张调用(复用 §5 provider 层 + 参考图即传入的单图 + prompt = 用户要求 + Ozon 平台规格),生成后落 storage 并回写素材;`append=false` 时不回写。
|
- 服务端实现 = 复用套图的持久化任务和模型路由(参考图即传入的单图 + prompt = 用户要求 + Ozon 平台规格),生成后落 storage 并回写素材;`append=false` 时不回写。提交接口不等待模型完成,用户可关闭弹窗继续工作。
|
||||||
|
|
||||||
## 8. 展示与防盗链
|
## 8. 展示与防盗链
|
||||||
|
|
||||||
|
|||||||
@@ -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 契约、索引、可观测性**:依据真实使用量逐步完善。
|
||||||
+89
-31
@@ -10,14 +10,17 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from api.proxy import guess_referer
|
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"])
|
router = APIRouter(prefix="/api", tags=["export"])
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ class ExportImageItem(BaseModel):
|
|||||||
|
|
||||||
class ExportImagesRequest(BaseModel):
|
class ExportImagesRequest(BaseModel):
|
||||||
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
|
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
|
||||||
images: list[ExportImageItem]
|
images: list[ExportImageItem] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
def _clean(name: str) -> str:
|
def _clean(name: str) -> str:
|
||||||
@@ -75,47 +78,102 @@ async def _download(url: str) -> tuple[bytes, str]:
|
|||||||
path = local_path(url)
|
path = local_path(url)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
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))
|
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")
|
@router.post("/export/images")
|
||||||
async def export_images(req: ExportImagesRequest):
|
async def export_images(req: ExportImagesRequest):
|
||||||
if not req.images:
|
settings = get_settings()
|
||||||
raise HTTPException(status_code=400, detail="没有可导出的图片")
|
if len(req.images) > settings.export_max_images:
|
||||||
|
raise HTTPException(status_code=413, detail=f"一次最多导出 {settings.export_max_images} 张图片,请分批导出")
|
||||||
|
|
||||||
root = _clean(req.title) or "采集图片"
|
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()
|
used: set[str] = set()
|
||||||
|
total_source_bytes = 0
|
||||||
|
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
try:
|
||||||
for img in req.images:
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
try:
|
for img in req.images:
|
||||||
data, ctype = await _download(img.url)
|
try:
|
||||||
except Exception: # noqa: BLE001
|
data, ctype = await _download(img.url)
|
||||||
continue # 单张失败不中断整包
|
except ResourceSizeLimitError as exc:
|
||||||
if not _is_image(img.url, ctype):
|
raise HTTPException(status_code=413, detail=f"无法导出「{img.key or '图片'}」:{exc}") from exc
|
||||||
continue
|
except Exception: # noqa: BLE001
|
||||||
|
continue # 单张下载失败不中断整包
|
||||||
|
if not _is_image(img.url, ctype):
|
||||||
|
continue
|
||||||
|
total_source_bytes += len(data)
|
||||||
|
if total_source_bytes > settings.export_zip_max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"导出图片总大小超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少勾选图片后重试",
|
||||||
|
)
|
||||||
|
|
||||||
ext = _ext(img.url, ctype)
|
ext = _ext(img.url, ctype)
|
||||||
base = _clean(img.key) or "image"
|
base = _clean(img.key) or "image"
|
||||||
if img.variantName:
|
if img.variantName:
|
||||||
base += f"-{_clean(img.variantName)}"
|
base += f"-{_clean(img.variantName)}"
|
||||||
filename = f"{base}{ext}"
|
filename = f"{base}{ext}"
|
||||||
if filename in used: # 同名加序号防覆盖
|
if filename in used: # 同名加序号防覆盖
|
||||||
stem = filename[: -len(ext)]
|
stem = filename[: -len(ext)]
|
||||||
n = 2
|
n = 2
|
||||||
while f"{stem}-{n}{ext}" in used:
|
while f"{stem}-{n}{ext}" in used:
|
||||||
n += 1
|
n += 1
|
||||||
filename = f"{stem}-{n}{ext}"
|
filename = f"{stem}-{n}{ext}"
|
||||||
used.add(filename)
|
used.add(filename)
|
||||||
|
|
||||||
group = _clean(img.groupName) or "图片"
|
group = _clean(img.groupName) or "图片"
|
||||||
zf.writestr(f"{root}/{group}/{filename}", data)
|
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(
|
return StreamingResponse(
|
||||||
buf,
|
_stream_file(archive),
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": 'attachment; filename="collect.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}"'},
|
||||||
|
)
|
||||||
|
|||||||
+165
-19
@@ -2,9 +2,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import Product, ProductAsset
|
||||||
from models.enums import AssetStatus, Stage
|
from models.enums import AssetStatus, Stage
|
||||||
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
|
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"])
|
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:
|
def _parse_number(text: str | None) -> float | None:
|
||||||
@@ -115,6 +147,11 @@ async def create_materials(
|
|||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
) -> MaterialsResponse:
|
) -> 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)
|
product = await _get_or_create_product(db, req)
|
||||||
_apply_texts(product, req.texts)
|
_apply_texts(product, req.texts)
|
||||||
|
|
||||||
@@ -176,9 +213,7 @@ async def create_materials(
|
|||||||
|
|
||||||
async def process_product_assets(product_id: str) -> None:
|
async def process_product_assets(product_id: str) -> None:
|
||||||
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
|
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
|
||||||
from services.storage import get_storage
|
image_assets = get_image_asset_service()
|
||||||
|
|
||||||
storage = get_storage()
|
|
||||||
async with get_session_factory()() as db:
|
async with get_session_factory()() as db:
|
||||||
assets = (await db.scalars(
|
assets = (await db.scalars(
|
||||||
select(ProductAsset).where(
|
select(ProductAsset).where(
|
||||||
@@ -190,8 +225,7 @@ async def process_product_assets(product_id: str) -> None:
|
|||||||
a.status = AssetStatus.downloading
|
a.status = AssetStatus.downloading
|
||||||
await db.commit()
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
|
await image_assets.save_original_from_url(a, a.source_url)
|
||||||
a.stored_url = stored
|
|
||||||
a.status = AssetStatus.uploaded
|
a.status = AssetStatus.uploaded
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
a.status = AssetStatus.failed
|
a.status = AssetStatus.failed
|
||||||
@@ -213,7 +247,17 @@ async def upload_material_bytes(
|
|||||||
product = await db.get(Product, UUID(product_id))
|
product = await db.get(Product, UUID(product_id))
|
||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
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(
|
asset = ProductAsset(
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
group_key=group_key,
|
group_key=group_key,
|
||||||
@@ -225,11 +269,10 @@ async def upload_material_bytes(
|
|||||||
)
|
)
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
# 直接转存字节
|
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
|
||||||
from services.storage import get_storage
|
image_assets = get_image_asset_service()
|
||||||
storage = get_storage()
|
|
||||||
try:
|
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
|
asset.status = AssetStatus.uploaded
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
asset.status = AssetStatus.failed
|
asset.status = AssetStatus.failed
|
||||||
@@ -269,20 +312,16 @@ async def is_collected(
|
|||||||
|
|
||||||
@router.delete("/assets/{asset_id}")
|
@router.delete("/assets/{asset_id}")
|
||||||
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""删除一条素材记录(DB 行 + 本地文件;七牛等远程存储仅删引用)。"""
|
"""删除一条素材记录及其 original/current 七牛对象。"""
|
||||||
from services.storage import local_path
|
|
||||||
|
|
||||||
asset = await db.get(ProductAsset, UUID(asset_id))
|
asset = await db.get(ProductAsset, UUID(asset_id))
|
||||||
if asset is None:
|
if asset is None:
|
||||||
raise HTTPException(status_code=404, detail="素材不存在")
|
raise HTTPException(status_code=404, detail="素材不存在")
|
||||||
|
|
||||||
if asset.stored_url:
|
try:
|
||||||
path = local_path(asset.stored_url)
|
await get_image_asset_service().delete_asset_media(asset)
|
||||||
if path is not None and path.is_file():
|
except Exception as exc: # noqa: BLE001
|
||||||
try:
|
raise HTTPException(status_code=502, detail=f"图片存储删除失败,素材未删除:{exc}") from exc
|
||||||
path.unlink()
|
|
||||||
except OSError: # noqa: BLE001
|
|
||||||
pass # 文件删除失败不阻断记录删除
|
|
||||||
|
|
||||||
product_id = asset.product_id
|
product_id = asset.product_id
|
||||||
group_key = asset.group_key
|
group_key = asset.group_key
|
||||||
@@ -298,3 +337,110 @@ async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"deleted": True}
|
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)
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ async def delete_product(
|
|||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
raise HTTPException(status_code=404, detail="商品不存在")
|
||||||
if hard:
|
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)
|
await db.delete(product)
|
||||||
else:
|
else:
|
||||||
product.stage = Stage.archived
|
product.stage = Stage.archived
|
||||||
@@ -160,6 +172,9 @@ async def list_assets(
|
|||||||
"type": a.type,
|
"type": a.type,
|
||||||
"source_url": a.source_url,
|
"source_url": a.source_url,
|
||||||
"stored_url": a.stored_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,
|
"status": a.status.value,
|
||||||
"width": a.width,
|
"width": a.width,
|
||||||
"height": a.height,
|
"height": a.height,
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
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"])
|
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")
|
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
|
||||||
try:
|
try:
|
||||||
data, ctype = await download_bytes(url, referer=guess_referer(url))
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
|
||||||
if not ctype.startswith("image/"):
|
if not ctype.startswith("image/"):
|
||||||
|
|||||||
+188
-116
@@ -1,7 +1,7 @@
|
|||||||
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 AI 生图。
|
"""套图规划 / 一键生成 / 任务查询 / 结果导出 / 单张 AI 生图。
|
||||||
|
|
||||||
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studio(services/planner|generator|
|
按 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 缺省时只落盘不回写。
|
生成图回调回写 product_assets(group_key='generated');product_id 缺省时只落盘不回写。
|
||||||
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
|
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
|
||||||
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
|
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
|
||||||
@@ -9,11 +9,11 @@ tasks|watermark + services/prompts),任务存进程内内存表(重启即
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
from functools import partial
|
from collections.abc import Iterator
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
@@ -21,7 +21,7 @@ from schemas.suite import (
|
|||||||
PLATFORM_SPECS,
|
PLATFORM_SPECS,
|
||||||
SUPPORTED_TYPES,
|
SUPPORTED_TYPES,
|
||||||
ImageEditSingleRequest,
|
ImageEditSingleRequest,
|
||||||
ImageEditSingleResponse,
|
ImageEditTaskResponse,
|
||||||
PlanItemOut,
|
PlanItemOut,
|
||||||
PlanResponse,
|
PlanResponse,
|
||||||
SuiteCreateResponse,
|
SuiteCreateResponse,
|
||||||
@@ -31,16 +31,34 @@ from schemas.suite import (
|
|||||||
api_model,
|
api_model,
|
||||||
resolve_provider,
|
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.planner import generate_plan
|
||||||
from services.prompts import build_context, build_prompt, type_name
|
from services.prompts import type_name
|
||||||
from services.storage import download_bytes, get_storage, local_path
|
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes
|
||||||
from services.suite_service import append_generated_asset, texts_to_raw, validate_model
|
from services.suite_service import texts_to_raw, validate_model
|
||||||
from services.tasks import IMG_OK, TaskImage, create_task, get_task
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["suite"])
|
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)
|
@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)
|
@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:
|
if not req.images:
|
||||||
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
|
raise HTTPException(status_code=400, detail="未勾选任何图片,无法生成")
|
||||||
|
|
||||||
@@ -111,94 +129,146 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||||||
|
|
||||||
task = create_task(
|
task = await create_image_job(
|
||||||
status="pending",
|
product_id=product_id,
|
||||||
|
kind="image_suite",
|
||||||
|
priority=0,
|
||||||
|
provider=provider_name,
|
||||||
|
model=model,
|
||||||
platform=req.platform,
|
platform=req.platform,
|
||||||
lang=spec["lang"],
|
lang=spec["lang"],
|
||||||
ratio=spec["ratio"],
|
ratio=spec["ratio"],
|
||||||
style_set=req.style_set,
|
style_set=req.style_set,
|
||||||
style_prompt=req.style_prompt,
|
request_payload={
|
||||||
requirements=req.requirements,
|
"context": texts_to_raw(req.texts),
|
||||||
provider=provider_name,
|
"style_prompt": req.style_prompt,
|
||||||
model=model,
|
"requirements": req.requirements,
|
||||||
total=len(jobs),
|
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||||
context=texts_to_raw(req.texts),
|
"append": bool(product_id),
|
||||||
plan=jobs,
|
# main 组优先;variant_name 精确匹配由 Worker 复用原有选图规则。
|
||||||
watermark=req.watermark.model_dump() if req.watermark else None,
|
"ref_images": [
|
||||||
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
|
{"url": image.url, "group_key": image.group_key, "variant_name": image.variant_name}
|
||||||
ref_images=[
|
for image in sorted(req.images, key=lambda image: 0 if image.group_key == "main" else 1)
|
||||||
{
|
],
|
||||||
"url": i.url,
|
},
|
||||||
"group_key": i.group_key,
|
item_payloads=[{**job, "position": index} for index, job in enumerate(jobs)],
|
||||||
"variant_name": i.variant_name,
|
|
||||||
}
|
|
||||||
for i in sorted(req.images, key=lambda x: 0 if x.group_key == "main" else 1)
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
return SuiteCreateResponse(suite_id=str(task.id))
|
||||||
# 每张成功即回写 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)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 任务查询 / 结果导出 ──────────────────────────────────────────────────
|
# ── 任务查询 / 结果导出 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/suites/{suite_id}", response_model=SuiteOut)
|
@router.get("/suites/{suite_id}", response_model=SuiteOut)
|
||||||
async def get_suite(suite_id: str):
|
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:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启),请重新生成")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
return SuiteOut(
|
result = await get_job(str(task.id))
|
||||||
id=task.id,
|
if result is None:
|
||||||
status=task.status,
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
style_set=task.style_set,
|
return SuiteOut(**suite_view(*result))
|
||||||
platform=task.platform,
|
|
||||||
lang=task.lang,
|
|
||||||
ratio=task.ratio,
|
@router.post("/suites/{suite_id}/retry", response_model=SuiteOut)
|
||||||
provider=task.provider,
|
async def retry_suite(suite_id: str):
|
||||||
model=task.model,
|
task = await retry_failed_items(suite_id)
|
||||||
total=task.total,
|
if task is None:
|
||||||
images=[
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
{"type_id": i.type_id, "name": i.name, "url": i.url, "status": i.status, "error": i.error}
|
result = await get_job(str(task.id))
|
||||||
for i in task.images
|
if result is None:
|
||||||
],
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
error=task.error,
|
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")
|
@router.get("/suites/{suite_id}/zip")
|
||||||
async def download_suite_zip(suite_id: str):
|
async def download_suite_zip(suite_id: str):
|
||||||
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
|
"""把任务内所有成功图打包成 ZIP(中文文件名)。"""
|
||||||
task = get_task(suite_id)
|
result = await get_job(suite_id)
|
||||||
if task is None:
|
if result is None:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在(服务可能已重启)")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
task, task_items = result
|
||||||
|
|
||||||
buf = io.BytesIO()
|
settings = get_settings()
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
images = [image for image in task_items if image.status == ITEM_DONE and image.result_url]
|
||||||
seen: set[str] = set()
|
if len(images) > settings.export_max_images:
|
||||||
for i, img in enumerate([i for i in task.images if i.status == IMG_OK]):
|
raise HTTPException(status_code=413, detail=f"该任务有 {len(images)} 张图片,单次最多导出 {settings.export_max_images} 张")
|
||||||
path = local_path(img.url or "")
|
|
||||||
if path is not None:
|
archive = tempfile.SpooledTemporaryFile(
|
||||||
data, suffix = path.read_bytes(), path.suffix or ".jpg"
|
max_size=settings.export_zip_memory_threshold_bytes,
|
||||||
else:
|
mode="w+b",
|
||||||
try: # 七牛等远程存储:直接拉取公开 URL
|
)
|
||||||
data, _ = await download_bytes(img.url or "")
|
total_source_bytes = 0
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for i, img in enumerate(images):
|
||||||
|
path = local_path(img.result_url or "")
|
||||||
|
try:
|
||||||
|
if path is not None:
|
||||||
|
data, suffix = read_local_bytes(path), path.suffix or ".jpg"
|
||||||
|
else: # 七牛等远程存储:直接拉取公开 URL
|
||||||
|
data, _ = await download_bytes(img.result_url or "")
|
||||||
|
suffix = ".png" if data[:8] == b"\x89PNG\r\n\x1a\n" else ".jpg"
|
||||||
|
except ResourceSizeLimitError as exc:
|
||||||
|
raise HTTPException(status_code=413, detail=f"无法导出「{img.name}」:{exc}") from exc
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
continue
|
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 filename in seen: # 同类型多张时加序号防覆盖
|
if total_source_bytes > settings.export_zip_max_bytes:
|
||||||
filename = f"{filename}-{i + 1}"
|
raise HTTPException(
|
||||||
seen.add(filename)
|
status_code=413,
|
||||||
zf.writestr(f"{filename}{suffix}", data)
|
detail=f"导出图片总大小超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
|
||||||
buf.seek(0)
|
)
|
||||||
|
payload = img.payload or {}
|
||||||
|
filename = payload.get("title") or payload.get("kind") or "AI 生图"
|
||||||
|
if filename in seen: # 同类型多张时加序号防覆盖
|
||||||
|
filename = f"{filename}-{i + 1}"
|
||||||
|
seen.add(filename)
|
||||||
|
zf.writestr(f"{filename}{suffix}", data)
|
||||||
|
if archive.tell() > settings.export_zip_max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"导出 ZIP 超过 {settings.export_zip_max_bytes // 1024 // 1024} MB,请减少图片后重试",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
archive.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
archive.seek(0)
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
buf,
|
_stream_file(archive),
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": f"attachment; filename=\"suite-{suite_id}.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 无对应实现)──────────────────────────────
|
# ── 单张 AI 生图(V2.1 新增,ISS 无对应实现)──────────────────────────────
|
||||||
|
|
||||||
@router.post("/suite/image-edit", response_model=ImageEditSingleResponse)
|
@router.post("/suite/image-edit", response_model=ImageEditTaskResponse)
|
||||||
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleResponse:
|
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
|
||||||
"""轻量单张再生成:原图 + 一句话要求 + 模型选择。同步接口(单张可长达数分钟)。"""
|
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
||||||
if provider_name not in GENERATORS:
|
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)
|
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
|
||||||
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
|
||||||
is_wan = provider_name == "tongyi" and model.lower().startswith("wan")
|
product_id = (req.product_id or "").strip() or None
|
||||||
|
if product_id:
|
||||||
# 复用套图的 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
asset_id = await append_generated_asset(
|
uuid.UUID(product_id)
|
||||||
req.product_id,
|
except ValueError as exc:
|
||||||
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
|
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
|
||||||
after_asset_id=req.after_asset_id,
|
|
||||||
)
|
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
|
||||||
except Exception: # noqa: BLE001
|
task = await create_image_job(
|
||||||
pass # 回写失败不影响结果返回,图片已在任务网格可见
|
product_id=product_id,
|
||||||
return ImageEditSingleResponse(url=url, asset_id=asset_id)
|
kind="image_edit",
|
||||||
|
priority=10,
|
||||||
|
provider=provider_name,
|
||||||
|
model=model,
|
||||||
|
platform="ozon",
|
||||||
|
lang=spec["lang"],
|
||||||
|
ratio=spec["ratio"],
|
||||||
|
style_set=5,
|
||||||
|
request_payload={
|
||||||
|
"context": {"title": "AI 精修"},
|
||||||
|
"style_prompt": None,
|
||||||
|
"requirements": req.prompt,
|
||||||
|
"watermark": req.watermark.model_dump() if req.watermark else None,
|
||||||
|
"append": bool(req.append and product_id),
|
||||||
|
"after_asset_id": req.after_asset_id,
|
||||||
|
"ref_images": [{"url": req.image_url, "group_key": "main", "variant_name": None}],
|
||||||
|
},
|
||||||
|
item_payloads=[{
|
||||||
|
"position": 0,
|
||||||
|
"kind": "custom",
|
||||||
|
"title": "AI生图",
|
||||||
|
"detail": "",
|
||||||
|
"prompt_hint": "",
|
||||||
|
"variant_name": None,
|
||||||
|
"prompt_extra": {"title": "", "detail": "", "prompt_hint": ""},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
return ImageEditTaskResponse(suite_id=str(task.id))
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class Settings(BaseSettings):
|
|||||||
# ── V2:对外地址(插件/前端回写、生成图回调)──
|
# ── V2:对外地址(插件/前端回写、生成图回调)──
|
||||||
app_base_url: str = "http://127.0.0.1:8800"
|
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 一致)──
|
# ── V2.1 套图生图(provider 路由与 image-suite-studio 一致)──
|
||||||
image_provider: str = "doubao" # 未知模型时的兜底 provider:doubao | tongyi | rightapi
|
image_provider: str = "doubao" # 未知模型时的兜底 provider:doubao | tongyi | rightapi
|
||||||
request_timeout: int = 300 # 单张生图请求超时(秒)
|
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")
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
由 legacy.api 路由 import 链注册到同一 Base.metadata。
|
由 legacy.api 路由 import 链注册到同一 Base.metadata。
|
||||||
"""
|
"""
|
||||||
from models.asset import ProductAsset
|
from models.asset import ProductAsset
|
||||||
|
from models.job import BackgroundJob, BackgroundJobItem
|
||||||
from models.product import Product
|
from models.product import Product
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
|
||||||
@@ -11,4 +12,6 @@ __all__ = [
|
|||||||
"User",
|
"User",
|
||||||
"Product",
|
"Product",
|
||||||
"ProductAsset",
|
"ProductAsset",
|
||||||
|
"BackgroundJob",
|
||||||
|
"BackgroundJobItem",
|
||||||
]
|
]
|
||||||
|
|||||||
+11
-3
@@ -4,11 +4,12 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from db import Base
|
from db import Base
|
||||||
from models.enums import AssetStatus
|
from models.enums import AssetStatus
|
||||||
|
from models.types import JSONType
|
||||||
|
|
||||||
|
|
||||||
class ProductAsset(Base):
|
class ProductAsset(Base):
|
||||||
@@ -22,8 +23,15 @@ class ProductAsset(Base):
|
|||||||
variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # SKU 规格名
|
variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # SKU 规格名
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
type: Mapped[str] = mapped_column(String(8), default="img") # img / video
|
type: Mapped[str] = mapped_column(String(8), default="img") # img / video
|
||||||
source_url: Mapped[str] = mapped_column(Text, default="")
|
source_url: Mapped[str] = mapped_column(Text, default="") # 采集源站地址;生成图旧数据可能存无水印 URL
|
||||||
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地路径或七牛公网 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(
|
status: Mapped[AssetStatus] = mapped_column(
|
||||||
Enum(AssetStatus, native_enum=False, length=16), default=AssetStatus.pending, index=True
|
Enum(AssetStatus, native_enum=False, length=16), default=AssetStatus.pending, index=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""持久化后台任务:AI 套图、单张编辑共用的父任务与逐图子任务。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from db import Base
|
||||||
|
from models.types import JSONType
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundJob(Base):
|
||||||
|
"""一个用户可见的后台操作:套图、单张编辑或批量水印。"""
|
||||||
|
|
||||||
|
__tablename__ = "background_jobs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
# 商品删除后保留任务诊断记录,但 Worker 不会再把结果回写到不存在的商品。
|
||||||
|
product_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String(32), index=True) # image_suite | image_edit | watermark_batch
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="queued", index=True)
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
platform: Mapped[str] = mapped_column(String(16), default="ozon")
|
||||||
|
lang: Mapped[str] = mapped_column(String(16), default="ru")
|
||||||
|
ratio: Mapped[str] = mapped_column(String(16), default="3:4")
|
||||||
|
style_set: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
# 原始请求的不可变快照:商品上下文、参考图池、风格、要求、水印等。
|
||||||
|
request_payload: Mapped[dict] = mapped_column(JSONType, default=dict)
|
||||||
|
total: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
completed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
failed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundJobItem(Base):
|
||||||
|
"""任务中的一个可调度工作单元:套图的一张图或单张编辑。"""
|
||||||
|
|
||||||
|
__tablename__ = "background_job_items"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
job_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("background_jobs.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
status: Mapped[str] = mapped_column(String(24), default="queued", index=True)
|
||||||
|
payload: Mapped[dict] = mapped_column(JSONType, default=dict)
|
||||||
|
# 供应商异步任务 ID 与类型(pcore 还需 edits/generations);重启后继续轮询,不重新提交。
|
||||||
|
provider_task_id: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
|
||||||
|
provider_task_kind: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
max_attempts: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
result_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
watermark_config: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||||
|
generated_asset_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
lease_owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True
|
||||||
|
)
|
||||||
@@ -18,6 +18,8 @@ class ProductListItem(BaseModel):
|
|||||||
currency_code: str = "RUB"
|
currency_code: str = "RUB"
|
||||||
source_platform: str | None = None
|
source_platform: str | None = None
|
||||||
source_url: str | None = None
|
source_url: str | None = None
|
||||||
|
# 采集箱显示中文标题:优先 raw.title_zh,兼容早期采集写入的 raw.title。
|
||||||
|
raw: dict | None = None
|
||||||
asset_counts: dict | None = None
|
asset_counts: dict | None = None
|
||||||
ozon_product_id: int | None = None
|
ozon_product_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
+16
-5
@@ -1,6 +1,7 @@
|
|||||||
"""套图规划 / 一键生成 / 单张 AI 生图 契约(studio/src/services/suite.ts 同源,见 docs/v2.1/api.md)。"""
|
"""套图规划 / 一键生成 / 单张 AI 生图 契约(studio/src/services/suite.ts 同源,见 docs/v2.1/api.md)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -61,7 +62,7 @@ PLATFORM_SPECS: dict[str, dict] = {
|
|||||||
|
|
||||||
|
|
||||||
class TextMaterial(BaseModel):
|
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 = ""
|
content: str = ""
|
||||||
pairs: list[dict] | None = None # [{key, value}]
|
pairs: list[dict] | None = None # [{key, value}]
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ class SuiteCreateResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SuiteImageOut(BaseModel):
|
class SuiteImageOut(BaseModel):
|
||||||
|
item_id: str
|
||||||
type_id: str
|
type_id: str
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
@@ -145,6 +147,7 @@ class SuiteImageOut(BaseModel):
|
|||||||
class SuiteOut(BaseModel):
|
class SuiteOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
status: str
|
status: str
|
||||||
|
kind: str = "image_suite"
|
||||||
style_set: int
|
style_set: int
|
||||||
platform: str
|
platform: str
|
||||||
lang: str
|
lang: str
|
||||||
@@ -152,6 +155,12 @@ class SuiteOut(BaseModel):
|
|||||||
provider: str
|
provider: str
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
total: int = 0 # 计划总张数(进度分母;images 是逐张追加,过程中 length < total)
|
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]
|
images: list[SuiteImageOut]
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
@@ -160,15 +169,17 @@ class ImageEditSingleRequest(BaseModel):
|
|||||||
"""POST /api/suite/image-edit:单张 AI 生图(试算页每张图的「AI 生图」入口)。"""
|
"""POST /api/suite/image-edit:单张 AI 生图(试算页每张图的「AI 生图」入口)。"""
|
||||||
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
|
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
|
||||||
image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)")
|
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")
|
model: str = Field(default="nano-banana-2")
|
||||||
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
|
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
|
||||||
after_asset_id: str | None = Field(
|
after_asset_id: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
|
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
|
||||||
)
|
)
|
||||||
|
watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传")
|
||||||
|
|
||||||
|
|
||||||
class ImageEditSingleResponse(BaseModel):
|
class ImageEditTaskResponse(BaseModel):
|
||||||
url: str
|
"""单张编辑已进入统一图片任务队列;用 GET /api/suites/{suite_id} 查询结果。"""
|
||||||
asset_id: str | None = None
|
|
||||||
|
suite_id: str
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import base64
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -30,7 +31,7 @@ from services.tasks import (
|
|||||||
TaskImage,
|
TaskImage,
|
||||||
)
|
)
|
||||||
from services.watermark import apply_watermark
|
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")
|
log = logging.getLogger("suite.generator")
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ async def _resolve_ref_bytes(url: str) -> tuple[bytes, str]:
|
|||||||
path = local_path(url)
|
path = local_path(url)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
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://")):
|
if url.startswith(("http://", "https://")):
|
||||||
from api.proxy import guess_referer
|
from api.proxy import guess_referer
|
||||||
data, ctype = await download_bytes(url, referer=guess_referer(url))
|
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}")
|
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]:
|
def _rightapi_extract_image(result: dict) -> tuple[str | None, str | None]:
|
||||||
"""从轮询完成结果里取 (kind, payload):kind ∈ url | b64,未取到返回 (None, 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}")
|
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:
|
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)。
|
"""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]
|
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 = {
|
GENERATORS = {
|
||||||
"doubao": generate_doubao,
|
"doubao": generate_doubao,
|
||||||
"tongyi": generate_tongyi,
|
"tongyi": generate_tongyi,
|
||||||
@@ -524,12 +708,12 @@ def _refs_for_job(images: list[dict], job: dict) -> list[str]:
|
|||||||
_GEN_LOCK = asyncio.Lock()
|
_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)。"""
|
"""生成图字节 → 存储落盘,返回可访问 URL。按 PNG 魔数定扩展名(部分中转不遵守 output_format)。"""
|
||||||
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
|
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
ext = ".png" if is_png else ".jpg"
|
ext = ".png" if is_png else ".jpg"
|
||||||
ctype = "image/png" if is_png else "image/jpeg"
|
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:
|
async def run_suite(task: Task, on_image_ok: OnImageOk | None = None) -> None:
|
||||||
@@ -565,17 +749,24 @@ async def run_suite(task: Task, on_image_ok: OnImageOk | None = None) -> None:
|
|||||||
task.images.append(image)
|
task.images.append(image)
|
||||||
try:
|
try:
|
||||||
# 提示词按模型家族分发:国产主体参考 / gpt edits 保真 / google 主体保持
|
# 提示词按模型家族分发:国产主体参考 / gpt edits 保真 / google 主体保持
|
||||||
|
# 单张 AI 编辑可传入 prompt_extra:任务展示名保留「AI 生图」,
|
||||||
|
# 但不把中文展示名拼进模型 prompt,避免生成图内出现中文。
|
||||||
|
prompt_extra = job.get("prompt_extra") or job
|
||||||
prompt = build_prompt(
|
prompt = build_prompt(
|
||||||
provider_name, model, type_id, ctx, task.style_set, task.lang,
|
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)
|
refs = _refs_for_job(list(task.ref_images or []), job)
|
||||||
data = await generator(prompt, refs, size=size, model=model)
|
data = await generator(prompt, refs, size=size, model=model)
|
||||||
# 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图)
|
# 水印:AI 出图返回后、落盘前的后处理(失败不阻断,内部返回原图)
|
||||||
wm = task.watermark or {}
|
wm = task.watermark or {}
|
||||||
if wm.get("enabled"):
|
if wm.get("enabled"):
|
||||||
|
image.source_url = await _store_image_bytes(task, len(task.images), data, version="original")
|
||||||
data = apply_watermark(data, wm)
|
data = apply_watermark(data, wm)
|
||||||
image.url = await _store_image_bytes(task, len(task.images), data)
|
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
|
image.status = IMG_OK
|
||||||
ok += 1
|
ok += 1
|
||||||
if on_image_ok is not None and image.url:
|
if on_image_ok is not None and image.url:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .common import (
|
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:
|
def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
|
||||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||||
params = ctx["params_line"]
|
params = params_line_for_output(ctx, lang)
|
||||||
return (
|
return (
|
||||||
f"E-commerce detail-page hero section for product \"{ctx['title']}\", square layout: "
|
f"E-commerce detail-page hero section for product \"{ctx['title']}\", square layout: "
|
||||||
f"top half is a hero banner with the product at a 3/4 angle ({style['bg']}); "
|
f"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:
|
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
|
||||||
dims = ctx["params_line"]
|
dims = params_line_for_output(ctx, lang)
|
||||||
return (
|
return (
|
||||||
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
|
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
|
||||||
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
|
f"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:
|
def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
|
||||||
hint = (extra.get("prompt_hint") or "").strip()
|
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 ""
|
bg = f" {style['bg']} as environment." if style.get("bg") else ""
|
||||||
composed = (
|
# title/detail 是规划器给前端展示的中文文案;只使用约定为英文的 prompt_hint,避免它们变成图中文字。
|
||||||
f"E-commerce marketing image for product \"{ctx['title']}\""
|
composed = f"E-commerce marketing image for product \"{ctx['title']}\"."
|
||||||
+ (f" — {purpose}" if purpose else "")
|
|
||||||
+ (f": {detail}" if detail else "")
|
|
||||||
+ "."
|
|
||||||
)
|
|
||||||
if hint:
|
if hint:
|
||||||
composed += f" Composition: {hint}."
|
composed += f" Composition: {hint}."
|
||||||
return f"{composed}{bg} {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
return f"{composed}{bg} {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
|
||||||
|
|||||||
@@ -12,27 +12,27 @@ import re
|
|||||||
STYLE_SETS: dict[int, dict] = {
|
STYLE_SETS: dict[int, dict] = {
|
||||||
1: {
|
1: {
|
||||||
"name": "北欧极简",
|
"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": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
"name": "清新明亮",
|
"name": "清新明亮",
|
||||||
"tone": "清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净",
|
"tone": "Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
"name": "高级感深色",
|
"name": "高级感深色",
|
||||||
"tone": "高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级",
|
"tone": "Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
"name": "暖调生活",
|
"name": "暖调生活",
|
||||||
"tone": "温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强",
|
"tone": "Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
5: {
|
5: {
|
||||||
"name": "纯净棚拍",
|
"name": "纯净棚拍",
|
||||||
"tone": "标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出",
|
"tone": "Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent",
|
||||||
"bg": "",
|
"bg": "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,9 @@ TEXT_RENDER = {
|
|||||||
"Render concise Russian marketing text inside the image: headline max 4 words, "
|
"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), "
|
"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. "
|
"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."
|
"No spelling errors, no mixed latin/cyrillic gibberish."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -86,8 +89,8 @@ def requirements_block(requirements: str | None) -> str:
|
|||||||
"""用户强制要求块:最高优先级、置于提示词最前、覆盖冲突指令(原文保留不翻译)。"""
|
"""用户强制要求块:最高优先级、置于提示词最前、覆盖冲突指令(原文保留不翻译)。"""
|
||||||
if requirements and requirements.strip():
|
if requirements and requirements.strip():
|
||||||
return (
|
return (
|
||||||
"STRICT REQUIREMENTS (highest priority, must be followed exactly, "
|
"STRICT REQUIREMENTS (highest priority for visual intent; interpret any Chinese source instruction "
|
||||||
"override any conflicting instruction): "
|
"by meaning only and never copy its Hanzi into newly generated text): "
|
||||||
+ requirements.strip().rstrip(".")
|
+ 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}
|
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)
|
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,
|
"desc": desc,
|
||||||
"selling_points": selling_points[:3],
|
"selling_points": selling_points[:3],
|
||||||
"params_line": params_line,
|
"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 "",
|
"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:
|
if not sps:
|
||||||
return ""
|
return ""
|
||||||
key = "zh" if lang == "zh" else "en"
|
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:
|
def type_name(type_id: str) -> str:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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 = (
|
||||||
"SUBJECT LOCK (highest priority): the product in the first image is the subject. "
|
"SUBJECT LOCK (highest priority): the product in the first image is the subject. "
|
||||||
@@ -32,8 +32,8 @@ _REMINDER = (
|
|||||||
def _anchor(ctx: dict) -> str:
|
def _anchor(ctx: dict) -> str:
|
||||||
"""商品文字锚定:仅供识别,明确以图为准(同 gpt 模块,避免文字反噬商品)。"""
|
"""商品文字锚定:仅供识别,明确以图为准(同 gpt 模块,避免文字反噬商品)。"""
|
||||||
line = f"Context (identification only): the product is \"{ctx['title']}\""
|
line = f"Context (identification only): the product is \"{ctx['title']}\""
|
||||||
if ctx.get("params_line"):
|
if ctx.get("params_line_non_cjk"):
|
||||||
line += f" ({ctx['params_line']})"
|
line += f" ({ctx['params_line_non_cjk']})"
|
||||||
return line + ". The image, not this text, defines the product's appearance."
|
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:
|
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
|
||||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||||
params = ctx["params_line"]
|
params = params_line_for_output(ctx, lang)
|
||||||
return (
|
return (
|
||||||
"Compose a square detail-page hero section: top half a hero banner with the unchanged product "
|
"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"
|
"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:
|
def _task_size_chart(ctx: dict, lang: str) -> str:
|
||||||
dims = ctx["params_line"]
|
dims = params_line_for_output(ctx, lang)
|
||||||
return (
|
return (
|
||||||
"Create a size chart: the unchanged product in clean front and side views on a light background, "
|
"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 "
|
"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()
|
hint = (extra.get("prompt_hint") or "").strip()
|
||||||
|
|
||||||
if type_id == "custom":
|
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 = "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 += "."
|
task += "."
|
||||||
wants_text = True
|
wants_text = True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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 唯一定义,其余指令一律不得触碰商品本体
|
# 保真锁:商品由 Image 1 唯一定义,其余指令一律不得触碰商品本体
|
||||||
_PRESERVE = (
|
_PRESERVE = (
|
||||||
@@ -55,8 +55,8 @@ def _anchor(ctx: dict) -> str:
|
|||||||
文字锚定用于帮模型认出"是哪件商品",而不是"长什么样"。
|
文字锚定用于帮模型认出"是哪件商品",而不是"长什么样"。
|
||||||
"""
|
"""
|
||||||
line = f"CONTEXT (identification only): the product is \"{ctx['title']}\""
|
line = f"CONTEXT (identification only): the product is \"{ctx['title']}\""
|
||||||
if ctx.get("params_line"):
|
if ctx.get("params_line_non_cjk"):
|
||||||
line += f" ({ctx['params_line']})"
|
line += f" ({ctx['params_line_non_cjk']})"
|
||||||
return (
|
return (
|
||||||
line
|
line
|
||||||
+ ". Image 1 — not this text — defines the product's appearance; "
|
+ ". 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:
|
def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
|
||||||
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
sp = selling_point_lines(ctx, lang) or ctx["title"]
|
||||||
params = ctx["params_line"]
|
params = params_line_for_output(ctx, lang)
|
||||||
return (
|
return (
|
||||||
"TASK: Detail-page hero section on a square canvas. Top half: hero banner with the unchanged "
|
"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"
|
"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:
|
def _task_size_chart(ctx: dict, lang: str) -> str:
|
||||||
dims = ctx["params_line"]
|
dims = params_line_for_output(ctx, lang)
|
||||||
return (
|
return (
|
||||||
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
|
"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 "
|
"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()
|
hint = (extra.get("prompt_hint") or "").strip()
|
||||||
|
|
||||||
if type_id == "custom":
|
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 = "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 += "."
|
task += "."
|
||||||
wants_text = True
|
wants_text = True
|
||||||
else:
|
else:
|
||||||
|
|||||||
+142
-23
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -13,11 +14,16 @@ from config import get_settings
|
|||||||
_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media"
|
_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media"
|
||||||
|
|
||||||
|
|
||||||
def _ext_from_url(url: str) -> str:
|
class ResourceSizeLimitError(ValueError):
|
||||||
ext = mimetypes.guess_extension(url.split("?")[0].lower()) or ".jpg"
|
"""上传、下载或本地读取超过单文件上限。"""
|
||||||
if ext == ".jpe":
|
|
||||||
ext = ".jpg"
|
def __init__(self, limit_bytes: int, actual_bytes: int | None = None) -> None:
|
||||||
return ext
|
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:
|
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")
|
return mapping.get(ctype, ".jpg")
|
||||||
|
|
||||||
|
|
||||||
async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]:
|
def image_extension(data: bytes, content_type: str = "") -> str:
|
||||||
"""下载远程字节。返回 (bytes, content_type)。"""
|
"""按真实图片字节优先推断扩展名,避免 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 {}
|
headers = {"Referer": referer} if referer else {}
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
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()
|
resp.raise_for_status()
|
||||||
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
|
length = resp.headers.get("content-length")
|
||||||
return resp.content, ctype
|
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 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:
|
class LocalStorage:
|
||||||
"""开发期:落 data/media/,由 FastAPI /media 静态托管,返回 app_base_url 可访问 URL。"""
|
"""开发期:落 data/media/,由 FastAPI /media 静态托管,返回 app_base_url 可访问 URL。"""
|
||||||
|
|
||||||
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
|
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
|
||||||
data, _ = await download_bytes(url, referer)
|
data, ctype = await download_bytes(url, referer)
|
||||||
key = self._write(data, key_prefix, url)
|
key = f"{key_prefix.rstrip('/') + '/' if key_prefix else ''}{uuid.uuid4().hex}{image_extension(data, ctype)}"
|
||||||
return self.public_url(key)
|
return await self.save_bytes(data, key, ctype)
|
||||||
|
|
||||||
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
|
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
|
||||||
key = self._write(data, "", key)
|
return self.public_url(self._write(data, key, content_type))
|
||||||
return self.public_url(key)
|
|
||||||
|
|
||||||
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)
|
_LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
ext = _ext_from_url(hint) if hint and not hint.startswith("data:") else ".jpg"
|
safe_key = key.strip().lstrip("/")
|
||||||
key = f"{key_prefix + '/' if key_prefix else ''}{uuid.uuid4().hex}{ext}"
|
if not safe_key:
|
||||||
(_LOCAL_ROOT / key).parent.mkdir(parents=True, exist_ok=True)
|
safe_key = uuid.uuid4().hex
|
||||||
(_LOCAL_ROOT / key).write_bytes(data)
|
if not Path(safe_key).suffix:
|
||||||
return key
|
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:
|
def public_url(self, key: str) -> str:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
return f"{settings.app_base_url.rstrip('/')}/media/{key}"
|
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:
|
class QiniuStorage:
|
||||||
"""生产:上传七牛,返回绑定域名公网 URL(Ozon 可拉取)。"""
|
"""生产:上传七牛,返回绑定域名公网 URL(Ozon 可拉取)。"""
|
||||||
@@ -97,6 +172,33 @@ class QiniuStorage:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
return f"{settings.qiniu_domain.rstrip('/')}/{key}"
|
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():
|
def get_storage():
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -105,6 +207,19 @@ def get_storage():
|
|||||||
return LocalStorage()
|
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:
|
def local_path(stored_url_or_key: str) -> Path | None:
|
||||||
"""stored_url(http…/media/xxx)或 media key → 本地文件路径。
|
"""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 ""
|
s = stored_url_or_key or ""
|
||||||
if "/media/" in s:
|
if "/media/" in s:
|
||||||
s = s.split("/media/", 1)[1]
|
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
|
return p if p.is_file() else None
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from schemas.suite import TextMaterial
|
from schemas.suite import TextMaterial
|
||||||
|
from services.image_assets import get_image_asset_service
|
||||||
from services.tasks import TaskImage
|
from services.tasks import TaskImage
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
|
|||||||
for t in texts:
|
for t in texts:
|
||||||
if t.kind == "title" and t.content:
|
if t.kind == "title" and t.content:
|
||||||
raw["title"] = 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:
|
elif t.kind == "price" and t.content:
|
||||||
raw["price"] = t.content
|
raw["price"] = t.content
|
||||||
elif t.kind == "brand" and t.content:
|
elif t.kind == "brand" and t.content:
|
||||||
@@ -67,6 +70,9 @@ async def append_generated_asset(
|
|||||||
|
|
||||||
pid = uuid.UUID(product_id)
|
pid = uuid.UUID(product_id)
|
||||||
async with get_session_factory()() as db:
|
async with get_session_factory()() as db:
|
||||||
|
product = await db.get(Product, pid)
|
||||||
|
if product is None:
|
||||||
|
raise RuntimeError("商品已删除,生成结果不再回写")
|
||||||
sort_order: int | None = None
|
sort_order: int | None = None
|
||||||
if after_asset_id:
|
if after_asset_id:
|
||||||
try:
|
try:
|
||||||
@@ -104,16 +110,30 @@ async def append_generated_asset(
|
|||||||
sort_order=sort_order,
|
sort_order=sort_order,
|
||||||
type="img",
|
type="img",
|
||||||
source_url="",
|
source_url="",
|
||||||
stored_url=image.url,
|
|
||||||
status="uploaded",
|
status="uploaded",
|
||||||
)
|
)
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
product = await db.get(Product, pid)
|
image_assets = get_image_asset_service()
|
||||||
if product is not None:
|
original_url = image.source_url or image.url
|
||||||
counts = dict(product.asset_counts or {})
|
await image_assets.adopt_generated_versions(
|
||||||
counts["generated"] = int(counts.get("generated") or 0) + 1
|
asset,
|
||||||
product.asset_counts = counts
|
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()
|
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)
|
return str(asset.id)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class TaskImage:
|
|||||||
name: str
|
name: str
|
||||||
status: str = IMG_PENDING # 循环里先建后跑,成功改 ok、失败显式改 failed
|
status: str = IMG_PENDING # 循环里先建后跑,成功改 ok、失败显式改 failed
|
||||||
url: str = ""
|
url: str = ""
|
||||||
|
source_url: str = "" # 水印版对应的无水印原图;未加水印时为空
|
||||||
|
watermark_config: dict | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ log = logging.getLogger("suite.watermark")
|
|||||||
# 尺寸比例(与 ozonSeller app.js 常量一致)
|
# 尺寸比例(与 ozonSeller app.js 常量一致)
|
||||||
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
|
BADGE_SCALE = 0.15 # 图片水印直径 / 图宽
|
||||||
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
|
BADGE_MARGIN = 0.01 # 图片水印边距 / 图宽(ozonSeller 固定 10px,按比例更稳)
|
||||||
TEXT_SCALE = 0.06 # 文字字号 / 图宽
|
TEXT_SCALE = 0.048 # 文字字号 / 图宽(原 0.06 缩小 20%)
|
||||||
TEXT_MIN_SIZE = 12
|
TEXT_MIN_SIZE = 12
|
||||||
STROKE_ALPHA = 0.55
|
STROKE_ALPHA = 0.55
|
||||||
STROKE_RATIO = 1 / 8 # 描边宽 / 字号
|
STROKE_RATIO = 1 / 8 # 描边宽 / 字号
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -24,6 +24,12 @@ if [[ ! -f .env ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 数据结构变更由 Alembic 管理;必须先迁移,再启动会读取新字段的服务端。
|
||||||
|
if ! alembic upgrade head; then
|
||||||
|
echo "⚠️ 数据库迁移失败,停止启动以避免新旧代码与表结构不一致"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
|
# ---- 采集插件(extensions/collector):启动时构建一次,之后监听代码改动自动重建 ----
|
||||||
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
|
# 产物 .output/chrome-mv3 由 Chrome 以解压方式加载;重建后仍需去 chrome://extensions 点刷新
|
||||||
COLLECTOR_WATCH_PID=""
|
COLLECTOR_WATCH_PID=""
|
||||||
@@ -64,7 +70,13 @@ echo
|
|||||||
|
|
||||||
# ---- 同步并启动前端(studio:Vite 开发服务器,/api 代理到后端 8800)----
|
# ---- 同步并启动前端(studio:Vite 开发服务器,/api 代理到后端 8800)----
|
||||||
STUDIO_PID=""
|
STUDIO_PID=""
|
||||||
|
WORKER_SUPERVISOR_PID=""
|
||||||
cleanup() {
|
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
|
if [[ -n "$STUDIO_PID" ]] && kill -0 "$STUDIO_PID" 2>/dev/null; then
|
||||||
kill "$STUDIO_PID" 2>/dev/null
|
kill "$STUDIO_PID" 2>/dev/null
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
@@ -79,6 +91,20 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
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
|
if ! command -v pnpm >/dev/null 2>&1; then
|
||||||
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
|
echo "⚠️ 未找到 pnpm,本次跳过 studio 前端(可手动:cd studio && pnpm dev)"
|
||||||
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
|
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
|
||||||
|
|||||||
@@ -33,3 +33,12 @@ body {
|
|||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
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();
|
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> = [
|
const columns: ColumnsType<ProductListItem> = [
|
||||||
{
|
{
|
||||||
title: '商品名',
|
title: '商品名',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
render: (v, r) => (
|
render: (_v, r) => (
|
||||||
<a onClick={() => navigate(`/trial/${r.id}`)}>{v || '(未命名)'}</a>
|
<a onClick={() => navigate(`/trial/${r.id}`)}>{displayProductName(r)}</a>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
|
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
|
||||||
|
|||||||
@@ -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 { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
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;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -13,35 +14,121 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
||||||
onGenerated?: (url: string) => void;
|
onGenerated?: (url: string) => void;
|
||||||
|
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
|
||||||
|
onSubmitted?: (suiteId: string) => void;
|
||||||
|
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
|
||||||
|
watermark?: WatermarkPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
|
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
|
||||||
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
|
* 对应 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 [prompt, setPrompt] = useState('');
|
||||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [resultUrl, setResultUrl] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open && !activeTaskRef.current) {
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
|
setTaskId('');
|
||||||
|
setTaskError('');
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
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 () => {
|
const onGenerate = async () => {
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
if (!prompt.trim()) {
|
|
||||||
message.warning('请先输入生图要求');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setResultUrl('');
|
setResultUrl('');
|
||||||
|
setTaskError('');
|
||||||
try {
|
try {
|
||||||
const r = await imageEditSingle({
|
const r = await imageEditSingle({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
@@ -50,17 +137,38 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
model,
|
model,
|
||||||
append: true,
|
append: true,
|
||||||
after_asset_id: source.afterAssetId,
|
after_asset_id: source.afterAssetId,
|
||||||
|
watermark: watermark?.enabled ? watermark : undefined,
|
||||||
});
|
});
|
||||||
setResultUrl(r.url);
|
setTaskId(r.suite_id);
|
||||||
message.success('生成完成,已追加到「生成结果」');
|
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
|
||||||
onGenerated?.(r.url);
|
if (onSubmitted) {
|
||||||
|
onSubmitted(r.suite_id);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// 独立使用该组件时仍保留自身轮询能力。
|
||||||
|
pollTask(r.suite_id);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(apiErrorMessage(e));
|
message.error(apiErrorMessage(e));
|
||||||
} finally {
|
setTaskError(apiErrorMessage(e));
|
||||||
setGenerating(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -88,15 +196,12 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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 strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
|
||||||
<Text type="secondary" style={{ fontSize: 12, maxWidth: 360 }} ellipsis={{ tooltip: source?.name }}>
|
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
|
||||||
{source?.name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
<div style={{ width: 260, flexShrink: 0 }}>
|
<div style={{ width: 260, flexShrink: 0 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>原图</Text>
|
|
||||||
<Image
|
<Image
|
||||||
src={source?.url}
|
src={source?.url}
|
||||||
alt="原图"
|
alt="原图"
|
||||||
@@ -108,37 +213,47 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
rows={5}
|
rows={5}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
placeholder={'输入生图要求,例如:把背景换成纯白色摄影棚,保留商品细节与比例;增加柔和阴影'}
|
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
/>
|
/>
|
||||||
<Space style={{ marginTop: 12 }} wrap>
|
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Select
|
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
|
||||||
style={{ width: 260 }}
|
<Select
|
||||||
popupMatchSelectWidth={false}
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
value={model}
|
popupMatchSelectWidth={false}
|
||||||
onChange={setModel}
|
value={model}
|
||||||
disabled={generating}
|
onChange={setModel}
|
||||||
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
disabled={generating}
|
||||||
optionRender={(option) => (
|
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
||||||
<div>
|
optionRender={(option) => (
|
||||||
<div>{option.label}</div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: '#999' }}>
|
<div>{option.label}</div>
|
||||||
{(option as { data?: { desc?: string } }).data?.desc}
|
<div style={{ fontSize: 11, color: '#999' }}>
|
||||||
|
{(option as { data?: { desc?: string } }).data?.desc}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
<Button type="primary" style={{ width: 112 }} loading={generating} onClick={onGenerate}>
|
||||||
<Button type="primary" loading={generating} onClick={onGenerate}>
|
提交任务
|
||||||
生成
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
<Button onClick={onClose}>关闭</Button>
|
</div>
|
||||||
</Space>
|
|
||||||
<Alert
|
<Alert
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
type="info"
|
type={taskError && !generating ? 'error' : 'info'}
|
||||||
showIcon
|
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>
|
||||||
</div>
|
</div>
|
||||||
{resultUrl && (
|
{resultUrl && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
|
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { CopyOutlined, DownloadOutlined, FileTextOutlined, PlusOutlined, RestOutlined } from '@ant-design/icons';
|
import { CopyOutlined, DownloadOutlined, FileTextOutlined, PlusOutlined, RestOutlined } from '@ant-design/icons';
|
||||||
import { ProductDetail } from '@/services/product';
|
import { ProductDetail } from '@/services/product';
|
||||||
@@ -224,6 +224,26 @@ export default function TrialExportPanel({ product }: Props) {
|
|||||||
dataIndex: field,
|
dataIndex: field,
|
||||||
render: (v: unknown) => <Text style={{ fontSize: 13 }}>{fmt(v)}</Text>,
|
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> = [
|
const base: ColumnsType<RegisterRecord> = [
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -265,28 +285,17 @@ export default function TrialExportPanel({ product }: Props) {
|
|||||||
title: 'Ozon地址',
|
title: 'Ozon地址',
|
||||||
dataIndex: 'sku',
|
dataIndex: 'sku',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (sku: string) =>
|
render: (sku: string) => {
|
||||||
sku ? (
|
const url = sku ? `https://www.ozon.ru/product/${sku}` : '';
|
||||||
<a href={`https://www.ozon.ru/product/${sku}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
|
return url ? renderLink(url, 128) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>;
|
||||||
{`https://www.ozon.ru/product/${sku}`}
|
},
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '采买地址',
|
title: '采买地址',
|
||||||
dataIndex: 'purchaseUrl',
|
dataIndex: 'purchaseUrl',
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string) =>
|
render: (v: string) =>
|
||||||
v ? (
|
v ? renderLink(v, 148) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>,
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return showOptional ? [...base, ...optional, ...rest, ...optional2, ...links] : [...base, ...rest, ...links];
|
return showOptional ? [...base, ...optional, ...rest, ...optional2, ...links] : [...base, ...rest, ...links];
|
||||||
@@ -306,6 +315,7 @@ export default function TrialExportPanel({ product }: Props) {
|
|||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
background: COLOR_SECONDARY,
|
background: COLOR_SECONDARY,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
width: 124,
|
||||||
paddingInline: 16,
|
paddingInline: 16,
|
||||||
boxShadow: '0 4px 12px rgba(16,185,129,0.4)',
|
boxShadow: '0 4px 12px rgba(16,185,129,0.4)',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,17 @@
|
|||||||
* 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。
|
* 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { Card, Menu, Space, Spin, Typography, message } from 'antd';
|
import { DeleteOutlined } from '@ant-design/icons';
|
||||||
import { getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
|
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 { apiErrorMessage } from '@/services/api';
|
||||||
import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage';
|
import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage';
|
||||||
import TrialInfoPanel from './TrialInfoPanel';
|
import TrialInfoPanel from './TrialInfoPanel';
|
||||||
import TrialPricingPanel from './TrialPricingPanel';
|
import TrialPricingPanel from './TrialPricingPanel';
|
||||||
import TrialSuitePanel from './TrialSuitePanel';
|
import TrialSuitePanel from './TrialSuitePanel';
|
||||||
import TrialExportPanel from './TrialExportPanel';
|
import TrialExportPanel from './TrialExportPanel';
|
||||||
|
import TrialTaskCenter from './TrialTaskCenter';
|
||||||
import CopyPanel from '../product/CopyPanel';
|
import CopyPanel from '../product/CopyPanel';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@@ -29,9 +31,11 @@ const SECTIONS = [
|
|||||||
|
|
||||||
export default function TrialPage() {
|
export default function TrialPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||||
const [assets, setAssets] = useState<ProductAsset[]>([]);
|
const [assets, setAssets] = useState<ProductAsset[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [active, setActive] = useState(SECTIONS[0].id);
|
const [active, setActive] = useState(SECTIONS[0].id);
|
||||||
|
|
||||||
// 静默刷新:不置 loading。置 loading 会让整页退回全屏 Spin、卸载 TrialSuitePanel,
|
// 静默刷新:不置 loading。置 loading 会让整页退回全屏 Spin、卸载 TrialSuitePanel,
|
||||||
@@ -82,6 +86,21 @@ export default function TrialPage() {
|
|||||||
[id, product],
|
[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) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 80, textAlign: 'center' }}>
|
<div style={{ padding: 80, textAlign: 'center' }}>
|
||||||
@@ -106,7 +125,21 @@ export default function TrialPage() {
|
|||||||
{STAGE_LABEL[product.stage] || product.stage}
|
{STAGE_LABEL[product.stage] || product.stage}
|
||||||
</span>
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
|
<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>
|
</div>
|
||||||
<Space size={16} style={{ marginTop: 4 }}>
|
<Space size={16} style={{ marginTop: 4 }}>
|
||||||
{product.name && product.name !== titleZh && (
|
{product.name && product.name !== titleZh && (
|
||||||
@@ -143,6 +176,9 @@ export default function TrialPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 固定操作坞:任务位于「录入商品」正上方,任务详情在右侧抽屉中统一管理。 */}
|
||||||
|
<TrialTaskCenter productId={product.id} onRefreshAssets={load} />
|
||||||
|
|
||||||
{/* 右侧区域导航 */}
|
{/* 右侧区域导航 */}
|
||||||
<div style={{ width: 120, flexShrink: 0 }}>
|
<div style={{ width: 120, flexShrink: 0 }}>
|
||||||
<div style={{ position: 'sticky', top: 80 }}>
|
<div style={{ position: 'sticky', top: 80 }}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export interface ProductListItem {
|
|||||||
currency_code: string;
|
currency_code: string;
|
||||||
source_platform: string | null;
|
source_platform: string | null;
|
||||||
source_url: string | null;
|
source_url: string | null;
|
||||||
|
/** 采集中文标题保存在 raw.title_zh;raw.title 为早期数据兼容字段。 */
|
||||||
|
raw?: Record<string, unknown> | null;
|
||||||
asset_counts: Record<string, number> | null;
|
asset_counts: Record<string, number> | null;
|
||||||
ozon_product_id: number | null;
|
ozon_product_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -63,6 +65,10 @@ export interface ProductAsset {
|
|||||||
type: string;
|
type: string;
|
||||||
source_url: string;
|
source_url: string;
|
||||||
stored_url: string | null;
|
stored_url: string | null;
|
||||||
|
/** 无水印原图;AI 参考图应优先使用该地址。 */
|
||||||
|
original_url?: string | null;
|
||||||
|
watermark_enabled?: boolean;
|
||||||
|
watermark_config?: Record<string, unknown> | null;
|
||||||
status: 'pending' | 'downloading' | 'uploaded' | 'failed';
|
status: 'pending' | 'downloading' | 'uploaded' | 'failed';
|
||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import apiClient from './api';
|
import apiClient from './api';
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
/** 套图类型白名单(与服务端 prompts/common.py 对齐) */
|
/** 套图类型白名单(与服务端 prompts/common.py 对齐) */
|
||||||
export const SUITE_TYPE_OPTIONS = [
|
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 = [
|
export const STYLE_SET_OPTIONS = [
|
||||||
{ value: 1, 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: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净' },
|
{ 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: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级' },
|
{ 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: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强' },
|
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere' },
|
||||||
{ value: 5, label: '纯净棚拍', prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出' },
|
{ value: 5, label: '纯净棚拍', prompt: 'Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
|
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
|
||||||
@@ -115,6 +116,7 @@ export interface SuiteGeneratePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SuiteImageInfo {
|
export interface SuiteImageInfo {
|
||||||
|
item_id: string;
|
||||||
type_id: string;
|
type_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -124,13 +126,20 @@ export interface SuiteImageInfo {
|
|||||||
|
|
||||||
export interface SuiteInfo {
|
export interface SuiteInfo {
|
||||||
id: string;
|
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;
|
style_set: number;
|
||||||
platform: string;
|
platform: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
total?: number;
|
total?: number;
|
||||||
|
completed?: number;
|
||||||
|
failed?: number;
|
||||||
|
cancel_requested?: boolean;
|
||||||
|
created_at?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
images: SuiteImageInfo[];
|
images: SuiteImageInfo[];
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
@@ -146,8 +155,26 @@ export function generateSuite(payload: SuiteGeneratePayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ★ 查询套图任务状态(轮询用) */
|
/** ★ 查询套图任务状态(轮询用) */
|
||||||
export function getSuite(suiteId: string) {
|
export function getSuite(suiteId: string, config?: AxiosRequestConfig) {
|
||||||
return api.get<SuiteInfo>(`/suites/${suiteId}`);
|
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) */
|
/** ★ 下载生成结果 ZIP(blob) */
|
||||||
@@ -159,16 +186,18 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
|
|||||||
export interface ImageEditSinglePayload {
|
export interface ImageEditSinglePayload {
|
||||||
product_id?: string;
|
product_id?: string;
|
||||||
image_url: string;
|
image_url: string;
|
||||||
prompt: string;
|
prompt?: string;
|
||||||
model: string;
|
model: string;
|
||||||
append?: boolean;
|
append?: boolean;
|
||||||
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
|
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
|
||||||
after_asset_id?: string;
|
after_asset_id?: string;
|
||||||
|
/** 单张生成完成后由服务端合成水印;关闭时不传。 */
|
||||||
|
watermark?: WatermarkPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
|
/** ★ 提交单张 AI 生图任务;通过 getSuite(suite_id) 查询进度。 */
|
||||||
export function imageEditSingle(payload: ImageEditSinglePayload) {
|
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 记录 + 本地文件),用于删除已生成的图片 */
|
/** 删除一条素材(DB 记录 + 本地文件),用于删除已生成的图片 */
|
||||||
@@ -176,6 +205,36 @@ export function deleteAsset(assetId: string) {
|
|||||||
return api.delete<{ deleted: boolean }>(`/assets/${assetId}`);
|
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 {
|
export interface ExportImagesPayload {
|
||||||
title: string;
|
title: string;
|
||||||
images: Array<{ url: string; groupName: string; variantName?: string | null; key: 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;
|
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) */
|
/** 上传补充参考图到商品素材(已有接口:POST /api/materials/bytes) */
|
||||||
export async function uploadProductAsset(
|
export async function uploadProductAsset(
|
||||||
productId: string,
|
productId: string,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { WatermarkState } from '@/types/image';
|
|||||||
|
|
||||||
export const WATERMARK_SCALE = 0.15; // 图片水印直径占原图宽度的比例
|
export const WATERMARK_SCALE = 0.15; // 图片水印直径占原图宽度的比例
|
||||||
export const WATERMARK_MARGIN = 10; // 默认位置距右下角的间距(原图像素)
|
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 {
|
interface Size {
|
||||||
width: number;
|
width: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user