Files
kefu/deploy/patient-session-20260918/backend-client.patch
T
2026-09-21 10:34:06 +08:00

385 lines
18 KiB
Diff
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.
--- 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