feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""汇率服务:CNY→RUB。数据源三级降级(FloatRates → 俄央行 → 兜底),服务端缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
_FALLBACK_RATE = 11.5
|
||||
_MIN, _MAX = 5.0, 25.0
|
||||
_CACHE_TTL = 3600 # 秒
|
||||
|
||||
_cache: dict = {"rate": None, "source": "", "ts": 0.0}
|
||||
|
||||
|
||||
def _valid(rate: float) -> bool:
|
||||
return _MIN <= rate <= _MAX
|
||||
|
||||
|
||||
async def _fetch_floatrates() -> float | None:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
resp = await client.get("https://www.floatrates.com/daily/cny.json")
|
||||
resp.raise_for_status()
|
||||
rub = resp.json().get("rub", {})
|
||||
rate = rub.get("rate")
|
||||
return float(rate) if rate else None
|
||||
|
||||
|
||||
async def _fetch_cbr() -> float | None:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
resp = await client.get("https://www.cbr-xml-daily.ru/daily_json.js")
|
||||
resp.raise_for_status()
|
||||
cny = resp.json().get("Valute", {}).get("CNY", {})
|
||||
value = cny.get("Value")
|
||||
return float(value) if value else None
|
||||
|
||||
|
||||
async def get_fx_rate() -> dict:
|
||||
"""返回 {rate, source, updated_at}。带 1 小时内存缓存。"""
|
||||
now = time.time()
|
||||
if _cache["rate"] and (now - _cache["ts"]) < _CACHE_TTL:
|
||||
return dict(_cache)
|
||||
|
||||
rate = None
|
||||
source = ""
|
||||
for name, fn in (("floatrates", _fetch_floatrates), ("cbr", _fetch_cbr)):
|
||||
try:
|
||||
r = await fn()
|
||||
if r is not None and _valid(r):
|
||||
rate, source = r, name
|
||||
break
|
||||
except Exception: # noqa: BLE001 - 数据源失败降级
|
||||
continue
|
||||
|
||||
if rate is None:
|
||||
rate, source = _FALLBACK_RATE, "fallback"
|
||||
|
||||
_cache.update({"rate": rate, "source": source, "ts": now})
|
||||
return dict(_cache)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Ozon Seller API 客户端(薄封装:鉴权头 + 错误映射 + 退避)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
OZON_BASE_URL = "https://api-seller.ozon.ru"
|
||||
|
||||
|
||||
class OzonAPIError(Exception):
|
||||
def __init__(self, status: int, detail: str):
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
super().__init__(f"Ozon API {status}: {detail}")
|
||||
|
||||
|
||||
class OzonClient:
|
||||
def __init__(self, client_id: str, api_key: str, base_url: str = OZON_BASE_URL):
|
||||
self.client_id = client_id
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Client-Id": self.client_id,
|
||||
"Api-Key": self.api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def post(self, path: str, body: dict | None = None, timeout: float = 60.0) -> dict:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.post(f"{self.base_url}{path}", headers=self._headers(), json=body or {})
|
||||
if resp.status_code >= 400:
|
||||
raise OzonAPIError(resp.status_code, resp.text[:500])
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
async def get(self, path: str, timeout: float = 60.0) -> dict:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.get(f"{self.base_url}{path}", headers=self._headers())
|
||||
if resp.status_code >= 400:
|
||||
raise OzonAPIError(resp.status_code, resp.text[:500])
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
|
||||
async def test_credentials(self) -> dict:
|
||||
"""调 /v1/roles 校验凭证与权限范围。"""
|
||||
return await self.post("/v1/roles", {})
|
||||
@@ -0,0 +1,79 @@
|
||||
"""发布:组装 ImportProductsV3 items[0] + 必填校验 + 轮询回填。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from models import Product
|
||||
|
||||
|
||||
def _fmt(v) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
|
||||
def validate_ready(product: Product) -> list[str]:
|
||||
"""返回缺失/非法必填项的中文提示列表;空列表表示可发布。"""
|
||||
missing: list[str] = []
|
||||
if not product.offer_id.strip():
|
||||
missing.append("货号 offer_id")
|
||||
if not product.name.strip():
|
||||
missing.append("商品名 name")
|
||||
if not product.description.strip():
|
||||
missing.append("描述 description")
|
||||
if not product.description_category_id:
|
||||
missing.append("类目 description_category_id")
|
||||
if product.price is None or product.price <= 0:
|
||||
missing.append("售价 price")
|
||||
if not product.weight or product.weight <= 0:
|
||||
missing.append("重量 weight")
|
||||
for label, val in (("长 depth", product.depth), ("宽 width", product.width), ("高 height", product.height)):
|
||||
if not val or val <= 0:
|
||||
missing.append(label)
|
||||
if not product.images:
|
||||
missing.append("主图 images(至少 1 张)")
|
||||
elif any(u and u.startswith("http://") for u in product.images):
|
||||
missing.append("图片链接必须使用 https(Ozon 不接受 http 直链)")
|
||||
return missing
|
||||
|
||||
|
||||
def _with_model_name(product: Product) -> list:
|
||||
"""把 raw.model_name 自动注入为 attribute 9048(型号名称),用于多变体合并。"""
|
||||
attrs = list(product.attributes or [])
|
||||
model_name = (product.raw or {}).get("model_name") if product.raw else None
|
||||
if not model_name:
|
||||
return attrs
|
||||
# 已手动映射 9048 就不重复添加
|
||||
for a in attrs:
|
||||
if isinstance(a, dict) and a.get("id") == 9048:
|
||||
return attrs
|
||||
attrs.append({"complex_id": 0, "id": 9048, "values": [{"value": model_name}]})
|
||||
return attrs
|
||||
|
||||
|
||||
def build_import_item(product: Product) -> dict:
|
||||
item: dict = {
|
||||
"offer_id": product.offer_id,
|
||||
"name": product.name,
|
||||
"description": product.description,
|
||||
"description_category_id": product.description_category_id,
|
||||
"price": _fmt(product.price),
|
||||
"old_price": _fmt(product.old_price),
|
||||
"currency_code": product.currency_code or "CNY",
|
||||
"vat": product.vat or "0",
|
||||
"depth": product.depth,
|
||||
"width": product.width,
|
||||
"height": product.height,
|
||||
"dimension_unit": product.dimension_unit or "mm",
|
||||
"weight": product.weight,
|
||||
"weight_unit": product.weight_unit or "g",
|
||||
"images": product.images or [],
|
||||
"primary_image": product.primary_image or "",
|
||||
"images360": product.images360 or [],
|
||||
"color_image": product.color_image or "",
|
||||
"attributes": _with_model_name(product),
|
||||
"complex_attributes": product.complex_attributes or [],
|
||||
}
|
||||
if product.type_id:
|
||||
item["type_id"] = product.type_id
|
||||
if product.barcode:
|
||||
item["barcode"] = product.barcode
|
||||
return item
|
||||
@@ -0,0 +1,105 @@
|
||||
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
|
||||
# 本地存储根目录(仓库根 data/media/)
|
||||
_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media"
|
||||
|
||||
|
||||
def _ext_from_url(url: str) -> str:
|
||||
ext = mimetypes.guess_extension(url.split("?")[0].lower()) or ".jpg"
|
||||
if ext == ".jpe":
|
||||
ext = ".jpg"
|
||||
return ext
|
||||
|
||||
|
||||
def _ext_from_content_type(content_type: str) -> str:
|
||||
ctype = (content_type or "").split(";")[0].strip().lower()
|
||||
mapping = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/heic": ".heic",
|
||||
"video/mp4": ".mp4",
|
||||
}
|
||||
return mapping.get(ctype, ".jpg")
|
||||
|
||||
|
||||
async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]:
|
||||
"""下载远程字节。返回 (bytes, content_type)。"""
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
|
||||
return resp.content, ctype
|
||||
|
||||
|
||||
class LocalStorage:
|
||||
"""开发期:落 data/media/,由 FastAPI /media 静态托管,返回 app_base_url 可访问 URL。"""
|
||||
|
||||
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
|
||||
data, _ = await download_bytes(url, referer)
|
||||
key = self._write(data, key_prefix, url)
|
||||
return self.public_url(key)
|
||||
|
||||
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
|
||||
key = self._write(data, "", key)
|
||||
return self.public_url(key)
|
||||
|
||||
def _write(self, data: bytes, key_prefix: str, hint: str) -> str:
|
||||
_LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
ext = _ext_from_url(hint) if hint and not hint.startswith("data:") else ".jpg"
|
||||
key = f"{key_prefix + '/' if key_prefix else ''}{uuid.uuid4().hex}{ext}"
|
||||
(_LOCAL_ROOT / key).parent.mkdir(parents=True, exist_ok=True)
|
||||
(_LOCAL_ROOT / key).write_bytes(data)
|
||||
return key
|
||||
|
||||
def public_url(self, key: str) -> str:
|
||||
settings = get_settings()
|
||||
return f"{settings.app_base_url.rstrip('/')}/media/{key}"
|
||||
|
||||
|
||||
class QiniuStorage:
|
||||
"""生产:上传七牛,返回绑定域名公网 URL(Ozon 可拉取)。"""
|
||||
|
||||
def _client(self):
|
||||
import qiniu
|
||||
|
||||
settings = get_settings()
|
||||
return qiniu.Auth(settings.qiniu_access_key, settings.qiniu_secret_key), settings
|
||||
|
||||
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
|
||||
data, ctype = await download_bytes(url, referer)
|
||||
return await self.save_bytes(data, f"{key_prefix}/{uuid.uuid4().hex}{_ext_from_content_type(ctype)}", ctype)
|
||||
|
||||
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
|
||||
import qiniu
|
||||
|
||||
auth, settings = self._client()
|
||||
bucket = settings.qiniu_bucket
|
||||
token = auth.upload_token(bucket, key, 3600)
|
||||
ret, info = qiniu.put_data(token, key, data)
|
||||
if info.status_code not in (200,):
|
||||
raise RuntimeError(f"七牛上传失败:{info.error or info.text_body or info.status_code}")
|
||||
return self.public_url(key)
|
||||
|
||||
def public_url(self, key: str) -> str:
|
||||
settings = get_settings()
|
||||
return f"{settings.qiniu_domain.rstrip('/')}/{key}"
|
||||
|
||||
|
||||
def get_storage():
|
||||
settings = get_settings()
|
||||
if settings.use_qiniu:
|
||||
return QiniuStorage()
|
||||
return LocalStorage()
|
||||
Reference in New Issue
Block a user