ForcePilot/backend/package/yuxi/channel/extensions/kuaishou/api.py

132 lines
4.9 KiB
Python
Raw Normal View History

from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from urllib.parse import urlencode
import httpx
from .types import TokenInfo
logger = logging.getLogger(__name__)
KUWAISHOU_OPEN_API_BASE = "https://open.kuaishou.com"
# 快手官方 access_token 有效期 48h (172800s)
DEFAULT_EXPIRES_IN = 172800
# refresh_token 有效期 180 天 (15552000s)
DEFAULT_REFRESH_EXPIRES_IN = 15552000
class KuaishouAPIError(Exception):
def __init__(self, message: str, code: int = -1, raw: dict | None = None):
super().__init__(message)
self.code = code
self.raw = raw or {}
class KuaishouAPIClient:
def __init__(self, app_id: str, app_secret: str, timeout: float = 15.0):
self._app_id = app_id
self._app_secret = app_secret
self._http = httpx.AsyncClient(
base_url=KUWAISHOU_OPEN_API_BASE,
timeout=timeout,
)
self._token_info: TokenInfo | None = None
self._token_lock = asyncio.Lock()
async def close(self):
await self._http.aclose()
@property
def access_token(self) -> str | None:
if self._token_info and time.time() < self._token_info.expires_at - 300:
return self._token_info.access_token
return None
def get_authorization_url(self, redirect_uri: str, scope: str = "user_info") -> str:
"""生成快手 OAuth 授权 URLauthorization_code 模式)。"""
params = {
"app_id": self._app_id,
"redirect_uri": redirect_uri,
"scope": scope,
"response_type": "code",
}
return f"{KUWAISHOU_OPEN_API_BASE}/oauth2/authorize?{urlencode(params)}"
async def fetch_access_token(self, code: str, redirect_uri: str) -> TokenInfo:
"""使用 authorization_code 换取 access_token。
快手官方格式GET /oauth2/access_token?grant_type=authorization_code
&code={code}&redirect_uri={redirect_uri}&app_id={app_id}&app_secret={app_secret}
"""
async with self._token_lock:
params = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"app_id": self._app_id,
"app_secret": self._app_secret,
}
resp = await self._http.get("/oauth2/access_token", params=params)
data = resp.json()
if data.get("result") != 1:
raise KuaishouAPIError(
f"获取 access_token 失败: {data}",
code=data.get("error_code", -1),
raw=data,
)
self._token_info = TokenInfo(
access_token=data["access_token"],
expires_at=time.time() + data.get("expires_in", DEFAULT_EXPIRES_IN),
refresh_token=data.get("refresh_token", ""),
refresh_expires_at=time.time() + data.get("refresh_expires_in", DEFAULT_REFRESH_EXPIRES_IN),
)
return self._token_info
async def refresh_access_token(self) -> TokenInfo:
if not self._token_info or not self._token_info.refresh_token:
raise KuaishouAPIError("无 refresh_token无法刷新")
async with self._token_lock:
params = {
"grant_type": "refresh_token",
"refresh_token": self._token_info.refresh_token,
"app_id": self._app_id,
"app_secret": self._app_secret,
}
resp = await self._http.get("/oauth2/refresh_token", params=params)
data = resp.json()
if data.get("result") != 1:
raise KuaishouAPIError(
f"刷新 access_token 失败: {data}",
code=data.get("error_code", -1),
raw=data,
)
self._token_info = TokenInfo(
access_token=data["access_token"],
expires_at=time.time() + data.get("expires_in", DEFAULT_EXPIRES_IN),
refresh_token=data.get("refresh_token", ""),
refresh_expires_at=time.time() + data.get("refresh_expires_in", DEFAULT_REFRESH_EXPIRES_IN),
)
return self._token_info
async def ensure_token(self) -> str:
if token := self.access_token:
return token
if self._token_info and self._token_info.refresh_token:
info = await self.refresh_access_token()
return info.access_token
raise KuaishouAPIError("未获取 access_token请先完成 OAuth 授权")
async def post(self, path: str, json: dict | None = None, **kwargs) -> dict[str, Any]:
resp = await self._http.post(path, json=json, **kwargs)
return resp.json()
async def get(self, path: str, **kwargs) -> dict[str, Any]:
resp = await self._http.get(path, **kwargs)
return resp.json()