from __future__ import annotations import logging import time from typing import Any import httpx from .config import LAZADA_SITE_ENDPOINTS, LAZADA_AUTH_ENDPOINT from .signature import generate_signature from .types import TokenInfo logger = logging.getLogger(__name__) class LazadaAPIError(Exception): def __init__(self, message: str, code: str = "-1", raw: dict | None = None): super().__init__(message) self.code = code self.raw = raw or {} class LazadaAPIClient: def __init__( self, app_key: str, app_secret: str, site_code: str = "SG", timeout: float = 15.0, ): self._app_key = app_key self._app_secret = app_secret self._site_code = site_code self._timeout = timeout self._http: httpx.AsyncClient | None = None self._token_info: TokenInfo | None = None @property def api_endpoint(self) -> str: return LAZADA_SITE_ENDPOINTS.get(self._site_code, LAZADA_SITE_ENDPOINTS["SG"]) @property def http(self) -> httpx.AsyncClient: if self._http is None: self._http = httpx.AsyncClient(timeout=self._timeout) return self._http async def close(self) -> None: if self._http: await self._http.aclose() self._http = None def set_token(self, token_info: TokenInfo) -> None: self._token_info = token_info @property def access_token(self) -> str | None: if self._token_info and time.time() < self._token_info.expires_at - 86400: return self._token_info.access_token return None def _build_system_params(self, access_token: str | None = None) -> dict: token = access_token or (self._token_info.access_token if self._token_info else "") return { "app_key": self._app_key, "timestamp": str(int(time.time() * 1000)), "sign_method": "sha256", "access_token": token, } async def call( self, api_path: str, method: str = "GET", params: dict | None = None, access_token: str | None = None, ) -> dict[str, Any]: all_params = {**self._build_system_params(access_token), **(params or {})} all_params["sign"] = generate_signature(self._app_secret, all_params) query_string = "&".join(f"{k}={v}" for k, v in all_params.items()) url = f"{self.api_endpoint}{api_path}?{query_string}" try: if method.upper() == "GET": resp = await self.http.get(url) else: resp = await self.http.post(url) data = resp.json() except httpx.TimeoutException: raise LazadaAPIError("API 请求超时", code="TIMEOUT") except Exception as e: raise LazadaAPIError(f"API 请求失败: {e}", code="NETWORK_ERROR") if data.get("code") == "0": return data error_code = data.get("code", "-1") error_msg = data.get("msg", data.get("message", "未知错误")) if error_code in ("InvalidAccessToken", "41", "AccessTokenExpired"): raise LazadaAPIError( f"Access Token 无效或过期: {error_msg}", code=error_code, raw=data, ) raise LazadaAPIError( f"Lazada API 错误 [{error_code}]: {error_msg}", code=error_code, raw=data, ) async def _call_multipart( self, api_path: str, files: dict[str, tuple[str, bytes, str]], data: dict | None = None, access_token: str | None = None, ) -> dict[str, Any]: sys_params = self._build_system_params(access_token) sys_params["sign"] = generate_signature(self._app_secret, {**sys_params, **(data or {})}) query_string = "&".join(f"{k}={v}" for k, v in sys_params.items()) url = f"{self.api_endpoint}{api_path}?{query_string}" try: resp = await self.http.post(url, data=data, files=files) result = resp.json() except httpx.TimeoutException: raise LazadaAPIError("API 请求超时", code="TIMEOUT") except Exception as e: raise LazadaAPIError(f"API 请求失败: {e}", code="NETWORK_ERROR") if result.get("code") == "0": return result error_code = result.get("code", "-1") error_msg = result.get("msg", result.get("message", "未知错误")) raise LazadaAPIError( f"Lazada API 错误 [{error_code}]: {error_msg}", code=error_code, raw=result, ) async def upload_image(self, file_content: bytes, filename: str) -> dict[str, Any]: return await self._call_multipart( "/image/upload", files={"image": (filename, file_content, "application/octet-stream")}, data={"filename": filename}, ) async def upload_video(self, file_content: bytes, filename: str) -> dict[str, Any]: return await self._call_multipart( "/media/video/block/create", files={"video": (filename, file_content, "application/octet-stream")}, data={"filename": filename}, ) async def get_video(self, video_id: str) -> dict[str, Any]: return await self.call("/media/video/get", params={"video_id": video_id}) async def fetch_token(self, code: str, redirect_uri: str) -> TokenInfo: params = { "code": code, "app_key": self._app_key, "sign_method": "sha256", "timestamp": str(int(time.time() * 1000)), } params["sign"] = generate_signature(self._app_secret, params) query_string = "&".join(f"{k}={v}" for k, v in params.items()) url = f"{LAZADA_AUTH_ENDPOINT}/auth/token/create?{query_string}" resp = await self.http.post(url) data = resp.json() if data.get("code") != "0": raise LazadaAPIError( f"获取 token 失败: {data.get('msg', '')}", code=data.get("code", "-1"), raw=data, ) now = time.time() token_info = TokenInfo( access_token=data["access_token"], refresh_token=data["refresh_token"], expires_at=now + data.get("expires_in", 15552000), refresh_expires_at=now + data.get("refresh_expires_in", 15552000), country=data.get("country", ""), account_id=data.get("account_id", ""), ) self._token_info = token_info return token_info async def refresh_token(self) -> TokenInfo: if not self._token_info or not self._token_info.refresh_token: raise LazadaAPIError("无 refresh_token 可刷新", code="NO_REFRESH_TOKEN") params = { "refresh_token": self._token_info.refresh_token, "app_key": self._app_key, "sign_method": "sha256", "timestamp": str(int(time.time() * 1000)), } params["sign"] = generate_signature(self._app_secret, params) query_string = "&".join(f"{k}={v}" for k, v in params.items()) url = f"{LAZADA_AUTH_ENDPOINT}/auth/token/refresh?{query_string}" resp = await self.http.post(url) data = resp.json() if data.get("code") != "0": raise LazadaAPIError( f"刷新 token 失败: {data.get('msg', '')}", code=data.get("code", "-1"), raw=data, ) now = time.time() token_info = TokenInfo( access_token=data["access_token"], refresh_token=data["refresh_token"], expires_at=now + data.get("expires_in", 15552000), refresh_expires_at=now + data.get("refresh_expires_in", 15552000), country=data.get("country", ""), account_id=data.get("account_id", ""), ) self._token_info = token_info return token_info def switch_site(self, site_code: str) -> None: if site_code not in LAZADA_SITE_ENDPOINTS: raise ValueError(f"无效的站点代码: {site_code}") self._site_code = site_code