Implement CHIS login flow
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
基卫 CHIS 上报数据项目。项目目标是提供稳定的后端 API,供第三方系统提交体检数据;`chisup` 负责校验、转换、维护 CHIS 登录会话,并把符合 CHIS 接口格式的数据提交到基卫 CHIS。
|
||||
|
||||
当前仓库处于 MVP 起步阶段:已有 Flask application factory 地基、health check、基础配置、pytest 测试入口和架构包目录。当前已确认可参考 `D:\hans\chupd\chis\login_client_v2.py` 迁移 CHIS 登录链路,public key 由配置提供;访问 CHIS 支持可选 `CHIS_PROXY` SOCKS5 代理;保存上报前先实现只读体检详情查询来验证登录、会话、加密和通用请求。
|
||||
当前仓库处于 MVP 起步阶段:已有 Flask application factory 地基、health check、基础配置、pytest 测试入口、CHIS 登录基础链路和架构包目录。当前已参考 `D:\hans\chupd\chis\login_client_v2.py` 迁移 `myRoles -> myApps -> cookie` 登录流程,public key 由配置提供;访问 CHIS 支持可选 `CHIS_PROXY` SOCKS5 代理;保存上报前先实现只读体检详情查询来验证登录、会话、加密和通用请求。
|
||||
|
||||
## 文档入口
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- Flask 结构:使用 application factory 模式,T-001 实现 `create_app(config_object=None)`。
|
||||
- 依赖管理:T-001 创建 `requirements.txt`,使用 `python -m pip install -r requirements.txt` 安装到当前系统 Python 3.8 环境。
|
||||
- 测试入口:T-003 固定 `pytest==8.3.5`,使用 `python -m pytest`。
|
||||
- CHIS 登录:T-101 已迁移登录基础链路到 `app/chis/auth.py`,SM2 加密在 `app/chis/crypto.py`。
|
||||
- 计划使用 Redis 保存 CHIS 会话缓存。
|
||||
- 访问 CHIS 支持可选 `CHIS_PROXY`:为空直连,有值时 CHIS 登录、查询和保存请求走 SOCKS5 代理。
|
||||
- 当前已有资料:`reverse_file/` 下的 CHIS 前端脚本、schema 和 HAR。
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Mapping, Optional, Sequence
|
||||
from urllib.parse import quote, urlencode, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from app.chis.crypto import sm2_encrypt
|
||||
|
||||
|
||||
DEFAULT_ALLOWED_ROLES = ("责任医生助理", "责任医生")
|
||||
|
||||
|
||||
class ChisLoginError(Exception):
|
||||
def __init__(self, code, message):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChisLoginSession:
|
||||
cookie: str
|
||||
jsessionid: str
|
||||
role_id: str
|
||||
role_name: str
|
||||
user_name: str
|
||||
roles_result: dict
|
||||
apps_result: dict
|
||||
|
||||
|
||||
class RequestsTransport:
|
||||
def __init__(self, proxy="", timeout=(10, 30)):
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
if proxy:
|
||||
self.session.proxies.update({"http": proxy, "https": proxy})
|
||||
|
||||
def post(self, url, headers, payload):
|
||||
response = self.session.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response_headers = dict(response.headers)
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"code": response.status_code,
|
||||
"msg": response.text,
|
||||
"data": None,
|
||||
}, response_headers
|
||||
if "json" in response.headers.get("Content-Type", ""):
|
||||
return response.json(), response_headers
|
||||
return response.text, response_headers
|
||||
|
||||
|
||||
class ChisLoginClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url,
|
||||
public_key,
|
||||
transport=None,
|
||||
encrypt=None,
|
||||
now_millis=None,
|
||||
allowed_roles: Sequence[str] = DEFAULT_ALLOWED_ROLES,
|
||||
):
|
||||
self.base_url = _normalize_chis_base_url(base_url)
|
||||
self.public_key = public_key
|
||||
self.transport = transport or RequestsTransport()
|
||||
self.encrypt = encrypt or sm2_encrypt
|
||||
self.now_millis = now_millis or _now_millis
|
||||
self.allowed_roles = tuple(allowed_roles)
|
||||
self.host = urlparse(self.base_url).netloc
|
||||
self.headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/95.0.4638.54 Safari/537.36",
|
||||
"encoding": "utf-8",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Host": self.host,
|
||||
"Referer": f"{self.base_url}/index.html",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config):
|
||||
return cls(
|
||||
base_url=config.get("CHIS_BASE_URL", ""),
|
||||
public_key=config.get("CHIS_PUBLIC_KEY", ""),
|
||||
transport=RequestsTransport(proxy=config.get("CHIS_PROXY", "")),
|
||||
)
|
||||
|
||||
def login(self, username, password):
|
||||
roles_result, jsessionid = self.get_my_roles(username, password)
|
||||
token = self._select_role(roles_result)
|
||||
apps_result, jsessionid = self.get_my_apps(
|
||||
username=username,
|
||||
password=password,
|
||||
role_id=str(token["id"]),
|
||||
jsessionid=jsessionid,
|
||||
)
|
||||
if not jsessionid or len(jsessionid) < 3:
|
||||
raise ChisLoginError("chis_login_failed", "CHIS login did not return JSESSIONID")
|
||||
|
||||
role_name = token["roleName"]
|
||||
cookie = self._build_cookie(username, role_name, jsessionid)
|
||||
return ChisLoginSession(
|
||||
cookie=cookie,
|
||||
jsessionid=jsessionid,
|
||||
role_id=str(token["id"]),
|
||||
role_name=role_name,
|
||||
user_name=token.get("userName", ""),
|
||||
roles_result=roles_result,
|
||||
apps_result=apps_result,
|
||||
)
|
||||
|
||||
def get_my_roles(self, username, password):
|
||||
payload = {
|
||||
"url": "logon/myRoles",
|
||||
"uid": username,
|
||||
"pwd": self.encrypt(self.public_key, password),
|
||||
"d": self.encrypt(self.public_key, str(self.now_millis())),
|
||||
}
|
||||
result, response_headers = self.transport.post(
|
||||
url=f"{self.base_url}/logon/myRoles",
|
||||
headers=self._headers_without_cookie(),
|
||||
payload=payload,
|
||||
)
|
||||
self._ensure_chis_ok(result, "chis_login_failed", "CHIS myRoles failed")
|
||||
jsessionid = _extract_jsessionid(response_headers)
|
||||
if not jsessionid:
|
||||
raise ChisLoginError("chis_login_failed", "CHIS myRoles did not return JSESSIONID")
|
||||
return result, jsessionid
|
||||
|
||||
def get_my_apps(self, username, password, role_id, jsessionid):
|
||||
query = urlencode(
|
||||
{
|
||||
"urt": role_id,
|
||||
"uid": username,
|
||||
"pwd": self.encrypt(self.public_key, password),
|
||||
"deep": "3",
|
||||
"d": self.encrypt(self.public_key, str(self.now_millis())),
|
||||
}
|
||||
)
|
||||
headers = self._headers_without_cookie()
|
||||
headers["Cookie"] = f"JSESSIONID={jsessionid}"
|
||||
result, response_headers = self.transport.post(
|
||||
url=f"{self.base_url}/logon/myApps?{query}",
|
||||
headers=headers,
|
||||
payload={"url": f"logon/myApps?{query}", "httpMethod": "POST"},
|
||||
)
|
||||
self._ensure_chis_ok(result, "chis_login_failed", "CHIS myApps failed")
|
||||
return result, _extract_jsessionid(response_headers) or jsessionid
|
||||
|
||||
def _select_role(self, roles_result):
|
||||
tokens = roles_result.get("body", {}).get("tokens", [])
|
||||
for token in tokens:
|
||||
if token.get("roleName") in self.allowed_roles:
|
||||
return token
|
||||
roles = " / ".join(self.allowed_roles)
|
||||
raise ChisLoginError("chis_role_not_allowed", f"CHIS account has no allowed role: {roles}")
|
||||
|
||||
def _headers_without_cookie(self):
|
||||
return {key: value for key, value in self.headers.items() if key.lower() != "cookie"}
|
||||
|
||||
def _build_cookie(self, username, role_name, jsessionid):
|
||||
cookie_map = {
|
||||
"JSESSIONID": jsessionid,
|
||||
"sessionId1": jsessionid,
|
||||
username: f"{quote(role_name)}@photo/default.jpg",
|
||||
}
|
||||
return "; ".join([f"{key}={value}" for key, value in cookie_map.items()])
|
||||
|
||||
def _ensure_chis_ok(self, result, code, prefix):
|
||||
if not isinstance(result, dict):
|
||||
raise ChisLoginError(code, f"{prefix}: invalid response")
|
||||
if result.get("code") != 200:
|
||||
message = result.get("msg") or result.get("message") or "unknown error"
|
||||
raise ChisLoginError(code, f"{prefix}: {message}")
|
||||
|
||||
|
||||
def _normalize_chis_base_url(base_url):
|
||||
if not base_url:
|
||||
raise ChisLoginError("chis_config_missing", "CHIS_BASE_URL is not configured")
|
||||
normalized = base_url.rstrip("/")
|
||||
if "://" not in normalized:
|
||||
normalized = f"http://{normalized}"
|
||||
if normalized.endswith("/chis"):
|
||||
return normalized
|
||||
return f"{normalized}/chis"
|
||||
|
||||
|
||||
def _now_millis():
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _extract_jsessionid(headers: Mapping[str, str]) -> Optional[str]:
|
||||
for key, value in headers.items():
|
||||
if key.lower() != "set-cookie":
|
||||
continue
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(value)
|
||||
if "JSESSIONID" in cookie:
|
||||
return cookie["JSESSIONID"].value
|
||||
for item in value.split(";"):
|
||||
if "JSESSIONID" in item:
|
||||
return item.split("=", 1)[1].strip()
|
||||
return None
|
||||
@@ -0,0 +1,24 @@
|
||||
import binascii
|
||||
|
||||
from gmssl import sm2
|
||||
|
||||
|
||||
class ChisCryptoError(Exception):
|
||||
"""Raised when CHIS crypto cannot encrypt a required field."""
|
||||
|
||||
|
||||
def sm2_encrypt(public_key, raw_text):
|
||||
if not public_key:
|
||||
raise ChisCryptoError("CHIS public key is not configured")
|
||||
|
||||
key = public_key
|
||||
if len(key) > 64 * 2:
|
||||
key = key[-64 * 2 :]
|
||||
|
||||
try:
|
||||
text_bytes = str(raw_text).encode("utf-8")
|
||||
crypt_sm2 = sm2.CryptSM2(public_key=key, private_key="", mode=0)
|
||||
encrypted = crypt_sm2.encrypt(text_bytes)
|
||||
return "04" + binascii.hexlify(encrypted).decode("utf-8")
|
||||
except Exception as exc:
|
||||
raise ChisCryptoError("CHIS SM2 encryption failed") from exc
|
||||
@@ -9,9 +9,9 @@
|
||||
| 运行时 | 当前系统 Python 3.8 | 已定 | 用户指定,不创建虚拟环境 |
|
||||
| Web 框架 | Flask 3.0.3 + application factory | 已定 | 用户指定 Flask 版本;factory 模式便于配置注入和测试 |
|
||||
| 数据校验 | 待定,建议 Pydantic v1 或 Marshmallow | 待定 | Python 3.8 下需注意版本兼容 |
|
||||
| HTTP 客户端 | 待定,建议 `requests.Session` + SOCKS 支持 | 待定 | CHIS 会话 cookie 管理简单稳定;`CHIS_PROXY` 有值时走 SOCKS5 代理 |
|
||||
| HTTP 客户端 | `requests[socks]` 2.32.4 | 已定 | CHIS 会话 cookie 管理简单稳定;`CHIS_PROXY` 有值时走 SOCKS5 代理 |
|
||||
| Redis 客户端 | 待定,建议 `redis-py` | 待定 | 保存 CHIS 会话、幂等和短期状态 |
|
||||
| SM2 加密 | 待定,建议 `gmssl` | 待定 | 需兼容 `hans_chis.sm2.sm2_encrypt` 的 `CryptSM2(mode=0)` 行为 |
|
||||
| SM2 加密 | `gmssl` 3.2.2 | 已定 | 兼容 `hans_chis.sm2.sm2_encrypt` 的 `CryptSM2(mode=0)` 行为 |
|
||||
| 日志 | Python logging + TimedRotatingFileHandler | 已定 | `logs/` 每天一个综合日志文件,保留 1 年 |
|
||||
| 请求归档 | 本地 JSON 文件 | 已定 | `archives/` 每个接口请求一个完整归档文件,部署在内网前置机,不脱敏 |
|
||||
| 数据库 | 待定 | 待定 | MVP 可先只用 Redis;上报流水可能需要 MySQL / SQLite / PostgreSQL |
|
||||
@@ -28,9 +28,10 @@
|
||||
- 运行日志保存到 `logs/`,每天一个综合日志文件,包含 INFO/WARNING/ERROR/EXCEPTION 等级摘要,保留 1 年。
|
||||
- 接口请求归档保存到 `archives/`,一个 API 请求一个 JSON archive 文件,文件内容保存完整 API 与 CHIS 请求 / 响应,不脱敏;该目录仅用于内网前置机本地审计排查,不提交 git。
|
||||
- CHIS public key 从配置文件或环境变量读取;当前不依赖 `/chis/logon/publicKey` 动态获取。
|
||||
- `CHIS_BASE_URL` 支持完整地址如 `http://host:port/chis`,也兼容旧项目的 host 写法如 `host:port`,代码会补齐为 `http://host:port/chis`。
|
||||
- CHIS 外呼请求支持可选代理:`CHIS_PROXY` 为空时直连;有值时只作用于访问 CHIS 的 HTTP client,不影响第三方调用 `chisup` 的入站请求。
|
||||
- `CHIS_PROXY` 推荐格式为 `socks5h://127.0.0.1:1080`;后续实现 `requests.Session` 时需同步加入 `requests[socks]` 或 `PySocks` 依赖,保证 SOCKS5 可用。
|
||||
- CHIS 登录代码来自 Django 项目参考实现,迁移到 Flask 时只复用登录链路、SM2 算法和请求形状,不复用 Django model/cache。
|
||||
- `CHIS_PROXY` 推荐格式为 `socks5h://127.0.0.1:1080`;`requests[socks]==2.32.4` 已固定到依赖,保证 SOCKS5 可用。
|
||||
- CHIS 登录代码来自 Django 项目参考实现,迁移到 Flask 时只复用登录链路、SM2 算法和请求形状,不复用 Django model/cache;当前登录实现位于 `app/chis/auth.py`,SM2 实现位于 `app/chis/crypto.py`。
|
||||
- 当前不引入异步队列,先跑通同步单条上报闭环;批量和重试队列放到 V2。
|
||||
- 当前不引入管理后台,先保证 API、会话和转换稳定。
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ API / service / CHIS client
|
||||
- 负责 Redis 会话缓存、清理和重登。
|
||||
- 登录链路参考 `D:\hans\chupd\chis\login_client_v2.py`,但不能原样依赖 Django model/cache。
|
||||
- CHIS public key 从 Flask 配置或环境变量读取,不从代码硬编码。
|
||||
- 当前 T-101 已落地 `app/chis/auth.py:ChisLoginClient`:负责 `myRoles`、角色选择、`myApps`、JSESSIONID 提取和 cookie 组装;Redis 会话缓存与有效性验证留给 T-102 / T-103。
|
||||
|
||||
**CHIS client**
|
||||
|
||||
@@ -285,7 +286,7 @@ archives/
|
||||
|
||||
| 难点 | 说明 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| CHIS 登录链路 | 可能涉及公钥、加密、角色、应用、机构上下文 | 等用户提供现有代码后接入,先写 auth 边界 |
|
||||
| CHIS 登录链路 | 涉及公钥、SM2 加密、角色、应用、JSESSIONID 和 cookie 组装 | T-101 已迁移为 `ChisLoginClient`,后续用真实账号做联调 |
|
||||
| CHIS 网络代理 | 部署在内网前置机时,访问 CHIS 可能必须走 SOCKS5 | 使用可选 `CHIS_PROXY` 配置;空值直连,有值时为 `http` / `https` 同时设置 requests proxies |
|
||||
| 会话有效性 | cookie 存在不代表 CHIS 会话仍有效 | 用账号信息查询接口确认,失败则清理缓存并重登 |
|
||||
| 只传体检 id 查询详情 | `getHMNIListOfHTML` 还需要 `phrId` 和 `idCard` | 先要求调用方传全参数,后续验证反查链路 |
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-101 | 迁移 CHIS 登录链路 | T-002 | 参考 `D:\hans\chupd\chis\login_client_v2.py`;登录逻辑进入 `app/chis/auth.py` 或等价模块;public key 从配置读取;`CHIS_PROXY` 有值时登录请求走代理,空值直连;账号密码不落日志;失败返回明确错误 | TODO |
|
||||
| T-101 | 迁移 CHIS 登录链路 | T-002 | 参考 `D:\hans\chupd\chis\login_client_v2.py`;登录逻辑进入 `app/chis/auth.py` 或等价模块;public key 从配置读取;`CHIS_PROXY` 有值时登录请求走代理,空值直连;账号密码不落日志;失败返回明确错误 | DONE |
|
||||
| T-102 | 接入账号信息查询验证会话 | T-101 | 能用已有 cookie 查询当前账号信息;失败可判断会话无效 | TODO |
|
||||
| T-103 | 实现 Redis 会话缓存 | T-102 | 会话对象包含 cookies、账号、角色/机构、过期时间;有效会话复用;无效会话清理 | TODO |
|
||||
| T-104 | 实现失效重登策略 | T-103 | CHIS 返回未登录时清缓存、重登一次、重试一次;不会无限重试 | TODO |
|
||||
|
||||
+32
@@ -146,6 +146,38 @@
|
||||
|
||||
## 内部模块合约
|
||||
|
||||
### `ChisLoginClient.from_config(config).login(username, password)`
|
||||
|
||||
输入:
|
||||
|
||||
```python
|
||||
username: str
|
||||
password: str
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```python
|
||||
ChisLoginSession(
|
||||
cookie=str,
|
||||
jsessionid=str,
|
||||
role_id=str,
|
||||
role_name=str,
|
||||
user_name=str,
|
||||
roles_result=dict,
|
||||
apps_result=dict,
|
||||
)
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- 从 `CHIS_BASE_URL`、`CHIS_PUBLIC_KEY`、`CHIS_PROXY` 创建登录客户端。
|
||||
- 调用 `logon/myRoles`,使用 SM2 加密密码和时间戳 `d`。
|
||||
- 优先选择 `责任医生助理` 或 `责任医生` 角色。
|
||||
- 调用 `logon/myApps` 获取应用上下文。
|
||||
- 从 `Set-Cookie` 提取 `JSESSIONID`,组装 CHIS cookie。
|
||||
- 登录失败、缺少允许角色、缺少 cookie 时抛出 `ChisLoginError`,错误对象包含稳定 `code`。
|
||||
|
||||
### `ChisSessionManager.ensure_session(account_ref)`
|
||||
|
||||
输入:
|
||||
|
||||
+11
-9
@@ -6,9 +6,9 @@
|
||||
|
||||
- 日期:2026-07-04
|
||||
- 阶段:MVP 起步 / Phase 0 地基已完成
|
||||
- 技术栈:当前系统 Python 3.8、Flask 3.0.3 application factory、pytest 8.3.5;不创建虚拟环境
|
||||
- 生产代码:`app/__init__.py`、`app/api/health.py`、`app/config.py`、`run.py`,以及 `app/middleware/`、`app/services/`、`app/mappers/`、`app/chis/`、`app/repositories/`、`app/validators/` 包目录
|
||||
- 测试:`tests/test_app_factory.py`、`tests/test_config_and_structure.py`、`tests/test_project_setup.py`
|
||||
- 技术栈:当前系统 Python 3.8、Flask 3.0.3 application factory、pytest 8.3.5、requests[socks] 2.32.4、gmssl 3.2.2;不创建虚拟环境
|
||||
- 生产代码:`app/__init__.py`、`app/api/health.py`、`app/config.py`、`run.py`、`app/chis/auth.py`、`app/chis/crypto.py`,以及 `app/middleware/`、`app/services/`、`app/mappers/`、`app/chis/`、`app/repositories/`、`app/validators/` 包目录
|
||||
- 测试:`tests/test_app_factory.py`、`tests/test_config_and_structure.py`、`tests/test_project_setup.py`、`tests/test_chis_auth.py`
|
||||
- 数据 / 资料:`reverse_file/` 下已有 CHIS HAR、前端脚本和 schema;`D:\hans\chupd\chis\login_client_v2.py` 可作为登录链路参考
|
||||
- 标准启动路径:`python run.py`
|
||||
- 标准验证路径:`python -m pytest`
|
||||
@@ -23,7 +23,7 @@
|
||||
| `reverse_file/20260704_query_health_check.har` | 已有 | 体检详情只读查询 HAR,包含 `getHMNIListOfHTML` 和 `getCheckInfoDetail` |
|
||||
| `D:\hans\chupd\chis\login_client_v2.py` | 外部参考 | CHIS 登录、SM2 加密、角色选择、cookie 拼接、`getLanderInfo` 会话验证 |
|
||||
| `app/` | 已有 | Flask application factory 和 health check API |
|
||||
| `app/chis/` | 已有 | CHIS integration layer 包目录 |
|
||||
| `app/chis/` | 已有 | CHIS integration layer;T-101 已包含登录客户端与 SM2 加密 |
|
||||
| `app/mappers/` | 已有 | 数据转换层包目录 |
|
||||
| `app/middleware/` | 已有 | 中间件包目录 |
|
||||
| `app/repositories/` | 已有 | 存储 / 缓存包目录 |
|
||||
@@ -31,28 +31,30 @@
|
||||
| `app/validators/` | 已有 | 请求校验包目录 |
|
||||
| `tests/` | 已有 | pytest 可收集的测试 |
|
||||
| `pytest.ini` | 已有 | pytest 测试入口配置,限制测试目录为 `tests` |
|
||||
| `requirements.txt` | 已有 | Python 依赖,固定 `Flask==3.0.3` 和 `pytest==8.3.5` |
|
||||
| `requirements.txt` | 已有 | Python 依赖,固定 `Flask==3.0.3`、`pytest==8.3.5`、`requests[socks]==2.32.4`、`gmssl==3.2.2` |
|
||||
| `.env.example` | 已有 | 环境变量占位示例,不含真实密钥 |
|
||||
|
||||
## 任务看板状态
|
||||
|
||||
任务状态以 [06-tasks.md](06-tasks.md) 为准。
|
||||
|
||||
- 已完成:DOC-001 建立 harness coding 项目文档;DOC-002 更新 CHIS 登录与只读查询验证路线;DOC-003 明确使用系统 Python 3.8;DOC-004 明确 Flask factory 模式;DOC-005 明确日志与请求归档方案;DOC-006 明确 CHIS SOCKS5 代理配置;T-001 初始化 Flask 项目骨架;T-002 建立基础配置与目录;T-003 建立最小测试框架。详见 [../progress.md](../progress.md)。
|
||||
- 已完成:DOC-001 建立 harness coding 项目文档;DOC-002 更新 CHIS 登录与只读查询验证路线;DOC-003 明确使用系统 Python 3.8;DOC-004 明确 Flask factory 模式;DOC-005 明确日志与请求归档方案;DOC-006 明确 CHIS SOCKS5 代理配置;T-001 初始化 Flask 项目骨架;T-002 建立基础配置与目录;T-003 建立最小测试框架;T-101 迁移 CHIS 登录链路。详见 [../progress.md](../progress.md)。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:T-101 迁移 CHIS 登录链路。
|
||||
- 下一个可领取任务:T-102 接入账号信息查询验证会话。
|
||||
|
||||
## 已确认技术事实
|
||||
|
||||
- CHIS public key 可通过配置文件或环境变量提供。
|
||||
- 使用当前系统 Python 3.8 环境,不创建虚拟环境;T-001 创建 `requirements.txt` 固定依赖。
|
||||
- pytest 8.3.5 已固定到 `requirements.txt`;标准测试命令为 `python -m pytest`。
|
||||
- requests[socks] 2.32.4 和 gmssl 3.2.2 已固定到 `requirements.txt`,用于 CHIS 登录请求、SOCKS5 代理和 SM2 加密。
|
||||
- Flask 应用使用 application factory 模式,`app/__init__.py` 暴露 `create_app(config_object=None)`。
|
||||
- 基础配置通过环境变量读取:`CHIS_BASE_URL`、`CHIS_PUBLIC_KEY`、`CHIS_PROXY`、`REDIS_URL`、`LOG_DIR`、`ARCHIVE_DIR`。
|
||||
- `CHIS_BASE_URL` 兼容完整地址和旧项目 host 写法;未带 scheme 时按 `http://<host>/chis` 处理。
|
||||
- CHIS 外呼支持可选 SOCKS5 代理配置:`CHIS_PROXY` 为空直连;有值时后续 CHIS client 对登录、会话验证、查询和保存请求统一走代理。
|
||||
- `.env.example` 只保留占位符和非敏感默认值,不写真实密钥。
|
||||
- 登录链路参考 `login_client_v2.py`:`myRoles` -> 选择 `责任医生助理` / `责任医生` -> `myApps` -> 拼接 cookie。
|
||||
- SM2 加密参考 `hans_chis.sm2.sm2_encrypt`:`gmssl.sm2.CryptSM2(mode=0)`,返回带 `04` 前缀密文。
|
||||
- 登录链路已迁移到 `app/chis/auth.py`:`myRoles` -> 选择 `责任医生助理` / `责任医生` -> `myApps` -> 提取 `JSESSIONID` -> 拼接 cookie。
|
||||
- SM2 加密已迁移到 `app/chis/crypto.py`,兼容 `hans_chis.sm2.sm2_encrypt`:`gmssl.sm2.CryptSM2(mode=0)`,返回带 `04` 前缀密文。
|
||||
- 会话验证可用 `chis.myPageService / getLanderInfo`。
|
||||
- 体检详情查询第一版可用 `healthCheck + phrId + idCard` 调 `getHMNIListOfHTML`,用 `idCard + checkDate` 调 `getCheckInfoDetail`。
|
||||
- 当前不能证明只传 `healthCheck` 就能查完整详情。
|
||||
|
||||
@@ -106,3 +106,12 @@
|
||||
- 阻塞:无。
|
||||
- 决策:T-003 后标准测试入口改为 `python -m pytest`;保留既有 unittest 风格测试,pytest 可正常收集运行。
|
||||
- 下一步:T-101 迁移 CHIS 登录链路。
|
||||
|
||||
## 2026-07-04 T-101 迁移 CHIS 登录链路
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `app/chis/auth.py` 和 `app/chis/crypto.py`;新增 `tests/test_chis_auth.py`;`requirements.txt` 增加 `requests[socks]==2.32.4`、`gmssl==3.2.2`;更新任务看板、技术栈、架构、API 合约、当前状态和根目录任务总览。
|
||||
- 验证:先执行 `python -m pytest tests/test_chis_auth.py` 看到 `ModuleNotFoundError: No module named 'app.chis.auth'`;新增依赖测试后执行 `python -m pytest tests/test_project_setup.py` 看到 `requests[socks]==2.32.4` 未固定;补充 host 兼容测试时先看到 `assert 'chis.example.test:9002/chis' == 'http://chis.example.test:9002/chis'`;实现后执行 `python -m pytest tests/test_chis_auth.py` 结果 `6 passed`,执行 `python -m pytest tests/test_project_setup.py` 结果 `1 passed`,执行 `python -m pip install -r requirements.txt` 显示 Flask、pytest、requests、gmssl 均已满足,执行 `python -m pytest` 结果 `10 passed`,临时启动 `python run.py` 后请求 `/health` 返回 `{"ok":true}`,执行 `git diff --check` 未发现空白错误。
|
||||
- 阻塞:无。
|
||||
- 决策:T-101 只迁移登录链路,不接 Redis,不做会话有效性查询;登录客户端从 `CHIS_BASE_URL`、`CHIS_PUBLIC_KEY`、`CHIS_PROXY` 创建,完成 `myRoles`、角色选择、`myApps`、JSESSIONID 提取和 cookie 组装。
|
||||
- 下一步:T-102 接入账号信息查询验证会话。
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
Flask==3.0.3
|
||||
pytest==8.3.5
|
||||
requests[socks]==2.32.4
|
||||
gmssl==3.2.2
|
||||
|
||||
@@ -13,7 +13,7 @@ MVP 起步。当前仓库已具备逆向资料、项目文档、Flask applicatio
|
||||
| T-001 | 初始化 Flask 项目骨架 | DONE |
|
||||
| T-002 | 建立基础配置与目录 | DONE |
|
||||
| T-003 | 建立最小测试框架 | DONE |
|
||||
| T-101 | 迁移 CHIS 登录链路 | TODO |
|
||||
| T-101 | 迁移 CHIS 登录链路 | DONE |
|
||||
| T-102 | 接入账号信息查询验证会话 | TODO |
|
||||
| T-103 | 实现 Redis 会话缓存 | TODO |
|
||||
| T-201 | 实现通用 CHIS jsonRequest client | TODO |
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import pytest
|
||||
|
||||
from app.chis.auth import ChisLoginClient, ChisLoginError
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, headers, payload):
|
||||
self.calls.append({"url": url, "headers": headers, "payload": payload})
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def fake_encrypt(public_key, raw_text):
|
||||
if raw_text == "secret":
|
||||
return "encrypted-password"
|
||||
if raw_text == "1234567890":
|
||||
return "encrypted-time"
|
||||
return "encrypted-value"
|
||||
|
||||
|
||||
def test_login_gets_role_apps_and_cookie_without_leaking_password():
|
||||
transport = FakeTransport(
|
||||
[
|
||||
(
|
||||
{
|
||||
"code": 200,
|
||||
"body": {
|
||||
"tokens": [
|
||||
{"roleName": "其他角色", "id": "1", "userName": "ignored"},
|
||||
{"roleName": "责任医生", "id": "role-2", "userName": "doctor"},
|
||||
]
|
||||
},
|
||||
},
|
||||
{"Set-Cookie": "JSESSIONID=abc123; Path=/chis; HttpOnly"},
|
||||
),
|
||||
({"code": 200, "body": {"apps": []}}, {}),
|
||||
]
|
||||
)
|
||||
|
||||
client = ChisLoginClient(
|
||||
base_url="http://chis.example.test/chis",
|
||||
public_key="public-key",
|
||||
transport=transport,
|
||||
encrypt=fake_encrypt,
|
||||
now_millis=lambda: 1234567890,
|
||||
)
|
||||
|
||||
session = client.login(username="u001", password="secret")
|
||||
|
||||
assert session.cookie == "JSESSIONID=abc123; sessionId1=abc123; u001=%E8%B4%A3%E4%BB%BB%E5%8C%BB%E7%94%9F@photo/default.jpg"
|
||||
assert session.role_id == "role-2"
|
||||
assert session.role_name == "责任医生"
|
||||
assert session.user_name == "doctor"
|
||||
assert transport.calls[0]["url"] == "http://chis.example.test/chis/logon/myRoles"
|
||||
assert transport.calls[0]["payload"] == {
|
||||
"url": "logon/myRoles",
|
||||
"uid": "u001",
|
||||
"pwd": "encrypted-password",
|
||||
"d": "encrypted-time",
|
||||
}
|
||||
assert "secret" not in str(transport.calls)
|
||||
assert transport.calls[1]["url"].startswith("http://chis.example.test/chis/logon/myApps?")
|
||||
assert transport.calls[1]["headers"]["Cookie"] == "JSESSIONID=abc123"
|
||||
assert "urt=role-2" in transport.calls[1]["url"]
|
||||
|
||||
|
||||
def test_login_raises_clear_error_when_roles_fail():
|
||||
transport = FakeTransport([({"code": 501, "msg": "PasswordNotRight"}, {})])
|
||||
client = ChisLoginClient(
|
||||
base_url="http://chis.example.test/chis",
|
||||
public_key="public-key",
|
||||
transport=transport,
|
||||
encrypt=fake_encrypt,
|
||||
)
|
||||
|
||||
with pytest.raises(ChisLoginError) as exc_info:
|
||||
client.login(username="u001", password="secret")
|
||||
|
||||
assert exc_info.value.code == "chis_login_failed"
|
||||
assert "PasswordNotRight" in str(exc_info.value)
|
||||
assert "secret" not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_login_raises_clear_error_when_required_role_missing():
|
||||
transport = FakeTransport(
|
||||
[
|
||||
(
|
||||
{
|
||||
"code": 200,
|
||||
"body": {
|
||||
"tokens": [
|
||||
{"roleName": "其他角色", "id": "1", "userName": "ignored"},
|
||||
]
|
||||
},
|
||||
},
|
||||
{"Set-Cookie": "JSESSIONID=abc123; Path=/chis"},
|
||||
),
|
||||
]
|
||||
)
|
||||
client = ChisLoginClient(
|
||||
base_url="http://chis.example.test/chis",
|
||||
public_key="public-key",
|
||||
transport=transport,
|
||||
encrypt=fake_encrypt,
|
||||
)
|
||||
|
||||
with pytest.raises(ChisLoginError) as exc_info:
|
||||
client.login(username="u001", password="secret")
|
||||
|
||||
assert exc_info.value.code == "chis_role_not_allowed"
|
||||
assert "责任医生助理" in str(exc_info.value)
|
||||
assert "责任医生" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_requests_transport_uses_proxy_when_configured():
|
||||
client = ChisLoginClient.from_config(
|
||||
{
|
||||
"CHIS_BASE_URL": "http://chis.example.test/chis",
|
||||
"CHIS_PUBLIC_KEY": "public-key",
|
||||
"CHIS_PROXY": "socks5h://127.0.0.1:1080",
|
||||
}
|
||||
)
|
||||
|
||||
assert client.transport.session.proxies["http"] == "socks5h://127.0.0.1:1080"
|
||||
assert client.transport.session.proxies["https"] == "socks5h://127.0.0.1:1080"
|
||||
|
||||
|
||||
def test_requests_transport_direct_when_proxy_empty():
|
||||
client = ChisLoginClient.from_config(
|
||||
{
|
||||
"CHIS_BASE_URL": "http://chis.example.test/chis",
|
||||
"CHIS_PUBLIC_KEY": "public-key",
|
||||
"CHIS_PROXY": "",
|
||||
}
|
||||
)
|
||||
|
||||
assert client.transport.session.proxies == {}
|
||||
|
||||
|
||||
def test_login_client_accepts_plain_host_config():
|
||||
client = ChisLoginClient(
|
||||
base_url="chis.example.test:9002",
|
||||
public_key="public-key",
|
||||
transport=FakeTransport([]),
|
||||
encrypt=fake_encrypt,
|
||||
)
|
||||
|
||||
assert client.base_url == "http://chis.example.test:9002/chis"
|
||||
assert client.host == "chis.example.test:9002"
|
||||
@@ -6,3 +6,5 @@ def test_requirements_pin_test_framework():
|
||||
|
||||
assert "Flask==3.0.3" in requirements
|
||||
assert "pytest==8.3.5" in requirements
|
||||
assert "requests[socks]==2.32.4" in requirements
|
||||
assert "gmssl==3.2.2" in requirements
|
||||
|
||||
Reference in New Issue
Block a user