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:
R524809
2026-08-28 15:09:22 +08:00
parent 2835914fd8
commit dc6d38c128
44 changed files with 1556 additions and 389 deletions
+3 -2
View File
@@ -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/组合码导出 | 做试算页前后端时读 | | [`trial-page.md`](./trial-page.md) | 商品试算页:页面结构、计价公式与校验、文案、采购地址、入库数据结构、CSV/组合码导出 | 做试算页前后端时读 |
| [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 | | [`image-suite.md`](./image-suite.md) | 图片生成:套图规划→一键生成、模型路由、单张 AI 生图、水印、生成图回写与导出 | 做图片功能时读 |
| [`api.md`](./api.md) | 新增/复用的服务端 REST 契约(前端服务层已按此开发) | 前后端联调时读 | | [`api.md`](./api.md) | 前后端 REST 契约(前端服务层已按此开发) | 前后端联调时读 |
| [`backend-structure.md`](./backend-structure.md) | 后端代码结构盘点:文件/接口/服务树形图、数据流、状态标注(在用/冻结/残留) | 改后端前先读 |
--- ---
+158
View File
@@ -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 ✅核心】三大生图 providerdoubao 火山 / 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 种图类型 builderwhite_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 ✅】存储抽象:Localdata/media//media 托管)+ Qiniu(七牛,生产)
│ │ + local_pathstored_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_assetgenerated 回写 + asset_counts 累加)
├── legacy/ ────────────────────────── 🧊 冻结代码(Ozon API 直传链路,接口仍挂载但不再投入)
│ ├── api/ shops(店铺 CRUD+连通测试)/ categories(类目字典代理)/
│ │ publishImportProductsV3 直传)/ ozon(空占位)
│ ├── services/ ozon_clientSeller API 薄封装)/ publish(请求体组装+必填校验)
│ ├── models/ shop / publish_task / category(字典缓存三表)
│ └── schemas/ shop
├── schemas/ ────────────────────────── Pydantic 契约(请求/响应模型)
│ ├── suite.py 【160 ✅】SuitePlanRequest / SuiteGenerateRequest / SuiteOut /
│ │ ImageEditSingleRequestproduct_id + append → 回写)/ WatermarkOptions / 模型白名单
│ ├── collection.py 【✅】MaterialsRequestcamelCasesource.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_urldedupe_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-settingsdatabase_url / DEEPSEEK·DASHSCOPE·ARK·RIGHTAPI 密钥 /
│ │ image_provider / request_timeout(300s) / poll_max_wait(600s) / 存储与水印配置
│ └── models.yaml 俄文文案模型白名单(deepseek-v4-flash 默认 / deepseek-v4-pro
└── migrations/ Alembic2 个版本: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/materialscollection.py 落库 products + 排队下载图片 → storage 落盘)
→ 试算页 /trial/{id}studio
├─ 01/02/03 编辑 → PATCH /api/products/{id} 自动保存(raw/pricing/copy JSON
├─ 04 生图:POST /api/suite/planplanner+deepseek)→ POST /api/suite/generate
│ → tasks 内存任务 → generatorprompts 家族 → 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_KEYgpt/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>({ const [settings, setSettings] = useState<BackendSettings>({
baseUrl: 'http://127.0.0.1:3300', baseUrl: 'http://127.0.0.1:3300',
token: '', token: '',
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 }, watermark: { enabled: false, type: 'image', text: 'Panda Store', opacity: 30 },
reportEnabled: true, reportEnabled: true,
reportBaseUrl: 'http://127.0.0.1:8800', reportBaseUrl: 'http://127.0.0.1:8800',
studioBaseUrl: 'http://localhost:8900', studioBaseUrl: 'http://localhost:8900',
+7 -5
View File
@@ -23,27 +23,29 @@ export interface BackendSettings {
const KEY = 'suite_backend_settings'; 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_REPORT_BASE_URL = 'http://127.0.0.1:8800';
export const DEFAULT_STUDIO_BASE_URL = 'http://localhost:8900'; export const DEFAULT_STUDIO_BASE_URL = 'http://localhost:8900';
const DEFAULT: BackendSettings = { const DEFAULT: BackendSettings = {
baseUrl: DEFAULT_BASE_URL, baseUrl: DEFAULT_BASE_URL,
token: '', token: '',
watermark: { enabled: false, type: 'image', text: 'xiongmaoyx', opacity: 30 }, watermark: { enabled: false, type: 'image', text: 'Panda Store', opacity: 30 },
reportEnabled: true, reportEnabled: true,
reportBaseUrl: DEFAULT_REPORT_BASE_URL, reportBaseUrl: DEFAULT_REPORT_BASE_URL,
studioBaseUrl: DEFAULT_STUDIO_BASE_URL, studioBaseUrl: DEFAULT_STUDIO_BASE_URL,
}; };
/** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置) */ /** 历史默认地址 → 当前默认地址(换端口后自动迁移用户已保存的设置)
* v2.1 并入 OSK 后生图服务端与上报同源(8800),ISS 的 3300 存档自动迁移。 */
const MIGRATE: Record<string, string> = { 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:8810': DEFAULT_BASE_URL,
'http://127.0.0.1:7000': 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://localhost:7000': DEFAULT_BASE_URL,
'http://127.0.0.1:7200': DEFAULT_BASE_URL,
'http://localhost:7200': DEFAULT_BASE_URL, 'http://localhost:7200': DEFAULT_BASE_URL,
'http://localhost:3300': DEFAULT_BASE_URL,
}; };
export async function loadSettings(): Promise<BackendSettings> { export async function loadSettings(): Promise<BackendSettings> {
-23
View File
@@ -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
View File
@@ -1,14 +1,13 @@
"""汇率路由。""" """汇率路由。"""
from __future__ import annotations 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 from services.fx import get_fx_rate
router = APIRouter(prefix="/api/fx", tags=["fx"]) router = APIRouter(prefix="/api/fx", tags=["fx"])
@router.get("") @router.get("")
async def fx(_user: dict = Depends(get_current_user)): async def fx():
return await get_fx_rate() return await get_fx_rate()
+267
View File
@@ -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
View File
@@ -8,7 +8,6 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db import get_db from db import get_db
from deps import get_current_user
from models import Product, ProductAsset from models import Product, ProductAsset
from models.enums import Stage from models.enums import Stage
from schemas.product import ProductDetail, ProductListItem, ProductUpdate from schemas.product import ProductDetail, ProductListItem, ProductUpdate
@@ -22,8 +21,7 @@ async def list_products(
q: str | None = None, q: str | None = None,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
_user: dict = Depends(get_current_user),
): ):
stmt = select(Product) stmt = select(Product)
if stage: if stage:
@@ -42,8 +40,7 @@ async def list_products(
@router.get("/{product_id}", response_model=ProductDetail) @router.get("/{product_id}", response_model=ProductDetail)
async def get_product( async def get_product(
product_id: str, product_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
_user: dict = Depends(get_current_user),
): ):
product = await db.get(Product, UUID(product_id)) product = await db.get(Product, UUID(product_id))
if product is None: if product is None:
@@ -54,8 +51,7 @@ async def get_product(
@router.post("", response_model=ProductDetail) @router.post("", response_model=ProductDetail)
async def create_product( async def create_product(
body: ProductUpdate, body: ProductUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
_user: dict = Depends(get_current_user),
): ):
product = Product(stage=Stage.collected) product = Product(stage=Stage.collected)
_apply_update(product, body) _apply_update(product, body)
@@ -68,8 +64,7 @@ async def create_product(
@router.post("/{product_id}/copy", response_model=ProductDetail) @router.post("/{product_id}/copy", response_model=ProductDetail)
async def copy_product( async def copy_product(
product_id: str, product_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
_user: dict = Depends(get_current_user),
): ):
"""复制商品为新变体:继承标题/描述/属性/型号名称/计价,重置货号与图片。""" """复制商品为新变体:继承标题/描述/属性/型号名称/计价,重置货号与图片。"""
src = await db.get(Product, UUID(product_id)) src = await db.get(Product, UUID(product_id))
@@ -118,8 +113,7 @@ async def copy_product(
async def update_product( async def update_product(
product_id: str, product_id: str,
body: ProductUpdate, body: ProductUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
_user: dict = Depends(get_current_user),
): ):
product = await db.get(Product, UUID(product_id)) product = await db.get(Product, UUID(product_id))
if product is None: if product is None:
@@ -134,8 +128,7 @@ async def update_product(
async def delete_product( async def delete_product(
product_id: str, product_id: str,
hard: bool = False, hard: bool = False,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
_user: dict = Depends(get_current_user),
): ):
product = await db.get(Product, UUID(product_id)) product = await db.get(Product, UUID(product_id))
if product is None: if product is None:
@@ -151,8 +144,7 @@ async def delete_product(
@router.get("/{product_id}/assets") @router.get("/{product_id}/assets")
async def list_assets( async def list_assets(
product_id: str, product_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
_user: dict = Depends(get_current_user),
): ):
rows = (await db.scalars( rows = (await db.scalars(
select(ProductAsset) select(ProductAsset)
+14 -82
View File
@@ -3,26 +3,23 @@
按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studioservices/planner|generator| 按 docs/v2.1/api.md §2-6 实现:引擎平移自 image-suite-studioservices/planner|generator|
tasks|watermark + services/prompts),任务存进程内内存表(重启即失效)。 tasks|watermark + services/prompts),任务存进程内内存表(重启即失效)。
生成图回调回写 product_assets(group_key='generated')product_id 缺省时只落盘不回写。 生成图回调回写 product_assets(group_key='generated')product_id 缺省时只落盘不回写。
业务逻辑(texts_to_raw / 模型校验 / generated 回写)在 services/suite_service.py。
注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。 注:V2.1 阶段本组接口暂不接鉴权(现有鉴权后续可能重做)。
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import mimetypes
import uuid import uuid
import zipfile import zipfile
from functools import partial
from fastapi import APIRouter, BackgroundTasks, HTTPException from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from config import get_settings from config import get_settings
from db import get_session_factory
from models import Product, ProductAsset
from schemas.suite import ( from schemas.suite import (
PLATFORM_SPECS, PLATFORM_SPECS,
RIGHTAPI_MODELS,
SUPPORTED_TYPES, SUPPORTED_TYPES,
TONGYI_MODELS,
ImageEditSingleRequest, ImageEditSingleRequest,
ImageEditSingleResponse, ImageEditSingleResponse,
PlanItemOut, PlanItemOut,
@@ -31,88 +28,18 @@ from schemas.suite import (
SuiteGenerateRequest, SuiteGenerateRequest,
SuiteOut, SuiteOut,
SuitePlanRequest, SuitePlanRequest,
TextMaterial,
resolve_provider, resolve_provider,
) )
from services.generator import _image_size, GENERATORS, run_suite from services.generator import _image_size, GENERATORS, run_suite
from services.planner import generate_plan from services.planner import generate_plan
from services.prompts import build_context, build_prompt, type_name from services.prompts import 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.tasks import IMG_OK, Task, TaskImage, create_task, get_task from services.suite_service import append_generated_asset, texts_to_raw, validate_model
from api.proxy import guess_referer from services.tasks import IMG_OK, create_task, get_task
router = APIRouter(prefix="/api", tags=["suite"]) 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) @router.post("/suite/plan", response_model=PlanResponse)
@@ -173,7 +100,7 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
settings = get_settings() settings = get_settings()
# 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi # 前端只传模型名:已知模型直接路由到对应 provider(如 gpt-image-2-vip → rightapi
provider_name = resolve_provider(req.model, None, settings.image_provider) 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 product_id = (req.product_id or "").strip() or None
if product_id: if product_id:
@@ -207,8 +134,13 @@ async def generate_suite(req: SuiteGenerateRequest, background: BackgroundTasks)
], ],
) )
# 每张成功即回写 generated 组;未关联商品时仅落存储不回写 # 每张成功即回写 generated 组;未关联商品时仅落存储不回写
background.add_task(run_suite, task, _append_generated_asset if product_id else None) # partial 绑定 product_idrun_suite 回调只传 image,签名须为 (image)
background.add_task(
run_suite,
task,
partial(append_generated_asset, product_id) if product_id else None,
)
return SuiteCreateResponse(suite_id=task.id) return SuiteCreateResponse(suite_id=task.id)
@@ -279,7 +211,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
provider_name = resolve_provider(req.model, None, settings.image_provider) provider_name = resolve_provider(req.model, None, settings.image_provider)
if provider_name not in GENERATORS: if provider_name not in GENERATORS:
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}") 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) spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4)
model = req.model model = req.model
@@ -311,7 +243,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
asset_id: str | None = None asset_id: str | None = None
if req.append and req.product_id: if req.append and req.product_id:
try: try:
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"),
) )
+3 -4
View File
@@ -34,10 +34,9 @@ class Settings(BaseSettings):
# 本地过渡用 SQLite;上线切 PostgreSQLpostgresql+asyncpg://user:pass@host:5432/ozon_seller # 本地过渡用 SQLite;上线切 PostgreSQLpostgresql+asyncpg://user:pass@host:5432/ozon_seller
database_url: str = "sqlite+aiosqlite:///./data/app.db" database_url: str = "sqlite+aiosqlite:///./data/app.db"
# ── V2:鉴权 ── # 店铺凭证 AES-GCM 加密密钥(shops/categories/publish 冻结链路使用;
app_token: str = "" # MVP 单用户登录 token(换发 JWT 用 # 鉴权/账户体系已按 V2.1 决策移除,后续引入账户时一并重做
secret_key: str = "" # 店铺凭证 AES-GCM 加密密钥 + JWT 签名密钥 secret_key: str = ""
jwt_expire_minutes: int = 60 * 24 * 7 # JWT 有效期(默认 7 天)
# ── V2:七牛(图片存储)── # ── V2:七牛(图片存储)──
qiniu_access_key: str = "" qiniu_access_key: str = ""
+4 -23
View File
@@ -1,35 +1,16 @@
"""JWT 鉴权 + 店铺凭证 AES-GCM 加解密""" """店铺凭证 AES-GCM 加解密shops/categories/publish 冻结链路使用)。
鉴权(JWT/APP_TOKEN)已按 V2.1 决策移除,后续加账户体系时再引入。
"""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import os import os
from datetime import datetime, timedelta, timezone
import jwt
from config import get_settings 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: def _derive_key() -> bytes:
settings = get_settings() settings = get_settings()
return hashlib.sha256(settings.secret_key.encode("utf-8")).digest() return hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
-26
View File
@@ -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}
View File
View File
+118
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
from fastapi import APIRouter
router = APIRouter(prefix="/api/ozon", tags=["ozon"])
# Phase 3: Ozon Seller API product upload
+181
View File
@@ -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
]
+119
View File
@@ -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}
+12
View File
@@ -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",
]
+61
View File
@@ -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())
+34
View File
@@ -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)
+31
View File
@@ -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()
)
View File
+34
View File
@@ -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
View File
+52
View File
@@ -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", {})
+80
View File
@@ -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
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text 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 config import get_settings
from db import get_engine from db import get_engine
@@ -26,22 +26,25 @@ if settings.cors_origin_list:
allow_headers=["*"], allow_headers=["*"],
) )
# 业务路由 # 业务路由(活跃:采集上报 / 商品库 / 套图生图 / 辅助)
app.include_router(auth.router) app.include_router(materials.router)
app.include_router(collection.router)
app.include_router(products.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(fx.router)
app.include_router(ai.router) app.include_router(ai.router)
app.include_router(image.router) app.include_router(image.router)
app.include_router(ozon.router)
# V2.1 套图生图(试算页 04 区块)/ 导出 / 图片代理 # V2.1 套图生图(试算页 04 区块)/ 导出 / 图片代理
app.include_router(suite.router) app.include_router(suite.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(proxy.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") @app.on_event("startup")
async def on_startup() -> None: async def on_startup() -> None:
+5 -9
View File
@@ -1,18 +1,14 @@
"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。""" """模型统一导出(供 Alembic autogenerate 与业务代码 import)。
冻结链路的模型已移至 legacy/modelsshops/categories/publish 相关),
由 legacy.api 路由 import 链注册到同一 Base.metadata。
"""
from models.asset import ProductAsset from models.asset import ProductAsset
from models.category import AttributeValue, CategoryAttribute, CategoryTree
from models.product import Product from models.product import Product
from models.publish_task import PublishTask
from models.shop import Shop
from models.user import User from models.user import User
__all__ = [ __all__ = [
"User", "User",
"Shop",
"Product", "Product",
"ProductAsset", "ProductAsset",
"PublishTask",
"CategoryTree",
"CategoryAttribute",
"AttributeValue",
] ]
+1 -1
View File
@@ -1,4 +1,4 @@
"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。""" """用户表(预留多用户;V2.1 已移除鉴权,后续加账户体系时启用)。"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
-14
View File
@@ -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
+2 -2
View File
@@ -57,11 +57,11 @@ class TextMaterial(BaseModel):
class WatermarkOptions(BaseModel): class WatermarkOptions(BaseModel):
"""生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。右下角,默认文字 xiongmaoyx""" """生成图水印:AI 出图后由服务端后处理合成(与生图模型无关)。右下角,默认文字 Panda Store"""
enabled: bool = Field(default=False, description="是否开启水印") enabled: bool = Field(default=False, description="是否开启水印")
type: Literal["image", "text"] = Field(default="image", 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="不透明度(%") opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%")
+86
View File
@@ -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)
-16
View File
@@ -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}</>;
}
+4 -3
View File
@@ -31,7 +31,8 @@ const MainLayout = () => {
{!isMobile && ( {!isMobile && (
<Sider <Sider
className="main-sider" className="main-sider"
width={240} width={200}
collapsedWidth={60}
collapsed={collapsed} collapsed={collapsed}
theme="dark" theme="dark"
style={{ style={{
@@ -58,7 +59,7 @@ const MainLayout = () => {
onClose={() => setMobileMenuOpen(false)} onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen} open={mobileMenuOpen}
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }} styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
width={240} width={200}
> >
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} /> <SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
</Drawer> </Drawer>
@@ -67,7 +68,7 @@ const MainLayout = () => {
<Layout <Layout
className="main-content-layout" className="main-content-layout"
style={{ style={{
marginLeft: isMobile ? 0 : collapsed ? 80 : 240, marginLeft: isMobile ? 0 : collapsed ? 60 : 200,
transition: 'margin-left 0.2s', transition: 'margin-left 0.2s',
}} }}
> >
+52
View File
@@ -80,3 +80,55 @@
padding: 14px 20px !important; 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;
}
-46
View File
@@ -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>
);
}
+59 -26
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; 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 { CopyOutlined, ExportOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product'; import { ProductDetail } from '@/services/product';
import { copyText } from '@/utils/file'; 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 [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
const [nameRu, setNameRu] = useState(product.name ?? ''); const [nameRu, setNameRu] = useState(product.name ?? '');
const [modelCode, setModelCode] = useState((raw.model_code as string) ?? ''); 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 初始化 // 采买地址:仅 1688/拼多多/淘宝/天猫 来源时用 source_url 初始化
const [purchaseUrl, setPurchaseUrl] = useState( const [purchaseUrl, setPurchaseUrl] = useState(
(raw.purchase_url as string) ?? (isPurchasePlatform(product.source_platform) ? (product.source_url ?? '') : ''), (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 }>) : []; const params = Array.isArray(raw.params) ? (raw.params as Array<{ key: string; value: string }>) : [];
/** 型号变化:货号前缀始终同步为新型号(对齐 v1 web:货号 = 型号-后缀)。 /** 型号/后缀变化后同步完整货号落库 */
* 货号为空 → 带入「型号-」;货号非空 → 替换第一个「-」前的前缀、保留后缀; const saveSku = (model: string, suffix: string) =>
* 型号清空时货号保持不动(避免误删已填后缀)。 */ onSave({ offer_id: [model.trim(), suffix.trim()].filter(Boolean).join('-') });
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 openUrl = purchaseUrl?.trim() const openUrl = purchaseUrl?.trim()
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}` ? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
@@ -85,17 +83,46 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
<Input <Input
value={modelCode} value={modelCode}
placeholder="如 YZ" placeholder="如 YZ"
onChange={(e) => onModelChange(e.target.value)} onChange={(e) => {
onBlur={() => patchRaw({ model_code: modelCode.trim() })} 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>
<Col span={12}> <Col span={12}>
<FieldLabel>SKU</FieldLabel> <FieldLabel>SKU{modelCode.trim() && <Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}> -</Text>}</FieldLabel>
<Input <Input
value={offerId} value={skuSuffix}
placeholder="型号-后缀(型号自动带入前缀)" placeholder="后缀,如 001"
onChange={(e) => setOfferId(e.target.value)} addonBefore={modelCode.trim() ? `${modelCode.trim()}-` : undefined}
onBlur={() => onSave({ offer_id: offerId.trim() })} 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> </Col>
</Row> </Row>
@@ -108,11 +135,17 @@ export default function TrialInfoPanel({ product, onSave }: Props) {
onChange={(e) => setPurchaseUrl(e.target.value)} onChange={(e) => setPurchaseUrl(e.target.value)}
onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })} onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })}
addonAfter={ addonAfter={
<span style={{ display: 'inline-flex', gap: 10 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{openUrl && ( {openUrl && (
<a href={openUrl} target="_blank" rel="noreferrer" title="打开采买地址"> <Button
<ExportOutlined /> size="small"
</a> type="primary"
ghost
icon={<ExportOutlined />}
onClick={() => window.open(openUrl, '_blank', 'noopener')}
>
</Button>
)} )}
<a title="复制采买地址" onClick={copyPurchaseUrl}> <a title="复制采买地址" onClick={copyPurchaseUrl}>
<CopyOutlined /> <CopyOutlined />
+141 -36
View File
@@ -3,7 +3,7 @@
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。 * 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。 * 交互对齐 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 { 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, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload, 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 DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
const WATERMARK_STORAGE_KEY = 'trialWatermark'; 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 { interface Props {
product: ProductDetail; product: ProductDetail;
assets: ProductAsset[]; assets: ProductAsset[];
@@ -109,7 +177,9 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// 单张 AI 生图弹窗 // 单张 AI 生图弹窗
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null); 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 groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url; const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
const rawObj = (product.raw ?? {}) as Record<string, unknown>; const rawObj = (product.raw ?? {}) as Record<string, unknown>;
@@ -597,7 +667,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Checkbox> </Checkbox>
<Button <Button
size="small"
type="primary" type="primary"
ghost ghost
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined />}
@@ -639,13 +708,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Space> </Space>
</div> </div>
<span onClick={(e) => e.stopPropagation()}> <span onClick={(e) => e.stopPropagation()}>
<InputNumber <PlanStepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
size="small"
min={0}
max={5}
value={p.count}
onChange={(v) => setPlanCount(idx, v ?? 0)}
/>
</span> </span>
</div> </div>
))} ))}
@@ -670,18 +733,10 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</a> </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 即不生成'} {planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
</Text> </Text>
</div> </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> </Card>
</Col> </Col>
<Col span={12}> <Col span={12}>
@@ -731,17 +786,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印" placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
/> />
</div> </div>
<Row align="middle" style={{ marginTop: 12, gap: 12 }} wrap={false}> <Row align="middle" style={{ marginTop: 12, gap: 12 }} justify="end" 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>
)}
<Select <Select
style={{ width: 260 }} style={{ width: 260 }}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
@@ -766,6 +811,15 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`} {generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</Button> </Button>
</Row> </Row>
{generating && (
<Progress
style={{ marginTop: 12 }}
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
size={['100%', 10]}
status="active"
format={() => `${doneCount}/${suiteTotal}`}
/>
)}
</div> </div>
</Col> </Col>
</Row> </Row>
@@ -777,17 +831,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
<Card <Card
title="6 生成结果" title="6 生成结果"
extra={ extra={
suite && ['done', 'partial'].includes(suite.status) && ( <Space>
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}> {suite && ['done', 'partial'].includes(suite.status) && (
ZIP <Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
</Button> 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 图片)" /> <Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
) : ( ) : (
<> <>
{suite && (
<>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Space wrap size={8}> <Space wrap size={8}>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
@@ -869,6 +933,47 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</div> </div>
</Image.PreviewGroup> </Image.PreviewGroup>
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />} {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> </Card>
-1
View File
@@ -20,6 +20,5 @@ export const router = createBrowserRouter([
], ],
}, },
// 后续加账户体系时再启用 /login // 后续加账户体系时再启用 /login
{ path: '/login', element: <Navigate to="/collection" replace /> },
{ path: '*', element: <Navigate to="/" replace /> }, { path: '*', element: <Navigate to="/" replace /> },
]); ]);
-11
View File
@@ -1,7 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import { envConfig } from '@/config/env'; import { envConfig } from '@/config/env';
import { getToken } from './auth';
const apiClient: AxiosInstance = axios.create({ const apiClient: AxiosInstance = axios.create({
baseURL: envConfig.apiBaseUrl, 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( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
-31
View File
@@ -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();
}
+1
View File
@@ -67,6 +67,7 @@ export interface ProductAsset {
width: number | null; width: number | null;
height: number | null; height: number | null;
error: string | null; error: string | null;
created_at?: string;
} }
export function listProducts(params?: { stage?: string; q?: string; page?: number; page_size?: number }) { export function listProducts(params?: { stage?: string; q?: string; page?: number; page_size?: number }) {
+1 -1
View File
@@ -80,7 +80,7 @@ export interface WatermarkPayload {
export const DEFAULT_WATERMARK: WatermarkPayload = { export const DEFAULT_WATERMARK: WatermarkPayload = {
enabled: false, enabled: false,
type: 'text', type: 'text',
text: 'xiongmaoyx', text: 'Panda Store',
opacity: 30, opacity: 30,
}; };