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