Vastai-ConnectHub/app/integrations/ehr.py

126 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_tokengrant_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)