refactor(server): 移除已放弃的 Ozon API 直传模块,新增素材删除接口

- 删除 shops/publish/categories/ozon 相关路由、模型、schema 与客户端服务(V2.1 决策放弃 API 直传,改人工上传)
- materials 新增 DELETE /assets/{id}:删除素材记录与本地文件,并同步修正 asset_counts
- 试算页支持单张素材删除,生图弹窗交互微调
- 新增 docs/v2.1/HANDOFF.md 工作交接说明,README 补充索引
This commit is contained in:
R524809
2026-08-28 17:47:57 +08:00
parent dc6d38c128
commit a77ec26a02
20 changed files with 248 additions and 1010 deletions
-121
View File
@@ -1,121 +0,0 @@
"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。"""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret
from db import get_db
from deps import get_current_user
from models import Shop
from services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/categories", tags=["categories"])
class ShopRef(BaseModel):
shop_id: str
lang: str = "ZH_HANS" # 中文类目
async def _client(shop_id: str, db: AsyncSession) -> OzonClient:
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
def _unwrap(result: dict) -> dict:
return result.get("result", result)
@router.post("/tree")
async def category_tree(
body: ShopRef,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
result = await client.post("/v1/description-category/tree", {"language": body.lang})
return _unwrap(result)
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
class AttributeQuery(BaseModel):
shop_id: str
type_id: int
lang: str = "ZH_HANS"
@router.post("/{category_id}/attributes")
async def category_attributes(
category_id: int,
body: AttributeQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
result = await client.post(
"/v1/description-category/attribute",
{
"description_category_id": category_id,
"type_id": body.type_id,
"language": body.lang,
},
)
return _unwrap(result)
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
class ValueQuery(BaseModel):
shop_id: str
category_id: int
type_id: int
q: str | None = None
limit: int = 100
last_value_id: int | None = None
lang: str = "ZH_HANS"
@router.post("/attribute/{attribute_id}/values")
async def attribute_values(
attribute_id: int,
body: ValueQuery,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
client = await _client(body.shop_id, db)
try:
if body.q and len(body.q) >= 2:
result = await client.post(
"/v1/description-category/attribute/values/search",
{
"attribute_id": attribute_id,
"description_category_id": body.category_id,
"type_id": body.type_id,
"limit": body.limit,
"value": body.q,
},
)
else:
result = await client.post(
"/v1/description-category/attribute/values",
{
"attribute_id": attribute_id,
"description_category_id": body.category_id,
"type_id": body.type_id,
"limit": body.limit,
"last_value_id": body.last_value_id or 0,
"language": body.lang,
},
)
return result # values 返回 {result, has_next}
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
-272
View File
@@ -1,272 +0,0 @@
"""采集入库:插件上传文本 + 图片 URL,落库后异步转存。"""
from __future__ import annotations
import re
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db import get_db, get_session_factory
from deps import get_current_user
from models import Product, ProductAsset
from models.enums import AssetStatus, Stage
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
router = APIRouter(prefix="/api", tags=["collection"])
def _parse_number(text: str | None) -> float | None:
"""'1 290 ₽' / '3.5 кг' / '48*18*25' → 1290.0 / 3.5 / 48"""
if not text:
return None
m = re.search(r"(\d+(?:[.,]\d+)?)", text.replace(" ", "").replace(",", "."))
return float(m.group(1)) if m else None
def _find_param(pairs: list[dict] | None, keys: list[str]) -> str | None:
for p in pairs or []:
k = (p.get("key") or "").lower()
if any(kw in k for kw in keys):
return p.get("value")
return None
def _apply_texts(product: Product, texts: list[TextMaterial]) -> None:
raw = dict(product.raw or {})
raw_texts: list[dict] = list(raw.get("texts") or [])
for t in texts:
raw_texts.append({"kind": t.kind, "content": t.content, "pairs": t.pairs})
if t.kind == "title" and t.content and not product.name:
product.name = t.content
raw["title"] = t.content
elif t.kind == "price":
raw["price"] = t.content
num = _parse_number(t.content)
if num is not None and (product.price is None or product.price == 0):
product.price = num
elif t.kind == "params":
raw["params"] = t.pairs
_apply_weight_dims(product, t.pairs)
elif t.kind == "selling_point":
raw["sellingPoints"] = t.content
elif t.kind == "desc":
raw["desc"] = t.content
if not product.description:
product.description = t.content
elif t.kind == "brand":
raw["brand"] = t.content
raw["texts"] = raw_texts
product.raw = raw
def _apply_weight_dims(product: Product, pairs: list[dict] | None) -> None:
"""从参数表里解析「包装重量 / 包装尺寸(长宽高)」,统一换算成克 / 毫米回填。"""
weight = _find_param(pairs, ["包装重量", "重量", "вес"])
if weight is not None:
num = _parse_number(weight)
if num is not None:
is_kg = any(u in weight.lower() for u in ("кг", "kg"))
product.weight = num * 1000 if is_kg else num # 统一为克
product.weight_unit = "g"
l = _find_param(pairs, ["包装长度", "长度", "длина"])
w = _find_param(pairs, ["包装宽度", "宽度", "ширина"])
h = _find_param(pairs, ["包装高度", "高度", "высота"])
if l or w or h:
combined = (l or "") + (w or "") + (h or "")
factor = 1 if any(u in combined.lower() for u in ("мм", "mm")) else 10 # 厘米→毫米
product.depth = (_parse_number(l) or 0) * factor if l else None
product.width = (_parse_number(w) or 0) * factor if w else None
product.height = (_parse_number(h) or 0) * factor if h else None
product.dimension_unit = "mm"
else:
dim = _find_param(pairs, ["包装尺寸", "размер", "габарит", "尺寸"])
if dim is not None:
nums = re.findall(r"\d+(?:[.,]\d+)?", dim.replace(",", "."))
if len(nums) >= 3:
factor = 1 if any(u in dim.lower() for u in ("мм", "mm")) else 10
product.depth = float(nums[0]) * factor
product.width = float(nums[1]) * factor
product.height = float(nums[2]) * factor
product.dimension_unit = "mm"
async def _get_or_create_product(db: AsyncSession, req: MaterialsRequest) -> Product:
if req.product_id:
product = await db.get(Product, UUID(req.product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
return product
product = Product(
stage=Stage.collected,
source_platform=req.source.platform,
source_item_id=req.source.itemId,
source_url=req.source.url,
)
db.add(product)
await db.flush()
return product
@router.post("/materials", response_model=MaterialsResponse)
async def create_materials(
req: MaterialsRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
) -> MaterialsResponse:
product = await _get_or_create_product(db, req)
_apply_texts(product, req.texts)
# 采集溯源(追加来源)
if not product.source_url:
product.source_url = req.source.url
if not product.source_platform:
product.source_platform = req.source.platform
# 去重 + 建素材
existing = set()
if req.images:
rows = (await db.execute(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == product.id,
ProductAsset.dedupe_key.isnot(None),
)
)).scalars().all()
existing = {k for k in rows if k}
queued, skipped = 0, 0
for img in req.images:
if img.dedupeKey and img.dedupeKey in existing:
skipped += 1
continue
db.add(ProductAsset(
product_id=product.id,
group_key=img.groupKey,
variant_name=img.variantName,
sort_order=img.index,
type=img.type,
source_url=img.url,
status=AssetStatus.pending,
dedupe_key=img.dedupeKey,
))
if img.dedupeKey:
existing.add(img.dedupeKey)
queued += 1
# 更新分组计数
counts: dict = {}
for a in await db.scalars(select(ProductAsset).where(ProductAsset.product_id == product.id)):
counts[a.group_key] = counts.get(a.group_key, 0) + 1
product.asset_counts = counts
product.stage = Stage.collected if product.stage == Stage.collected else product.stage
await db.commit()
await db.refresh(product)
if queued:
background.add_task(process_product_assets, str(product.id))
return MaterialsResponse(
product_id=str(product.id),
stage=product.stage.value,
assets_queued=queued,
assets_skipped=skipped,
)
async def process_product_assets(product_id: str) -> None:
"""后台:下载 pending 素材 → 转存 storage。失败逐张标记,不中断。"""
from services.storage import get_storage
storage = get_storage()
async with get_session_factory()() as db:
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.status == AssetStatus.pending,
)
)).all()
for a in assets:
a.status = AssetStatus.downloading
await db.commit()
try:
stored = await storage.save_from_url(a.source_url, key_prefix="assets")
a.stored_url = stored
a.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
a.status = AssetStatus.failed
a.error = str(exc)[:500]
await db.commit()
@router.post("/materials/bytes")
async def upload_material_bytes(
background: BackgroundTasks,
product_id: str = Form(...),
group_key: str = Form("main"),
variant_name: str | None = Form(None),
sort_order: int = Form(0),
type: str = Form("img"),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
data = await file.read()
asset = ProductAsset(
product_id=product.id,
group_key=group_key,
variant_name=variant_name,
sort_order=sort_order,
type=type,
source_url="",
status=AssetStatus.pending,
)
db.add(asset)
await db.flush()
# 直接转存字节
from services.storage import get_storage
storage = get_storage()
try:
asset.stored_url = await storage.save_bytes(data, f"assets/{asset.id}", file.content_type or "")
asset.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001
asset.status = AssetStatus.failed
asset.error = str(exc)[:500]
await db.commit()
return {"asset_id": str(asset.id), "status": asset.status.value}
@router.get("/products/{product_id}/fingerprints")
async def product_fingerprints(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(
select(ProductAsset.dedupe_key).where(
ProductAsset.product_id == UUID(product_id),
ProductAsset.dedupe_key.isnot(None),
)
)).all()
return {"dedupe_keys": list(rows)}
@router.get("/collected")
async def is_collected(
platform: str,
itemId: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.execute(
select(Product).where(
Product.source_platform == platform,
Product.source_item_id == itemId,
)
)).scalars().all()
return {"collected": len(rows) > 0, "count": len(rows)}
+33
View File
@@ -265,3 +265,36 @@ async def is_collected(
)
)).scalars().all()
return {"collected": len(rows) > 0, "count": len(rows)}
@router.delete("/assets/{asset_id}")
async def delete_asset(asset_id: str, db: AsyncSession = Depends(get_db)):
"""删除一条素材记录(DB 行 + 本地文件;七牛等远程存储仅删引用)。"""
from services.storage import local_path
asset = await db.get(ProductAsset, UUID(asset_id))
if asset is None:
raise HTTPException(status_code=404, detail="素材不存在")
if asset.stored_url:
path = local_path(asset.stored_url)
if path is not None and path.is_file():
try:
path.unlink()
except OSError: # noqa: BLE001
pass # 文件删除失败不阻断记录删除
product_id = asset.product_id
group_key = asset.group_key
await db.delete(asset)
# 同步修正 asset_countsgenerated 计数由生图回写时累加)
product = await db.get(Product, product_id)
if product is not None:
counts = dict(product.asset_counts or {})
if group_key in counts:
counts[group_key] = max(0, int(counts.get(group_key) or 0) - 1)
product.asset_counts = counts
await db.commit()
return {"deleted": True}
-5
View File
@@ -1,5 +0,0 @@
from fastapi import APIRouter
router = APIRouter(prefix="/api/ozon", tags=["ozon"])
# Phase 3: Ozon Seller API product upload
-183
View File
@@ -1,183 +0,0 @@
"""发布端点:提交 ImportProductsV3 + 后台轮询回填。"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret
from db import get_db, get_session_factory
from deps import get_current_user
from models import Product, PublishTask, Shop
from models.enums import PublishStatus, Stage
from services.ozon_client import OzonClient, OzonAPIError
from services.publish import build_import_item, validate_ready
router = APIRouter(prefix="/api", tags=["publish"])
class PublishRequest(BaseModel):
shop_id: str
def _client(shop: Shop) -> OzonClient:
return OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
@router.post("/products/{product_id}/publish")
async def publish_product(
product_id: str,
body: PublishRequest,
background: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
product = await db.get(Product, UUID(product_id))
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
shop = await db.get(Shop, UUID(body.shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
missing = validate_ready(product)
if missing:
raise HTTPException(status_code=422, detail=f"缺少必填项:{''.join(missing)}")
item = build_import_item(product)
client = _client(shop)
try:
result = await client.post("/v3/product/import", {"items": [item]})
except OzonAPIError as exc:
raise HTTPException(status_code=502, detail=exc.detail)
task_id = (result.get("result") or {}).get("task_id")
if not task_id:
raise HTTPException(status_code=502, detail=f"Ozon 未返回 task_id{result}")
task = PublishTask(
product_id=product.id,
shop_id=shop.id,
ozon_task_id=int(task_id),
status=PublishStatus.pending,
request_payload=item,
)
db.add(task)
product.stage = Stage.publishing
await db.commit()
await db.refresh(task)
background.add_task(_poll, str(task.id))
return {"task_id": str(task.id), "ozon_task_id": task.ozon_task_id}
async def _poll(task_id: str) -> None:
"""后台轮询 import/info,直到 imported / failed 或超时(约 40s)。"""
async with get_session_factory()() as db:
task = await db.get(PublishTask, UUID(task_id))
if task is None:
return
shop = await db.get(Shop, task.shop_id)
product = await db.get(Product, task.product_id)
if shop is None or product is None:
return
client = _client(shop)
for attempt in range(8):
try:
result = await client.post("/v1/product/import/info", {"task_id": task.ozon_task_id})
except OzonAPIError as exc:
task.status = PublishStatus.failed
task.errors = [{"error": exc.detail}]
task.completed_at = datetime.now(timezone.utc)
product.stage = Stage.failed
await db.commit()
return
items = (result.get("result") or {}).get("items") or []
item = items[0] if items else {}
status = item.get("status", "")
product_id = item.get("product_id")
errors = item.get("errors") or []
if status == "imported":
task.status = PublishStatus.imported
task.response = item
task.completed_at = datetime.now(timezone.utc)
if product_id:
product.ozon_product_id = int(product_id)
product.stage = Stage.published
product.published_at = datetime.now(timezone.utc)
await db.commit()
return
if status == "failed":
task.status = PublishStatus.failed
task.errors = errors
task.response = item
task.completed_at = datetime.now(timezone.utc)
product.stage = Stage.failed
await db.commit()
return
# pending / moderation → 继续等
task.status = PublishStatus.moderation if status in ("moderating", "moderation") else PublishStatus.processing
if product_id:
product.ozon_product_id = int(product_id)
await db.commit()
await asyncio.sleep(5 * (attempt + 1))
# 超时未定:保留 processing,前端可刷新
task.status = PublishStatus.moderation
task.response = item
await db.commit()
@router.get("/publish/{task_id}")
async def get_publish_task(
task_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
task = await db.get(PublishTask, UUID(task_id))
if task is None:
raise HTTPException(status_code=404, detail="发布任务不存在")
return {
"id": str(task.id),
"product_id": str(task.product_id),
"shop_id": str(task.shop_id),
"ozon_task_id": task.ozon_task_id,
"status": task.status.value,
"errors": task.errors,
"response": task.response,
"created_at": task.created_at,
"completed_at": task.completed_at,
}
@router.get("/products/{product_id}/publish-history")
async def publish_history(
product_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(
select(PublishTask)
.where(PublishTask.product_id == UUID(product_id))
.order_by(PublishTask.created_at.desc())
)).all()
return [
{
"id": str(t.id),
"ozon_task_id": t.ozon_task_id,
"status": t.status.value,
"errors": t.errors,
"created_at": t.created_at,
"completed_at": t.completed_at,
}
for t in rows
]
-124
View File
@@ -1,124 +0,0 @@
"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import decrypt_secret, encrypt_secret
from db import get_db
from deps import get_current_user
from models import Shop
from models.enums import ShopStatus
from schemas.shop import ShopCreate, ShopListItem, ShopUpdate
from services.ozon_client import OzonClient, OzonAPIError
router = APIRouter(prefix="/api/shops", tags=["shops"])
def _mask(client_id: str) -> str:
return f"{client_id[-4:]}" if len(client_id) > 4 else ""
@router.get("", response_model=list[ShopListItem])
async def list_shops(
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
rows = (await db.scalars(select(Shop).order_by(Shop.created_at))).all()
items = []
for s in rows:
item = ShopListItem.model_validate(s)
try:
item.client_id_masked = _mask(decrypt_secret(s.client_id_enc))
except Exception: # noqa: BLE001
item.client_id_masked = ""
items.append(item)
return items
@router.post("", response_model=ShopListItem)
async def create_shop(
body: ShopCreate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = Shop(
name=body.name,
client_id_enc=encrypt_secret(body.client_id),
api_key_enc=encrypt_secret(body.api_key),
currency_code=body.currency_code or "RUB",
status=ShopStatus.active,
)
db.add(shop)
await db.commit()
await db.refresh(shop)
item = ShopListItem.model_validate(shop)
item.client_id_masked = _mask(body.client_id)
return item
@router.patch("/{shop_id}", response_model=ShopListItem)
async def update_shop(
shop_id: str,
body: ShopUpdate,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
if body.name is not None:
shop.name = body.name
if body.currency_code is not None:
shop.currency_code = body.currency_code
if body.client_id:
shop.client_id_enc = encrypt_secret(body.client_id)
if body.api_key:
shop.api_key_enc = encrypt_secret(body.api_key)
await db.commit()
await db.refresh(shop)
item = ShopListItem.model_validate(shop)
item.client_id_masked = _mask(decrypt_secret(shop.client_id_enc))
return item
@router.delete("/{shop_id}")
async def delete_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
await db.delete(shop)
await db.commit()
return {"deleted": True}
@router.post("/{shop_id}/test")
async def test_shop(
shop_id: str,
db: AsyncSession = Depends(get_db),
_user: dict = Depends(get_current_user),
):
shop = await db.get(Shop, UUID(shop_id))
if shop is None:
raise HTTPException(status_code=404, detail="店铺不存在")
client = OzonClient(decrypt_secret(shop.client_id_enc), decrypt_secret(shop.api_key_enc))
try:
result = await client.test_credentials()
except OzonAPIError as exc:
shop.status = ShopStatus.invalid
await db.commit()
return {"ok": False, "error": exc.detail, "roles": []}
shop.status = ShopStatus.active
shop.last_checked_at = datetime.now(timezone.utc)
await db.commit()
roles = [r.get("name") for r in result.get("roles", [])]
return {"ok": True, "roles": roles}
+2 -1
View File
@@ -35,7 +35,7 @@ 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.suite_service import append_generated_asset, texts_to_raw, validate_model
from services.tasks import IMG_OK, create_task, get_task
from services.tasks import IMG_OK, TaskImage, create_task, get_task
router = APIRouter(prefix="/api", tags=["suite"])
@@ -246,6 +246,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditSingleRespon
asset_id = await append_generated_asset(
req.product_id,
TaskImage(type_id="custom", name="AI生图", url=url, status="ok"),
after_asset_id=req.after_asset_id,
)
except Exception: # noqa: BLE001
pass # 回写失败不影响结果返回,图片已在任务网格可见
-60
View File
@@ -1,60 +0,0 @@
"""Ozon 类目字典缓存(可重建,不作为业务真源)。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
class CategoryTree(Base):
__tablename__ = "category_tree"
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
parent_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
category_name: Mapped[str] = mapped_column(String(255), default="")
type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
type_name: Mapped[str] = mapped_column(String(255), default="")
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
level: Mapped[int] = mapped_column(Integer, default=0)
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CategoryAttribute(Base):
__tablename__ = "category_attributes"
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
name: Mapped[str] = mapped_column(String(255), default="")
description: Mapped[str] = mapped_column(Text, default="")
type: Mapped[str] = mapped_column(String(32), default="")
dictionary_id: Mapped[int] = mapped_column(BigInteger, default=0)
group_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
group_name: Mapped[str] = mapped_column(String(255), default="")
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
is_aspect: Mapped[bool] = mapped_column(Boolean, default=False)
is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
max_value_count: Mapped[int] = mapped_column(Integer, default=0)
attribute_complex_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
complex_is_collection: Mapped[bool] = mapped_column(Boolean, default=False)
category_dependent: Mapped[bool] = mapped_column(Boolean, default=False)
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AttributeValue(Base):
__tablename__ = "attribute_values"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
attribute_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
description_category_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
type_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
value: Mapped[str] = mapped_column(String(512), default="")
picture: Mapped[str] = mapped_column(Text, default="")
info: Mapped[str] = mapped_column(Text, default="")
lang: Mapped[str] = mapped_column(String(8), default="DEFAULT")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
-33
View File
@@ -1,33 +0,0 @@
"""发布任务:一次 ImportProductsV3 请求与轮询结果。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import PublishStatus
from models.types import JSONType
class PublishTask(Base):
__tablename__ = "publish_tasks"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
product_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), index=True
)
shop_id: Mapped[uuid.UUID] = mapped_column(
Uuid(as_uuid=True), ForeignKey("shops.id", ondelete="CASCADE"), index=True
)
ozon_task_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
status: Mapped[PublishStatus] = mapped_column(
Enum(PublishStatus, native_enum=False, length=16), default=PublishStatus.pending, index=True
)
request_payload: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 脱敏后的 items[0]
response: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # import/info 原始结果
errors: Mapped[list | None] = mapped_column(JSONType, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
-30
View File
@@ -1,30 +0,0 @@
"""Ozon 店铺(Client-Id / Api-Key 加密落库)。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from db import Base
from models.enums import ShopStatus
class Shop(Base):
__tablename__ = "shops"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 预留多用户
name: Mapped[str] = mapped_column(String(128), nullable=False)
client_id_enc: Mapped[str] = mapped_column(String(1024), nullable=False) # AES-GCM 密文
api_key_enc: Mapped[str] = mapped_column(String(1024), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
status: Mapped[ShopStatus] = mapped_column(
Enum(ShopStatus, native_enum=False, length=16), default=ShopStatus.active
)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
-33
View File
@@ -1,33 +0,0 @@
"""店铺(Ozon 凭证)请求/响应模型。"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class ShopCreate(BaseModel):
name: str
client_id: str
api_key: str
currency_code: str = "CNY"
class ShopUpdate(BaseModel):
name: str | None = None
client_id: str | None = None
api_key: str | None = None
currency_code: str | None = None
class ShopListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
currency_code: str
status: str
client_id_masked: str = "" # 打码尾号
last_checked_at: datetime | None = None
created_at: datetime
+4
View File
@@ -153,6 +153,10 @@ class ImageEditSingleRequest(BaseModel):
prompt: str = Field(..., min_length=1, description="用户要求(必填,进生图指令)")
model: str = Field(default="nano-banana-2")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field(
default=None,
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
)
class ImageEditSingleResponse(BaseModel):
-51
View File
@@ -1,51 +0,0 @@
"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。"""
from __future__ import annotations
import httpx
OZON_BASE_URL = "https://api-seller.ozon.ru"
class OzonAPIError(Exception):
def __init__(self, status: int, detail: str):
self.status = status
self.detail = detail
super().__init__(f"Ozon API {status}: {detail}")
class OzonClient:
def __init__(self, client_id: str, api_key: str, base_url: str = OZON_BASE_URL):
self.client_id = client_id
self.api_key = api_key
self.base_url = base_url
def _headers(self) -> dict:
return {
"Client-Id": self.client_id,
"Api-Key": self.api_key,
"Content-Type": "application/json",
}
async def post(self, path: str, body: dict | None = None, timeout: float = 60.0) -> dict:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.post(f"{self.base_url}{path}", headers=self._headers(), json=body or {})
if resp.status_code >= 400:
raise OzonAPIError(resp.status_code, resp.text[:500])
try:
return resp.json()
except Exception: # noqa: BLE001
return {}
async def get(self, path: str, timeout: float = 60.0) -> dict:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.get(f"{self.base_url}{path}", headers=self._headers())
if resp.status_code >= 400:
raise OzonAPIError(resp.status_code, resp.text[:500])
try:
return resp.json()
except Exception: # noqa: BLE001
return {}
async def test_credentials(self) -> dict:
"""调 /v1/roles 校验凭证与权限范围。"""
return await self.post("/v1/roles", {})
-79
View File
@@ -1,79 +0,0 @@
"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。"""
from __future__ import annotations
from models import Product
def _fmt(v) -> str:
if v is None:
return ""
return str(v)
def validate_ready(product: Product) -> list[str]:
"""返回缺失/非法必填项的中文提示列表;空列表表示可发布。"""
missing: list[str] = []
if not product.offer_id.strip():
missing.append("货号 offer_id")
if not product.name.strip():
missing.append("商品名 name")
if not product.description.strip():
missing.append("描述 description")
if not product.description_category_id:
missing.append("类目 description_category_id")
if product.price is None or product.price <= 0:
missing.append("售价 price")
if not product.weight or product.weight <= 0:
missing.append("重量 weight")
for label, val in (("长 depth", product.depth), ("宽 width", product.width), ("高 height", product.height)):
if not val or val <= 0:
missing.append(label)
if not product.images:
missing.append("主图 images(至少 1 张)")
elif any(u and u.startswith("http://") for u in product.images):
missing.append("图片链接必须使用 https(Ozon 不接受 http 直链)")
return missing
def _with_model_name(product: Product) -> list:
"""把 raw.model_name 自动注入为 attribute 9048(型号名称),用于多变体合并。"""
attrs = list(product.attributes or [])
model_name = (product.raw or {}).get("model_name") if product.raw else None
if not model_name:
return attrs
# 已手动映射 9048 就不重复添加
for a in attrs:
if isinstance(a, dict) and a.get("id") == 9048:
return attrs
attrs.append({"complex_id": 0, "id": 9048, "values": [{"value": model_name}]})
return attrs
def build_import_item(product: Product) -> dict:
item: dict = {
"offer_id": product.offer_id,
"name": product.name,
"description": product.description,
"description_category_id": product.description_category_id,
"price": _fmt(product.price),
"old_price": _fmt(product.old_price),
"currency_code": product.currency_code or "CNY",
"vat": product.vat or "0",
"depth": product.depth,
"width": product.width,
"height": product.height,
"dimension_unit": product.dimension_unit or "mm",
"weight": product.weight,
"weight_unit": product.weight_unit or "g",
"images": product.images or [],
"primary_image": product.primary_image or "",
"images360": product.images360 or [],
"color_image": product.color_image or "",
"attributes": _with_model_name(product),
"complex_attributes": product.complex_attributes or [],
}
if product.type_id:
item["type_id"] = product.type_id
if product.barcode:
item["barcode"] = product.barcode
return item
+38 -8
View File
@@ -46,10 +46,16 @@ def validate_model(provider_name: str, model: str | None) -> None:
raise ValueError(f"不支持的模型: {model}rightapi 支持: {RIGHTAPI_MODELS}")
async def append_generated_asset(product_id: str, image: TaskImage) -> str | None:
async def append_generated_asset(
product_id: str,
image: TaskImage,
after_asset_id: str | None = None,
) -> str | None:
"""把一张生成完成的图追加为 product_assets(generated),并累加 asset_counts。返回 asset_id。
作为 run_suite 的逐张回调使用(签名须为 (image)),调用方用 partial 绑定 product_id。
after_asset_id:插入锚点——新素材排在锚点之后(其余素材顺次后移),方便与原图对比;
缺省/锚点无效时追加到组尾。作为 run_suite 的逐张回调使用时签名须为 (image),
调用方用 partial 绑定 product_id。
"""
from sqlalchemy import func, select
@@ -58,17 +64,41 @@ async def append_generated_asset(product_id: str, image: TaskImage) -> str | Non
pid = uuid.UUID(product_id)
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",
sort_order: int | None = None
if after_asset_id:
try:
anchor = await db.get(ProductAsset, UUID(after_asset_id))
except ValueError:
anchor = None
if anchor is not None and anchor.product_id == pid and anchor.group_key == "generated":
# 锚点之后的素材顺次后移,腾出插入位
followers = (
await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == pid,
ProductAsset.group_key == "generated",
ProductAsset.sort_order > anchor.sort_order,
)
)
).scalars().all()
for follower in followers:
follower.sort_order += 1
sort_order = anchor.sort_order + 1
if sort_order is None:
sort_order = await db.scalar(
select(func.coalesce(func.max(ProductAsset.sort_order), -1)).where(
ProductAsset.product_id == pid,
ProductAsset.group_key == "generated",
)
)
)
sort_order = (sort_order or 0) + 1
asset = ProductAsset(
product_id=pid,
group_key="generated",
variant_name=None,
sort_order=count or 0,
sort_order=sort_order,
type="img",
source_url="",
stored_url=image.url,