ForcePilot/backend/package/yuxi/channel/extensions/jd/gateway.py

240 lines
8.5 KiB
Python
Raw Normal View History

import asyncio
import json
import logging
import os
import time
import httpx
from yuxi.channel.extensions.jd.config import JDConfig
from yuxi.channel.extensions.jd.types import JDAccount, JOSToken
logger = logging.getLogger(__name__)
OAUTH_TOKEN_URL = "https://oauth.jd.com/oauth/token"
TOKEN_REFRESH_MARGIN = 300
REFRESH_INTERVAL = 1800
RETRY_MAX = 3
RETRY_BASE_DELAY = 2
_TOKEN_STORE_DIR = os.path.join(os.path.dirname(__file__), ".token_cache")
class JDGateway:
def __init__(self):
self._account: JDAccount | None = None
self._queue: asyncio.Queue | None = None
self._cancel_event = asyncio.Event()
self._access_token: str | None = None
self._token_obj: JOSToken | None = None
self._token_expires_at: float = 0
self._refresh_task: asyncio.Task | None = None
self._http: httpx.AsyncClient | None = None
self._running = False
self._token_lock = asyncio.Lock()
self._last_chat_id: dict[str, str] = {}
async def start(self, ctx) -> object:
config = JDConfig()
account = config.resolve_account(ctx.account_id if hasattr(ctx, "account_id") else "default")
if not account.is_configured():
logger.warning("JD account not configured, skipping start")
return {"running": False, "reason": "not-configured"}
self._account = account
self._queue = asyncio.Queue(maxsize=1000)
self._cancel_event.clear()
self._http = httpx.AsyncClient(timeout=15.0)
try:
await self._restore_or_fetch_token()
self._refresh_task = asyncio.create_task(self._token_refresh_loop())
self._running = True
logger.info(
"JD gateway started: account=%s shop=%s token_expires=%d",
account.account_id,
account.shop_id,
int(self._token_expires_at - time.time()) if self._token_expires_at else 0,
)
return {
"running": True,
"account_id": account.account_id,
"shop_id": account.shop_id,
"queue": self._queue,
}
except Exception:
logger.exception("JD gateway failed to start")
await self.stop(ctx)
raise
async def stop(self, ctx) -> None:
self._running = False
self._cancel_event.set()
if self._refresh_task and not self._refresh_task.done():
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
if self._http:
await self._http.aclose()
self._http = None
self._queue = None
self._last_chat_id.clear()
logger.info("JD gateway stopped")
@property
def account(self) -> JDAccount | None:
return self._account
@property
def queue(self) -> asyncio.Queue | None:
return self._queue
def get_token(self, account_id: str = "default") -> str | None:
if self._access_token and time.time() < self._token_expires_at:
return self._access_token
return None
def get_account(self, account_id: str = "default") -> JDAccount | None:
return self._account
def cache_chat_id(self, user_id: str, chat_id: str):
self._last_chat_id[user_id] = chat_id
def get_cached_chat_id(self, user_id: str) -> str | None:
return self._last_chat_id.get(user_id)
async def _restore_or_fetch_token(self):
account = self._account
loaded = await self._load_token_from_disk()
if loaded and loaded.get("expires_at", 0) > time.time() + TOKEN_REFRESH_MARGIN:
self._access_token = loaded["access_token"]
self._token_expires_at = loaded["expires_at"]
if loaded.get("refresh_token"):
self._account.refresh_token = loaded["refresh_token"]
logger.info("JD token restored from disk")
return
if account.access_token and account.token_expires_at:
if account.token_expires_at > time.time() + TOKEN_REFRESH_MARGIN:
self._access_token = account.access_token
self._token_expires_at = account.token_expires_at
return
if account.refresh_token:
try:
await self._refresh_token()
except Exception as e:
logger.error("JD token refresh failed on start: %s", e)
if not self._access_token:
logger.warning("JD account %s has no valid access token", account.account_id)
async def _token_refresh_loop(self):
while not self._cancel_event.is_set():
try:
await asyncio.sleep(REFRESH_INTERVAL)
if self._cancel_event.is_set():
return
remaining = self._token_expires_at - time.time()
if remaining < TOKEN_REFRESH_MARGIN:
logger.info("JD token refresh triggered: remaining=%ds", int(remaining))
await self._refresh_token()
except asyncio.CancelledError:
break
except Exception as e:
logger.error("JD token refresh loop error: %s", e)
async def _refresh_token(self):
async with self._token_lock:
if not self._account.refresh_token:
return
params = {
"grant_type": "refresh_token",
"client_id": self._account.app_key,
"client_secret": self._account.app_secret,
"refresh_token": self._account.refresh_token,
}
for attempt in range(RETRY_MAX):
try:
resp = await self._http.post(OAUTH_TOKEN_URL, data=params)
resp.raise_for_status()
data = resp.json()
expires_in = data.get("expires_in", 7200)
self._access_token = data["access_token"]
self._token_expires_at = time.time() + expires_in - TOKEN_REFRESH_MARGIN
new_refresh = data.get("refresh_token", "")
if new_refresh:
self._account.refresh_token = new_refresh
self._token_obj = JOSToken(
access_token=data["access_token"],
refresh_token=data.get("refresh_token", self._account.refresh_token or ""),
expires_at=time.time() + expires_in,
uid=data.get("uid", ""),
user_nick=data.get("user_nick", ""),
)
await self._save_token_to_disk()
logger.info(
"JD token refreshed: expires_in=%ds uid=%s",
expires_in,
data.get("uid", ""),
)
return
except Exception as e:
logger.warning("JD token refresh attempt %d failed: %s", attempt + 1, e)
if attempt == RETRY_MAX - 1:
raise
delay = RETRY_BASE_DELAY * (2**attempt)
await asyncio.sleep(delay)
async def _save_token_to_disk(self):
if not self._token_obj:
return
try:
os.makedirs(_TOKEN_STORE_DIR, exist_ok=True)
token_file = os.path.join(_TOKEN_STORE_DIR, f"{self._account.shop_id}.json")
with open(token_file, "w") as f:
json.dump(
{
"access_token": self._access_token,
"refresh_token": self._account.refresh_token,
"expires_at": self._token_expires_at,
"uid": self._token_obj.uid,
"user_nick": self._token_obj.user_nick,
},
f,
)
except Exception as e:
logger.warning("Failed to save JD token to disk: %s", e)
async def _load_token_from_disk(self) -> dict | None:
if not self._account:
return None
token_file = os.path.join(_TOKEN_STORE_DIR, f"{self._account.shop_id}.json")
if not os.path.exists(token_file):
return None
try:
with open(token_file) as f:
data = json.load(f)
if data.get("expires_at", 0) > time.time():
return data
except Exception as e:
logger.warning("Failed to load JD token from disk: %s", e)
return None