diff --git a/README.md b/README.md index 9e37bbc..9531ce2 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/app/chis/session_store.py b/app/chis/session_store.py new file mode 100644 index 0000000..5297134 --- /dev/null +++ b/app/chis/session_store.py @@ -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 \ No newline at end of file diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 4f663e4..bc4ed67 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -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` 动态获取。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index fbf9b8d..2e63a2e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -96,7 +96,7 @@ API / service / CHIS client **Redis repository** -- 保存 CHIS 会话对象。 +- 保存 CHIS 会话对象;当前 `app/chis/session_store.py:RedisChisSessionStore` 负责 JSON 序列化、`setex/get/delete`、过期会话清理。 - 保存幂等键和处理结果摘要。 - 可保存短期锁,避免同一账号并发重登。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index ec9ff22..b4d921a 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 只读查询验证 diff --git a/docs/api.md b/docs/api.md index 4ec4531..852d198 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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)` 输入: diff --git a/docs/current-state.md b/docs/current-state.md index da879ca..24b6811 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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:///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 年。 diff --git a/progress.md b/progress.md index 77cfcc0..d858edd 100644 --- a/progress.md +++ b/progress.md @@ -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 实现失效重登策略。 diff --git a/requirements.txt b/requirements.txt index cc5eca8..ecb8a27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tasks.md b/tasks.md index ef898e8..5a830a3 100644 --- a/tasks.md +++ b/tasks.md @@ -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 | diff --git a/tests/test_chis_session_store.py b/tests/test_chis_session_store.py new file mode 100644 index 0000000..76c959e --- /dev/null +++ b/tests/test_chis_session_store.py @@ -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"] \ No newline at end of file diff --git a/tests/test_project_setup.py b/tests/test_project_setup.py index 7efcceb..9986cc0 100644 --- a/tests/test_project_setup.py +++ b/tests/test_project_setup.py @@ -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