feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""鉴权路由。"""
|
||||
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)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""采集入库:插件上传文本 + 图片 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)}
|
||||
@@ -0,0 +1,14 @@
|
||||
"""汇率路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
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)):
|
||||
return await get_fx_rate()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""商品 CRUD(采集箱 / 编辑 / 删除)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/products", tags=["products"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_products(
|
||||
stage: str | None = None,
|
||||
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),
|
||||
):
|
||||
stmt = select(Product)
|
||||
if stage:
|
||||
stmt = stmt.where(Product.stage == stage)
|
||||
if q:
|
||||
stmt = stmt.where(Product.name.ilike(f"%{q}%") | Product.offer_id.ilike(f"%{q}%"))
|
||||
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
|
||||
rows = (await db.execute(
|
||||
stmt.order_by(Product.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)).scalars().all()
|
||||
items = [ProductListItem.model_validate(r) for r in rows]
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
product = await db.get(Product, UUID(product_id))
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
return ProductDetail.model_validate(product)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductDetail)
|
||||
async def create_product(
|
||||
body: ProductUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
):
|
||||
product = Product(stage=Stage.collected)
|
||||
_apply_update(product, body)
|
||||
db.add(product)
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
return ProductDetail.model_validate(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),
|
||||
):
|
||||
"""复制商品为新变体:继承标题/描述/属性/型号名称/计价,重置货号与图片。"""
|
||||
src = await db.get(Product, UUID(product_id))
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="商品不存在")
|
||||
clone = Product(
|
||||
stage=Stage.collected,
|
||||
shop_id=src.shop_id,
|
||||
source_platform=src.source_platform,
|
||||
source_item_id=None, # 新变体,不沿用源 itemId(避免去重冲突)
|
||||
source_url=src.source_url,
|
||||
offer_id="", # 重置货号
|
||||
name=src.name,
|
||||
description=src.description,
|
||||
description_category_id=src.description_category_id,
|
||||
type_id=src.type_id,
|
||||
price=src.price,
|
||||
old_price=src.old_price,
|
||||
currency_code=src.currency_code,
|
||||
vat=src.vat,
|
||||
depth=src.depth,
|
||||
width=src.width,
|
||||
height=src.height,
|
||||
dimension_unit=src.dimension_unit,
|
||||
weight=src.weight,
|
||||
weight_unit=src.weight_unit,
|
||||
barcode=src.barcode,
|
||||
images=None, # 重置图片
|
||||
primary_image=None,
|
||||
images360=None,
|
||||
color_image=None,
|
||||
attributes=src.attributes,
|
||||
complex_attributes=src.complex_attributes,
|
||||
raw=src.raw, # 含 model_name(型号名称)
|
||||
pricing=src.pricing,
|
||||
copy=src.copy,
|
||||
fx_rate=src.fx_rate,
|
||||
)
|
||||
db.add(clone)
|
||||
await db.commit()
|
||||
await db.refresh(clone)
|
||||
return ProductDetail.model_validate(clone)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=ProductDetail)
|
||||
async def update_product(
|
||||
product_id: str,
|
||||
body: ProductUpdate,
|
||||
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="商品不存在")
|
||||
_apply_update(product, body)
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
return ProductDetail.model_validate(product)
|
||||
|
||||
|
||||
@router.delete("/{product_id}")
|
||||
async def delete_product(
|
||||
product_id: str,
|
||||
hard: bool = False,
|
||||
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="商品不存在")
|
||||
if hard:
|
||||
await db.delete(product)
|
||||
else:
|
||||
product.stage = Stage.archived
|
||||
await db.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.get("/{product_id}/assets")
|
||||
async def list_assets(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: dict = Depends(get_current_user),
|
||||
):
|
||||
rows = (await db.scalars(
|
||||
select(ProductAsset)
|
||||
.where(ProductAsset.product_id == UUID(product_id))
|
||||
.order_by(ProductAsset.group_key, ProductAsset.sort_order)
|
||||
)).all()
|
||||
return [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"group_key": a.group_key,
|
||||
"variant_name": a.variant_name,
|
||||
"sort_order": a.sort_order,
|
||||
"type": a.type,
|
||||
"source_url": a.source_url,
|
||||
"stored_url": a.stored_url,
|
||||
"status": a.status.value,
|
||||
"width": a.width,
|
||||
"height": a.height,
|
||||
"error": a.error,
|
||||
}
|
||||
for a in rows
|
||||
]
|
||||
|
||||
|
||||
def _apply_update(product: Product, body: ProductUpdate) -> None:
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "stage" in data and data["stage"]:
|
||||
data["stage"] = Stage(data["stage"])
|
||||
for key, value in data.items():
|
||||
if value is not None or key in ("raw", "pricing", "copy", "images", "attributes", "complex_attributes", "shop_id"):
|
||||
setattr(product, key, value)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""发布端点:提交 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
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""店铺管理:绑定 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}
|
||||
Reference in New Issue
Block a user