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 - 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 "。 # 这里统一规范为首字母大写,避免服务端大小写敏感导致 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)