Implement Redis CHIS session store

This commit is contained in:
ila
2026-07-05 11:35:59 +08:00
parent 83c1100aa8
commit 32037a04e1
12 changed files with 216 additions and 16 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
- 依赖管理: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 会话缓存。
- Redis 会话缓存:T-103 已实现 `RedisChisSessionStore`,使用 `redis==3.5.3` 保存 CHIS 会话对象。
- 访问 CHIS 支持可选 `CHIS_PROXY`:为空直连,有值时 CHIS 登录、查询和保存请求走 SOCKS5 代理。
- 当前已有资料:`reverse_file/` 下的 CHIS 前端脚本、schema 和 HAR。
- 当前已有查询 HAR:`reverse_file/20260704_query_health_check.har`,包含 `getHMNIListOfHTML` 和 `getCheckInfoDetail`。
+106
View File
@@ -0,0 +1,106 @@
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Dict, Optional
@dataclass
class ChisSession:
base_url: str
uid: str
role_id: Optional[str]
manage_unit: Optional[str]
cookies: Dict[str, str]
login_at: datetime
expires_at: datetime
last_validated_at: Optional[datetime] = None
class RedisChisSessionStore:
key_prefix = "chis:session:"
def __init__(self, redis_client, now: Optional[Callable[[], datetime]] = None):
self.redis = redis_client
self.now = now or _utc_now
@classmethod
def from_url(cls, redis_url, now: Optional[Callable[[], datetime]] = None):
import redis
return cls(redis.Redis.from_url(redis_url), now=now)
def save(self, account_ref, session: ChisSession):
ttl_seconds = self._ttl_seconds(session)
if ttl_seconds <= 0:
self.delete(account_ref)
return
self.redis.setex(self._key(account_ref), ttl_seconds, self.dumps(session))
def get(self, account_ref):
raw_value = self.redis.get(self._key(account_ref))
if not raw_value:
return None
session = self.loads(raw_value)
if session.expires_at <= self.now():
self.delete(account_ref)
return None
return session
def delete(self, account_ref):
self.redis.delete(self._key(account_ref))
def dumps(self, session: ChisSession):
return json.dumps(
{
"base_url": session.base_url,
"uid": session.uid,
"role_id": session.role_id,
"manage_unit": session.manage_unit,
"cookies": session.cookies,
"login_at": _datetime_to_text(session.login_at),
"expires_at": _datetime_to_text(session.expires_at),
"last_validated_at": _datetime_to_text(session.last_validated_at),
},
ensure_ascii=False,
sort_keys=True,
)
def loads(self, raw_value):
if isinstance(raw_value, bytes):
raw_value = raw_value.decode("utf-8")
payload = json.loads(raw_value)
return ChisSession(
base_url=payload["base_url"],
uid=payload["uid"],
role_id=payload.get("role_id"),
manage_unit=payload.get("manage_unit"),
cookies=payload.get("cookies", {}),
login_at=_datetime_from_text(payload["login_at"]),
expires_at=_datetime_from_text(payload["expires_at"]),
last_validated_at=_datetime_from_text(payload.get("last_validated_at")),
)
def _ttl_seconds(self, session):
return int((session.expires_at - self.now()).total_seconds())
def _key(self, account_ref):
return f"{self.key_prefix}{account_ref}"
def _utc_now():
return datetime.now(timezone.utc)
def _datetime_to_text(value):
if value is None:
return None
return value.isoformat()
def _datetime_from_text(value):
if value is None:
return None
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
+2 -2
View File
@@ -10,7 +10,7 @@
| Web 框架 | Flask 3.0.3 + application factory | 已定 | 用户指定 Flask 版本;factory 模式便于配置注入和测试 |
| 数据校验 | 待定,建议 Pydantic v1 或 Marshmallow | 待定 | Python 3.8 下需注意版本兼容 |
| HTTP 客户端 | `requests[socks]` 2.32.4 | 已定 | CHIS 会话 cookie 管理简单稳定;`CHIS_PROXY` 有值时走 SOCKS5 代理 |
| Redis 客户端 | 待定,建议 `redis-py` | 待定 | 保存 CHIS 会话、幂等和短期状态 |
| Redis 客户端 | `redis-py` 3.5.3 | 已定 | 保存 CHIS 会话、幂等和短期状态;兼容当前系统 Python 中既有 `django-q` 约束 |
| SM2 加密 | `gmssl` 3.2.2 | 已定 | 兼容 `hans_chis.sm2.sm2_encrypt` 的 `CryptSM2(mode=0)` 行为 |
| 日志 | Python logging + TimedRotatingFileHandler | 已定 | `logs/` 每天一个综合日志文件,保留 1 年 |
| 请求归档 | 本地 JSON 文件 | 已定 | `archives/` 每个接口请求一个完整归档文件,部署在内网前置机,不脱敏 |
@@ -24,7 +24,7 @@
- Flask 3.0.3 + Python 3.8 已定,但 Python 3.8 生命周期已结束;如果部署环境允许,未来建议评估 Python 3.10+。
- 本项目使用当前系统 Python 3.8 环境,不创建虚拟环境;依赖仍必须写入 `requirements.txt` 并固定关键版本。
- Flask 应用必须使用 application factory 模式,即 `app/__init__.py` 暴露 `create_app(config_object=None)`;不要在模块导入时创建并配置全局业务 app。
- Redis 是会话缓存核心,不建议把 CHIS 会话保存在进程内存。
- Redis 是会话缓存核心,不建议把 CHIS 会话保存在进程内存;当前固定 `redis==3.5.3`,避免破坏系统 Python 环境里 `django-q` 的 `redis<4.0.0` 约束。
- 运行日志保存到 `logs/`,每天一个综合日志文件,包含 INFO/WARNING/ERROR/EXCEPTION 等级摘要,保留 1 年。
- 接口请求归档保存到 `archives/`,一个 API 请求一个 JSON archive 文件,文件内容保存完整 API 与 CHIS 请求 / 响应,不脱敏;该目录仅用于内网前置机本地审计排查,不提交 git。
- CHIS public key 从配置文件或环境变量读取;当前不依赖 `/chis/logon/publicKey` 动态获取。
+1 -1
View File
@@ -96,7 +96,7 @@ API / service / CHIS client
**Redis repository**
- 保存 CHIS 会话对象。
- 保存 CHIS 会话对象;当前 `app/chis/session_store.py:RedisChisSessionStore` 负责 JSON 序列化、`setex/get/delete`、过期会话清理。
- 保存幂等键和处理结果摘要。
- 可保存短期锁,避免同一账号并发重登。
+1 -1
View File
@@ -28,7 +28,7 @@
| --- | --- | --- | --- | --- |
| 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 查询当前账号信息;失败可判断会话无效 | DONE |
| T-103 | 实现 Redis 会话缓存 | T-102 | 会话对象包含 cookies、账号、角色/机构、过期时间;有效会话复用;无效会话清理 | TODO |
| T-103 | 实现 Redis 会话缓存 | T-102 | 会话对象包含 cookies、账号、角色/机构、过期时间;有效会话复用;无效会话清理 | DONE |
| T-104 | 实现失效重登策略 | T-103 | CHIS 返回未登录时清缓存、重登一次、重试一次;不会无限重试 | TODO |
## Phase 2 · CHIS 只读查询验证
+10
View File
@@ -195,6 +195,16 @@ cookie: str
- 成功时返回当前账号信息响应。
- CHIS 返回非 200 业务码时抛出 `ChisLoginError(code="chis_session_invalid")`,用于判断会话无效。
### `RedisChisSessionStore`
职责:
- 使用 Redis key `chis:session:{account_ref}` 保存 CHIS 会话对象。
- `save(account_ref, session)` 按 `expires_at` 计算 TTL 并调用 Redis `setex`。
- `get(account_ref)` 只返回未过期会话;发现过期会话时删除缓存并返回 `None`。
- `delete(account_ref)` 清理指定账号引用的缓存。
- 会话 JSON 包含 `base_url`、`uid`、`role_id`、`manage_unit`、`cookies`、`login_at`、`expires_at`、`last_validated_at`,不得包含明文密码。
### `ChisSessionManager.ensure_session(account_ref)`
输入:
+10 -10
View File
@@ -6,13 +6,13 @@
- 日期:2026-07-05
- 阶段:MVP 起步 / Phase 1 CHIS 登录与会话验证推进中
- 技术栈:当前系统 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/` 包目录;`ChisLoginClient` 已支持登录和 `getLanderInfo` 会话验证
- 测试:`tests/test_app_factory.py`、`tests/test_config_and_structure.py`、`tests/test_project_setup.py`、`tests/test_chis_auth.py`
- 技术栈:当前系统 Python 3.8、Flask 3.0.3 application factory、pytest 8.3.5、requests[socks] 2.32.4、gmssl 3.2.2、redis-py 3.5.3;不创建虚拟环境
- 生产代码:`app/__init__.py`、`app/api/health.py`、`app/config.py`、`run.py`、`app/chis/auth.py`、`app/chis/crypto.py`、`app/chis/session_store.py`,以及 `app/middleware/`、`app/services/`、`app/mappers/`、`app/chis/`、`app/repositories/`、`app/validators/` 包目录;`ChisLoginClient` 已支持登录和 `getLanderInfo` 会话验证,`RedisChisSessionStore` 已支持会话缓存读写和过期清理
- 测试:`tests/test_app_factory.py`、`tests/test_config_and_structure.py`、`tests/test_project_setup.py`、`tests/test_chis_auth.py`、`tests/test_chis_session_store.py`
- 数据 / 资料:`reverse_file/` 下已有 CHIS HAR、前端脚本和 schema;`D:\hans\chupd\chis\login_client_v2.py` 可作为登录链路参考
- 标准启动路径:`python run.py`
- 标准验证路径:`python -m pytest`
- 当前 blocker:无硬阻塞;后续实现需要接入 Redis 会话缓存,并在失效时清理缓存和重登一次
- 当前 blocker:无硬阻塞;后续实现需要接入失效重登策略:会话验证失败时清缓存、重登一次、重试一次
## 当前目录要点
@@ -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;T-101 已包含登录客户端与 SM2 加密,T-102 已包含 `getLanderInfo` 会话验证 |
| `app/chis/` | 已有 | CHIS integration layer;T-101 已包含登录客户端与 SM2 加密,T-102 已包含 `getLanderInfo` 会话验证,T-103 已包含 Redis 会话缓存 |
| `app/mappers/` | 已有 | 数据转换层包目录 |
| `app/middleware/` | 已有 | 中间件包目录 |
| `app/repositories/` | 已有 | 存储 / 缓存包目录 |
@@ -31,23 +31,23 @@
| `app/validators/` | 已有 | 请求校验包目录 |
| `tests/` | 已有 | pytest 可收集的测试 |
| `pytest.ini` | 已有 | pytest 测试入口配置,限制测试目录为 `tests` |
| `requirements.txt` | 已有 | Python 依赖,固定 `Flask==3.0.3`、`pytest==8.3.5`、`requests[socks]==2.32.4`、`gmssl==3.2.2` |
| `requirements.txt` | 已有 | Python 依赖,固定 `Flask==3.0.3`、`pytest==8.3.5`、`requests[socks]==2.32.4`、`gmssl==3.2.2`、`redis==3.5.3` |
| `.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 建立最小测试框架;T-101 迁移 CHIS 登录链路;T-102 接入账号信息查询验证会话。详见 [../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 登录链路;T-102 接入账号信息查询验证会话;T-103 实现 Redis 会话缓存。详见 [../progress.md](../progress.md)。
- 正在进行:无。
- 下一个可领取任务:T-103 实现 Redis 会话缓存。
- 下一个可领取任务:T-104 实现失效重登策略。
## 已确认技术事实
- 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 加密。
- requests[socks] 2.32.4 和 gmssl 3.2.2 已固定到 `requirements.txt`,用于 CHIS 登录请求、SOCKS5 代理和 SM2 加密。`redis==3.5.3` 已固定用于 CHIS 会话缓存,并兼容当前系统 Python 环境里的 `django-q` 约束。
- 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` 处理。
@@ -55,7 +55,7 @@
- `.env.example` 只保留占位符和非敏感默认值,不写真实密钥。
- 登录链路已迁移到 `app/chis/auth.py`:`myRoles` -> 选择 `责任医生助理` / `责任医生` -> `myApps` -> 提取 `JSESSIONID` -> 拼接 cookie。
- SM2 加密已迁移到 `app/chis/crypto.py`,兼容 `hans_chis.sm2.sm2_encrypt`:`gmssl.sm2.CryptSM2(mode=0)`,返回带 `04` 前缀密文。
- 会话验证已接入 `ChisLoginClient.get_lander_info(cookie)`,请求 `chis.myPageService / getLanderInfo`;CHIS 非 200 业务码会抛出 `chis_session_invalid`。
- 会话验证已接入 `ChisLoginClient.get_lander_info(cookie)`,请求 `chis.myPageService / getLanderInfo`;CHIS 非 200 业务码会抛出 `chis_session_invalid`。Redis 会话缓存已接入 `RedisChisSessionStore`,会话对象包含 `base_url`、`uid`、`role_id`、`manage_unit`、`cookies`、`login_at`、`expires_at`、`last_validated_at`,过期会话读取时自动删除。
- 体检详情查询第一版可用 `healthCheck + phrId + idCard` 调 `getHMNIListOfHTML`,用 `idCard + checkDate` 调 `getCheckInfoDetail`。
- 当前不能证明只传 `healthCheck` 就能查完整详情。
- 运行日志保存到 `logs/`,每天一个综合日志文件,保留 1 年。
+9
View File
@@ -124,3 +124,12 @@
- 阻塞:无。
- 决策:T-102 只做已有 cookie 的账号信息查询和失效判断;Redis 缓存、清理缓存和重登策略留给 T-103 / T-104。
- 下一步:T-103 实现 Redis 会话缓存。
## 2026-07-05 T-103 实现 Redis 会话缓存
- 状态:DONE
- 变更:新增 `app/chis/session_store.py`,定义 `ChisSession` 和 `RedisChisSessionStore`,支持 Redis `setex/get/delete`、会话 JSON 序列化、按 `expires_at` 计算 TTL、过期会话自动删除;新增 `tests/test_chis_session_store.py`;`requirements.txt` 增加 `redis==3.5.3`;更新任务看板、技术栈、架构、API 合约、当前状态和根目录任务总览。
- 验证:先执行 `python -m pytest tests\test_chis_session_store.py`,看到 `ModuleNotFoundError: No module named 'app.chis.session_store'`;实现后执行同一命令结果 `3 passed`。为固定 Redis 依赖,先执行 `python -m pytest tests\test_project_setup.py` 看到缺少 `redis==5.0.8` 的断言失败;安装时发现系统环境 `django-q` 要求 `redis<4.0.0`,因此改为兼容的 `redis==3.5.3` 并执行 `python -m pip install -r requirements.txt` 成功;执行 `python -m pytest tests\test_project_setup.py tests\test_chis_session_store.py` 结果 `4 passed`。
- 阻塞:无。
- 决策:T-103 只实现会话缓存读写和过期清理;会话验证失败后的清缓存、重登一次和重试一次留给 T-104。由于项目使用系统 Python 且不建虚拟环境,Redis 依赖固定为 `redis==3.5.3`,避免破坏同环境 `django-q` 依赖。
- 下一步:T-104 实现失效重登策略。
+1
View File
@@ -2,3 +2,4 @@ Flask==3.0.3
pytest==8.3.5
requests[socks]==2.32.4
gmssl==3.2.2
redis==3.5.3
+1 -1
View File
@@ -15,7 +15,7 @@ MVP 起步。当前仓库已具备逆向资料、项目文档、Flask applicatio
| T-003 | 建立最小测试框架 | DONE |
| T-101 | 迁移 CHIS 登录链路 | DONE |
| T-102 | 接入账号信息查询验证会话 | DONE |
| T-103 | 实现 Redis 会话缓存 | TODO |
| T-103 | 实现 Redis 会话缓存 | DONE |
| T-201 | 实现通用 CHIS jsonRequest client | TODO |
| T-202 | 实现体检详情查询 client | TODO |
| T-203 | 跑通只读查询 spike | TODO |
+73
View File
@@ -0,0 +1,73 @@
from datetime import datetime, timedelta, timezone
from app.chis.session_store import ChisSession, RedisChisSessionStore
class FakeRedis:
def __init__(self):
self.values = {}
self.setex_calls = []
self.deleted = []
def setex(self, key, ttl_seconds, value):
self.values[key] = value
self.setex_calls.append((key, ttl_seconds, value))
def get(self, key):
return self.values.get(key)
def delete(self, key):
self.deleted.append(key)
self.values.pop(key, None)
def fixed_now():
return datetime(2026, 7, 5, 8, 0, tzinfo=timezone.utc)
def make_session(expires_at=None):
return ChisSession(
base_url="http://chis.example.test/chis",
uid="u001",
role_id="role-2",
manage_unit="unit-1",
cookies={"JSESSIONID": "abc123", "sessionId1": "abc123"},
login_at=fixed_now(),
expires_at=expires_at or fixed_now() + timedelta(hours=2),
last_validated_at=fixed_now() + timedelta(minutes=5),
)
def test_session_store_saves_session_with_ttl_and_reads_it_back():
redis = FakeRedis()
store = RedisChisSessionStore(redis_client=redis, now=fixed_now)
session = make_session()
store.save("account-a", session)
loaded = store.get("account-a")
assert redis.setex_calls[0][0] == "chis:session:account-a"
assert redis.setex_calls[0][1] == 7200
assert loaded == session
assert "abc123" in redis.setex_calls[0][2]
assert "password" not in redis.setex_calls[0][2].lower()
def test_session_store_deletes_and_ignores_expired_session():
redis = FakeRedis()
store = RedisChisSessionStore(redis_client=redis, now=fixed_now)
expired = make_session(expires_at=fixed_now() - timedelta(seconds=1))
redis.values["chis:session:account-a"] = store.dumps(expired)
assert store.get("account-a") is None
assert redis.deleted == ["chis:session:account-a"]
def test_session_store_deletes_session_by_account_ref():
redis = FakeRedis()
store = RedisChisSessionStore(redis_client=redis, now=fixed_now)
store.delete("account-a")
assert redis.deleted == ["chis:session:account-a"]
+1
View File
@@ -8,3 +8,4 @@ def test_requirements_pin_test_framework():
assert "pytest==8.3.5" in requirements
assert "requests[socks]==2.32.4" in requirements
assert "gmssl==3.2.2" in requirements
assert "redis==3.5.3" in requirements