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
-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,