refactor(server): 移除鉴权并归档遗留路由至 legacy/
- 删除 auth.py 与 deps.py,各路由去除 get_current_user 依赖 - collection.py 更名为 materials.py,冻结链路(ozon/publish/shops/categories)移入 legacy/ - 扩展默认生图服务端口并入 8800 并自动迁移旧配置,水印默认文案改为 Panda Store - 新增 docs/v2.1/backend-structure.md 后端结构盘点文档
This commit is contained in:
+3
-2
@@ -107,10 +107,11 @@ V2(原计划) V2.1(现在)
|
||||
|
||||
| 文档 | 内容 | 什么时候读 |
|
||||
|---|---|---|
|
||||
| [`collect.md`](./collect.md) | 采集方案:以 image-suite-studio 为主要参考的引擎架构、三平台路径、采集后双动作、插件改造点 | 做插件(Phase C)时读 |
|
||||
| [`collect.md`](./collect.md) | 采集方案:扩展并入 `extensions/collector/` + 上报开关 + 自动打开试算页(已实施) | 做插件改动时读 |
|
||||
| [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
|
||||
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
|
||||
| [`api.md`](./api.md) | 新增/复用的服务端 REST 契约(前端服务层已按此开发) | 前后端联调时读 |
|
||||
| [`api.md`](./api.md) | 前后端 REST 契约(前端服务层已按此开发) | 前后端联调时读 |
|
||||
| [`backend-structure.md`](./backend-structure.md) | 后端代码结构盘点:文件/接口/服务树形图、数据流、状态标注(在用/冻结/残留) | 改后端前先读 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# V2.1 后端代码结构盘点(server/)
|
||||
|
||||
> 状态:现状盘点(2026-08-28,鉴权移除后)
|
||||
> 用途:一眼看清后端有哪些代码、各自处理什么逻辑、哪些在用哪些冻结。做后端改动前先读本文。
|
||||
> 契约细节另见 [`api.md`](./api.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总览
|
||||
|
||||
共 5270 行 Python。分层:`api/`(HTTP 路由)→ `services/`(业务逻辑)→ `models/`(ORM 表);`schemas/`(Pydantic 契约)、`core/`(AES 加解密)、`config/`(配置)、`migrations/`(Alembic)。
|
||||
|
||||
状态标注:✅ 在用 · 🧊 冻结(保留不维护)· 🗑 残留(可删)。
|
||||
|
||||
```
|
||||
server/
|
||||
├── main.py 【72·入口】
|
||||
│ ├── FastAPI app + CORS + startup create_all(幂等建表,生产以 Alembic 为准)
|
||||
│ ├── 挂载 12 个路由器(auth 已移除)
|
||||
│ ├── /media 静态托管 data/media/(本地图片)
|
||||
│ └── / 挂载 web/(v1 工具台,冻结)
|
||||
│
|
||||
├── api/ ──────────────────────────── 路由层(HTTP 接口,冻结链路已移至 legacy/)
|
||||
│ ├── materials.py 【267 ✅核心】(原 collection.py,与 /api/materials 路径对齐)
|
||||
│ │ ├── POST /api/materials 插件采集上报入库(建/复用商品 + 文本解析 + 图片队列下载转存)
|
||||
│ │ ├── POST /api/materials/bytes 直接上传图片字节(试算页「上传图片」)
|
||||
│ │ ├── GET /api/products/{id}/fingerprints 素材去重指纹
|
||||
│ │ └── GET /api/collected 按 platform+itemId 查重(扩展防重复上报)
|
||||
│ ├── products.py 【178 ✅核心】
|
||||
│ │ ├── GET·POST /api/products 商品列表 / 新建
|
||||
│ │ ├── GET·PATCH·DELETE /api/products/{id} 详情 / 自动保存 / 删除(软删 archived | 硬删)
|
||||
│ │ └── POST /api/products/{id}/copy 复制为新变体(重置货号/图片防冲突)
|
||||
│ ├── suite.py 【326 ✅核心·Phase B】
|
||||
│ │ ├── POST /api/suite/plan DeepSeek 出图方案规划(同步,数秒级)
|
||||
│ │ ├── POST /api/suite/generate 一键生成:方案展开→内存任务→串行生图→回写 generated 素材
|
||||
│ │ ├── GET /api/suites/{id} 任务状态轮询(前端 3s 一次)
|
||||
│ │ ├── GET /api/suites/{id}/zip 生成结果打包下载(中文文件名)
|
||||
│ │ └── POST /api/suite/image-edit 单张 AI 生图(试算页每图按钮,append=true 回写素材)
|
||||
│ ├── export.py 【121 ✅】POST /api/export/images 采集图片打包 ZIP(标题/分组/SKU规格 文件夹结构)
|
||||
│ ├── proxy.py 【54 ✅】 GET /api/proxy-image?url= 图片防盗链代理(带站点 Referer 代下)
|
||||
│ ├── fx.py 【13 ✅】 GET /api/fx 汇率(FloatRates→俄央行→er-api 三级回退 + 区间校验)
|
||||
│ ├── ai.py 【~30 ✅】 GET /api/ai/models、POST /api/ai/copy 俄文文案生成(试算页 03 区块)
|
||||
│ ├── image.py 【~12 保留】POST /api/image/edit 万相 wanx2.1 图像编辑(智能修图页专用)
|
||||
│ ├── shops.py 【118 🧊冻结】店铺 CRUD + 连通测试(发布链路配套,AES 加密凭证)
|
||||
│ ├── categories.py 【117 🧊冻结】Ozon 类目树/属性/字典值代理
|
||||
│ ├── publish.py 【179 🧊冻结】Ozon ImportProductsV3 直传 + 任务轮询
|
||||
│ └── ozon.py 【5 🧊冻结】空占位(远期直传重启时用)
|
||||
│
|
||||
├── services/ ──────────────────────── 业务逻辑层
|
||||
│ ├── generator.py 【494 ✅核心】三大生图 provider(doubao 火山 / tongyi 通义 / rightapi 中转)
|
||||
│ │ + asyncio.Lock 全局串行队列 + run_suite 任务执行 + 参考图解析
|
||||
│ │ (variant 精确匹配 SKU 图 → 回退 main 首张;≤2 张转 data-URI)
|
||||
│ ├── planner.py 【227 ✅核心】DeepSeek 出图方案规划器
|
||||
│ │ (单行 JSON 解析 / kind 白名单 / count 钳 0-3 / 幻觉 SKU 丢弃绑定 / 截断修复)
|
||||
│ ├── prompts/ 【✅核心】生图提示词引擎(按模型家族分发)
|
||||
│ │ ├── __init__.py (provider, model) → 家族路由 + build_prompt / build_context / type_name
|
||||
│ │ ├── common.py 10 种图类型 builder(white_bg/lifestyle/…)+ 5 套风格 + 图内文案语言规范
|
||||
│ │ ├── alibaba.py 通义系「主体参考」语义(doubao 复用):商品逐像素一致,只改背景/角度
|
||||
│ │ ├── gpt.py gpt-image-2「edits 保真」语义:以参考图为准,禁止按文案重造商品
|
||||
│ │ ├── google.py nano-banana「主体保持」语义:一句话钉死主体 + 自然语言指令
|
||||
│ │ └── doubao.py 豆包(复用 alibaba)
|
||||
│ ├── tasks.py 【70 ✅】套图内存任务注册表(⚠️ server 重启丢任务状态;已落盘图片不丢)
|
||||
│ ├── watermark.py 【122 ✅】Pillow 水印合成(图片徽章/文字,右下角,失败回退原图)
|
||||
│ ├── deepseek.py 【182 ✅】DeepSeek chat 封装(俄文文案 + 出图规划共用;JSON 容错解析)
|
||||
│ ├── models_catalog.py【84 ✅】读 config/models.yaml(俄文文案模型白名单:快/省、质量两档)
|
||||
│ ├── storage.py 【117 ✅】存储抽象:Local(data/media/,/media 托管)+ Qiniu(七牛,生产)
|
||||
│ │ + local_path(stored_url→本地路径,ZIP 打包用)+ download_bytes(带 Referer 代下)
|
||||
│ ├── fx.py 【58 ✅】多源汇率 + [5,25] 区间脏数据校验(v1 移植)
|
||||
│ ├── image_edit.py【185 保留】wanx2.1-imageedit 多模型图像编辑(智能修图页后端)
|
||||
│ └── suite_service.py【~95 ✅】套图业务逻辑(api 层下沉):texts_to_raw / 模型白名单校验 /
|
||||
│ append_generated_asset(generated 回写 + asset_counts 累加)
|
||||
│
|
||||
├── legacy/ ────────────────────────── 🧊 冻结代码(Ozon API 直传链路,接口仍挂载但不再投入)
|
||||
│ ├── api/ shops(店铺 CRUD+连通测试)/ categories(类目字典代理)/
|
||||
│ │ publish(ImportProductsV3 直传)/ ozon(空占位)
|
||||
│ ├── services/ ozon_client(Seller API 薄封装)/ publish(请求体组装+必填校验)
|
||||
│ ├── models/ shop / publish_task / category(字典缓存三表)
|
||||
│ └── schemas/ shop
|
||||
│
|
||||
│
|
||||
├── schemas/ ────────────────────────── Pydantic 契约(请求/响应模型)
|
||||
│ ├── suite.py 【160 ✅】SuitePlanRequest / SuiteGenerateRequest / SuiteOut /
|
||||
│ │ ImageEditSingleRequest(product_id + append → 回写)/ WatermarkOptions / 模型白名单
|
||||
│ ├── collection.py 【✅】MaterialsRequest(camelCase:source.itemId / groupKey / variantName,对齐扩展)
|
||||
│ ├── product.py 【102 ✅】列表 / 详情 / 部分更新(自动保存)
|
||||
│ ├── copy.py 【✅】俄文文案(titles_ru/zh、description、tags、usage)
|
||||
│ ├── image_edit.py 【125 保留】智能修图(模型白名单 / function / 强度)
|
||||
│ └── (shop 已移至 legacy/schemas/;auth.py 残留已删除)
|
||||
│
|
||||
├── models/ ─────────────────────────── SQLAlchemy ORM(表结构)
|
||||
│ ├── product.py products 商品主表(Ozon 字段 + raw/pricing/copy 三个 JSON 扩展)
|
||||
│ ├── asset.py product_assets 素材表(group_key: main/sku/detail/generated/upload/…;stored_url;dedupe_key)
|
||||
│ ├── user.py users(预留空置;鉴权移除后暂无用途,表已在库)
|
||||
│ ├── enums.py stage 状态机(collected→editing→ready→…→archived)等枚举
|
||||
│ └── types.py JSON/JSONB 兼容类型
|
||||
│
|
||||
├── core/security.py 【保留】店铺凭证 AES-GCM 加解密(shops/categories/publish 使用)
|
||||
│ 鉴权(JWT/APP_TOKEN)已按 V2.1 决策移除,加账户体系时在此重引
|
||||
├── db.py 【50】async engine + session 工厂(SQLite 开发 / PostgreSQL 生产)
|
||||
├── config/
|
||||
│ ├── settings.py 【94】pydantic-settings:database_url / DEEPSEEK·DASHSCOPE·ARK·RIGHTAPI 密钥 /
|
||||
│ │ image_provider / request_timeout(300s) / poll_max_wait(600s) / 存储与水印配置
|
||||
│ └── models.yaml 俄文文案模型白名单(deepseek-v4-flash 默认 / deepseek-v4-pro)
|
||||
└── migrations/ Alembic(2 个版本:initial_v2_schema、add_shop_id_to_products)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 接口一览(20 个端点,全部无鉴权)
|
||||
|
||||
| 分组 | 端点 | 用途 | 消费方 |
|
||||
|---|---|---|---|
|
||||
| **采集上报** | POST /api/materials | 采集结果入库(返回 product_id) | extensions/collector「上报商品」 |
|
||||
| | POST /api/materials/bytes | 上传图片字节(FormData) | 试算页「上传图片」 |
|
||||
| | GET /api/collected?platform=&itemId= | 采集前查重 | 扩展面板 |
|
||||
| | GET /api/products/{id}/fingerprints | 素材去重指纹 | 扩展 |
|
||||
| **商品库** | GET·POST /api/products | 列表 / 新建 | 采集箱、登记表导出 |
|
||||
| | GET·PATCH·DELETE /api/products/{id} | 详情 / 自动保存 / 删除 | 试算页、编辑页 |
|
||||
| | POST /api/products/{id}/copy | 复制为新变体 | 采集箱 |
|
||||
| | GET /api/products/{id}/assets | 素材列表 | 试算页 04 区块 |
|
||||
| **套图生图** | POST /api/suite/plan | DeepSeek 出图方案 | 试算页「AI 智能规划」 |
|
||||
| | POST /api/suite/generate | 提交套图任务(返回 suite_id) | 试算页「一键生图」 |
|
||||
| | GET /api/suites/{id} | 任务状态轮询 | 试算页进度条 |
|
||||
| | GET /api/suites/{id}/zip | 结果打包下载 | 试算页「导出 ZIP」 |
|
||||
| | POST /api/suite/image-edit | 单张 AI 生图(可回写素材) | 试算页每图「AI 生图」 |
|
||||
| **辅助** | GET /api/fx | 汇率 | 试算页计价 |
|
||||
| | GET /api/ai/models · POST /api/ai/copy | 文案模型 / 生成 | 试算页 03 区块 |
|
||||
| | POST /api/export/images | 采集图片 ZIP | 试算页「下载采集图片」 |
|
||||
| | GET /api/proxy-image | 防盗链代理 | 前端图片回退 |
|
||||
| | POST /api/image/edit | 万相图像编辑 | 智能修图页 |
|
||||
| | GET /api/health | 健康检查 | 运维 |
|
||||
| **🧊冻结** | /api/shops/* · /api/categories/* · /api/publish/* · /api/ozon/* | Ozon 直传链路(代码在 legacy/,接口仍挂载) | 无(保留代码) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心数据流
|
||||
|
||||
```
|
||||
extensions/collector 采集
|
||||
→ POST /api/materials(collection.py 落库 products + 排队下载图片 → storage 落盘)
|
||||
→ 试算页 /trial/{id}(studio)
|
||||
├─ 01/02/03 编辑 → PATCH /api/products/{id} 自动保存(raw/pricing/copy JSON)
|
||||
├─ 04 生图:POST /api/suite/plan(planner+deepseek)→ POST /api/suite/generate
|
||||
│ → tasks 内存任务 → generator(prompts 家族 → provider API → watermark → storage)
|
||||
│ → 每张成功回写 product_assets(generated) → 前端轮询 GET /api/suites/{id} + 刷新素材
|
||||
└─ 05 登记(localStorage)→ 导出 CSV / 组合码(纯前端)
|
||||
```
|
||||
|
||||
## 4. 已知特性与注意事项
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 套图任务在内存 | `tasks.py` 进程内注册表:server 重启丢任务状态(前端提示重新生成);已落盘图片不丢 |
|
||||
| 生图全局串行 | `generator.py` 的 `asyncio.Lock`:一次只跑一个生成队列(防中转限流,ISS 同款) |
|
||||
| 全部接口无鉴权 | V2.1 决策:先功能后鉴权;重新引入时从 `deps.py` + 路由依赖层加 |
|
||||
| 残留可删 | `schemas/auth.py`(无引用);`api/ozon.py`(空占位,随冻结链路保留) |
|
||||
| 密钥 | `.env`:DEEPSEEK_API_KEY(规划+文案)、RIGHTAPI_API_KEY(gpt/nano 生图)、DASHSCOPE_API_KEY(通义)、ARK_API_KEY(豆包,可选) |
|
||||
| 前端契约 | 试算页 `studio/src/services/suite.ts` 与 `schemas/suite.py` 逐字段对齐(联调已验证) |
|
||||
@@ -141,7 +141,7 @@ const App: React.FC = () => {
|
||||
const [settings, setSettings] = useState<BackendSettings>({
|
||||
baseUrl: 'http://127.0.0.1:3300',
|
||||
token: '',
|
||||
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 },
|
||||
watermark: { enabled: false, type: 'image', text: 'Panda Store', opacity: 30 },
|
||||
reportEnabled: true,
|
||||
reportBaseUrl: 'http://127.0.0.1:8800',
|
||||
studioBaseUrl: 'http://localhost:8900',
|
||||
|
||||
@@ -23,27 +23,29 @@ export interface BackendSettings {
|
||||
|
||||
const KEY = 'suite_backend_settings';
|
||||
|
||||
export const DEFAULT_BASE_URL = 'http://127.0.0.1:3300';
|
||||
export const DEFAULT_BASE_URL = 'http://127.0.0.1:8800';
|
||||
export const DEFAULT_REPORT_BASE_URL = 'http://127.0.0.1:8800';
|
||||
export const DEFAULT_STUDIO_BASE_URL = 'http://localhost:8900';
|
||||
|
||||
const DEFAULT: BackendSettings = {
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
token: '',
|
||||
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 },
|
||||
watermark: { enabled: false, type: 'image', text: 'Panda Store', opacity: 30 },
|
||||
reportEnabled: true,
|
||||
reportBaseUrl: DEFAULT_REPORT_BASE_URL,
|
||||
studioBaseUrl: DEFAULT_STUDIO_BASE_URL,
|
||||
};
|
||||
|
||||
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置) */
|
||||
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置)。
|
||||
* v2.1 并入 OSK 后生图服务端与上报同源(8800),ISS 的 3300 存档自动迁移。 */
|
||||
const MIGRATE: Record<string, string> = {
|
||||
'http://127.0.0.1:3300': DEFAULT_BASE_URL,
|
||||
'http://localhost:3300': DEFAULT_BASE_URL,
|
||||
'http://127.0.0.1:8810': DEFAULT_BASE_URL,
|
||||
'http://127.0.0.1:7000': DEFAULT_BASE_URL,
|
||||
'http://127.0.0.1:7200': DEFAULT_BASE_URL,
|
||||
'http://localhost:7000': DEFAULT_BASE_URL,
|
||||
'http://127.0.0.1:7200': DEFAULT_BASE_URL,
|
||||
'http://localhost:7200': DEFAULT_BASE_URL,
|
||||
'http://localhost:3300': DEFAULT_BASE_URL,
|
||||
};
|
||||
|
||||
export async function loadSettings(): Promise<BackendSettings> {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""鉴权路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from config import get_settings
|
||||
from core.security import create_access_token
|
||||
from schemas.auth import LoginRequest, LoginResponse
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(body: LoginRequest) -> LoginResponse:
|
||||
settings = get_settings()
|
||||
if not settings.app_token:
|
||||
raise HTTPException(status_code=500, detail="服务端未配置 APP_TOKEN")
|
||||
if not secrets.compare_digest(body.token, settings.app_token):
|
||||
raise HTTPException(status_code=401, detail="Token 不正确")
|
||||
token, expires_at = create_access_token("app")
|
||||
return LoginResponse(access_token=token, expires_at=expires_at)
|
||||
+2
-3
@@ -1,14 +1,13 @@
|
||||
"""汇率路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter
|
||||
|
||||
from deps import get_current_user
|
||||
from services.fx import get_fx_rate
|
||||
|
||||
router = APIRouter(prefix="/api/fx", tags=["fx"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def fx(_user: dict = Depends(get_current_user)):
|
||||
async def fx():
|
||||
return await get_fx_rate()
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""采集入库:插件上传文本 + 图片 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 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)
|
||||
) -> 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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)}
|
||||
+7
-15
@@ -8,7 +8,6 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db import get_db
|
||||
from deps import get_current_user
|
||||
from models import Product, ProductAsset
|
||||
from models.enums import Stage
|
||||
from schemas.product import ProductDetail, ProductListItem, ProductUpdate
|
||||
@@ -22,8 +21,7 @@ async def list_products(
|
||||
q: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
stmt = select(Product)
|
||||
if stage:
|
||||
@@ -42,8 +40,7 @@ async def list_products(
|
||||
@router.get("/{product_id}", response_model=ProductDetail)
|
||||
async def get_product(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
@@ -54,8 +51,7 @@ async def get_product(
|
||||
@router.post("", response_model=ProductDetail)
|
||||
async def create_product(
|
||||
body: ProductUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
product = Product(stage=Stage.collected)
|
||||
_apply_update(product, body)
|
||||
@@ -68,8 +64,7 @@ async def create_product(
|
||||
@router.post("/{product_id}/copy", response_model=ProductDetail)
|
||||
async def copy_product(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""复制商品为新变体:继承标题/描述/属性/型号名称/计价,重置货号与图片。"""
|
||||
src = await db.get(Product, UUID(product_id))
|
||||
@@ -118,8 +113,7 @@ async def copy_product(
|
||||
async def update_product(
|
||||
product_id: str,
|
||||
body: ProductUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
@@ -134,8 +128,7 @@ async def update_product(
|
||||
async def delete_product(
|
||||
product_id: str,
|
||||
hard: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
@@ -151,8 +144,7 @@ async def delete_product(
|
||||
@router.get("/{product_id}/assets")
|
||||
async def list_assets(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
rows = (await db.scalars(
|
||||
select(ProductAsset)
|
||||
|
||||
+14
-82
@@ -3,26 +3,23 @@
|
||||
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studio(services/planner|generator|
|
||||
tasks|watermark + services/prompts),任务存进程内内存表(重启即失效)。
|
||||
生成图回调回写 product_assets(group_key='generated');product_id 缺省时只落盘不回写。
|
||||
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
|
||||
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import mimetypes
|
||||
import uuid
|
||||
import zipfile
|
||||
from functools import partial
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import get_settings
|
||||
from db import get_session_factory
|
||||
from models import Product, ProductAsset
|
||||
from schemas.suite import (
|
||||
PLATFORM_SPECS,
|
||||
RIGHTAPI_MODELS,
|
||||
SUPPORTED_TYPES,
|
||||
TONGYI_MODELS,
|
||||
ImageEditSingleRequest,
|
||||
ImageEditSingleResponse,
|
||||
PlanItemOut,
|
||||
@@ -31,88 +28,18 @@ from schemas.suite import (
|
||||
SuiteGenerateRequest,
|
||||
SuiteOut,
|
||||
SuitePlanRequest,
|
||||
TextMaterial,
|
||||
resolve_provider,
|
||||
)
|
||||
from services.generator import _image_size, GENERATORS, run_suite
|
||||
from services.planner import generate_plan
|
||||
from services.prompts import build_context, build_prompt, type_name
|
||||
from services.storage import download_bytes, get_storage, local_path
|
||||
from services.tasks import IMG_OK, Task, TaskImage, create_task, get_task
|
||||
from api.proxy import guess_referer
|
||||
from services.suite_service import append_generated_asset, texts_to_raw, validate_model
|
||||
from services.tasks import IMG_OK, create_task, get_task
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["suite"])
|
||||
|
||||
|
||||
def texts_to_raw(texts: list[TextMaterial]) -> dict:
|
||||
"""前端组装的文本素材 → prompt 上下文用的 raw dict(后写的覆盖先写的)。"""
|
||||
raw: dict = {}
|
||||
for t in texts:
|
||||
if t.kind == "title" and t.content:
|
||||
raw["title"] = t.content
|
||||
elif t.kind == "price" and t.content:
|
||||
raw["price"] = t.content
|
||||
elif t.kind == "brand" and t.content:
|
||||
raw["brand"] = t.content
|
||||
elif t.kind == "params" and t.pairs:
|
||||
merged = {p["key"]: p["value"] for p in (raw.get("params") or [])}
|
||||
for p in t.pairs:
|
||||
merged.setdefault(p["key"], p["value"])
|
||||
raw["params"] = [{"key": k, "value": v} for k, v in merged.items()]
|
||||
elif t.kind == "selling_point" and t.content:
|
||||
raw["sellingPoints"] = t.content
|
||||
elif t.kind == "desc" and t.content:
|
||||
raw["desc"] = t.content
|
||||
elif t.kind == "sales" and t.content:
|
||||
raw["sales"] = t.content
|
||||
elif t.kind == "shop" and t.content:
|
||||
raw["shop"] = t.content
|
||||
return raw
|
||||
|
||||
|
||||
def _validate_model(provider_name: str, model: str | None) -> None:
|
||||
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})")
|
||||
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||
|
||||
|
||||
# ── 生成图回写商品素材 ────────────────────────────────────────────────────
|
||||
|
||||
async def _append_generated_asset(product_id: str, image: TaskImage) -> str | None:
|
||||
"""把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
pid = uuid.UUID(product_id)
|
||||
async with get_session_factory()() as db:
|
||||
count = await db.scalar(
|
||||
select(func.count(ProductAsset.id)).where(
|
||||
ProductAsset.product_id == pid,
|
||||
ProductAsset.group_key == "generated",
|
||||
)
|
||||
)
|
||||
asset = ProductAsset(
|
||||
product_id=pid,
|
||||
group_key="generated",
|
||||
variant_name=None,
|
||||
sort_order=count or 0,
|
||||
type="img",
|
||||
source_url="",
|
||||
stored_url=image.url,
|
||||
status="uploaded",
|
||||
)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
|
||||
product = await db.get(Product, pid)
|
||||
if product is not None:
|
||||
counts = dict(product.asset_counts or {})
|
||||
counts["generated"] = int(counts.get("generated") or 0) + 1
|
||||
product.asset_counts = counts
|
||||
await db.commit()
|
||||
return str(asset.id)
|
||||
|
||||
|
||||
# ── 出图方案规划 ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/suite/plan", response_model=PlanResponse)
|
||||
@@ -173,7 +100,7 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
|
||||
settings = get_settings()
|
||||
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi)
|
||||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
||||
_validate_model(provider_name, req.model)
|
||||
validate_model(provider_name, req.model)
|
||||
|
||||
product_id = (req.product_id or "").strip() or None
|
||||
if product_id:
|
||||
@@ -207,8 +134,13 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
|
||||
],
|
||||
)
|
||||
|
||||
# 每张成功即回写 generated 组;未关联商品时仅落存储不回写
|
||||
background.add_task(run_suite, task, _append_generated_asset if product_id else None)
|
||||
# 每张成功即回写 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)
|
||||
|
||||
|
||||
@@ -279,7 +211,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
|
||||
provider_name = resolve_provider(req.model, None, settings.image_provider)
|
||||
if provider_name not in GENERATORS:
|
||||
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
|
||||
_validate_model(provider_name, req.model)
|
||||
validate_model(provider_name, req.model)
|
||||
|
||||
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
|
||||
model = req.model
|
||||
@@ -311,7 +243,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
|
||||
asset_id: str | None = None
|
||||
if req.append and req.product_id:
|
||||
try:
|
||||
asset_id = await _append_generated_asset(
|
||||
asset_id = await append_generated_asset(
|
||||
req.product_id,
|
||||
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
|
||||
)
|
||||
|
||||
@@ -34,10 +34,9 @@ class Settings(BaseSettings):
|
||||
# 本地过渡用 SQLite;上线切 PostgreSQL:postgresql+asyncpg://user:pass@host:5432/ozon_seller
|
||||
database_url: str = "sqlite+aiosqlite:///./data/app.db"
|
||||
|
||||
# ── V2:鉴权 ──
|
||||
app_token: str = "" # MVP 单用户登录 token(换发 JWT 用)
|
||||
secret_key: str = "" # 店铺凭证 AES-GCM 加密密钥 + JWT 签名密钥
|
||||
jwt_expire_minutes: int = 60 * 24 * 7 # JWT 有效期(默认 7 天)
|
||||
# 店铺凭证 AES-GCM 加密密钥(shops/categories/publish 冻结链路使用;
|
||||
# 鉴权/账户体系已按 V2.1 决策移除,后续引入账户时一并重做)
|
||||
secret_key: str = ""
|
||||
|
||||
# ── V2:七牛(图片存储)──
|
||||
qiniu_access_key: str = ""
|
||||
|
||||
+4
-23
@@ -1,35 +1,16 @@
|
||||
"""JWT 鉴权 + 店铺凭证 AES-GCM 加解密。"""
|
||||
"""店铺凭证 AES-GCM 加解密(shops/categories/publish 冻结链路使用)。
|
||||
|
||||
鉴权(JWT/APP_TOKEN)已按 V2.1 决策移除,后续加账户体系时再引入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
# ── JWT ──
|
||||
|
||||
def create_access_token(subject: str = "app") -> tuple[str, int]:
|
||||
"""签发 JWT。返回 (token, 过期 epoch 秒)。"""
|
||||
settings = get_settings()
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload = {"sub": subject, "exp": expires}
|
||||
token = jwt.encode(payload, settings.secret_key, algorithm="HS256")
|
||||
return token, int(expires.timestamp())
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""校验并解析 JWT;失败抛 jwt.PyJWTError。"""
|
||||
settings = get_settings()
|
||||
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
|
||||
|
||||
|
||||
# ── AES-GCM 店铺凭证加密 ──
|
||||
|
||||
def _derive_key() -> bytes:
|
||||
settings = get_settings()
|
||||
return hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""FastAPI 依赖:数据库会话 + 鉴权。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import jwt as pyjwt
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from core.security import decode_token
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> dict:
|
||||
"""校验 Bearer JWT,返回 payload。
|
||||
|
||||
MVP:单用户宽松模式 —— 未带 / 失效 token 也放行(返回匿名身份),
|
||||
后续加账户体系时再收紧为强制校验。
|
||||
"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
return {"sub": "app", "anonymous": True}
|
||||
try:
|
||||
return decode_token(credentials.credentials)
|
||||
except pyjwt.PyJWTError:
|
||||
return {"sub": "app", "anonymous": True}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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 legacy.models import Shop
|
||||
from legacy.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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
@@ -0,0 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/ozon", tags=["ozon"])
|
||||
|
||||
# Phase 3: Ozon Seller API product upload
|
||||
@@ -0,0 +1,181 @@
|
||||
"""发布端点:提交 ImportProductsV3 + 后台轮询回填。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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 models import Product
|
||||
from legacy.models import PublishTask, Shop
|
||||
from models.enums import PublishStatus, Stage
|
||||
from legacy.services.ozon_client import OzonClient, OzonAPIError
|
||||
from legacy.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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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 legacy.models import Shop
|
||||
from models.enums import ShopStatus
|
||||
from legacy.schemas.shop import ShopCreate, ShopListItem, ShopUpdate
|
||||
from legacy.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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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}
|
||||
@@ -0,0 +1,12 @@
|
||||
"""legacy 冻结模型导出(Ozon 直传链路;表结构随 legacy api import 链注册到 Base.metadata)。"""
|
||||
from legacy.models.category import AttributeValue, CategoryAttribute, CategoryTree
|
||||
from legacy.models.publish_task import PublishTask
|
||||
from legacy.models.shop import Shop
|
||||
|
||||
__all__ = [
|
||||
"Shop",
|
||||
"PublishTask",
|
||||
"CategoryTree",
|
||||
"CategoryAttribute",
|
||||
"AttributeValue",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Ozon 类目字典缓存(可重建,不作为业务真源)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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())
|
||||
@@ -0,0 +1,34 @@
|
||||
"""发布任务:一次 ImportProductsV3 请求与轮询结果。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Ozon 店铺(Client-Id / Api-Key 加密落库)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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()
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""店铺(Ozon 凭证)请求/响应模型。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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", {})
|
||||
@@ -0,0 +1,80 @@
|
||||
"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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
|
||||
+11
-8
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
from api import ai, auth, categories, collection, export, fx, image, ozon, products, proxy, publish, shops, suite
|
||||
from api import ai, export, fx, image, materials, products, proxy, suite
|
||||
from config import get_settings
|
||||
from db import get_engine
|
||||
|
||||
@@ -26,22 +26,25 @@ if settings.cors_origin_list:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 业务路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(collection.router)
|
||||
# 业务路由(活跃:采集上报 / 商品库 / 套图生图 / 辅助)
|
||||
app.include_router(materials.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(shops.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(publish.router)
|
||||
app.include_router(fx.router)
|
||||
app.include_router(ai.router)
|
||||
app.include_router(image.router)
|
||||
app.include_router(ozon.router)
|
||||
# V2.1 套图生图(试算页 04 区块)/ 导出 / 图片代理
|
||||
app.include_router(suite.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(proxy.router)
|
||||
|
||||
# ⚠️ 冻结链路(Ozon API 直传):代码移至 legacy/,接口保留但不再投入
|
||||
from legacy.api import categories, ozon, publish, shops # noqa: E402
|
||||
|
||||
app.include_router(shops.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(publish.router)
|
||||
app.include_router(ozon.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup() -> None:
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。"""
|
||||
"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。
|
||||
|
||||
冻结链路的模型已移至 legacy/models(shops/categories/publish 相关),
|
||||
由 legacy.api 路由 import 链注册到同一 Base.metadata。
|
||||
"""
|
||||
from models.asset import ProductAsset
|
||||
from models.category import AttributeValue, CategoryAttribute, CategoryTree
|
||||
from models.product import Product
|
||||
from models.publish_task import PublishTask
|
||||
from models.shop import Shop
|
||||
from models.user import User
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Shop",
|
||||
"Product",
|
||||
"ProductAsset",
|
||||
"PublishTask",
|
||||
"CategoryTree",
|
||||
"CategoryAttribute",
|
||||
"AttributeValue",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。"""
|
||||
"""用户表(预留多用户;V2.1 已移除鉴权,后续加账户体系时启用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"""鉴权请求/响应模型。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_at: int
|
||||
@@ -57,11 +57,11 @@ class TextMaterial(BaseModel):
|
||||
|
||||
|
||||
class WatermarkOptions(BaseModel):
|
||||
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。右下角,默认文字 xiongmaoyx。"""
|
||||
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。右下角,默认文字 Panda Store。"""
|
||||
|
||||
enabled: bool = Field(default=False, description="是否开启水印")
|
||||
type: Literal["image", "text"] = Field(default="image", description="图片水印 | 文字水印")
|
||||
text: str = Field(default="xiongmaoyx", description="文字水印内容")
|
||||
text: str = Field(default="Panda Store", description="文字水印内容")
|
||||
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""套图业务逻辑(从 api/suite.py 下沉):文本素材转换 / 模型校验 / 生成图回写商品素材。
|
||||
|
||||
api 层只留参数校验与调用;本模块可独立测试。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from schemas.suite import TextMaterial
|
||||
from services.tasks import TaskImage
|
||||
|
||||
|
||||
def texts_to_raw(texts: list[TextMaterial]) -> dict:
|
||||
"""前端组装的文本素材 → prompt 上下文用的 raw dict(后写的覆盖先写的)。"""
|
||||
raw: dict = {}
|
||||
for t in texts:
|
||||
if t.kind == "title" and t.content:
|
||||
raw["title"] = t.content
|
||||
elif t.kind == "price" and t.content:
|
||||
raw["price"] = t.content
|
||||
elif t.kind == "brand" and t.content:
|
||||
raw["brand"] = t.content
|
||||
elif t.kind == "params" and t.pairs:
|
||||
merged = {p["key"]: p["value"] for p in (raw.get("params") or [])}
|
||||
for p in t.pairs:
|
||||
merged.setdefault(p["key"], p["value"])
|
||||
raw["params"] = [{"key": k, "value": v} for k, v in merged.items()]
|
||||
elif t.kind == "selling_point" and t.content:
|
||||
raw["sellingPoints"] = t.content
|
||||
elif t.kind == "desc" and t.content:
|
||||
raw["desc"] = t.content
|
||||
elif t.kind == "sales" and t.content:
|
||||
raw["sales"] = t.content
|
||||
elif t.kind == "shop" and t.content:
|
||||
raw["shop"] = t.content
|
||||
return raw
|
||||
|
||||
|
||||
def validate_model(provider_name: str, model: str | None) -> None:
|
||||
"""按 provider 校验模型名白名单,不合法抛 ValueError。"""
|
||||
from schemas.suite import RIGHTAPI_MODELS, TONGYI_MODELS
|
||||
|
||||
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
|
||||
raise ValueError(f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})")
|
||||
if provider_name == "rightapi" and model and model not in RIGHTAPI_MODELS:
|
||||
raise ValueError(f"不支持的模型: {model}(rightapi 支持: {RIGHTAPI_MODELS})")
|
||||
|
||||
|
||||
async def append_generated_asset(product_id: str, image: TaskImage) -> str | None:
|
||||
"""把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。
|
||||
|
||||
作为 run_suite 的逐张回调使用(签名须为 (image)),调用方用 partial 绑定 product_id。
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from db import get_session_factory
|
||||
from models import Product, ProductAsset
|
||||
|
||||
pid = uuid.UUID(product_id)
|
||||
async with get_session_factory()() as db:
|
||||
count = await db.scalar(
|
||||
select(func.count(ProductAsset.id)).where(
|
||||
ProductAsset.product_id == pid,
|
||||
ProductAsset.group_key == "generated",
|
||||
)
|
||||
)
|
||||
asset = ProductAsset(
|
||||
product_id=pid,
|
||||
group_key="generated",
|
||||
variant_name=None,
|
||||
sort_order=count or 0,
|
||||
type="img",
|
||||
source_url="",
|
||||
stored_url=image.url,
|
||||
status="uploaded",
|
||||
)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
|
||||
product = await db.get(Product, pid)
|
||||
if product is not None:
|
||||
counts = dict(product.asset_counts or {})
|
||||
counts["generated"] = int(counts.get("generated") or 0) + 1
|
||||
product.asset_counts = counts
|
||||
await db.commit()
|
||||
return str(asset.id)
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getToken } from '@/services/auth';
|
||||
|
||||
/** 未登录则跳 /login */
|
||||
export default function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const token = getToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) navigate('/login', { replace: true });
|
||||
}, [token, navigate]);
|
||||
|
||||
if (!token) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -31,7 +31,8 @@ const MainLayout = () => {
|
||||
{!isMobile && (
|
||||
<Sider
|
||||
className="main-sider"
|
||||
width={240}
|
||||
width={200}
|
||||
collapsedWidth={60}
|
||||
collapsed={collapsed}
|
||||
theme="dark"
|
||||
style={{
|
||||
@@ -58,7 +59,7 @@ const MainLayout = () => {
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
|
||||
width={240}
|
||||
width={200}
|
||||
>
|
||||
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
|
||||
</Drawer>
|
||||
@@ -67,7 +68,7 @@ const MainLayout = () => {
|
||||
<Layout
|
||||
className="main-content-layout"
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : collapsed ? 80 : 240,
|
||||
marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
|
||||
transition: 'margin-left 0.2s',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -80,3 +80,55 @@
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 折叠态(60px):图标居中,覆盖展开态的左对齐 padding/位移 ── */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||
padding: 12px 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-title-content {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item:hover .ant-menu-item-icon,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected .ant-menu-item-icon {
|
||||
transform: none !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠时去掉左侧 3px 选中边框(会挤偏图标),选中态用高亮背景区分 */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item-selected {
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
/* 折叠时隐藏 label 占位(antd 折叠动画保留 opacity 占位,会把图标挤离中心) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item .ant-menu-title-content {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 兼容:部分版本折叠态用 vertical 类渲染,同样居中 */
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item,
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.sidebar-menu.ant-menu-vertical .ant-menu-item .ant-menu-title-content {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 折叠态 flex 子元素居中(display:flex 已生效,缺 justify-content) */
|
||||
.sidebar-menu.ant-menu-inline-collapsed .ant-menu-item {
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Card, Input, message, Typography } from 'antd';
|
||||
import { login } from '@/services/auth';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [token, setToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!token.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(token.trim());
|
||||
message.success('登录成功');
|
||||
navigate('/collection', { replace: true });
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f5f5f5' }}>
|
||||
<Card style={{ width: 420 }}>
|
||||
<Title level={3} style={{ marginTop: 0 }}>Ozon 发布工作台</Title>
|
||||
<Text type="secondary">请输入访问 Token(对应服务端 .env 的 APP_TOKEN)</Text>
|
||||
<Input.Password
|
||||
placeholder="APP_TOKEN"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onPressEnter={onSubmit}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
<Button type="primary" block loading={loading} onClick={onSubmit} style={{ marginTop: 16 }}>
|
||||
登录
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Col, Input, message, Row, Typography } from 'antd';
|
||||
import { Button, Col, Input, message, Row, Typography } from 'antd';
|
||||
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { copyText } from '@/utils/file';
|
||||
@@ -27,7 +27,15 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
|
||||
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
|
||||
const [nameRu, setNameRu] = useState(product.name ?? '');
|
||||
const [modelCode, setModelCode] = useState((raw.model_code as string) ?? '');
|
||||
const [offerId, setOfferId] = useState(product.offer_id ?? '');
|
||||
// 货号 = 型号-后缀:后缀独立存储(对齐 v1 modelCode+skuSuffix),型号只做前缀拼接、永不反推
|
||||
const [skuSuffix, setSkuSuffix] = useState(() => {
|
||||
const model = ((raw.model_code as string) ?? '').trim();
|
||||
const offer = (product.offer_id ?? '').trim();
|
||||
if (!offer) return '';
|
||||
return model && offer.startsWith(`${model}-`) ? offer.slice(model.length + 1) : offer;
|
||||
});
|
||||
// 完整货号:型号与后缀拼接(任一为空则省略对应段与「-」)
|
||||
const fullSku = [modelCode.trim(), skuSuffix.trim()].filter(Boolean).join('-');
|
||||
// 采买地址:仅 1688/拼多多/淘宝/天猫 来源时用 source_url 初始化
|
||||
const [purchaseUrl, setPurchaseUrl] = useState(
|
||||
(raw.purchase_url as string) ?? (isPurchasePlatform(product.source_platform) ? (product.source_url ?? '') : ''),
|
||||
@@ -38,19 +46,9 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
|
||||
|
||||
const params = Array.isArray(raw.params) ? (raw.params as Array<{ key: string; value: string }>) : [];
|
||||
|
||||
/** 型号变化:货号前缀始终同步为新型号(对齐 v1 web:货号 = 型号-后缀)。
|
||||
* 货号为空 → 带入「型号-」;货号非空 → 替换第一个「-」前的前缀、保留后缀;
|
||||
* 型号清空时货号保持不动(避免误删已填后缀)。 */
|
||||
const onModelChange = (v: string) => {
|
||||
setModelCode(v);
|
||||
setOfferId((prev) => {
|
||||
if (!prev) return v ? `${v}-` : '';
|
||||
if (!v) return prev;
|
||||
const idx = prev.indexOf('-');
|
||||
const suffix = idx >= 0 ? prev.slice(idx) : '-';
|
||||
return `${v}${suffix}`;
|
||||
});
|
||||
};
|
||||
/** 型号/后缀变化后同步完整货号落库 */
|
||||
const saveSku = (model: string, suffix: string) =>
|
||||
onSave({ offer_id: [model.trim(), suffix.trim()].filter(Boolean).join('-') });
|
||||
|
||||
const openUrl = purchaseUrl?.trim()
|
||||
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
|
||||
@@ -85,17 +83,46 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
|
||||
<Input
|
||||
value={modelCode}
|
||||
placeholder="如 YZ"
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
onBlur={() => patchRaw({ model_code: modelCode.trim() })}
|
||||
onChange={(e) => {
|
||||
setModelCode(e.target.value);
|
||||
saveSku(e.target.value, skuSuffix);
|
||||
}}
|
||||
addonAfter={
|
||||
<a
|
||||
title="复制型号"
|
||||
style={{ opacity: modelCode.trim() ? 1 : 0.35 }}
|
||||
onClick={() => {
|
||||
if (!modelCode.trim()) return;
|
||||
copyText(modelCode.trim()).then((ok) => ok && message.success('已复制型号'));
|
||||
}}
|
||||
>
|
||||
<CopyOutlined />
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FieldLabel>货号(SKU)</FieldLabel>
|
||||
<FieldLabel>货号(SKU){modelCode.trim() && <Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}>= 型号-后缀</Text>}</FieldLabel>
|
||||
<Input
|
||||
value={offerId}
|
||||
placeholder="型号-后缀(型号自动带入前缀)"
|
||||
onChange={(e) => setOfferId(e.target.value)}
|
||||
onBlur={() => onSave({ offer_id: offerId.trim() })}
|
||||
value={skuSuffix}
|
||||
placeholder="后缀,如 001"
|
||||
addonBefore={modelCode.trim() ? `${modelCode.trim()}-` : undefined}
|
||||
onChange={(e) => {
|
||||
setSkuSuffix(e.target.value);
|
||||
saveSku(modelCode, e.target.value);
|
||||
}}
|
||||
addonAfter={
|
||||
<a
|
||||
title="复制完整货号"
|
||||
style={{ opacity: fullSku ? 1 : 0.35 }}
|
||||
onClick={() => {
|
||||
if (!fullSku) return;
|
||||
copyText(fullSku).then((ok) => ok && message.success(`已复制货号:${fullSku}`));
|
||||
}}
|
||||
>
|
||||
<CopyOutlined />
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -108,11 +135,17 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
|
||||
onChange={(e) => setPurchaseUrl(e.target.value)}
|
||||
onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })}
|
||||
addonAfter={
|
||||
<span style={{ display: 'inline-flex', gap: 10 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
{openUrl && (
|
||||
<a href={openUrl} target="_blank" rel="noreferrer" title="打开采买地址">
|
||||
<ExportOutlined />
|
||||
</a>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => window.open(openUrl, '_blank', 'noopener')}
|
||||
>
|
||||
打开地址
|
||||
</Button>
|
||||
)}
|
||||
<a title="复制采买地址" onClick={copyPurchaseUrl}>
|
||||
<CopyOutlined />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
|
||||
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
|
||||
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
|
||||
@@ -34,6 +34,74 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
|
||||
const WATERMARK_STORAGE_KEY = 'trialWatermark';
|
||||
|
||||
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
|
||||
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
const [text, setText] = useState(String(value));
|
||||
useEffect(() => setText(String(value)), [value]);
|
||||
const clamp = (v: number) => Math.max(0, Math.min(5, Number.isNaN(v) ? 0 : v));
|
||||
const commit = () => onChange(clamp(parseInt(text, 10)));
|
||||
const btnBase: CSSProperties = {
|
||||
width: 26,
|
||||
height: 26,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: '#f5f5f5',
|
||||
cursor: 'pointer',
|
||||
fontSize: 15,
|
||||
lineHeight: '26px',
|
||||
color: '#555',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="减少"
|
||||
style={{ ...btnBase, color: value <= 0 ? '#ccc' : '#555', cursor: value <= 0 ? 'not-allowed' : 'pointer' }}
|
||||
disabled={value <= 0}
|
||||
onClick={() => onChange(clamp(value - 1))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value.replace(/[^\d]/g, ''))}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
title="可直接输入数量(0-5)"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 26,
|
||||
textAlign: 'center',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: value === 0 ? '#bbb' : '#333',
|
||||
background: '#fff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="增加"
|
||||
style={{ ...btnBase, color: value >= 5 ? '#ccc' : '#555', cursor: value >= 5 ? 'not-allowed' : 'pointer' }}
|
||||
disabled={value >= 5}
|
||||
onClick={() => onChange(clamp(value + 1))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
assets: ProductAsset[];
|
||||
@@ -109,7 +177,9 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
// 单张 AI 生图弹窗
|
||||
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video'), [assets]);
|
||||
// 采集图片卡只放采集/上传素材;单张 AI 生图结果(generated)放「生成结果」卡展示
|
||||
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video' && a.group_key !== 'generated'), [assets]);
|
||||
const generatedAssets = useMemo(() => assets.filter((a) => a.group_key === 'generated'), [assets]);
|
||||
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
|
||||
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
|
||||
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
|
||||
@@ -597,7 +667,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
规划并生成
|
||||
</Checkbox>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ThunderboltOutlined />}
|
||||
@@ -639,13 +708,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
</Space>
|
||||
</div>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={5}
|
||||
value={p.count}
|
||||
onChange={(v) => setPlanCount(idx, v ?? 0)}
|
||||
/>
|
||||
<PlanStepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -670,17 +733,9 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
恢复默认方案
|
||||
</a>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12, flex: 1 }} ellipsis={{ tooltip: planSummary }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, flex: 1, whiteSpace: 'normal', wordBreak: 'break-all' }}>
|
||||
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
|
||||
</Text>
|
||||
</div>
|
||||
{/* 水印设置:左列最下、靠右 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
|
||||
<Button size="small" icon={<SettingOutlined />}>
|
||||
水印
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -731,17 +786,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
|
||||
/>
|
||||
</div>
|
||||
<Row align="middle" style={{ marginTop: 12, gap: 12 }} wrap={false}>
|
||||
{generating && (
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Progress
|
||||
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
|
||||
size={['100%', 10]}
|
||||
status="active"
|
||||
format={() => `${doneCount}/${suiteTotal}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Row align="middle" style={{ marginTop: 12, gap: 12 }} justify="end" wrap={false}>
|
||||
<Select
|
||||
style={{ width: 260 }}
|
||||
popupMatchSelectWidth={false}
|
||||
@@ -766,6 +811,15 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
|
||||
</Button>
|
||||
</Row>
|
||||
{generating && (
|
||||
<Progress
|
||||
style={{ marginTop: 12 }}
|
||||
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
|
||||
size={['100%', 10]}
|
||||
status="active"
|
||||
format={() => `${doneCount}/${suiteTotal}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -777,16 +831,26 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
<Card
|
||||
title="6 生成结果"
|
||||
extra={
|
||||
suite && ['done', 'partial'].includes(suite.status) && (
|
||||
<Space>
|
||||
{suite && ['done', 'partial'].includes(suite.status) && (
|
||||
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
|
||||
导出 ZIP
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{/* 水印设置(作用于下次生成) */}
|
||||
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
|
||||
<Button size="small" icon={<SettingOutlined />}>
|
||||
水印
|
||||
</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{!suite ? (
|
||||
{!suite && generatedAssets.length === 0 ? (
|
||||
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
|
||||
) : (
|
||||
<>
|
||||
{suite && (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Space wrap size={8}>
|
||||
@@ -871,6 +935,47 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
|
||||
</>
|
||||
)}
|
||||
{/* 单张 AI 生图结果(采集图片区「AI 生图」生成,回写 generated 组) */}
|
||||
{generatedAssets.length > 0 && (
|
||||
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
|
||||
<Text strong>
|
||||
单张 AI 生图
|
||||
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
|
||||
(采集图片区「AI 生图」的结果 · {generatedAssets.length} 张)
|
||||
</Text>
|
||||
</Text>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
|
||||
{generatedAssets.map((a) => (
|
||||
<div key={a.id} style={{ width: 112 }}>
|
||||
<Image
|
||||
src={assetUrl(a)}
|
||||
width={100}
|
||||
height={133}
|
||||
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>"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'rgba(0,0,0,0.45)',
|
||||
marginTop: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={a.stored_url ?? undefined}
|
||||
>
|
||||
AI 生图 {a.created_at ? new Date(a.created_at).toLocaleTimeString() : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,5 @@ export const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
// 后续加账户体系时再启用 /login
|
||||
{ path: '/login', element: <Navigate to="/collection" replace /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import { envConfig } from '@/config/env';
|
||||
import { getToken } from './auth';
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
@@ -11,16 +10,6 @@ const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截:附带 JWT
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { api } from './api';
|
||||
|
||||
const TOKEN_KEY = 'ozon_kit_jwt';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export async function login(appToken: string): Promise<LoginResult> {
|
||||
const res = await api.post<LoginResult>('/auth/login', { token: appToken });
|
||||
setToken(res.access_token);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken();
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export interface ProductAsset {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
error: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export function listProducts(params?: { stage?: string; q?: string; page?: number; page_size?: number }) {
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface WatermarkPayload {
|
||||
export const DEFAULT_WATERMARK: WatermarkPayload = {
|
||||
enabled: false,
|
||||
type: 'text',
|
||||
text: 'xiongmaoyx',
|
||||
text: 'Panda Store',
|
||||
opacity: 30,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user