refactor(server): 移除已放弃的 Ozon API 直传模块,新增素材删除接口
- 删除 shops/publish/categories/ozon 相关路由、模型、schema 与客户端服务(V2.1 决策放弃 API 直传,改人工上传)
- materials 新增 DELETE /assets/{id}:删除素材记录与本地文件,并同步修正 asset_counts
- 试算页支持单张素材删除,生图弹窗交互微调
- 新增 docs/v2.1/HANDOFF.md 工作交接说明,README 补充索引
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
# 工作交接说明(HANDOFF)
|
||||||
|
|
||||||
|
> 更新:2026-08-28 晚
|
||||||
|
> 读者:下一个接手工作的 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)(后端结构)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目一句话
|
||||||
|
|
||||||
|
Ozon 跨境上品工具链。当前主链路(V2.1,已放弃 Ozon API 直传):
|
||||||
|
|
||||||
|
```
|
||||||
|
extensions/collector 扩展采集(Ozon/1688/淘宝/天猫)
|
||||||
|
→ 「上报商品」POST /api/materials 入库 + 自动打开试算页
|
||||||
|
→ studio /trial/{id} 试算页:计价 → 俄文文案 → AI 生图(套图+单张)→ 登记
|
||||||
|
→ 导出 CSV / 组合码 → 人工上 Ozon 卖家后台
|
||||||
|
```
|
||||||
|
|
||||||
|
技术栈:FastAPI(server,8800)+ React19/Vite/antd6(studio,8900)+ WXT Chrome 扩展(extensions/collector)。SQLite(`data/app.db`),无鉴权(V2.1 决策:先功能后鉴权)。
|
||||||
|
|
||||||
|
## 2. 启动与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# server(我最后用的方式;日志在 /tmp/osk-server.log)
|
||||||
|
cd ozon-seller-kit
|
||||||
|
.venv/bin/uvicorn main:app --app-dir server --reload --reload-include "*.yaml" --host 127.0.0.1 --port 8800
|
||||||
|
|
||||||
|
# studio
|
||||||
|
pnpm -C studio dev # http://localhost:8900,/api 代理到 8800
|
||||||
|
|
||||||
|
# 扩展(改了扩展代码后必跑,然后 chrome://extensions 刷新)
|
||||||
|
pnpm -C extensions/collector install # 首次
|
||||||
|
pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
||||||
|
```
|
||||||
|
|
||||||
|
密钥在根目录 `.env`(已配好):`DEEPSEEK_API_KEY`(规划+文案)、`RIGHTAPI_API_KEY`(生图主力:gpt-image-2-vip / nano-banana 系列)、`DASHSCOPE_API_KEY`(通义,可选)、`ARK_API_KEY` 未配(豆包未用)。
|
||||||
|
|
||||||
|
## 3. 当前进度
|
||||||
|
|
||||||
|
### ✅ 已完成(已提交,git log 有记录)
|
||||||
|
| 阶段 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| Phase A | 试算页前端 `/trial/:id` 五区块:1 商品信息 / 2 价格试算(公式对齐 v1 web)/ 3 俄文文案 / 4 采集图片+出图方案+生成结果 / 5 入库与导出 |
|
||||||
|
| Phase C | ISS 扩展并入 `extensions/collector/`;「上报商品」按钮(01 区块下方)→ 入库 + 新标签打开试算页;设置弹窗含上报开关/后台地址/试算页地址 |
|
||||||
|
| Phase B | 生图服务端(`server/api/suite.py` + `services/{planner,generator,prompts/,tasks,watermark}`,平移自 image-suite-studio) |
|
||||||
|
| 后端整理 | 鉴权全删(server+studio);冻结链路隔离 `server/legacy/`(接口仍挂载);`collection.py`→`materials.py`;suite 业务下沉 `services/suite_service.py` |
|
||||||
|
|
||||||
|
### ✅ 已联调验证(真实调用生图 API)
|
||||||
|
plan / generate(串行队列+轮询+回写 generated)/ image-edit(单张,含 `after_asset_id` 插入原图后)/ 水印(文字 Panda Store 右下角)/ export/images(ZIP 分组结构)/ proxy-image / DELETE /api/assets/{id}(删素材+文件)/ materials 上报(product_id 返回)。
|
||||||
|
|
||||||
|
### 🔨 最近一轮 UI 迭代(**部分未提交**,见 git status)
|
||||||
|
- 出图方案卡:左右 12:12;左列灰卡内含「规划并生成 + AI 智能规划」;数量改 Stepper(− 输入 +,0-5);AI 智能规划按钮高度与一键生图一致;说明文字支持换行
|
||||||
|
- 水印设置移到「6 生成结果」标题栏最右;默认文本改 `Panda Store`
|
||||||
|
- 生成结果卡:新增「单张 AI 生图」小节(含套图回写与单张图);底部「AI 编辑」可点击 → 同款生图弹窗,新图经 `after_asset_id` 插在原图后;每张图右上角 ✕ 删除(Popconfirm 二次确认 → `DELETE /api/assets/{id}`)
|
||||||
|
- 采集图片卡不再显示 generated 素材(归生成结果卡)
|
||||||
|
- 型号/货号:后缀独立存储(货号框 `addonBefore` 展示 `型号-`,只输后缀;型号/货号框后有复制按钮);型号含 `-` 不再增殖
|
||||||
|
- 侧边栏:展开 200 / 折叠 60,折叠图标居中(`SidebarMenu.css` 尾部规则)
|
||||||
|
- 采买地址:「打开地址」按钮 + 复制图标;仅 1688/淘宝/天猫/拼多多来源自动填充
|
||||||
|
- 试算页水印弹窗默认文本 Panda Store(注意 localStorage `trialWatermark` 有旧值需手改一次)
|
||||||
|
|
||||||
|
## 4. 下一步(按优先级)
|
||||||
|
|
||||||
|
1. **提交当前改动**:`git status` 有一批未提交(后端整理 + 最近 UI 迭代 + extensions settings 改动),建议按模块分 2-3 个 commit
|
||||||
|
2. **手动全流程回归**(浏览器 + Chrome 真机扩展):采集 → 上报 → 试算页五区块 → 生成/编辑/删除 → 登记表录入/导出 CSV/组合码 → 清空
|
||||||
|
3. **Phase D 待办**:批量 CSV 服务端化(`GET /api/export/trial-csv`,替代前端逐个拉详情拼装);登记表数据当前存 localStorage(换设备不可见,如需跨设备要落库)
|
||||||
|
4. **远期**:鉴权/账户体系重做(从 `server/deps.py` 层重新引入);ISS-only 产品模式构建配置;`extension-v1/v2`、`web/`、`server/legacy/` 的清理时机
|
||||||
|
|
||||||
|
## 5. 已知坑(新会话必读)
|
||||||
|
|
||||||
|
| # | 坑 | 对策 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `uvicorn --reload` 不监控 `.env` | 改 `.env` 后手动重启 server |
|
||||||
|
| 2 | 套图任务在内存(`services/tasks.py`) | server 重启丢任务状态(前端已提示重新生成);已落盘图片不丢 |
|
||||||
|
| 3 | 生图全局串行(`generator.py` 的 `asyncio.Lock`) | 一次只跑一个生成队列,测试时别并发提交 |
|
||||||
|
| 4 | 单张 image-edit 是同步接口 | gpt 系列单张 1-5 分钟,前端 axios 超时 120s 可能先断(后端仍在跑,图会出现在生成结果) |
|
||||||
|
| 5 | **ZCode IAB 浏览器调试 quirks**:`press`/Backspace 键注入在该页时灵时不灵(`fill` 可靠);`fullPage` 截图偶发失败;文件上传不可用;改代码后页面 React 事件偶发失灵 → **reload 页面**即恢复 | 优先用 `fill`/`domSnapshot`/`getBoundingClientRect` 的 evaluate(注意 evaluate 可能被拒 side-effect,读值即可) |
|
||||||
|
| 6 | 试算页水印文本存 localStorage(`trialWatermark`) | 改默认值不影响已保存设置;登记表同理存 localStorage `trialRegisterRecords` |
|
||||||
|
| 7 | `studio/tsconfig.app.tsbuildinfo` 是构建产物 | 提交前 `git checkout -- studio/tsconfig.app.tsbuildinfo` |
|
||||||
|
| 8 | 冻结代码不要投入 | `server/legacy/`、`extension-v1/`、`extension-v2/`、`web/`、studio 商品编辑页的属性映射/发布区块 |
|
||||||
|
| 9 | AI 规划/生图花钱 | rightapi 按张计费;联调时 plan 张数设 1-2、优先 `nano-banana-2-lite`(快/便宜) |
|
||||||
|
|
||||||
|
## 6. 关键文件速查
|
||||||
|
|
||||||
|
| 功能 | 前端(studio/src/) | 后端(server/) |
|
||||||
|
|---|---|---|
|
||||||
|
| 试算页骨架 | `pages/trial/TrialPage.tsx` | — |
|
||||||
|
| 01 商品信息(型号/货号联动、采买地址) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py`(上报/删素材) |
|
||||||
|
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
|
||||||
|
| 03 俄文文案 | `pages/product/CopyPanel.tsx`(共用) | `api/ai.py` + `services/deepseek.py` + `prompts/copy_ru.py` |
|
||||||
|
| 04 图片与生图 | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{suite_service,generator,planner,prompts/,tasks,watermark}.py` |
|
||||||
|
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — |
|
||||||
|
| 前端服务层 | `services/{suite,product,ai,fx,api}.ts` | `schemas/suite.py`(契约对齐) |
|
||||||
|
| 扩展上报 | `extensions/collector/src/api/report.ts` + `entrypoints/{background,sidepanel/App}.tsx` + `storage/settings.ts` | `api/materials.py` |
|
||||||
|
|
||||||
|
## 7. 验收标准(改完怎么算完成)
|
||||||
|
|
||||||
|
1. `pnpm -C studio exec tsc -b` 零错误、`pnpm -C studio build` 通过(提交前还原 `tsconfig.app.tsbuildinfo`)
|
||||||
|
2. 改扩展:`pnpm -C extensions/collector build` 通过
|
||||||
|
3. 改 server:`/api/health` 200、涉及接口 curl/页面冒烟通过
|
||||||
|
4. UI 改动:浏览器实测 + 必要时截图确认
|
||||||
@@ -107,6 +107,7 @@ V2(原计划) V2.1(现在)
|
|||||||
|
|
||||||
| 文档 | 内容 | 什么时候读 |
|
| 文档 | 内容 | 什么时候读 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [`HANDOFF.md`](./HANDOFF.md) | **工作交接说明**:当前进度、启动/构建命令、已知坑、下一步(新会话先读) | 接手工作时先读 |
|
||||||
| [`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 生图、水印、生成图回写与导出 | 做图片功能时读 |
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from core.security import decrypt_secret
|
|
||||||
from db import get_db
|
|
||||||
from deps import get_current_user
|
|
||||||
from models import Shop
|
|
||||||
from services.ozon_client import OzonClient, OzonAPIError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/categories", tags=["categories"])
|
|
||||||
|
|
||||||
|
|
||||||
class ShopRef(BaseModel):
|
|
||||||
shop_id: str
|
|
||||||
lang: str = "ZH_HANS" # 中文类目
|
|
||||||
|
|
||||||
|
|
||||||
async def _client(shop_id: str, db: AsyncSession) -> OzonClient:
|
|
||||||
shop = await db.get(Shop, UUID(shop_id))
|
|
||||||
if shop is None:
|
|
||||||
raise HTTPException(status_code=404, detail="店铺不存在")
|
|
||||||
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
|
|
||||||
|
|
||||||
|
|
||||||
def _unwrap(result: dict) -> dict:
|
|
||||||
return result.get("result", result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tree")
|
|
||||||
async def category_tree(
|
|
||||||
body: ShopRef,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
client = await _client(body.shop_id, db)
|
|
||||||
try:
|
|
||||||
result = await client.post("/v1/description-category/tree", {"language": body.lang})
|
|
||||||
return _unwrap(result)
|
|
||||||
except OzonAPIError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=exc.detail)
|
|
||||||
|
|
||||||
|
|
||||||
class AttributeQuery(BaseModel):
|
|
||||||
shop_id: str
|
|
||||||
type_id: int
|
|
||||||
lang: str = "ZH_HANS"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{category_id}/attributes")
|
|
||||||
async def category_attributes(
|
|
||||||
category_id: int,
|
|
||||||
body: AttributeQuery,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
client = await _client(body.shop_id, db)
|
|
||||||
try:
|
|
||||||
result = await client.post(
|
|
||||||
"/v1/description-category/attribute",
|
|
||||||
{
|
|
||||||
"description_category_id": category_id,
|
|
||||||
"type_id": body.type_id,
|
|
||||||
"language": body.lang,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return _unwrap(result)
|
|
||||||
except OzonAPIError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=exc.detail)
|
|
||||||
|
|
||||||
|
|
||||||
class ValueQuery(BaseModel):
|
|
||||||
shop_id: str
|
|
||||||
category_id: int
|
|
||||||
type_id: int
|
|
||||||
q: str | None = None
|
|
||||||
limit: int = 100
|
|
||||||
last_value_id: int | None = None
|
|
||||||
lang: str = "ZH_HANS"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attribute/{attribute_id}/values")
|
|
||||||
async def attribute_values(
|
|
||||||
attribute_id: int,
|
|
||||||
body: ValueQuery,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
client = await _client(body.shop_id, db)
|
|
||||||
try:
|
|
||||||
if body.q and len(body.q) >= 2:
|
|
||||||
result = await client.post(
|
|
||||||
"/v1/description-category/attribute/values/search",
|
|
||||||
{
|
|
||||||
"attribute_id": attribute_id,
|
|
||||||
"description_category_id": body.category_id,
|
|
||||||
"type_id": body.type_id,
|
|
||||||
"limit": body.limit,
|
|
||||||
"value": body.q,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = await client.post(
|
|
||||||
"/v1/description-category/attribute/values",
|
|
||||||
{
|
|
||||||
"attribute_id": attribute_id,
|
|
||||||
"description_category_id": body.category_id,
|
|
||||||
"type_id": body.type_id,
|
|
||||||
"limit": body.limit,
|
|
||||||
"last_value_id": body.last_value_id or 0,
|
|
||||||
"language": body.lang,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return result # values 返回 {result, has_next}
|
|
||||||
except OzonAPIError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=exc.detail)
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from db import get_db, get_session_factory
|
|
||||||
from deps import get_current_user
|
|
||||||
from models import Product, ProductAsset
|
|
||||||
from models.enums import AssetStatus, Stage
|
|
||||||
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["collection"])
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_number(text: str | None) -> float | None:
|
|
||||||
"""'1 290 ₽' / '3.5 кг' / '48*18*25' → 1290.0 / 3.5 / 48"""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", "."))
|
|
||||||
return float(m.group(1)) if m else None
|
|
||||||
|
|
||||||
|
|
||||||
def _find_param(pairs: list[dict] | None, keys: list[str]) -> str | None:
|
|
||||||
for p in pairs or []:
|
|
||||||
k = (p.get("key") or "").lower()
|
|
||||||
if any(kw in k for kw in keys):
|
|
||||||
return p.get("value")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_texts(product: Product, texts: list[TextMaterial]) -> None:
|
|
||||||
raw = dict(product.raw or {})
|
|
||||||
raw_texts: list[dict] = list(raw.get("texts") or [])
|
|
||||||
for t in texts:
|
|
||||||
raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs})
|
|
||||||
if t.kind == "title" and t.content and not product.name:
|
|
||||||
product.name = t.content
|
|
||||||
raw["title"] = t.content
|
|
||||||
elif t.kind == "price":
|
|
||||||
raw["price"] = t.content
|
|
||||||
num = _parse_number(t.content)
|
|
||||||
if num is not None and (product.price is None or product.price == 0):
|
|
||||||
product.price = num
|
|
||||||
elif t.kind == "params":
|
|
||||||
raw["params"] = t.pairs
|
|
||||||
_apply_weight_dims(product, t.pairs)
|
|
||||||
elif t.kind == "selling_point":
|
|
||||||
raw["sellingPoints"] = t.content
|
|
||||||
elif t.kind == "desc":
|
|
||||||
raw["desc"] = t.content
|
|
||||||
if not product.description:
|
|
||||||
product.description = t.content
|
|
||||||
elif t.kind == "brand":
|
|
||||||
raw["brand"] = t.content
|
|
||||||
raw["texts"] = raw_texts
|
|
||||||
product.raw = raw
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_weight_dims(product: Product, pairs: list[dict] | None) -> None:
|
|
||||||
"""从参数表里解析「包装重量 / 包装尺寸(长宽高)」,统一换算成克 / 毫米回填。"""
|
|
||||||
weight = _find_param(pairs, ["包装重量", "重量", "вес"])
|
|
||||||
if weight is not None:
|
|
||||||
num = _parse_number(weight)
|
|
||||||
if num is not None:
|
|
||||||
is_kg = any(u in weight.lower() for u in ("кг", "kg"))
|
|
||||||
product.weight = num * 1000 if is_kg else num # 统一为克
|
|
||||||
product.weight_unit = "g"
|
|
||||||
|
|
||||||
l = _find_param(pairs, ["包装长度", "长度", "длина"])
|
|
||||||
w = _find_param(pairs, ["包装宽度", "宽度", "ширина"])
|
|
||||||
h = _find_param(pairs, ["包装高度", "高度", "высота"])
|
|
||||||
if l or w or h:
|
|
||||||
combined = (l or "") + (w or "") + (h or "")
|
|
||||||
factor = 1 if any(u in combined.lower() for u in ("мм", "mm")) else 10 # 厘米→毫米
|
|
||||||
product.depth = (_parse_number(l) or 0) * factor if l else None
|
|
||||||
product.width = (_parse_number(w) or 0) * factor if w else None
|
|
||||||
product.height = (_parse_number(h) or 0) * factor if h else None
|
|
||||||
product.dimension_unit = "mm"
|
|
||||||
else:
|
|
||||||
dim = _find_param(pairs, ["包装尺寸", "размер", "габарит", "尺寸"])
|
|
||||||
if dim is not None:
|
|
||||||
nums = re.findall(r"\d+(?:[.,]\d+)?", dim.replace(",", "."))
|
|
||||||
if len(nums) >= 3:
|
|
||||||
factor = 1 if any(u in dim.lower() for u in ("мм", "mm")) else 10
|
|
||||||
product.depth = float(nums[0]) * factor
|
|
||||||
product.width = float(nums[1]) * factor
|
|
||||||
product.height = float(nums[2]) * factor
|
|
||||||
product.dimension_unit = "mm"
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product:
|
|
||||||
if req.product_id:
|
|
||||||
product = await db.get(Product, UUID(req.product_id))
|
|
||||||
if product is None:
|
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
|
||||||
return product
|
|
||||||
product = Product(
|
|
||||||
stage=Stage.collected,
|
|
||||||
source_platform=req.source.platform,
|
|
||||||
source_item_id=req.source.itemId,
|
|
||||||
source_url=req.source.url,
|
|
||||||
)
|
|
||||||
db.add(product)
|
|
||||||
await db.flush()
|
|
||||||
return product
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/materials", response_model=MaterialsResponse)
|
|
||||||
async def create_materials(
|
|
||||||
req: MaterialsRequest,
|
|
||||||
background: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
) -> MaterialsResponse:
|
|
||||||
product = await _get_or_create_product(db, req)
|
|
||||||
_apply_texts(product, req.texts)
|
|
||||||
|
|
||||||
# 采集溯源(追加来源)
|
|
||||||
if not product.source_url:
|
|
||||||
product.source_url = req.source.url
|
|
||||||
if not product.source_platform:
|
|
||||||
product.source_platform = req.source.platform
|
|
||||||
|
|
||||||
# 去重 + 建素材
|
|
||||||
existing = set()
|
|
||||||
if req.images:
|
|
||||||
rows = (await db.execute(
|
|
||||||
select(ProductAsset.dedupe_key).where(
|
|
||||||
ProductAsset.product_id == product.id,
|
|
||||||
ProductAsset.dedupe_key.isnot(None),
|
|
||||||
)
|
|
||||||
)).scalars().all()
|
|
||||||
existing = {k for k in rows if k}
|
|
||||||
|
|
||||||
queued, skipped = 0, 0
|
|
||||||
for img in req.images:
|
|
||||||
if img.dedupeKey and img.dedupeKey in existing:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
db.add(ProductAsset(
|
|
||||||
product_id=product.id,
|
|
||||||
group_key=img.groupKey,
|
|
||||||
variant_name=img.variantName,
|
|
||||||
sort_order=img.index,
|
|
||||||
type=img.type,
|
|
||||||
source_url=img.url,
|
|
||||||
status=AssetStatus.pending,
|
|
||||||
dedupe_key=img.dedupeKey,
|
|
||||||
))
|
|
||||||
if img.dedupeKey:
|
|
||||||
existing.add(img.dedupeKey)
|
|
||||||
queued += 1
|
|
||||||
|
|
||||||
# 更新分组计数
|
|
||||||
counts: dict = {}
|
|
||||||
for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)):
|
|
||||||
counts[a.group_key] = counts.get(a.group_key, 0) + 1
|
|
||||||
product.asset_counts = counts
|
|
||||||
product.stage = Stage.collected if product.stage == Stage.collected else product.stage
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(product)
|
|
||||||
|
|
||||||
if queued:
|
|
||||||
background.add_task(process_product_assets, str(product.id))
|
|
||||||
return MaterialsResponse(
|
|
||||||
product_id=str(product.id),
|
|
||||||
stage=product.stage.value,
|
|
||||||
assets_queued=queued,
|
|
||||||
assets_skipped=skipped,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def process_product_assets(product_id: str) -> None:
|
|
||||||
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
|
|
||||||
from services.storage import get_storage
|
|
||||||
|
|
||||||
storage = get_storage()
|
|
||||||
async with get_session_factory()() as db:
|
|
||||||
assets = (await db.scalars(
|
|
||||||
select(ProductAsset).where(
|
|
||||||
ProductAsset.product_id == UUID(product_id),
|
|
||||||
ProductAsset.status == AssetStatus.pending,
|
|
||||||
)
|
|
||||||
)).all()
|
|
||||||
for a in assets:
|
|
||||||
a.status = AssetStatus.downloading
|
|
||||||
await db.commit()
|
|
||||||
try:
|
|
||||||
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
|
|
||||||
a.stored_url = stored
|
|
||||||
a.status = AssetStatus.uploaded
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
a.status = AssetStatus.failed
|
|
||||||
a.error = str(exc)[:500]
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/materials/bytes")
|
|
||||||
async def upload_material_bytes(
|
|
||||||
background: BackgroundTasks,
|
|
||||||
product_id: str = Form(...),
|
|
||||||
group_key: str = Form("main"),
|
|
||||||
variant_name: str | None = Form(None),
|
|
||||||
sort_order: int = Form(0),
|
|
||||||
type: str = Form("img"),
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
product = await db.get(Product, UUID(product_id))
|
|
||||||
if product is None:
|
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
|
||||||
data = await file.read()
|
|
||||||
asset = ProductAsset(
|
|
||||||
product_id=product.id,
|
|
||||||
group_key=group_key,
|
|
||||||
variant_name=variant_name,
|
|
||||||
sort_order=sort_order,
|
|
||||||
type=type,
|
|
||||||
source_url="",
|
|
||||||
status=AssetStatus.pending,
|
|
||||||
)
|
|
||||||
db.add(asset)
|
|
||||||
await db.flush()
|
|
||||||
# 直接转存字节
|
|
||||||
from services.storage import get_storage
|
|
||||||
storage = get_storage()
|
|
||||||
try:
|
|
||||||
asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "")
|
|
||||||
asset.status = AssetStatus.uploaded
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
asset.status = AssetStatus.failed
|
|
||||||
asset.error = str(exc)[:500]
|
|
||||||
await db.commit()
|
|
||||||
return {"asset_id": str(asset.id), "status": asset.status.value}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products/{product_id}/fingerprints")
|
|
||||||
async def product_fingerprints(
|
|
||||||
product_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
rows = (await db.scalars(
|
|
||||||
select(ProductAsset.dedupe_key).where(
|
|
||||||
ProductAsset.product_id == UUID(product_id),
|
|
||||||
ProductAsset.dedupe_key.isnot(None),
|
|
||||||
)
|
|
||||||
)).all()
|
|
||||||
return {"dedupe_keys": list(rows)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/collected")
|
|
||||||
async def is_collected(
|
|
||||||
platform: str,
|
|
||||||
itemId: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
rows = (await db.execute(
|
|
||||||
select(Product).where(
|
|
||||||
Product.source_platform == platform,
|
|
||||||
Product.source_item_id == itemId,
|
|
||||||
)
|
|
||||||
)).scalars().all()
|
|
||||||
return {"collected": len(rows) > 0, "count": len(rows)}
|
|
||||||
@@ -265,3 +265,36 @@ async def is_collected(
|
|||||||
)
|
)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
return {"collected": len(rows) > 0, "count": len(rows)}
|
return {"collected": len(rows) > 0, "count": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/assets/{asset_id}")
|
||||||
|
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""删除一条素材记录(DB 行 + 本地文件;七牛等远程存储仅删引用)。"""
|
||||||
|
from services.storage import local_path
|
||||||
|
|
||||||
|
asset = await db.get(ProductAsset, UUID(asset_id))
|
||||||
|
if asset is None:
|
||||||
|
raise HTTPException(status_code=404, detail="素材不存在")
|
||||||
|
|
||||||
|
if asset.stored_url:
|
||||||
|
path = local_path(asset.stored_url)
|
||||||
|
if path is not None and path.is_file():
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError: # noqa: BLE001
|
||||||
|
pass # 文件删除失败不阻断记录删除
|
||||||
|
|
||||||
|
product_id = asset.product_id
|
||||||
|
group_key = asset.group_key
|
||||||
|
await db.delete(asset)
|
||||||
|
|
||||||
|
# 同步修正 asset_counts(generated 计数由生图回写时累加)
|
||||||
|
product = await db.get(Product, product_id)
|
||||||
|
if product is not None:
|
||||||
|
counts = dict(product.asset_counts or {})
|
||||||
|
if group_key in counts:
|
||||||
|
counts[group_key] = max(0, int(counts.get(group_key) or 0) - 1)
|
||||||
|
product.asset_counts = counts
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"deleted": True}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/ozon", tags=["ozon"])
|
|
||||||
|
|
||||||
# Phase 3: Ozon Seller API product upload
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""发布端点:提交 ImportProductsV3 + 后台轮询回填。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from core.security import decrypt_secret
|
|
||||||
from db import get_db, get_session_factory
|
|
||||||
from deps import get_current_user
|
|
||||||
from models import Product, PublishTask, Shop
|
|
||||||
from models.enums import PublishStatus, Stage
|
|
||||||
from services.ozon_client import OzonClient, OzonAPIError
|
|
||||||
from services.publish import build_import_item, validate_ready
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["publish"])
|
|
||||||
|
|
||||||
|
|
||||||
class PublishRequest(BaseModel):
|
|
||||||
shop_id: str
|
|
||||||
|
|
||||||
|
|
||||||
def _client(shop: Shop) -> OzonClient:
|
|
||||||
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/products/{product_id}/publish")
|
|
||||||
async def publish_product(
|
|
||||||
product_id: str,
|
|
||||||
body: PublishRequest,
|
|
||||||
background: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
product = await db.get(Product, UUID(product_id))
|
|
||||||
if product is None:
|
|
||||||
raise HTTPException(status_code=404, detail="商品不存在")
|
|
||||||
shop = await db.get(Shop, UUID(body.shop_id))
|
|
||||||
if shop is None:
|
|
||||||
raise HTTPException(status_code=404, detail="店铺不存在")
|
|
||||||
|
|
||||||
missing = validate_ready(product)
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(status_code=422, detail=f"缺少必填项:{'、'.join(missing)}")
|
|
||||||
|
|
||||||
item = build_import_item(product)
|
|
||||||
client = _client(shop)
|
|
||||||
try:
|
|
||||||
result = await client.post("/v3/product/import", {"items": [item]})
|
|
||||||
except OzonAPIError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=exc.detail)
|
|
||||||
|
|
||||||
task_id = (result.get("result") or {}).get("task_id")
|
|
||||||
if not task_id:
|
|
||||||
raise HTTPException(status_code=502, detail=f"Ozon 未返回 task_id:{result}")
|
|
||||||
|
|
||||||
task = PublishTask(
|
|
||||||
product_id=product.id,
|
|
||||||
shop_id=shop.id,
|
|
||||||
ozon_task_id=int(task_id),
|
|
||||||
status=PublishStatus.pending,
|
|
||||||
request_payload=item,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
product.stage = Stage.publishing
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(task)
|
|
||||||
|
|
||||||
background.add_task(_poll, str(task.id))
|
|
||||||
return {"task_id": str(task.id), "ozon_task_id": task.ozon_task_id}
|
|
||||||
|
|
||||||
|
|
||||||
async def _poll(task_id: str) -> None:
|
|
||||||
"""后台轮询 import/info,直到 imported / failed 或超时(约 40s)。"""
|
|
||||||
async with get_session_factory()() as db:
|
|
||||||
task = await db.get(PublishTask, UUID(task_id))
|
|
||||||
if task is None:
|
|
||||||
return
|
|
||||||
shop = await db.get(Shop, task.shop_id)
|
|
||||||
product = await db.get(Product, task.product_id)
|
|
||||||
if shop is None or product is None:
|
|
||||||
return
|
|
||||||
client = _client(shop)
|
|
||||||
|
|
||||||
for attempt in range(8):
|
|
||||||
try:
|
|
||||||
result = await client.post("/v1/product/import/info", {"task_id": task.ozon_task_id})
|
|
||||||
except OzonAPIError as exc:
|
|
||||||
task.status = PublishStatus.failed
|
|
||||||
task.errors = [{"error": exc.detail}]
|
|
||||||
task.completed_at = datetime.now(timezone.utc)
|
|
||||||
product.stage = Stage.failed
|
|
||||||
await db.commit()
|
|
||||||
return
|
|
||||||
|
|
||||||
items = (result.get("result") or {}).get("items") or []
|
|
||||||
item = items[0] if items else {}
|
|
||||||
status = item.get("status", "")
|
|
||||||
product_id = item.get("product_id")
|
|
||||||
errors = item.get("errors") or []
|
|
||||||
|
|
||||||
if status == "imported":
|
|
||||||
task.status = PublishStatus.imported
|
|
||||||
task.response = item
|
|
||||||
task.completed_at = datetime.now(timezone.utc)
|
|
||||||
if product_id:
|
|
||||||
product.ozon_product_id = int(product_id)
|
|
||||||
product.stage = Stage.published
|
|
||||||
product.published_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
return
|
|
||||||
|
|
||||||
if status == "failed":
|
|
||||||
task.status = PublishStatus.failed
|
|
||||||
task.errors = errors
|
|
||||||
task.response = item
|
|
||||||
task.completed_at = datetime.now(timezone.utc)
|
|
||||||
product.stage = Stage.failed
|
|
||||||
await db.commit()
|
|
||||||
return
|
|
||||||
|
|
||||||
# pending / moderation → 继续等
|
|
||||||
task.status = PublishStatus.moderation if status in ("moderating", "moderation") else PublishStatus.processing
|
|
||||||
if product_id:
|
|
||||||
product.ozon_product_id = int(product_id)
|
|
||||||
await db.commit()
|
|
||||||
await asyncio.sleep(5 * (attempt + 1))
|
|
||||||
|
|
||||||
# 超时未定:保留 processing,前端可刷新
|
|
||||||
task.status = PublishStatus.moderation
|
|
||||||
task.response = item
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/publish/{task_id}")
|
|
||||||
async def get_publish_task(
|
|
||||||
task_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
task = await db.get(PublishTask, UUID(task_id))
|
|
||||||
if task is None:
|
|
||||||
raise HTTPException(status_code=404, detail="发布任务不存在")
|
|
||||||
return {
|
|
||||||
"id": str(task.id),
|
|
||||||
"product_id": str(task.product_id),
|
|
||||||
"shop_id": str(task.shop_id),
|
|
||||||
"ozon_task_id": task.ozon_task_id,
|
|
||||||
"status": task.status.value,
|
|
||||||
"errors": task.errors,
|
|
||||||
"response": task.response,
|
|
||||||
"created_at": task.created_at,
|
|
||||||
"completed_at": task.completed_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products/{product_id}/publish-history")
|
|
||||||
async def publish_history(
|
|
||||||
product_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
rows = (await db.scalars(
|
|
||||||
select(PublishTask)
|
|
||||||
.where(PublishTask.product_id == UUID(product_id))
|
|
||||||
.order_by(PublishTask.created_at.desc())
|
|
||||||
)).all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(t.id),
|
|
||||||
"ozon_task_id": t.ozon_task_id,
|
|
||||||
"status": t.status.value,
|
|
||||||
"errors": t.errors,
|
|
||||||
"created_at": t.created_at,
|
|
||||||
"completed_at": t.completed_at,
|
|
||||||
}
|
|
||||||
for t in rows
|
|
||||||
]
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from core.security import decrypt_secret, encrypt_secret
|
|
||||||
from db import get_db
|
|
||||||
from deps import get_current_user
|
|
||||||
from models import Shop
|
|
||||||
from models.enums import ShopStatus
|
|
||||||
from schemas.shop import ShopCreate, ShopListItem, ShopUpdate
|
|
||||||
from services.ozon_client import OzonClient, OzonAPIError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/shops", tags=["shops"])
|
|
||||||
|
|
||||||
|
|
||||||
def _mask(client_id: str) -> str:
|
|
||||||
return f"…{client_id[-4:]}" if len(client_id) > 4 else "…"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ShopListItem])
|
|
||||||
async def list_shops(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
rows = (await db.scalars(select(Shop).order_by(Shop.created_at))).all()
|
|
||||||
items = []
|
|
||||||
for s in rows:
|
|
||||||
item = ShopListItem.model_validate(s)
|
|
||||||
try:
|
|
||||||
item.client_id_masked = _mask(decrypt_secret(s.client_id_enc))
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
item.client_id_masked = "…"
|
|
||||||
items.append(item)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ShopListItem)
|
|
||||||
async def create_shop(
|
|
||||||
body: ShopCreate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
shop = Shop(
|
|
||||||
name=body.name,
|
|
||||||
client_id_enc=encrypt_secret(body.client_id),
|
|
||||||
api_key_enc=encrypt_secret(body.api_key),
|
|
||||||
currency_code=body.currency_code or "RUB",
|
|
||||||
status=ShopStatus.active,
|
|
||||||
)
|
|
||||||
db.add(shop)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(shop)
|
|
||||||
item = ShopListItem.model_validate(shop)
|
|
||||||
item.client_id_masked = _mask(body.client_id)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{shop_id}", response_model=ShopListItem)
|
|
||||||
async def update_shop(
|
|
||||||
shop_id: str,
|
|
||||||
body: ShopUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
shop = await db.get(Shop, UUID(shop_id))
|
|
||||||
if shop is None:
|
|
||||||
raise HTTPException(status_code=404, detail="店铺不存在")
|
|
||||||
if body.name is not None:
|
|
||||||
shop.name = body.name
|
|
||||||
if body.currency_code is not None:
|
|
||||||
shop.currency_code = body.currency_code
|
|
||||||
if body.client_id:
|
|
||||||
shop.client_id_enc = encrypt_secret(body.client_id)
|
|
||||||
if body.api_key:
|
|
||||||
shop.api_key_enc = encrypt_secret(body.api_key)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(shop)
|
|
||||||
item = ShopListItem.model_validate(shop)
|
|
||||||
item.client_id_masked = _mask(decrypt_secret(shop.client_id_enc))
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{shop_id}")
|
|
||||||
async def delete_shop(
|
|
||||||
shop_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
shop = await db.get(Shop, UUID(shop_id))
|
|
||||||
if shop is None:
|
|
||||||
raise HTTPException(status_code=404, detail="店铺不存在")
|
|
||||||
await db.delete(shop)
|
|
||||||
await db.commit()
|
|
||||||
return {"deleted": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{shop_id}/test")
|
|
||||||
async def test_shop(
|
|
||||||
shop_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: dict = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
shop = await db.get(Shop, UUID(shop_id))
|
|
||||||
if shop is None:
|
|
||||||
raise HTTPException(status_code=404, detail="店铺不存在")
|
|
||||||
client = OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
|
|
||||||
try:
|
|
||||||
result = await client.test_credentials()
|
|
||||||
except OzonAPIError as exc:
|
|
||||||
shop.status = ShopStatus.invalid
|
|
||||||
await db.commit()
|
|
||||||
return {"ok": False, "error": exc.detail, "roles": []}
|
|
||||||
|
|
||||||
shop.status = ShopStatus.active
|
|
||||||
shop.last_checked_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
roles = [r.get("name") for r in result.get("roles", [])]
|
|
||||||
return {"ok": True, "roles": roles}
|
|
||||||
+2
-1
@@ -35,7 +35,7 @@ from services.planner import generate_plan
|
|||||||
from services.prompts import build_context, build_prompt, type_name
|
from services.prompts import build_context, build_prompt, type_name
|
||||||
from services.storage import download_bytes, get_storage, local_path
|
from services.storage import download_bytes, get_storage, local_path
|
||||||
from services.suite_service import append_generated_asset, texts_to_raw, validate_model
|
from services.suite_service import append_generated_asset, texts_to_raw, validate_model
|
||||||
from services.tasks import IMG_OK, create_task, get_task
|
from services.tasks import IMG_OK, TaskImage, create_task, get_task
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["suite"])
|
router = APIRouter(prefix="/api", tags=["suite"])
|
||||||
|
|
||||||
@@ -246,6 +246,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
|
|||||||
asset_id = await append_generated_asset(
|
asset_id = await append_generated_asset(
|
||||||
req.product_id,
|
req.product_id,
|
||||||
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
|
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
|
||||||
|
after_asset_id=req.after_asset_id,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass # 回写失败不影响结果返回,图片已在任务网格可见
|
pass # 回写失败不影响结果返回,图片已在任务网格可见
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Ozon 类目字典缓存(可重建,不作为业务真源)。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, Text, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from db import Base
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryTree(Base):
|
|
||||||
__tablename__ = "category_tree"
|
|
||||||
|
|
||||||
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
parent_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
|
||||||
category_name: Mapped[str] = mapped_column(String(255), default="")
|
|
||||||
type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
||||||
type_name: Mapped[str] = mapped_column(String(255), default="")
|
|
||||||
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
level: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryAttribute(Base):
|
|
||||||
__tablename__ = "category_attributes"
|
|
||||||
|
|
||||||
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), default="")
|
|
||||||
description: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
type: Mapped[str] = mapped_column(String(32), default="")
|
|
||||||
dictionary_id: Mapped[int] = mapped_column(BigInteger, default=0)
|
|
||||||
group_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
||||||
group_name: Mapped[str] = mapped_column(String(255), default="")
|
|
||||||
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
is_aspect: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
max_value_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
attribute_complex_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
||||||
complex_is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
category_dependent: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
|
|
||||||
class AttributeValue(Base):
|
|
||||||
__tablename__ = "attribute_values"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
||||||
value: Mapped[str] = mapped_column(String(512), default="")
|
|
||||||
picture: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
info: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""发布任务:一次 ImportProductsV3 请求与轮询结果。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Uuid, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from db import Base
|
|
||||||
from models.enums import PublishStatus
|
|
||||||
from models.types import JSONType
|
|
||||||
|
|
||||||
|
|
||||||
class PublishTask(Base):
|
|
||||||
__tablename__ = "publish_tasks"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
shop_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
Uuid(as_uuid=True), ForeignKey("shops.id", ondelete="CASCADE"), index=True
|
|
||||||
)
|
|
||||||
ozon_task_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
|
||||||
status: Mapped[PublishStatus] = mapped_column(
|
|
||||||
Enum(PublishStatus, native_enum=False, length=16), default=PublishStatus.pending, index=True
|
|
||||||
)
|
|
||||||
request_payload: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 脱敏后的 items[0]
|
|
||||||
response: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # import/info 原始结果
|
|
||||||
errors: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"""Ozon 店铺(Client-Id / Api-Key 加密落库)。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Enum, String, Uuid, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from db import Base
|
|
||||||
from models.enums import ShopStatus
|
|
||||||
|
|
||||||
|
|
||||||
class Shop(Base):
|
|
||||||
__tablename__ = "shops"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 预留多用户
|
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
client_id_enc: Mapped[str] = mapped_column(String(1024), nullable=False) # AES-GCM 密文
|
|
||||||
api_key_enc: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
||||||
currency_code: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
|
||||||
status: Mapped[ShopStatus] = mapped_column(
|
|
||||||
Enum(ShopStatus, native_enum=False, length=16), default=ShopStatus.active
|
|
||||||
)
|
|
||||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""店铺(Ozon 凭证)请求/响应模型。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class ShopCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
client_id: str
|
|
||||||
api_key: str
|
|
||||||
currency_code: str = "CNY"
|
|
||||||
|
|
||||||
|
|
||||||
class ShopUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
client_id: str | None = None
|
|
||||||
api_key: str | None = None
|
|
||||||
currency_code: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ShopListItem(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
name: str
|
|
||||||
currency_code: str
|
|
||||||
status: str
|
|
||||||
client_id_masked: str = "" # 打码尾号
|
|
||||||
last_checked_at: datetime | None = None
|
|
||||||
created_at: datetime
|
|
||||||
@@ -153,6 +153,10 @@ class ImageEditSingleRequest(BaseModel):
|
|||||||
prompt: str = Field(..., min_length=1, description="用户要求(必填,进生图指令)")
|
prompt: str = Field(..., min_length=1, 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(
|
||||||
|
default=None,
|
||||||
|
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImageEditSingleResponse(BaseModel):
|
class ImageEditSingleResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
OZON_BASE_URL = "https://api-seller.ozon.ru"
|
|
||||||
|
|
||||||
|
|
||||||
class OzonAPIError(Exception):
|
|
||||||
def __init__(self, status: int, detail: str):
|
|
||||||
self.status = status
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(f"Ozon API {status}: {detail}")
|
|
||||||
|
|
||||||
|
|
||||||
class OzonClient:
|
|
||||||
def __init__(self, client_id: str, api_key: str, base_url: str = OZON_BASE_URL):
|
|
||||||
self.client_id = client_id
|
|
||||||
self.api_key = api_key
|
|
||||||
self.base_url = base_url
|
|
||||||
|
|
||||||
def _headers(self) -> dict:
|
|
||||||
return {
|
|
||||||
"Client-Id": self.client_id,
|
|
||||||
"Api-Key": self.api_key,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def post(self, path: str, body: dict | None = None, timeout: float = 60.0) -> dict:
|
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
||||||
resp = await client.post(f"{self.base_url}{path}", headers=self._headers(), json=body or {})
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise OzonAPIError(resp.status_code, resp.text[:500])
|
|
||||||
try:
|
|
||||||
return resp.json()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def get(self, path: str, timeout: float = 60.0) -> dict:
|
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
||||||
resp = await client.get(f"{self.base_url}{path}", headers=self._headers())
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise OzonAPIError(resp.status_code, resp.text[:500])
|
|
||||||
try:
|
|
||||||
return resp.json()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def test_credentials(self) -> dict:
|
|
||||||
"""调 /v1/roles 校验凭证与权限范围。"""
|
|
||||||
return await self.post("/v1/roles", {})
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from models import Product
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt(v) -> str:
|
|
||||||
if v is None:
|
|
||||||
return ""
|
|
||||||
return str(v)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_ready(product: Product) -> list[str]:
|
|
||||||
"""返回缺失/非法必填项的中文提示列表;空列表表示可发布。"""
|
|
||||||
missing: list[str] = []
|
|
||||||
if not product.offer_id.strip():
|
|
||||||
missing.append("货号 offer_id")
|
|
||||||
if not product.name.strip():
|
|
||||||
missing.append("商品名 name")
|
|
||||||
if not product.description.strip():
|
|
||||||
missing.append("描述 description")
|
|
||||||
if not product.description_category_id:
|
|
||||||
missing.append("类目 description_category_id")
|
|
||||||
if product.price is None or product.price <= 0:
|
|
||||||
missing.append("售价 price")
|
|
||||||
if not product.weight or product.weight <= 0:
|
|
||||||
missing.append("重量 weight")
|
|
||||||
for label, val in (("长 depth", product.depth), ("宽 width", product.width), ("高 height", product.height)):
|
|
||||||
if not val or val <= 0:
|
|
||||||
missing.append(label)
|
|
||||||
if not product.images:
|
|
||||||
missing.append("主图 images(至少 1 张)")
|
|
||||||
elif any(u and u.startswith("http://") for u in product.images):
|
|
||||||
missing.append("图片链接必须使用 https(Ozon 不接受 http 直链)")
|
|
||||||
return missing
|
|
||||||
|
|
||||||
|
|
||||||
def _with_model_name(product: Product) -> list:
|
|
||||||
"""把 raw.model_name 自动注入为 attribute 9048(型号名称),用于多变体合并。"""
|
|
||||||
attrs = list(product.attributes or [])
|
|
||||||
model_name = (product.raw or {}).get("model_name") if product.raw else None
|
|
||||||
if not model_name:
|
|
||||||
return attrs
|
|
||||||
# 已手动映射 9048 就不重复添加
|
|
||||||
for a in attrs:
|
|
||||||
if isinstance(a, dict) and a.get("id") == 9048:
|
|
||||||
return attrs
|
|
||||||
attrs.append({"complex_id": 0, "id": 9048, "values": [{"value": model_name}]})
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
def build_import_item(product: Product) -> dict:
|
|
||||||
item: dict = {
|
|
||||||
"offer_id": product.offer_id,
|
|
||||||
"name": product.name,
|
|
||||||
"description": product.description,
|
|
||||||
"description_category_id": product.description_category_id,
|
|
||||||
"price": _fmt(product.price),
|
|
||||||
"old_price": _fmt(product.old_price),
|
|
||||||
"currency_code": product.currency_code or "CNY",
|
|
||||||
"vat": product.vat or "0",
|
|
||||||
"depth": product.depth,
|
|
||||||
"width": product.width,
|
|
||||||
"height": product.height,
|
|
||||||
"dimension_unit": product.dimension_unit or "mm",
|
|
||||||
"weight": product.weight,
|
|
||||||
"weight_unit": product.weight_unit or "g",
|
|
||||||
"images": product.images or [],
|
|
||||||
"primary_image": product.primary_image or "",
|
|
||||||
"images360": product.images360 or [],
|
|
||||||
"color_image": product.color_image or "",
|
|
||||||
"attributes": _with_model_name(product),
|
|
||||||
"complex_attributes": product.complex_attributes or [],
|
|
||||||
}
|
|
||||||
if product.type_id:
|
|
||||||
item["type_id"] = product.type_id
|
|
||||||
if product.barcode:
|
|
||||||
item["barcode"] = product.barcode
|
|
||||||
return item
|
|
||||||
@@ -46,10 +46,16 @@ def validate_model(provider_name: str, model: str | None) -> None:
|
|||||||
raise ValueError(f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
raise ValueError(f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||||
|
|
||||||
|
|
||||||
async def append_generated_asset(product_id: str, image: TaskImage) -> str | None:
|
async def append_generated_asset(
|
||||||
|
product_id: str,
|
||||||
|
image: TaskImage,
|
||||||
|
after_asset_id: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
"""把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。
|
"""把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。
|
||||||
|
|
||||||
作为 run_suite 的逐张回调使用(签名须为 (image)),调用方用 partial 绑定 product_id。
|
after_asset_id:插入锚点——新素材排在锚点之后(其余素材顺次后移),方便与原图对比;
|
||||||
|
缺省/锚点无效时追加到组尾。作为 run_suite 的逐张回调使用时签名须为 (image),
|
||||||
|
调用方用 partial 绑定 product_id。
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
@@ -58,17 +64,41 @@ async def append_generated_asset(product_id: str, image: TaskImage) -> str | Non
|
|||||||
|
|
||||||
pid = uuid.UUID(product_id)
|
pid = uuid.UUID(product_id)
|
||||||
async with get_session_factory()() as db:
|
async with get_session_factory()() as db:
|
||||||
count = await db.scalar(
|
sort_order: int | None = None
|
||||||
select(func.count(ProductAsset.id)).where(
|
if after_asset_id:
|
||||||
|
try:
|
||||||
|
anchor = await db.get(ProductAsset, UUID(after_asset_id))
|
||||||
|
except ValueError:
|
||||||
|
anchor = None
|
||||||
|
if anchor is not None and anchor.product_id == pid and anchor.group_key == "generated":
|
||||||
|
# 锚点之后的素材顺次后移,腾出插入位
|
||||||
|
followers = (
|
||||||
|
await db.scalars(
|
||||||
|
select(ProductAsset).where(
|
||||||
|
ProductAsset.product_id == pid,
|
||||||
|
ProductAsset.group_key == "generated",
|
||||||
|
ProductAsset.sort_order > anchor.sort_order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for follower in followers:
|
||||||
|
follower.sort_order += 1
|
||||||
|
sort_order = anchor.sort_order + 1
|
||||||
|
|
||||||
|
if sort_order is None:
|
||||||
|
sort_order = await db.scalar(
|
||||||
|
select(func.coalesce(func.max(ProductAsset.sort_order), -1)).where(
|
||||||
ProductAsset.product_id == pid,
|
ProductAsset.product_id == pid,
|
||||||
ProductAsset.group_key == "generated",
|
ProductAsset.group_key == "generated",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
sort_order = (sort_order or 0) + 1
|
||||||
|
|
||||||
asset = ProductAsset(
|
asset = ProductAsset(
|
||||||
product_id=pid,
|
product_id=pid,
|
||||||
group_key="generated",
|
group_key="generated",
|
||||||
variant_name=None,
|
variant_name=None,
|
||||||
sort_order=count or 0,
|
sort_order=sort_order,
|
||||||
type="img",
|
type="img",
|
||||||
source_url="",
|
source_url="",
|
||||||
stored_url=image.url,
|
stored_url=image.url,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
productId: string;
|
productId: string;
|
||||||
/** 待生成的源图(采集图或生成图) */
|
/** 待生成的源图(采集图或生成图) */
|
||||||
source: { url: string; name: string } | null;
|
source: { url: string; name: string; afterAssetId?: string } | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
||||||
onGenerated?: (url: string) => void;
|
onGenerated?: (url: string) => void;
|
||||||
@@ -49,9 +49,10 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
|
|||||||
prompt: prompt.trim(),
|
prompt: prompt.trim(),
|
||||||
model,
|
model,
|
||||||
append: true,
|
append: true,
|
||||||
|
after_asset_id: source.afterAssetId,
|
||||||
});
|
});
|
||||||
setResultUrl(r.url);
|
setResultUrl(r.url);
|
||||||
message.success('生成完成,已追加到「生成图」分组');
|
message.success('生成完成,已追加到「生成结果」');
|
||||||
onGenerated?.(r.url);
|
onGenerated?.(r.url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(apiErrorMessage(e));
|
message.error(apiErrorMessage(e));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
|
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popconfirm, Popover, Progress,
|
||||||
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
|
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
@@ -14,7 +14,7 @@ import { apiErrorMessage } from '@/services/api';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
|
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
|
||||||
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
|
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
|
||||||
downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset,
|
deleteAsset, downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset,
|
||||||
type PlanItem,
|
type PlanItem,
|
||||||
} from '@/services/suite';
|
} from '@/services/suite';
|
||||||
import { cleanFilename, downloadBlob } from '@/utils/file';
|
import { cleanFilename, downloadBlob } from '@/utils/file';
|
||||||
@@ -174,8 +174,8 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
/** 规划请求序号:重置后丢弃迟到的过期响应 */
|
/** 规划请求序号:重置后丢弃迟到的过期响应 */
|
||||||
const planSeqRef = useRef(0);
|
const planSeqRef = useRef(0);
|
||||||
|
|
||||||
// 单张 AI 生图弹窗
|
// 单张 AI 生图弹窗(afterAssetId:结果插到该素材之后,方便对比)
|
||||||
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null);
|
const [genModal, setGenModal] = useState<{ url: string; name: string; afterAssetId?: string } | null>(null);
|
||||||
|
|
||||||
// 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示
|
// 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示
|
||||||
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]);
|
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]);
|
||||||
@@ -510,6 +510,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectableCount = imgs.length;
|
const selectableCount = imgs.length;
|
||||||
|
/** 删除一张已生成的图片(二次确认后调用),成功后刷新素材列表 */
|
||||||
|
const handleDeleteGenerated = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAsset(assetId);
|
||||||
|
message.success('已删除');
|
||||||
|
onRefreshAssets();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(apiErrorMessage(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
|
const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -947,7 +958,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
<Image.PreviewGroup>
|
<Image.PreviewGroup>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
|
||||||
{generatedAssets.map((a) => (
|
{generatedAssets.map((a) => (
|
||||||
<div key={a.id} style={{ width: 112 }}>
|
<div key={a.id} style={{ width: 100, position: 'relative' }}>
|
||||||
<Image
|
<Image
|
||||||
src={assetUrl(a)}
|
src={assetUrl(a)}
|
||||||
width={100}
|
width={100}
|
||||||
@@ -955,18 +966,58 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||||
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>"
|
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>"
|
||||||
/>
|
/>
|
||||||
|
{/* 删除(二次确认) */}
|
||||||
|
<Popconfirm
|
||||||
|
title="删除图片"
|
||||||
|
description="确认删除这张已生成的图片?"
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => handleDeleteGenerated(a.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
title="删除这张图片"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.95)',
|
||||||
|
color: '#ff4d4f',
|
||||||
|
border: '1px solid #ffccc7',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
zIndex: 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
</Popconfirm>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: 'rgba(0,0,0,0.45)',
|
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
title={a.stored_url ?? undefined}
|
title="基于这张图再次 AI 编辑"
|
||||||
|
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || 'AI 编辑', afterAssetId: a.id })}
|
||||||
>
|
>
|
||||||
AI 生图 {a.created_at ? new Date(a.created_at).toLocaleTimeString() : ''}
|
<a style={{ color: '#1677ff' }}>AI 编辑</a>
|
||||||
|
{a.created_at && (
|
||||||
|
<span style={{ color: 'rgba(0,0,0,0.45)', marginLeft: 4 }}>
|
||||||
|
{new Date(a.created_at).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ export interface ImageEditSinglePayload {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
model: string;
|
model: string;
|
||||||
append?: boolean;
|
append?: boolean;
|
||||||
|
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
|
||||||
|
after_asset_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
|
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
|
||||||
@@ -168,6 +170,11 @@ export function imageEditSingle(payload: ImageEditSinglePayload) {
|
|||||||
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
|
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除一条素材(DB 记录 + 本地文件),用于删除已生成的图片 */
|
||||||
|
export function deleteAsset(assetId: string) {
|
||||||
|
return api.delete<{ deleted: boolean }>(`/assets/${assetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
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 }>;
|
||||||
|
|||||||
Reference in New Issue
Block a user