140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
"""用户中心客户端:用请求携带的 token 换取当前用户,结果短时缓存"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import time
|
|
from functools import cache
|
|
|
|
import httpx
|
|
from loguru import logger
|
|
|
|
from app.config import Settings, settings
|
|
from app.core.exceptions import BizException
|
|
from app.schemas.user import CurrentUser
|
|
|
|
# 用户中心 401 时提示调用方用 Bearer token 重新认证
|
|
BEARER_CHALLENGE = {"WWW-Authenticate": "Bearer"}
|
|
|
|
# 瞬时故障的重试间隔(秒),长度即重试次数
|
|
RETRY_DELAYS = (0.2, 0.5)
|
|
|
|
|
|
class UserCenterClient:
|
|
def __init__(
|
|
self,
|
|
config: Settings | None = None,
|
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
):
|
|
self._config = config if config is not None else settings
|
|
self._transport = transport
|
|
self._client: httpx.AsyncClient | None = None
|
|
# token 摘要 -> (过期时刻, 用户);不在内存中保存明文 token
|
|
self._cache: dict[str, tuple[float, CurrentUser]] = {}
|
|
|
|
def _get_client(self) -> httpx.AsyncClient:
|
|
if self._client is None:
|
|
self._client = httpx.AsyncClient(
|
|
base_url=self._config.user_center_url,
|
|
timeout=self._config.user_center_timeout_seconds,
|
|
transport=self._transport,
|
|
)
|
|
return self._client
|
|
|
|
def _cached(self, key: str) -> CurrentUser | None:
|
|
entry = self._cache.get(key)
|
|
if entry is None:
|
|
return None
|
|
if entry[0] <= time.monotonic():
|
|
del self._cache[key]
|
|
return None
|
|
return entry[1]
|
|
|
|
def _remember(self, key: str, user: CurrentUser) -> None:
|
|
ttl = self._config.auth_cache_ttl_seconds
|
|
if not ttl:
|
|
return
|
|
now = time.monotonic()
|
|
# 写入时顺带清掉过期项,缓存规模不超过 TTL 内的活跃 token 数
|
|
for stale in [k for k, (expires, _) in self._cache.items() if expires <= now]:
|
|
del self._cache[stale]
|
|
self._cache[key] = (now + ttl, user)
|
|
|
|
async def _request_with_retry(self, token: str) -> httpx.Response:
|
|
"""校验是幂等的 GET:连接失败、超时和 5xx 属于瞬时故障,短间隔重试"""
|
|
attempts = 1 + len(RETRY_DELAYS)
|
|
for attempt in range(attempts):
|
|
try:
|
|
response = await self._get_client().get(
|
|
"/api/users/me", headers={"Authorization": f"Bearer {token}"}
|
|
)
|
|
except httpx.HTTPError as error:
|
|
logger.opt(exception=error).warning(
|
|
"用户中心请求失败 attempt={}/{}", attempt + 1, attempts
|
|
)
|
|
if attempt == attempts - 1:
|
|
raise BizException(503, "用户中心不可用") from error
|
|
else:
|
|
if response.status_code < 500:
|
|
return response
|
|
logger.warning(
|
|
"用户中心返回 HTTP {} attempt={}/{}",
|
|
response.status_code,
|
|
attempt + 1,
|
|
attempts,
|
|
)
|
|
if attempt == attempts - 1:
|
|
raise BizException(503, "用户中心不可用")
|
|
await asyncio.sleep(RETRY_DELAYS[attempt])
|
|
raise AssertionError("unreachable")
|
|
|
|
async def fetch_current_user(self, token: str) -> CurrentUser:
|
|
key = hashlib.sha256(token.encode()).hexdigest()
|
|
user = self._cached(key)
|
|
if user is not None:
|
|
return user
|
|
|
|
response = await self._request_with_retry(token)
|
|
try:
|
|
body = response.json()
|
|
except ValueError:
|
|
body = None
|
|
if not isinstance(body, dict):
|
|
body = {}
|
|
if response.status_code >= 400:
|
|
# 用户中心与本服务的错误响应同构,直接透传状态码和提示
|
|
raise BizException(
|
|
response.status_code,
|
|
body.get("msg") or "认证失败",
|
|
BEARER_CHALLENGE if response.status_code == 401 else None,
|
|
)
|
|
try:
|
|
user = CurrentUser.model_validate(body["data"])
|
|
except (KeyError, TypeError, ValueError) as error:
|
|
logger.warning("用户中心返回的数据格式无效")
|
|
raise BizException(503, "用户中心不可用") from error
|
|
|
|
self._remember(key, user)
|
|
return user
|
|
|
|
async def aclose(self) -> None:
|
|
if self._client is not None:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
|
|
@cache
|
|
def _cached_client() -> UserCenterClient:
|
|
return UserCenterClient()
|
|
|
|
|
|
def get_user_center() -> UserCenterClient:
|
|
"""共享入口;适用于单个服务事件循环"""
|
|
return _cached_client()
|
|
|
|
|
|
async def close_user_center() -> None:
|
|
"""在应用关闭阶段调用"""
|
|
if _cached_client.cache_info().currsize:
|
|
await _cached_client().aclose()
|
|
_cached_client.cache_clear()
|