Implement CHIS login flow

This commit is contained in:
ila
2026-07-05 11:01:30 +08:00
parent aaf35f2d7a
commit 07e6c2b9a3
13 changed files with 450 additions and 17 deletions
+2 -1
View File
@@ -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。
+207
View File
@@ -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
+24
View File
@@ -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
+5 -4
View File
@@ -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、会话和转换稳定。
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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` 就能查完整详情。
+9
View File
@@ -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 接入账号信息查询验证会话。
+2
View File
@@ -1,2 +1,4 @@
Flask==3.0.3
pytest==8.3.5
requests[socks]==2.32.4
gmssl==3.2.2
+1 -1
View File
@@ -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 |
+152
View File
@@ -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"
+2
View File
@@ -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