diff --git a/docs/v2.1/README.md b/docs/v2.1/README.md index dcbdf5f..2bf3eba 100644 --- a/docs/v2.1/README.md +++ b/docs/v2.1/README.md @@ -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) | 后端代码结构盘点:文件/接口/服务树形图、数据流、状态标注(在用/冻结/残留) | 改后端前先读 | --- diff --git a/docs/v2.1/backend-structure.md b/docs/v2.1/backend-structure.md new file mode 100644 index 0000000..a3170b3 --- /dev/null +++ b/docs/v2.1/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` 逐字段对齐(联调已验证) | diff --git a/extensions/collector/entrypoints/sidepanel/App.tsx b/extensions/collector/entrypoints/sidepanel/App.tsx index 20dbdbf..50fec5c 100644 --- a/extensions/collector/entrypoints/sidepanel/App.tsx +++ b/extensions/collector/entrypoints/sidepanel/App.tsx @@ -141,7 +141,7 @@ const App: React.FC = () => { const [settings, setSettings] = useState({ 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', diff --git a/extensions/collector/src/storage/settings.ts b/extensions/collector/src/storage/settings.ts index 636a8a6..bd928ec 100644 --- a/extensions/collector/src/storage/settings.ts +++ b/extensions/collector/src/storage/settings.ts @@ -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 = { + '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 { diff --git a/server/api/auth.py b/server/api/auth.py deleted file mode 100644 index ffeeabb..0000000 --- a/server/api/auth.py +++ /dev/null @@ -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) diff --git a/server/api/fx.py b/server/api/fx.py index a896526..355ce74 100644 --- a/server/api/fx.py +++ b/server/api/fx.py @@ -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() diff --git a/server/api/materials.py b/server/api/materials.py new file mode 100644 index 0000000..d7ec10b --- /dev/null +++ b/server/api/materials.py @@ -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)} diff --git a/server/api/products.py b/server/api/products.py index 9b18b00..c30e5d8 100644 --- a/server/api/products.py +++ b/server/api/products.py @@ -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) diff --git a/server/api/suite.py b/server/api/suite.py index c1322e7..618d8c0 100644 --- a/server/api/suite.py +++ b/server/api/suite.py @@ -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"), ) diff --git a/server/config/settings.py b/server/config/settings.py index b60ffff..ced101c 100644 --- a/server/config/settings.py +++ b/server/config/settings.py @@ -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 = "" diff --git a/server/core/security.py b/server/core/security.py index d2fa45d..36a9087 100644 --- a/server/core/security.py +++ b/server/core/security.py @@ -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() diff --git a/server/deps.py b/server/deps.py deleted file mode 100644 index 9bb23ed..0000000 --- a/server/deps.py +++ /dev/null @@ -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} diff --git a/server/legacy/__init__.py b/server/legacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/legacy/api/__init__.py b/server/legacy/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/legacy/api/categories.py b/server/legacy/api/categories.py new file mode 100644 index 0000000..8e029ee --- /dev/null +++ b/server/legacy/api/categories.py @@ -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) diff --git a/server/legacy/api/ozon.py b/server/legacy/api/ozon.py new file mode 100644 index 0000000..9600644 --- /dev/null +++ b/server/legacy/api/ozon.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/api/ozon", tags=["ozon"]) + +# Phase 3: Ozon Seller API product upload diff --git a/server/legacy/api/publish.py b/server/legacy/api/publish.py new file mode 100644 index 0000000..a9a1bc2 --- /dev/null +++ b/server/legacy/api/publish.py @@ -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 + ] diff --git a/server/legacy/api/shops.py b/server/legacy/api/shops.py new file mode 100644 index 0000000..53d21fd --- /dev/null +++ b/server/legacy/api/shops.py @@ -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} diff --git a/server/legacy/models/__init__.py b/server/legacy/models/__init__.py new file mode 100644 index 0000000..debdc13 --- /dev/null +++ b/server/legacy/models/__init__.py @@ -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", +] diff --git a/server/legacy/models/category.py b/server/legacy/models/category.py new file mode 100644 index 0000000..b182c88 --- /dev/null +++ b/server/legacy/models/category.py @@ -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()) diff --git a/server/legacy/models/publish_task.py b/server/legacy/models/publish_task.py new file mode 100644 index 0000000..a0475bf --- /dev/null +++ b/server/legacy/models/publish_task.py @@ -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) diff --git a/server/legacy/models/shop.py b/server/legacy/models/shop.py new file mode 100644 index 0000000..2eb6041 --- /dev/null +++ b/server/legacy/models/shop.py @@ -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() + ) diff --git a/server/legacy/schemas/__init__.py b/server/legacy/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/legacy/schemas/shop.py b/server/legacy/schemas/shop.py new file mode 100644 index 0000000..edb8de3 --- /dev/null +++ b/server/legacy/schemas/shop.py @@ -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 diff --git a/server/legacy/services/__init__.py b/server/legacy/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/legacy/services/ozon_client.py b/server/legacy/services/ozon_client.py new file mode 100644 index 0000000..3c56967 --- /dev/null +++ b/server/legacy/services/ozon_client.py @@ -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", {}) diff --git a/server/legacy/services/publish.py b/server/legacy/services/publish.py new file mode 100644 index 0000000..6407365 --- /dev/null +++ b/server/legacy/services/publish.py @@ -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 diff --git a/server/main.py b/server/main.py index d24569e..616cb64 100644 --- a/server/main.py +++ b/server/main.py @@ -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: diff --git a/server/models/__init__.py b/server/models/__init__.py index 7167329..2bdb75f 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -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", ] diff --git a/server/models/user.py b/server/models/user.py index 2c5ad37..5d608a2 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -1,4 +1,4 @@ -"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。""" +"""用户表(预留多用户;V2.1 已移除鉴权,后续加账户体系时启用)。""" from __future__ import annotations import uuid diff --git a/server/schemas/auth.py b/server/schemas/auth.py deleted file mode 100644 index 9aa0d72..0000000 --- a/server/schemas/auth.py +++ /dev/null @@ -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 diff --git a/server/schemas/suite.py b/server/schemas/suite.py index 0f9a7db..d075bbf 100644 --- a/server/schemas/suite.py +++ b/server/schemas/suite.py @@ -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="不透明度(%)") diff --git a/server/services/suite_service.py b/server/services/suite_service.py new file mode 100644 index 0000000..3f745bc --- /dev/null +++ b/server/services/suite_service.py @@ -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) diff --git a/studio/src/components/RequireAuth.tsx b/studio/src/components/RequireAuth.tsx deleted file mode 100644 index dcd9e51..0000000 --- a/studio/src/components/RequireAuth.tsx +++ /dev/null @@ -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}; -} diff --git a/studio/src/layouts/MainLayout.tsx b/studio/src/layouts/MainLayout.tsx index e3e9cbb..e6c8ffe 100644 --- a/studio/src/layouts/MainLayout.tsx +++ b/studio/src/layouts/MainLayout.tsx @@ -31,7 +31,8 @@ const MainLayout = () => { {!isMobile && ( { onClose={() => setMobileMenuOpen(false)} open={mobileMenuOpen} styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }} - width={240} + width={200} > setMobileMenuOpen(false)} /> @@ -67,7 +68,7 @@ const MainLayout = () => { diff --git a/studio/src/layouts/SidebarMenu.css b/studio/src/layouts/SidebarMenu.css index dee6f15..2e1efee 100644 --- a/studio/src/layouts/SidebarMenu.css +++ b/studio/src/layouts/SidebarMenu.css @@ -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; +} diff --git a/studio/src/pages/login/LoginPage.tsx b/studio/src/pages/login/LoginPage.tsx deleted file mode 100644 index 9e558b4..0000000 --- a/studio/src/pages/login/LoginPage.tsx +++ /dev/null @@ -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 ( -
- - Ozon 发布工作台 - 请输入访问 Token(对应服务端 .env 的 APP_TOKEN) - setToken(e.target.value)} - onPressEnter={onSubmit} - style={{ marginTop: 16 }} - /> - - -
- ); -} diff --git a/studio/src/pages/trial/TrialInfoPanel.tsx b/studio/src/pages/trial/TrialInfoPanel.tsx index e78d59f..a5d59ca 100644 --- a/studio/src/pages/trial/TrialInfoPanel.tsx +++ b/studio/src/pages/trial/TrialInfoPanel.tsx @@ -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) { onModelChange(e.target.value)} - onBlur={() => patchRaw({ model_code: modelCode.trim() })} + onChange={(e) => { + setModelCode(e.target.value); + saveSku(e.target.value, skuSuffix); + }} + addonAfter={ + { + if (!modelCode.trim()) return; + copyText(modelCode.trim()).then((ok) => ok && message.success('已复制型号')); + }} + > + + + } /> - 货号(SKU) + 货号(SKU){modelCode.trim() && = 型号-后缀} 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={ + { + if (!fullSku) return; + copyText(fullSku).then((ok) => ok && message.success(`已复制货号:${fullSku}`)); + }} + > + + + } /> @@ -108,11 +135,17 @@ export default function TrialInfoPanel({ product, onSave }: Props) { onChange={(e) => setPurchaseUrl(e.target.value)} onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })} addonAfter={ - + {openUrl && ( - - - + )} diff --git a/studio/src/pages/trial/TrialSuitePanel.tsx b/studio/src/pages/trial/TrialSuitePanel.tsx index 745d4e0..bf4e540 100644 --- a/studio/src/pages/trial/TrialSuitePanel.tsx +++ b/studio/src/pages/trial/TrialSuitePanel.tsx @@ -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 = { 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 ( + + + 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', + }} + /> + + + ); +} + 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; @@ -597,7 +667,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr 规划并生成 - - @@ -731,17 +786,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印" /> - - {generating && ( -
- `${doneCount}/${suiteTotal}`} - /> -
- )} +