feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+23
View File
@@ -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)
+121
View File
@@ -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)
+272
View File
@@ -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)}
+14
View File
@@ -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()
+186
View File
@@ -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)
+183
View File
@@ -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
]
+124
View File
@@ -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}
+29 -1
View File
@@ -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;上线切 PostgreSQLpostgresql+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:
View File
+56
View File
@@ -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")
+50
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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")
+53
View File
@@ -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()
+25
View File
@@ -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"}
View File
@@ -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 ###
+18
View File
@@ -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",
]
+34
View File
@@ -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())
+60
View File
@@ -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())
+39
View File
@@ -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")
+76
View File
@@ -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)
+33
View File
@@ -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)
+30
View File
@@ -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()
)
+8
View File
@@ -0,0 +1,8 @@
"""共享列类型:JSONSQLite 存 TEXTPostgreSQL 存 JSON;跨库一致)。"""
from __future__ import annotations
from sqlalchemy import JSON
# 统一用 generic JSONSQLite/PostgreSQL 均可,避免 autogenerate 对 JSONB 变体渲染异常。
# 生产若需 JSONB 的索引能力,可再按需迁移,量级上差异可忽略。
JSONType = JSON
+19
View File
@@ -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())
+9
View File
@@ -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
+14
View File
@@ -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
+42
View File
@@ -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 重复而跳过
+102
View File
@@ -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
+33
View File
@@ -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
+58
View File
@@ -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)
+51
View File
@@ -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", {})
+79
View File
@@ -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
+105
View File
@@ -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()