385 lines
18 KiB
Diff
385 lines
18 KiB
Diff
--- a/backend_client.py
|
||
+++ b/backend_client.py
|
||
@@ -51,6 +51,7 @@
|
||
MODEL_CALL_PATHS = ("/api/v2/model/calls", "/api/v1/model/calls")
|
||
DESKTOP_AUTH_CONFIG_PATH = "/api/v2/desktop/auth/config"
|
||
DESKTOP_AUTH_EXCHANGE_PATH = "/api/v2/desktop/auth/exchange"
|
||
+DESKTOP_ZYT_SESSION_PATH = "/api/v2/desktop/auth/zyt-session"
|
||
DESKTOP_ME_PATH = "/api/v2/desktop/me"
|
||
DESKTOP_LOGOUT_PATH = "/api/v2/desktop/auth/logout"
|
||
DESKTOP_PATIENT_CONTEXT_PATH = "/api/v2/archive/desktop/patient-context"
|
||
@@ -67,6 +68,9 @@
|
||
LOGOUT_PATHS = ("/api/v2/auth/logout", "/api/v1/auth/logout")
|
||
CONFIG_PATHS = ("/api/v2/config", "/api/v1/config")
|
||
_LOCK = threading.RLock()
|
||
+_PATIENT_AUTH_LOCK = threading.Lock()
|
||
+_PATIENT_KEEPALIVE_LOCK = threading.Lock()
|
||
+_PATIENT_KEEPALIVE_LAST: dict[str, Any] = {}
|
||
_SENSITIVE_NAME_PARTS = (
|
||
"api_key",
|
||
"apikey",
|
||
@@ -94,13 +98,21 @@
|
||
"接口不存在"、"页面不存在"来猜,只要后台换个措辞就悄悄失效。
|
||
"""
|
||
|
||
- def __init__(self, message: str, *, status: int | None = None):
|
||
+ def __init__(self, message: str, *, status: int | None = None, code: str = ""):
|
||
super().__init__(message)
|
||
self.status = status
|
||
+ self.code = str(code or "")
|
||
|
||
|
||
class AuthenticationError(BackendError):
|
||
"""登录状态无效。"""
|
||
+
|
||
+
|
||
+class PatientSessionExpired(BackendError):
|
||
+ """Only the upstream patient credential expired; the desktop may stay active."""
|
||
+
|
||
+ def __init__(self, message: str = "ZYT 患者查询登录已过期,请重新验证账号。", *, status=None):
|
||
+ super().__init__(message, status=status, code="zyt_session_expired")
|
||
|
||
|
||
def _process_is_running(pid: int) -> bool:
|
||
@@ -349,6 +361,30 @@
|
||
os.replace(temporary, CONNECTION_FILE)
|
||
|
||
|
||
+def _desktop_session_identity(settings: dict[str, Any]) -> tuple[str, str, str]:
|
||
+ return (resolve_backend_url(settings.get("server_url")),
|
||
+ str(settings.get("desktop_token_protected") or ""),
|
||
+ str(settings.get("zyt_account") or ""))
|
||
+
|
||
+
|
||
+def _save_session_changes(snapshot: dict[str, Any], changes: dict[str, Any]) -> bool:
|
||
+ """Merge after network I/O; an old request cannot restore or replace a session."""
|
||
+ with _LOCK:
|
||
+ current = load_settings()
|
||
+ if _desktop_session_identity(current) != _desktop_session_identity(snapshot):
|
||
+ return False
|
||
+ current.update(changes)
|
||
+ save_settings(current)
|
||
+ return True
|
||
+
|
||
+
|
||
+def _require_current_session(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||
+ current = load_settings()
|
||
+ if _desktop_session_identity(current) != _desktop_session_identity(snapshot):
|
||
+ raise BackendError("桌面账号会话已变更,请重新操作", code="session_changed")
|
||
+ return current
|
||
+
|
||
+
|
||
def normalize_server_url(value: Any) -> str:
|
||
url = str(value or DEFAULT_SERVER_URL).strip().rstrip("/")
|
||
if not url.startswith(("http://", "https://")):
|
||
@@ -414,10 +450,12 @@
|
||
except BackendError:
|
||
return ""
|
||
token = str(response["access_token"])
|
||
- settings["desktop_token_protected"] = _protect_secret(token)
|
||
- settings["desktop_account"] = response.get("account") or {}
|
||
- settings["desktop_expires_at"] = int(time.time()) + int(response.get("expires_in") or 0)
|
||
- save_settings(settings)
|
||
+ if not _save_session_changes(settings, {
|
||
+ "desktop_token_protected": _protect_secret(token),
|
||
+ "desktop_account": response.get("account") or {},
|
||
+ "desktop_expires_at": int(time.time()) + int(response.get("expires_in") or 0),
|
||
+ }):
|
||
+ return ""
|
||
return token
|
||
|
||
|
||
@@ -588,18 +626,30 @@
|
||
data = json.loads(exc.read().decode("utf-8"))
|
||
except Exception:
|
||
data = {"error": f"后台返回 HTTP {exc.code}"}
|
||
+ data = data if isinstance(data, dict) else {}
|
||
detail = data.get("detail")
|
||
+ error_code = str((detail.get("code") if isinstance(detail, dict) else None)
|
||
+ or data.get("code") or "")
|
||
+ if isinstance(detail, dict):
|
||
+ detail = detail.get("message") or detail.get("msg") or detail.get("error") or ""
|
||
if isinstance(detail, list):
|
||
detail = ";".join(
|
||
str(item.get("msg") if isinstance(item, dict) else item)
|
||
for item in detail
|
||
)
|
||
- message = str(
|
||
- data.get("error") or data.get("message") or detail or f"HTTP {exc.code}"
|
||
- )
|
||
+ message = str(data.get("error") or data.get("message") or detail or f"HTTP {exc.code}")
|
||
+ patient_path = urllib.parse.urlsplit(path).path in {
|
||
+ DESKTOP_PATIENT_CONTEXT_PATH, DESKTOP_PATIENT_SEARCH_PATH, DESKTOP_PATIENT_BINDING_PATH,
|
||
+ }
|
||
+ legacy_expired = (patient_path and exc.code == 502
|
||
+ and message.strip().rstrip("。.!!") == "登录超时,请重新登录")
|
||
+ if error_code == "zyt_session_expired" or legacy_expired:
|
||
+ raise PatientSessionExpired(message, status=exc.code) from exc
|
||
+ if exc.code == 403 and patient_path:
|
||
+ raise BackendError(message, status=exc.code, code=error_code or "permission_denied") from exc
|
||
if exc.code in (401, 403):
|
||
- raise AuthenticationError(message, status=exc.code) from exc
|
||
- raise BackendError(message, status=exc.code) from exc
|
||
+ raise AuthenticationError(message, status=exc.code, code=error_code) from exc
|
||
+ raise BackendError(message, status=exc.code, code=error_code) from exc
|
||
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
||
reason = getattr(exc, "reason", exc)
|
||
if "unknown url type: https" in str(reason).lower():
|
||
@@ -617,11 +667,13 @@
|
||
payload: dict[str, Any] | None = None,
|
||
timeout: float = 10.0,
|
||
) -> dict[str, Any]:
|
||
- settings = load_settings()
|
||
- server_url = str(settings.get("server_url") or DEFAULT_SERVER_URL)
|
||
token = desktop_access_token()
|
||
if not token:
|
||
raise AuthenticationError("请先登录桌面账号")
|
||
+ settings = load_settings()
|
||
+ if _unprotect_secret(settings.get("desktop_token_protected")) != token:
|
||
+ raise BackendError("桌面账号会话已变更,请重试", code="session_changed")
|
||
+ server_url = str(settings.get("server_url") or DEFAULT_SERVER_URL)
|
||
query = urllib.parse.urlencode(
|
||
{
|
||
str(key): value
|
||
@@ -851,7 +903,7 @@
|
||
|
||
|
||
def fetch_desktop_auth_config(
|
||
- server_url: str, *, timeout: float = 8.0
|
||
+ server_url: str, *, timeout: float = 8.0, persist: bool = True
|
||
) -> dict[str, Any]:
|
||
"""软件启动时从管理端读取公开登录配置,并缓存实际登录服务地址。"""
|
||
|
||
@@ -874,15 +926,11 @@
|
||
"zyt_api_url": zyt_api_url,
|
||
"terminal": terminal,
|
||
}
|
||
- settings = load_settings()
|
||
- settings.update(
|
||
- {
|
||
- "server_url": server_url,
|
||
- "zyt_api_url": zyt_api_url,
|
||
- "last_error": "",
|
||
- }
|
||
- )
|
||
- save_settings(settings)
|
||
+ if persist:
|
||
+ with _LOCK:
|
||
+ settings = load_settings()
|
||
+ settings.update({"server_url": server_url, "zyt_api_url": zyt_api_url, "last_error": ""})
|
||
+ save_settings(settings)
|
||
return config
|
||
|
||
|
||
@@ -930,6 +978,89 @@
|
||
return response
|
||
|
||
|
||
+def reauthenticate_patient_session(password: str, *, timeout: float = 12.0) -> dict[str, Any]:
|
||
+ """Explicitly renew only ZYT, keeping this device's current desktop session."""
|
||
+ if not isinstance(password, str) or not password:
|
||
+ raise ValueError("请输入当前账号密码")
|
||
+ with _PATIENT_AUTH_LOCK:
|
||
+ snapshot = load_settings()
|
||
+ desktop_token = _unprotect_secret(snapshot.get("desktop_token_protected"))
|
||
+ account = str(snapshot.get("zyt_account") or "").strip()
|
||
+ if not desktop_token or not account:
|
||
+ raise AuthenticationError("请先登录桌面账号")
|
||
+ validate_desktop_session(timeout=timeout)
|
||
+ _require_current_session(snapshot)
|
||
+ server_url = str(snapshot.get("server_url") or DEFAULT_SERVER_URL)
|
||
+ # Use the management server's configured identity origin, never a caller URL.
|
||
+ config = fetch_desktop_auth_config(server_url, timeout=timeout, persist=False)
|
||
+ _require_current_session(snapshot)
|
||
+ new_zyt_token, _ = _zyt_login(str(config["zyt_api_url"]), account, password)
|
||
+ _require_current_session(snapshot)
|
||
+ _, result = _request("POST", server_url, DESKTOP_ZYT_SESSION_PATH,
|
||
+ token=desktop_token, payload={"zyt_token": new_zyt_token}, timeout=timeout)
|
||
+ if not isinstance(result, dict) or result.get("ok") is not True:
|
||
+ raise BackendError("患者账号验证未完成,请重试")
|
||
+ if not _save_session_changes(snapshot, {"zyt_token_protected": _protect_secret(new_zyt_token)}):
|
||
+ raise BackendError("桌面账号会话已变更,未保存旧账号凭证", code="session_changed")
|
||
+ return {"ok": True}
|
||
+
|
||
+
|
||
+def keepalive_zyt_session(*, timeout: float = 8.0) -> dict[str, Any]:
|
||
+ """Touch an existing ZYT session at most every 30 min; never log in or log out."""
|
||
+ if not _PATIENT_KEEPALIVE_LOCK.acquire(blocking=False):
|
||
+ return {"status": "skipped", "reason": "in_progress"}
|
||
+ try:
|
||
+ snapshot = load_settings()
|
||
+ identity = (*_desktop_session_identity(snapshot), str(snapshot.get("zyt_token_protected") or ""))
|
||
+ now = time.monotonic()
|
||
+ if (_PATIENT_KEEPALIVE_LAST.get("identity") == identity
|
||
+ and now - float(_PATIENT_KEEPALIVE_LAST.get("at", 0)) < 1800):
|
||
+ return {"status": "skipped", "reason": "throttled"}
|
||
+ _PATIENT_KEEPALIVE_LAST.update(identity=identity, at=now)
|
||
+ if not _unprotect_secret(snapshot.get("desktop_token_protected")):
|
||
+ return {"status": "desktop_invalid", "message": "桌面账号尚未登录"}
|
||
+ try:
|
||
+ validate_desktop_session(timeout=timeout)
|
||
+ current = _require_current_session(snapshot)
|
||
+ except AuthenticationError:
|
||
+ return {"status": "desktop_invalid", "message": "桌面登录已失效,请重新登录"}
|
||
+ except BackendError as exc:
|
||
+ return {"status": "unreachable", "message": str(exc)}
|
||
+ zyt_token = _unprotect_secret(current.get("zyt_token_protected"))
|
||
+ if not zyt_token:
|
||
+ return {"status": "expired", "message": "患者查询登录已过期,请重新验证账号"}
|
||
+ api_url = str(current.get("zyt_api_url") or "")
|
||
+ try:
|
||
+ if not api_url:
|
||
+ api_url = str(fetch_desktop_auth_config(str(current.get("server_url") or DEFAULT_SERVER_URL),
|
||
+ timeout=timeout, persist=False)["zyt_api_url"])
|
||
+ _require_current_session(snapshot)
|
||
+ _, response = _request("GET", api_url, "/adminapi/desktop/session", identity_service=True,
|
||
+ extra_headers={"token": zyt_token}, timeout=timeout)
|
||
+ if not isinstance(response, dict):
|
||
+ raise BackendError("ZYT 会话服务返回了无效数据")
|
||
+ message = str(response.get("msg") or "").strip()
|
||
+ if str(response.get("code")) == "1":
|
||
+ result = {"status": "active"}
|
||
+ elif message.rstrip("。.!!") in {"登录超时", "登录超时,请重新登录", "登录已失效", "登录已失效,请重新登录", "登录已过期", "登录已过期,请重新登录", "请先登录"}:
|
||
+ result = {"status": "expired", "message": "患者查询登录已过期,请重新验证账号"}
|
||
+ elif any(word in message for word in ("无权限", "没有权限", "权限不足", "禁止访问")):
|
||
+ result = {"status": "forbidden", "message": "当前账号没有患者会话保活权限"}
|
||
+ else:
|
||
+ result = {"status": "unreachable", "message": "ZYT 会话服务暂时不可用"}
|
||
+ except AuthenticationError as exc:
|
||
+ result = {"status": "expired" if exc.status == 401 else "forbidden",
|
||
+ "message": "患者查询登录已过期,请重新验证账号" if exc.status == 401 else "当前账号没有患者会话保活权限"}
|
||
+ except BackendError as exc:
|
||
+ result = {"status": "unsupported" if exc.status in (404, 405) else "unreachable", "message": str(exc)}
|
||
+ current = load_settings()
|
||
+ if ((*_desktop_session_identity(current), str(current.get("zyt_token_protected") or "")) != identity):
|
||
+ return {"status": "skipped", "reason": "session_changed"}
|
||
+ return result
|
||
+ finally:
|
||
+ _PATIENT_KEEPALIVE_LOCK.release()
|
||
+
|
||
+
|
||
def _clear_desktop_credentials(settings: dict[str, Any] | None = None) -> None:
|
||
"""清除会话;保留用户明确选择记住的登录账号与加密密码。"""
|
||
|
||
@@ -962,20 +1093,20 @@
|
||
"GET", server_url, DESKTOP_ME_PATH, token=token, timeout=timeout
|
||
)
|
||
except BackendError as exc:
|
||
- settings["last_error"] = str(exc)
|
||
- save_settings(settings)
|
||
+ if not _save_session_changes(settings, {"last_error": str(exc)}):
|
||
+ raise BackendError("桌面账号会话已更新,已忽略旧校验结果", code="session_changed") from None
|
||
raise
|
||
if not isinstance(account, dict) or not account.get("id"):
|
||
raise BackendError("后台没有返回有效的桌面账号")
|
||
- settings["desktop_account"] = account
|
||
+ changes = {"desktop_account": account, "last_error": ""}
|
||
try:
|
||
renewed_expires_at = int(account.get("expires_at") or 0)
|
||
except (TypeError, ValueError):
|
||
renewed_expires_at = 0
|
||
if renewed_expires_at > int(time.time()):
|
||
- settings["desktop_expires_at"] = renewed_expires_at
|
||
- settings["last_error"] = ""
|
||
- save_settings(settings)
|
||
+ changes["desktop_expires_at"] = renewed_expires_at
|
||
+ if not _save_session_changes(settings, changes):
|
||
+ raise BackendError("桌面账号会话已更新,已忽略旧校验结果", code="session_changed")
|
||
return dict(account)
|
||
|
||
|
||
@@ -995,7 +1126,10 @@
|
||
except AuthenticationError:
|
||
# 本机记录的令牌尚未过期却被服务端拒绝,说明已被管理端、账号
|
||
# 策略或另一台设备撤销;此时禁止使用缓存令牌静默恢复。
|
||
- _clear_desktop_credentials(settings)
|
||
+ with _LOCK:
|
||
+ current = load_settings()
|
||
+ if _desktop_session_identity(current) == _desktop_session_identity(settings):
|
||
+ _clear_desktop_credentials(current)
|
||
return None
|
||
zyt_token = _unprotect_secret(settings.get("zyt_token_protected"))
|
||
if not zyt_token:
|
||
@@ -1004,12 +1138,14 @@
|
||
response = _exchange_desktop_token(server_url, zyt_token, timeout=timeout)
|
||
except BackendError:
|
||
return None
|
||
- settings["desktop_token_protected"] = _protect_secret(response["access_token"])
|
||
- settings["desktop_account"] = response.get("account") or {}
|
||
- settings["desktop_expires_at"] = int(time.time()) + int(response.get("expires_in") or 0)
|
||
- settings["last_error"] = ""
|
||
- save_settings(settings)
|
||
- return dict(settings["desktop_account"])
|
||
+ if not _save_session_changes(settings, {
|
||
+ "desktop_token_protected": _protect_secret(response["access_token"]),
|
||
+ "desktop_account": response.get("account") or {},
|
||
+ "desktop_expires_at": int(time.time()) + int(response.get("expires_in") or 0),
|
||
+ "last_error": "",
|
||
+ }):
|
||
+ return None
|
||
+ return dict(response.get("account") or {})
|
||
|
||
|
||
def desktop_logout(*, revoke_remote: bool = True) -> None:
|
||
@@ -1041,10 +1177,23 @@
|
||
)
|
||
except BackendError:
|
||
pass
|
||
- _clear_desktop_credentials(settings)
|
||
+ with _LOCK:
|
||
+ current = load_settings()
|
||
+ if _desktop_session_identity(current) == _desktop_session_identity(settings):
|
||
+ _clear_desktop_credentials(current)
|
||
|
||
|
||
def _apply_config_response(
|
||
+ response: dict[str, Any], settings: dict[str, Any], *, request_url: str = ""
|
||
+) -> dict[str, Any]:
|
||
+ # Periodic sync may overlap patient reauthentication. Apply only to the same
|
||
+ # desktop session, starting from fresh settings so credentials never regress.
|
||
+ with _LOCK:
|
||
+ current = _require_current_session(settings)
|
||
+ return _apply_config_response_current(response, current, request_url=request_url)
|
||
+
|
||
+
|
||
+def _apply_config_response_current(
|
||
response: dict[str, Any], settings: dict[str, Any], *, request_url: str = ""
|
||
) -> dict[str, Any]:
|
||
config = response.get("config")
|
||
@@ -1221,8 +1370,7 @@
|
||
request_url=normalize_server_url(settings["server_url"]) + used_path,
|
||
)
|
||
except Exception as exc:
|
||
- settings["last_error"] = str(exc)
|
||
- save_settings(settings)
|
||
+ _save_session_changes(settings, {"last_error": str(exc)})
|
||
raise
|
||
|
||
|
||
@@ -1290,11 +1438,13 @@
|
||
|
||
def sync_cloud_config(*, timeout: float = 10.0) -> dict[str, Any]:
|
||
"""使用当前桌面账号读取其有效配置。"""
|
||
- settings = load_settings()
|
||
- server_url = str(settings.get("server_url") or DEFAULT_SERVER_URL)
|
||
access_token = desktop_access_token()
|
||
if not access_token:
|
||
raise AuthenticationError("请先登录桌面账号")
|
||
+ settings = load_settings()
|
||
+ if _unprotect_secret(settings.get("desktop_token_protected")) != access_token:
|
||
+ raise BackendError("桌面账号会话已变更,请重试", code="session_changed")
|
||
+ server_url = str(settings.get("server_url") or DEFAULT_SERVER_URL)
|
||
try:
|
||
response, used_path = _fetch_desktop_config(
|
||
server_url, access_token, timeout
|
||
@@ -1305,8 +1455,7 @@
|
||
request_url=normalize_server_url(server_url) + used_path,
|
||
)
|
||
except Exception as exc:
|
||
- settings["last_error"] = str(exc)
|
||
- save_settings(settings)
|
||
+ _save_session_changes(settings, {"last_error": str(exc)})
|
||
raise
|
||
|
||
|