# -*- coding: utf-8 -*- """ZYT 患者只读 API 适配器。 这个模块只调用 ZYT 已有的管理端查询接口,不要求 ZYT 增加路由、字段或数据表。 调用方传入桌面端已经取得的 ZYT token;本模块不会持久化账号密码。 """ from __future__ import annotations import json import socket import urllib.error import urllib.parse import urllib.request from typing import Any class ZytPatientError(RuntimeError): """ZYT 患者查询失败。""" class ZytPatientSessionExpired(ZytPatientError): """The upstream patient session needs reauthentication, not desktop logout.""" class ZytPatientPermissionDenied(ZytPatientError): """The authenticated ZYT account cannot query patients.""" def _session_expired_message(message: str) -> bool: normalized = str(message or "").lower().replace(" ", "") return any(part in normalized for part in ( "登录超时", "登录已失效", "登录失效", "登录已过期", "登录过期", "请重新登录", "请先登录", "token过期", "token已过期", "token失效", "token已失效", "tokenexpired", "unauthorized", )) def mask_mobile(value: Any) -> str: text = str(value or "").strip() if len(text) >= 7: return f"{text[:3]}****{text[-4:]}" if len(text) >= 3: return f"{text[:1]}***{text[-1:]}" return "***" if text else "" class ZytPatientClient: def __init__(self, api_url: str, token: str, *, timeout: float = 10.0): self.api_url = str(api_url or "").strip().rstrip("/") self.token = str(token or "").strip() self.timeout = max(2.0, min(float(timeout), 30.0)) parsed = urllib.parse.urlparse(self.api_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username: raise ZytPatientError("ZYT 患者接口地址配置不正确") if not self.token: raise ZytPatientSessionExpired("ZYT 患者查询会话缺失,请重新验证账号") def _get(self, path: str, params: dict[str, Any]) -> Any: query = urllib.parse.urlencode( {key: value for key, value in params.items() if value not in (None, "")} ) url = f"{self.api_url}{path}" if query: url = f"{url}?{query}" request = urllib.request.Request( url, headers={ "Accept": "application/json", "User-Agent": "WeCom-RPA-Patient-Bridge/1.0", # ZYT 管理端中间件读取 token 头,不使用 Bearer。 "token": self.token, }, method="GET", ) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: payload = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: if exc.code == 401: raise ZytPatientSessionExpired("ZYT 患者查询登录已过期,请重新验证账号") from exc if exc.code == 403: raise ZytPatientPermissionDenied("当前 ZYT 账号没有患者查询权限") from exc raise ZytPatientError(f"ZYT 患者接口请求失败(HTTP {exc.code})") from exc except (urllib.error.URLError, TimeoutError, socket.timeout) as exc: raise ZytPatientError( f"无法连接 ZYT 患者接口:{getattr(exc, 'reason', exc)}" ) from exc except (ValueError, TypeError, UnicodeDecodeError) as exc: raise ZytPatientError("ZYT 患者接口返回了无效数据") from exc if not isinstance(payload, dict) or str(payload.get("code")) != "1": message = str(payload.get("msg") or "").strip() if isinstance(payload, dict) else "" if "权限" in message or "无权" in message: raise ZytPatientPermissionDenied(message) if (isinstance(payload, dict) and str(payload.get("code")) == "-1") or _session_expired_message(message): raise ZytPatientSessionExpired(message or "ZYT 患者查询会话已失效") raise ZytPatientError(message or "ZYT 患者查询失败") return payload.get("data") @staticmethod def _list_payload(data: Any) -> tuple[list[dict[str, Any]], int]: if isinstance(data, list): return [dict(item) for item in data if isinstance(item, dict)], len(data) if not isinstance(data, dict): return [], 0 raw = data.get("lists") if not isinstance(raw, list): raw = data.get("items") if not isinstance(raw, list): raw = data.get("data") rows = [dict(item) for item in raw if isinstance(item, dict)] if isinstance(raw, list) else [] total = data.get("count", data.get("total", len(rows))) try: total_value = max(0, int(total)) except (TypeError, ValueError): total_value = len(rows) return rows, total_value def search_patients( self, keyword: str, *, page_no: int = 1, page_size: int = 20 ) -> dict[str, Any]: keyword = str(keyword or "").strip() if not keyword: raise ValueError("请输入患者姓名、手机号或患者编号") if len(keyword) > 64: raise ValueError("患者搜索关键词不能超过 64 个字符") page_no = max(1, int(page_no)) page_size = max(1, min(int(page_size), 50)) data = self._get( "/adminapi/tcm.diagnosis/lists", {"keyword": keyword, "page_no": page_no, "page_size": page_size}, ) rows, total = self._list_payload(data) # 诊单列表中同一患者可能出现多次。保留接口返回顺序中的第一条(通常是最新诊单)。 patients: list[dict[str, Any]] = [] seen: set[int] = set() for row in rows: try: diagnosis_id = int(row.get("id") or 0) patient_id = int(row.get("patient_id") or diagnosis_id) except (TypeError, ValueError): continue if patient_id <= 0 or patient_id in seen: continue seen.add(patient_id) patients.append( { "patient_id": patient_id, "diagnosis_id": max(0, diagnosis_id), "patient_name": str(row.get("patient_name") or "").strip(), "phone_masked": mask_mobile(row.get("phone")), "gender": row.get("gender"), "age": row.get("age"), "source_update_time": str(row.get("update_time") or ""), } ) return { "items": patients, "total": total, "page_no": page_no, "page_size": page_size, } def get_patient(self, patient_id: int) -> dict[str, Any] | None: """按患者主键精确读取,并继续复用 ZYT 列表的数据权限过滤。""" patient_id = int(patient_id) if patient_id <= 0: raise ValueError("患者编号不正确") data = self._get( "/adminapi/tcm.diagnosis/lists", {"patient_id": patient_id, "page_no": 1, "page_size": 20}, ) rows, _total = self._list_payload(data) for row in rows: try: diagnosis_id = int(row.get("id") or 0) resolved_patient_id = int(row.get("patient_id") or diagnosis_id) except (TypeError, ValueError): continue if resolved_patient_id != patient_id: continue return { "patient_id": resolved_patient_id, "diagnosis_id": max(0, diagnosis_id), "patient_name": str(row.get("patient_name") or "").strip(), "phone_masked": mask_mobile(row.get("phone")), "gender": row.get("gender"), "age": row.get("age"), "source_update_time": str(row.get("update_time") or ""), } return None