Files
kefu/wechat_rpa/zyt_patient_client.py
T
2026-09-21 10:34:06 +08:00

194 lines
8.0 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.
# -*- 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