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}
|
||||
@@ -18,22 +18,50 @@ class Settings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── AI 密钥 ──
|
||||
deepseek_api_key: str = ""
|
||||
openai_api_key: str = ""
|
||||
dashscope_api_key: str = ""
|
||||
# 华北2(北京)业务空间时需填:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1
|
||||
# 普通 API Key 调用留空即可。
|
||||
dashscope_base_http_api_url: str = ""
|
||||
|
||||
# ── 运行 ──
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8800
|
||||
cors_origins: str = ""
|
||||
|
||||
# ── V2:数据层 ──
|
||||
# 本地过渡用 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 天)
|
||||
|
||||
# ── V2:七牛(图片存储)──
|
||||
qiniu_access_key: str = ""
|
||||
qiniu_secret_key: str = ""
|
||||
qiniu_bucket: str = ""
|
||||
qiniu_domain: str = "" # 绑定域名,如 https://cdn.example.com
|
||||
# 为空时用本地文件系统兜底(开发期),不为空时走七牛
|
||||
storage_backend: str = "local" # local | qiniu
|
||||
|
||||
# ── V2:对外地址(插件/前端回写、生成图回调)──
|
||||
app_base_url: str = "http://127.0.0.1:8800"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
if not self.cors_origins.strip():
|
||||
return []
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def use_qiniu(self) -> bool:
|
||||
return self.storage_backend == "qiniu" and bool(
|
||||
self.qiniu_access_key and self.qiniu_secret_key and self.qiniu_bucket
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""JWT 鉴权 + 店铺凭证 AES-GCM 加解密。"""
|
||||
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()
|
||||
|
||||
|
||||
def encrypt_secret(plaintext: str) -> str:
|
||||
"""AES-GCM 加密,返回 base64(nonce + ciphertext + tag)。"""
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
key = _derive_key()
|
||||
nonce = os.urandom(12)
|
||||
aesgcm = AESGCM(key)
|
||||
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return base64.b64encode(nonce + ct).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_secret(ciphertext: str) -> str:
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
key = _derive_key()
|
||||
raw = base64.b64decode(ciphertext.encode("ascii"))
|
||||
nonce, ct = raw[:12], raw[12:]
|
||||
aesgcm = AESGCM(key)
|
||||
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""数据库引擎与会话工厂(SQLite 本地过渡 / PostgreSQL 生产)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
connect_args: dict = {}
|
||||
# SQLite 需允许多线程/多协程访问同一文件
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
future=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = async_sessionmaker(
|
||||
get_engine(),
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""FastAPI 依赖:请求级 AsyncSession。"""
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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}
|
||||
+38
-5
@@ -3,14 +3,18 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
from api import ai, image, ozon
|
||||
from api import ai, auth, categories, collection, fx, image, ozon, products, publish, shops
|
||||
from config import get_settings
|
||||
from db import get_engine
|
||||
|
||||
# web/ 是 v1 工具台,留在仓库根,故上跳一级
|
||||
WEB_DIR = Path(__file__).resolve().parents[1] / "web"
|
||||
# 本地存储(开发兜底)媒体目录
|
||||
MEDIA_DIR = Path(__file__).resolve().parents[1] / "data" / "media"
|
||||
|
||||
app = FastAPI(title="Ozon Seller Kit", version="0.1.0")
|
||||
app = FastAPI(title="Ozon Seller Kit", version="0.2.0")
|
||||
|
||||
settings = get_settings()
|
||||
if settings.cors_origin_list:
|
||||
@@ -22,15 +26,44 @@ if settings.cors_origin_list:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 业务路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(collection.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)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@app.on_event("startup")
|
||||
async def on_startup() -> None:
|
||||
# 开发便利:确保表存在(生产以 Alembic 迁移为准,create_all 幂等不删表)
|
||||
from db import Base
|
||||
import models # noqa: F401
|
||||
|
||||
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
async with get_engine().begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
db_ok = True
|
||||
try:
|
||||
async with get_engine().connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
except Exception: # noqa: BLE001
|
||||
db_ok = False
|
||||
return {"status": "ok" if db_ok else "degraded", "db": db_ok}
|
||||
|
||||
|
||||
# 本地媒体(开发兜底存储)
|
||||
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/media", StaticFiles(directory=str(MEDIA_DIR)), name="media")
|
||||
|
||||
if WEB_DIR.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Alembic 迁移环境。URL 从 server/config/settings.py 读取,支持 autogenerate。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import create_engine, pool
|
||||
|
||||
# 让 `from config import ...` / `from db import ...` / `import models` 可解析
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SERVER_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
from config import get_settings # noqa: E402
|
||||
from db import Base # noqa: E402
|
||||
import models # noqa: E402,F401 确保所有模型注册到 Base.metadata
|
||||
|
||||
config = context.config
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _sync_url(url: str) -> str:
|
||||
"""异步 URL → 同步 URL(迁移用同步引擎跑更稳)。"""
|
||||
return url.replace("+aiosqlite", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=_sync_url(get_settings().database_url),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = create_engine(
|
||||
_sync_url(get_settings().database_url),
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""initial v2 schema
|
||||
|
||||
Revision ID: 51715d16e5c3
|
||||
Revises:
|
||||
Create Date: 2026-08-15 10:06:57.793304
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '51715d16e5c3'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('attribute_values',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('attribute_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('description_category_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('type_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('value', sa.String(length=512), nullable=False),
|
||||
sa.Column('picture', sa.Text(), nullable=False),
|
||||
sa.Column('info', sa.Text(), nullable=False),
|
||||
sa.Column('lang', sa.String(length=8), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', 'attribute_id', 'description_category_id', 'type_id')
|
||||
)
|
||||
op.create_table('category_attributes',
|
||||
sa.Column('description_category_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('type_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('attribute_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('type', sa.String(length=32), nullable=False),
|
||||
sa.Column('dictionary_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('group_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('group_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('is_required', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_aspect', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_collection', sa.Boolean(), nullable=False),
|
||||
sa.Column('max_value_count', sa.Integer(), nullable=False),
|
||||
sa.Column('attribute_complex_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('complex_is_collection', sa.Boolean(), nullable=False),
|
||||
sa.Column('category_dependent', sa.Boolean(), nullable=False),
|
||||
sa.Column('lang', sa.String(length=8), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('description_category_id', 'type_id', 'attribute_id')
|
||||
)
|
||||
op.create_table('category_tree',
|
||||
sa.Column('description_category_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('parent_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('category_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('type_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('type_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('disabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('level', sa.Integer(), nullable=False),
|
||||
sa.Column('lang', sa.String(length=8), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('description_category_id')
|
||||
)
|
||||
op.create_index(op.f('ix_category_tree_parent_id'), 'category_tree', ['parent_id'], unique=False)
|
||||
op.create_table('products',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('stage', sa.Enum('collected', 'editing', 'ready', 'publishing', 'published', 'failed', 'archived', name='stage', native_enum=False, length=16), nullable=False),
|
||||
sa.Column('source_platform', sa.String(length=16), nullable=True),
|
||||
sa.Column('source_item_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('source_url', sa.Text(), nullable=True),
|
||||
sa.Column('offer_id', sa.String(length=255), nullable=False),
|
||||
sa.Column('ozon_product_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('ozon_sku', sa.BigInteger(), nullable=True),
|
||||
sa.Column('name', sa.Text(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('description_category_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('type_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('price', sa.Float(), nullable=True),
|
||||
sa.Column('old_price', sa.Float(), nullable=True),
|
||||
sa.Column('currency_code', sa.String(length=3), server_default='RUB', nullable=False),
|
||||
sa.Column('vat', sa.String(length=8), server_default='0', nullable=False),
|
||||
sa.Column('depth', sa.Float(), nullable=True),
|
||||
sa.Column('width', sa.Float(), nullable=True),
|
||||
sa.Column('height', sa.Float(), nullable=True),
|
||||
sa.Column('dimension_unit', sa.String(length=4), server_default='mm', nullable=False),
|
||||
sa.Column('weight', sa.Float(), nullable=True),
|
||||
sa.Column('weight_unit', sa.String(length=4), server_default='g', nullable=False),
|
||||
sa.Column('barcode', sa.String(length=64), nullable=True),
|
||||
sa.Column('images', sa.JSON(), nullable=True),
|
||||
sa.Column('primary_image', sa.Text(), nullable=True),
|
||||
sa.Column('images360', sa.JSON(), nullable=True),
|
||||
sa.Column('color_image', sa.Text(), nullable=True),
|
||||
sa.Column('pdf_list', sa.JSON(), nullable=True),
|
||||
sa.Column('promotions', sa.JSON(), nullable=True),
|
||||
sa.Column('attributes', sa.JSON(), nullable=True),
|
||||
sa.Column('complex_attributes', sa.JSON(), nullable=True),
|
||||
sa.Column('raw', sa.JSON(), nullable=True),
|
||||
sa.Column('pricing', sa.JSON(), nullable=True),
|
||||
sa.Column('copy', sa.JSON(), nullable=True),
|
||||
sa.Column('fx_rate', sa.Float(), nullable=True),
|
||||
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('asset_counts', sa.JSON(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_products_offer_id'), 'products', ['offer_id'], unique=False)
|
||||
op.create_index(op.f('ix_products_ozon_product_id'), 'products', ['ozon_product_id'], unique=False)
|
||||
op.create_index(op.f('ix_products_source_item_id'), 'products', ['source_item_id'], unique=False)
|
||||
op.create_index(op.f('ix_products_stage'), 'products', ['stage'], unique=False)
|
||||
op.create_index(op.f('ix_products_updated_at'), 'products', ['updated_at'], unique=False)
|
||||
op.create_table('shops',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('client_id_enc', sa.String(length=1024), nullable=False),
|
||||
sa.Column('api_key_enc', sa.String(length=1024), nullable=False),
|
||||
sa.Column('currency_code', sa.String(length=3), server_default='RUB', nullable=False),
|
||||
sa.Column('status', sa.Enum('active', 'invalid', 'disabled', name='shopstatus', native_enum=False, length=16), nullable=False),
|
||||
sa.Column('last_checked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('username', sa.String(length=64), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_table('product_assets',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('product_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('group_key', sa.String(length=16), nullable=False),
|
||||
sa.Column('variant_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('type', sa.String(length=8), nullable=False),
|
||||
sa.Column('source_url', sa.Text(), nullable=False),
|
||||
sa.Column('stored_url', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.Enum('pending', 'downloading', 'uploaded', 'failed', name='assetstatus', native_enum=False, length=16), nullable=False),
|
||||
sa.Column('dedupe_key', sa.String(length=512), nullable=True),
|
||||
sa.Column('width', sa.Integer(), nullable=True),
|
||||
sa.Column('height', sa.Integer(), nullable=True),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_product_assets_dedupe_key'), 'product_assets', ['dedupe_key'], unique=False)
|
||||
op.create_index(op.f('ix_product_assets_product_id'), 'product_assets', ['product_id'], unique=False)
|
||||
op.create_index(op.f('ix_product_assets_status'), 'product_assets', ['status'], unique=False)
|
||||
op.create_table('publish_tasks',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('product_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('shop_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('ozon_task_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('status', sa.Enum('pending', 'processing', 'moderation', 'imported', 'failed', name='publishstatus', native_enum=False, length=16), nullable=False),
|
||||
sa.Column('request_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('response', sa.JSON(), nullable=True),
|
||||
sa.Column('errors', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_publish_tasks_ozon_task_id'), 'publish_tasks', ['ozon_task_id'], unique=False)
|
||||
op.create_index(op.f('ix_publish_tasks_product_id'), 'publish_tasks', ['product_id'], unique=False)
|
||||
op.create_index(op.f('ix_publish_tasks_shop_id'), 'publish_tasks', ['shop_id'], unique=False)
|
||||
op.create_index(op.f('ix_publish_tasks_status'), 'publish_tasks', ['status'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_publish_tasks_status'), table_name='publish_tasks')
|
||||
op.drop_index(op.f('ix_publish_tasks_shop_id'), table_name='publish_tasks')
|
||||
op.drop_index(op.f('ix_publish_tasks_product_id'), table_name='publish_tasks')
|
||||
op.drop_index(op.f('ix_publish_tasks_ozon_task_id'), table_name='publish_tasks')
|
||||
op.drop_table('publish_tasks')
|
||||
op.drop_index(op.f('ix_product_assets_status'), table_name='product_assets')
|
||||
op.drop_index(op.f('ix_product_assets_product_id'), table_name='product_assets')
|
||||
op.drop_index(op.f('ix_product_assets_dedupe_key'), table_name='product_assets')
|
||||
op.drop_table('product_assets')
|
||||
op.drop_table('users')
|
||||
op.drop_table('shops')
|
||||
op.drop_index(op.f('ix_products_updated_at'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_stage'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_source_item_id'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_ozon_product_id'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_offer_id'), table_name='products')
|
||||
op.drop_table('products')
|
||||
op.drop_index(op.f('ix_category_tree_parent_id'), table_name='category_tree')
|
||||
op.drop_table('category_tree')
|
||||
op.drop_table('category_attributes')
|
||||
op.drop_table('attribute_values')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add shop_id to products
|
||||
|
||||
Revision ID: 658b0503f71c
|
||||
Revises: 51715d16e5c3
|
||||
Create Date: 2026-08-15 14:30:34.342589
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '658b0503f71c'
|
||||
down_revision = '51715d16e5c3'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('products', sa.Column('shop_id', sa.Uuid(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('products', 'shop_id')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,18 @@
|
||||
"""模型统一导出(供 Alembic autogenerate 与业务代码 import)。"""
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""采集素材(图片/视频):分组、源站 URL、转存 URL、状态。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from db import Base
|
||||
from models.enums import AssetStatus
|
||||
|
||||
|
||||
class ProductAsset(Base):
|
||||
__tablename__ = "product_assets"
|
||||
|
||||
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
|
||||
)
|
||||
group_key: Mapped[str] = mapped_column(String(16), default="main") # main/sku/detail/video/param/generated
|
||||
variant_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # SKU 规格名
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
type: Mapped[str] = mapped_column(String(8), default="img") # img / video
|
||||
source_url: Mapped[str] = mapped_column(Text, default="")
|
||||
stored_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 本地路径或七牛公网 URL
|
||||
status: Mapped[AssetStatus] = mapped_column(
|
||||
Enum(AssetStatus, native_enum=False, length=16), default=AssetStatus.pending, index=True
|
||||
)
|
||||
dedupe_key: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
|
||||
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,60 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""业务枚举。值写入数据库字符串列(native_enum=False,跨 SQLite/PG 一致)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class Stage(str, enum.Enum):
|
||||
collected = "collected" # 插件刚上传,只有素材与原文
|
||||
editing = "editing" # 用户正在编辑
|
||||
ready = "ready" # 必填项齐全,可发布
|
||||
publishing = "publishing" # 已提交 ImportProductsV3,等待轮询
|
||||
published = "published" # 轮询 imported 成功
|
||||
failed = "failed" # 轮询返回 errors / 校验失败
|
||||
archived = "archived" # 手动归档(软删)
|
||||
|
||||
|
||||
class AssetStatus(str, enum.Enum):
|
||||
pending = "pending" # 已入库,等待下载
|
||||
downloading = "downloading" # 正在下载源图
|
||||
uploaded = "uploaded" # 已转存(本地/七牛)
|
||||
failed = "failed" # 下载或转存失败
|
||||
|
||||
|
||||
class PublishStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
moderation = "moderation"
|
||||
imported = "imported"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class ShopStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
invalid = "invalid" # 连通性校验失败
|
||||
disabled = "disabled"
|
||||
|
||||
|
||||
# 图片分组(对齐契约 _images)
|
||||
IMAGE_GROUPS = ("main", "sku", "detail", "video", "param", "generated")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""商品主表:对齐 Ozon ImportProductsV3 字段 + 本地扩展(raw/pricing/copy)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, Float, Integer, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from db import Base
|
||||
from models.enums import Stage
|
||||
from models.types import JSONType
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
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)
|
||||
shop_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), nullable=True) # 上架店铺
|
||||
stage: Mapped[Stage] = mapped_column(
|
||||
Enum(Stage, native_enum=False, length=16), default=Stage.collected, index=True
|
||||
)
|
||||
|
||||
# 采集溯源
|
||||
source_platform: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
source_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# ── Ozon 字段(对齐 ImportProductsV3)──
|
||||
offer_id: Mapped[str] = mapped_column(String(255), default="", index=True)
|
||||
ozon_product_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
ozon_sku: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
name: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
description_category_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
type_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
old_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# 币种固定人民币(跨境卖家 CNY 计价);vat 恒为 0(简化税制,无 НДС),前端不再展示
|
||||
currency_code: Mapped[str] = mapped_column(String(3), default="CNY", server_default="CNY")
|
||||
vat: Mapped[str] = mapped_column(String(8), default="0", server_default="0")
|
||||
depth: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
dimension_unit: Mapped[str] = mapped_column(String(4), default="mm", server_default="mm")
|
||||
weight: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
weight_unit: Mapped[str] = mapped_column(String(4), default="g", server_default="g")
|
||||
barcode: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# 图片(有序公网 URL,≤15)
|
||||
images: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
||||
primary_image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
images360: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
||||
color_image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
pdf_list: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
||||
promotions: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 动态属性(工作台映射后填)
|
||||
attributes: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
||||
complex_attributes: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# ── 本地扩展(提交 Ozon 前剥离)──
|
||||
raw: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 采集原文 + texts
|
||||
pricing: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # 计价结果
|
||||
copy: Mapped[dict | None] = mapped_column(JSONType, nullable=True) # AI 文案结果
|
||||
fx_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
published_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(), index=True
|
||||
)
|
||||
|
||||
# 采集素材数量(冗余,供列表快速展示;由 service 维护)
|
||||
asset_counts: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""发布任务:一次 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)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""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()
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""共享列类型:JSON(SQLite 存 TEXT,PostgreSQL 存 JSON;跨库一致)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import JSON
|
||||
|
||||
# 统一用 generic JSON:SQLite/PostgreSQL 均可,避免 autogenerate 对 JSONB 变体渲染异常。
|
||||
# 生产若需 JSONB 的索引能力,可再按需迁移,量级上差异可忽略。
|
||||
JSONType = JSON
|
||||
@@ -0,0 +1,19 @@
|
||||
"""用户表(预留多用户;MVP 用 APP_TOKEN 时为空)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from db import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -3,5 +3,14 @@ uvicorn[standard]>=0.32.0
|
||||
httpx>=0.27.0
|
||||
pydantic-settings>=2.6.0
|
||||
python-dotenv>=1.0.0
|
||||
python-multipart>=0.0.9
|
||||
PyYAML>=6.0.0
|
||||
dashscope>=1.23.8
|
||||
|
||||
# V2:数据层 / 鉴权 / 对象存储
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
aiosqlite>=0.20.0
|
||||
alembic>=1.13.0
|
||||
PyJWT>=2.8.0
|
||||
cryptography>=42.0.0
|
||||
qiniu>=7.13.0
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""鉴权请求/响应模型。"""
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
"""采集上传(插件 → 服务端)请求/响应模型,对齐 docs/extension/plan.md §14。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SourceInfo(BaseModel):
|
||||
platform: str = Field(..., description="ozon | 1688 | taobao")
|
||||
itemId: str | None = None
|
||||
url: str = ""
|
||||
collectedAt: int | None = None # epoch 毫秒
|
||||
|
||||
|
||||
class TextMaterial(BaseModel):
|
||||
kind: str = Field(..., description="title | params | selling_point | desc | price | brand")
|
||||
content: str = ""
|
||||
pairs: list[dict] | None = None # table 模式 kv:[{key, value}]
|
||||
|
||||
|
||||
class ImageMaterial(BaseModel):
|
||||
groupKey: str = Field(..., description="main | sku | detail | video | param")
|
||||
groupName: str = ""
|
||||
variantName: str | None = None # SKU 规格名
|
||||
url: str = Field(..., description="源站原图 URL")
|
||||
index: int = 0
|
||||
type: str = "img" # img | video
|
||||
dedupeKey: str | None = None
|
||||
|
||||
|
||||
class MaterialsRequest(BaseModel):
|
||||
product_id: str | None = Field(default=None, description="传了=追加到已有商品(跨平台补素材)")
|
||||
source: SourceInfo
|
||||
texts: list[TextMaterial] = Field(default_factory=list)
|
||||
images: list[ImageMaterial] = Field(default_factory=list)
|
||||
refererOrigin: str | None = None # 下载源图时需带的 Referer
|
||||
|
||||
|
||||
class MaterialsResponse(BaseModel):
|
||||
product_id: str
|
||||
stage: str
|
||||
assets_queued: int
|
||||
assets_skipped: int = 0 # 因 dedupeKey 重复而跳过
|
||||
@@ -0,0 +1,102 @@
|
||||
"""商品 Pydantic 模型(列表 / 详情 / 部分更新)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProductListItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
stage: str
|
||||
name: str = ""
|
||||
offer_id: str = ""
|
||||
price: float | None = None
|
||||
currency_code: str = "RUB"
|
||||
source_platform: str | None = None
|
||||
source_url: str | None = None
|
||||
asset_counts: dict | None = None
|
||||
ozon_product_id: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProductDetail(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
shop_id: UUID | None = None
|
||||
stage: str
|
||||
source_platform: str | None = None
|
||||
source_item_id: str | None = None
|
||||
source_url: str | None = None
|
||||
|
||||
offer_id: str = ""
|
||||
ozon_product_id: int | None = None
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
description_category_id: int | None = None
|
||||
type_id: int | None = None
|
||||
price: float | None = None
|
||||
old_price: float | None = None
|
||||
currency_code: str = "RUB"
|
||||
vat: str = "0"
|
||||
depth: float | None = None
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
dimension_unit: str = "mm"
|
||||
weight: float | None = None
|
||||
weight_unit: str = "g"
|
||||
barcode: str | None = None
|
||||
|
||||
images: list | None = None
|
||||
primary_image: str | None = None
|
||||
images360: list | None = None
|
||||
color_image: str | None = None
|
||||
attributes: list | None = None
|
||||
complex_attributes: list | None = None
|
||||
|
||||
raw: dict | None = None
|
||||
pricing: dict | None = None
|
||||
copy: dict | None = None
|
||||
fx_rate: float | None = None
|
||||
asset_counts: dict | None = None
|
||||
|
||||
published_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
"""编辑页 autosave 的部分更新。仅允许业务字段,id/时间由服务端维护。"""
|
||||
|
||||
shop_id: UUID | None = None
|
||||
stage: str | None = None
|
||||
offer_id: str | None = None
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
description_category_id: int | None = None
|
||||
type_id: int | None = None
|
||||
price: float | None = None
|
||||
old_price: float | None = None
|
||||
currency_code: str | None = None
|
||||
vat: str | None = None
|
||||
depth: float | None = None
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
dimension_unit: str | None = None
|
||||
weight: float | None = None
|
||||
weight_unit: str | None = None
|
||||
barcode: str | None = None
|
||||
images: list | None = None
|
||||
primary_image: str | None = None
|
||||
attributes: list | None = None
|
||||
complex_attributes: list | None = None
|
||||
raw: dict | None = None
|
||||
pricing: dict | None = None
|
||||
copy: dict | None = None
|
||||
fx_rate: float | None = None
|
||||
source_url: str | None = None
|
||||
@@ -0,0 +1,33 @@
|
||||
"""店铺(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
|
||||
@@ -0,0 +1,58 @@
|
||||
"""汇率服务:CNY→RUB。数据源三级降级(FloatRates → 俄央行 → 兜底),服务端缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
_FALLBACK_RATE = 11.5
|
||||
_MIN, _MAX = 5.0, 25.0
|
||||
_CACHE_TTL = 3600 # 秒
|
||||
|
||||
_cache: dict = {"rate": None, "source": "", "ts": 0.0}
|
||||
|
||||
|
||||
def _valid(rate: float) -> bool:
|
||||
return _MIN <= rate <= _MAX
|
||||
|
||||
|
||||
async def _fetch_floatrates() -> float | None:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
resp = await client.get("https://www.floatrates.com/daily/cny.json")
|
||||
resp.raise_for_status()
|
||||
rub = resp.json().get("rub", {})
|
||||
rate = rub.get("rate")
|
||||
return float(rate) if rate else None
|
||||
|
||||
|
||||
async def _fetch_cbr() -> float | None:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
resp = await client.get("https://www.cbr-xml-daily.ru/daily_json.js")
|
||||
resp.raise_for_status()
|
||||
cny = resp.json().get("Valute", {}).get("CNY", {})
|
||||
value = cny.get("Value")
|
||||
return float(value) if value else None
|
||||
|
||||
|
||||
async def get_fx_rate() -> dict:
|
||||
"""返回 {rate, source, updated_at}。带 1 小时内存缓存。"""
|
||||
now = time.time()
|
||||
if _cache["rate"] and (now - _cache["ts"]) < _CACHE_TTL:
|
||||
return dict(_cache)
|
||||
|
||||
rate = None
|
||||
source = ""
|
||||
for name, fn in (("floatrates", _fetch_floatrates), ("cbr", _fetch_cbr)):
|
||||
try:
|
||||
r = await fn()
|
||||
if r is not None and _valid(r):
|
||||
rate, source = r, name
|
||||
break
|
||||
except Exception: # noqa: BLE001 - 数据源失败降级
|
||||
continue
|
||||
|
||||
if rate is None:
|
||||
rate, source = _FALLBACK_RATE, "fallback"
|
||||
|
||||
_cache.update({"rate": rate, "source": source, "ts": now})
|
||||
return dict(_cache)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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", {})
|
||||
@@ -0,0 +1,79 @@
|
||||
"""发布:组装 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
|
||||
@@ -0,0 +1,105 @@
|
||||
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
|
||||
# 本地存储根目录(仓库根 data/media/)
|
||||
_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media"
|
||||
|
||||
|
||||
def _ext_from_url(url: str) -> str:
|
||||
ext = mimetypes.guess_extension(url.split("?")[0].lower()) or ".jpg"
|
||||
if ext == ".jpe":
|
||||
ext = ".jpg"
|
||||
return ext
|
||||
|
||||
|
||||
def _ext_from_content_type(content_type: str) -> str:
|
||||
ctype = (content_type or "").split(";")[0].strip().lower()
|
||||
mapping = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/heic": ".heic",
|
||||
"video/mp4": ".mp4",
|
||||
}
|
||||
return mapping.get(ctype, ".jpg")
|
||||
|
||||
|
||||
async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]:
|
||||
"""下载远程字节。返回 (bytes, content_type)。"""
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
|
||||
return resp.content, ctype
|
||||
|
||||
|
||||
class LocalStorage:
|
||||
"""开发期:落 data/media/,由 FastAPI /media 静态托管,返回 app_base_url 可访问 URL。"""
|
||||
|
||||
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
|
||||
data, _ = await download_bytes(url, referer)
|
||||
key = self._write(data, key_prefix, url)
|
||||
return self.public_url(key)
|
||||
|
||||
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
|
||||
key = self._write(data, "", key)
|
||||
return self.public_url(key)
|
||||
|
||||
def _write(self, data: bytes, key_prefix: str, hint: str) -> str:
|
||||
_LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
ext = _ext_from_url(hint) if hint and not hint.startswith("data:") else ".jpg"
|
||||
key = f"{key_prefix + '/' if key_prefix else ''}{uuid.uuid4().hex}{ext}"
|
||||
(_LOCAL_ROOT / key).parent.mkdir(parents=True, exist_ok=True)
|
||||
(_LOCAL_ROOT / key).write_bytes(data)
|
||||
return key
|
||||
|
||||
def public_url(self, key: str) -> str:
|
||||
settings = get_settings()
|
||||
return f"{settings.app_base_url.rstrip('/')}/media/{key}"
|
||||
|
||||
|
||||
class QiniuStorage:
|
||||
"""生产:上传七牛,返回绑定域名公网 URL(Ozon 可拉取)。"""
|
||||
|
||||
def _client(self):
|
||||
import qiniu
|
||||
|
||||
settings = get_settings()
|
||||
return qiniu.Auth(settings.qiniu_access_key, settings.qiniu_secret_key), settings
|
||||
|
||||
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
|
||||
data, ctype = await download_bytes(url, referer)
|
||||
return await self.save_bytes(data, f"{key_prefix}/{uuid.uuid4().hex}{_ext_from_content_type(ctype)}", ctype)
|
||||
|
||||
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
|
||||
import qiniu
|
||||
|
||||
auth, settings = self._client()
|
||||
bucket = settings.qiniu_bucket
|
||||
token = auth.upload_token(bucket, key, 3600)
|
||||
ret, info = qiniu.put_data(token, key, data)
|
||||
if info.status_code not in (200,):
|
||||
raise RuntimeError(f"七牛上传失败:{info.error or info.text_body or info.status_code}")
|
||||
return self.public_url(key)
|
||||
|
||||
def public_url(self, key: str) -> str:
|
||||
settings = get_settings()
|
||||
return f"{settings.qiniu_domain.rstrip('/')}/{key}"
|
||||
|
||||
|
||||
def get_storage():
|
||||
settings = get_settings()
|
||||
if settings.use_qiniu:
|
||||
return QiniuStorage()
|
||||
return LocalStorage()
|
||||
Reference in New Issue
Block a user