refactor(server): 移除鉴权并归档遗留路由至 legacy/
- 删除 auth.py 与 deps.py,各路由去除 get_current_user 依赖 - collection.py 更名为 materials.py,冻结链路(ozon/publish/shops/categories)移入 legacy/ - 扩展默认生图服务端口并入 8800 并自动迁移旧配置,水印默认文案改为 Panda Store - 新增 docs/v2.1/backend-structure.md 后端结构盘点文档
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""Ozon 类目/属性字典代理(服务端持店铺凭证调用 Ozon,前端不直连)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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 legacy.models import Shop
|
||||
from legacy.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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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,5 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/api/ozon", tags=["ozon"])
|
||||
|
||||
# Phase 3: Ozon Seller API product upload
|
||||
@@ -0,0 +1,181 @@
|
||||
"""发布端点:提交 ImportProductsV3 + 后台轮询回填。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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 models import Product
|
||||
from legacy.models import PublishTask, Shop
|
||||
from models.enums import PublishStatus, Stage
|
||||
from legacy.services.ozon_client import OzonClient, OzonAPIError
|
||||
from legacy.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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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,119 @@
|
||||
"""店铺管理:绑定 Ozon Client-Id/Api-Key(加密落库)+ 连通性校验。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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 legacy.models import Shop
|
||||
from models.enums import ShopStatus
|
||||
from legacy.schemas.shop import ShopCreate, ShopListItem, ShopUpdate
|
||||
from legacy.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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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)
|
||||
):
|
||||
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}
|
||||
@@ -0,0 +1,12 @@
|
||||
"""legacy 冻结模型导出(Ozon 直传链路;表结构随 legacy api import 链注册到 Base.metadata)。"""
|
||||
from legacy.models.category import AttributeValue, CategoryAttribute, CategoryTree
|
||||
from legacy.models.publish_task import PublishTask
|
||||
from legacy.models.shop import Shop
|
||||
|
||||
__all__ = [
|
||||
"Shop",
|
||||
"PublishTask",
|
||||
"CategoryTree",
|
||||
"CategoryAttribute",
|
||||
"AttributeValue",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Ozon 类目字典缓存(可重建,不作为业务真源)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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,34 @@
|
||||
"""发布任务:一次 ImportProductsV3 请求与轮询结果。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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,31 @@
|
||||
"""Ozon 店铺(Client-Id / Api-Key 加密落库)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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,34 @@
|
||||
"""店铺(Ozon 凭证)请求/响应模型。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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,52 @@
|
||||
"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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,80 @@
|
||||
"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。"""
|
||||
# ⚠️ 冻结代码(Ozon API 直传链路):保留不维护,V2.1 起接口保留但不再投入。
|
||||
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
|
||||
Reference in New Issue
Block a user