126 lines
4.4 KiB
Python
126 lines
4.4 KiB
Python
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from app.integrations.base import BaseClient
|
||
|
||
|
||
logger = logging.getLogger("connecthub.integrations.ehr")
|
||
|
||
|
||
class EhrClient(BaseClient):
|
||
"""
|
||
北森 EHR OpenAPI Client:
|
||
- POST /token 获取 access_token(grant_type=client_credentials)
|
||
- 业务请求自动携带 Authorization: Bearer <token>
|
||
- 401 自动刷新 token 并重试一次
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
base_url: str = "https://openapi.italent.cn",
|
||
secret_params: dict[str, str],
|
||
grant_type: str = "client_credentials",
|
||
token_skew_s: int = 30,
|
||
timeout_s: float = 10.0,
|
||
retries: int = 2,
|
||
retry_backoff_s: float = 0.5,
|
||
headers: dict[str, str] | None = None,
|
||
) -> None:
|
||
super().__init__(
|
||
base_url=base_url,
|
||
timeout_s=timeout_s,
|
||
retries=retries,
|
||
retry_backoff_s=retry_backoff_s,
|
||
headers=headers,
|
||
)
|
||
|
||
app_key = str((secret_params or {}).get("app_key", "") or "")
|
||
app_secret = str((secret_params or {}).get("app_secret", "") or "")
|
||
if not app_key or not app_secret:
|
||
raise ValueError("secret_params must contain app_key and app_secret")
|
||
|
||
self.secret_params = dict(secret_params)
|
||
self.grant_type = grant_type
|
||
self.token_skew_s = token_skew_s
|
||
|
||
self._access_token: str | None = None
|
||
self._token_type: str | None = None
|
||
self._token_expires_at: float | None = None
|
||
|
||
@staticmethod
|
||
def _normalize_token_type(token_type: str | None) -> str:
|
||
# 北森文档示例返回 token_type=bearer(小写),但鉴权头要求 "Bearer <token>"。
|
||
# 这里统一规范为首字母大写,避免服务端大小写敏感导致 401。
|
||
raw = str(token_type or "").strip()
|
||
if not raw:
|
||
return "Bearer"
|
||
if raw.lower() == "bearer":
|
||
return "Bearer"
|
||
return raw
|
||
|
||
def authenticate(self) -> str:
|
||
body: dict[str, Any] = {
|
||
"grant_type": self.grant_type,
|
||
"app_key": self.secret_params["app_key"],
|
||
"app_secret": self.secret_params["app_secret"],
|
||
}
|
||
resp = super().request(
|
||
"POST",
|
||
"/token",
|
||
json=body,
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
data = resp.json() if resp.content else {}
|
||
access_token = str(data.get("access_token", "") or "")
|
||
token_type = self._normalize_token_type(data.get("token_type"))
|
||
expires_in = int(data.get("expires_in", 0) or 0)
|
||
if not access_token:
|
||
raise RuntimeError("EHR authenticate failed (access_token missing)")
|
||
|
||
now = time.time()
|
||
skew = max(0, int(self.token_skew_s or 0))
|
||
self._access_token = access_token
|
||
self._token_type = token_type
|
||
self._token_expires_at = now + max(0, expires_in - skew)
|
||
logger.info("EHR access_token acquired (cached) expires_in=%s token_type=%s", expires_in, token_type)
|
||
return access_token
|
||
|
||
def _get_access_token(self) -> str:
|
||
now = time.time()
|
||
if self._access_token and self._token_expires_at and now < self._token_expires_at:
|
||
return self._access_token
|
||
return self.authenticate()
|
||
|
||
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: # type: ignore[override]
|
||
token = self._get_access_token()
|
||
token_type = self._normalize_token_type(self._token_type)
|
||
|
||
headers = dict(kwargs.pop("headers", {}) or {})
|
||
headers["Authorization"] = f"{token_type} {token}"
|
||
|
||
try:
|
||
return super().request(method, path, headers=headers, **kwargs)
|
||
except httpx.HTTPStatusError as e:
|
||
resp = e.response
|
||
if resp.status_code != 401:
|
||
raise
|
||
|
||
logger.info("EHR access_token invalid (401), refreshing and retrying once")
|
||
self._access_token = None
|
||
self._token_type = None
|
||
self._token_expires_at = None
|
||
|
||
token2 = self._get_access_token()
|
||
token_type2 = self._normalize_token_type(self._token_type)
|
||
headers["Authorization"] = f"{token_type2} {token2}"
|
||
return super().request(method, path, headers=headers, **kwargs)
|
||
|
||
def request_authed(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||
return self.request(method, path, **kwargs)
|