diff --git a/.agents/docs/plans/MCP化改造方案-2026-10-19.md b/.agents/docs/plans/MCP化改造方案-2026-10-19.md index a0c9a40..61a6be1 100644 --- a/.agents/docs/plans/MCP化改造方案-2026-10-19.md +++ b/.agents/docs/plans/MCP化改造方案-2026-10-19.md @@ -496,6 +496,29 @@ FakeBot 缺方法误判成产品故障。补 `_wait_any` / `_table_text` / `row_ 1. `details` / `payment` / `attachments` 三步没联机验过(本轮只到 basic);travel 五步整条未联机 2. P4 提交护栏未做;删除操作刻意不进工具层(破坏性动作交人工) -3. `config.json` 里 `sso_login_url` / `portal_url` / `reimburse_url` 三个键被 `src/config.py` 忽略 +3. ~~`config.json` 里的 URL 三键被 `src/config.py` 忽略,示例里还留着占位假值~~ → 已修,见 §14 (只读环境变量 + 内置默认),示例文件里留着占位假值会误导人:要么让它们生效,要么删掉键 4. 经济科目固定取第 3 行、项目固定取第 1 行,仍无配置入口 + +## 14. 配置读取修复(2026-10-20) + +P3 联机时顺带发现:文档写着「地址来自 `scripts/data/config.json`」,但 `src/config.py` 读 URL +**只看环境变量和内置默认**,压根不读这个文件。本地那份还是改造前的旧文件,里面留着的 +`sso_login_url` 等三个键是示例占位假值(`https://your-sso-url/login`),另外还带着早已删除的 +`consumable_storage` 与 `attachment_dir`。也就是说:用户改文件不生效,改对了反而可能生效得晚。 + +修法是让文档变成真的,而不是把文档改回去: + +- 优先级 **环境变量 > config.json > 内置默认**;值统一 `strip()`;坏 JSON / 文件缺失都不炸,退回默认并告警 +- 含 `your-` 的占位值一律当成没配,打 WARNING 说明去哪改。这条不是多余:优先级一改, + 老文件里的假地址就会**立刻生效**并把登录打挂,守卫是这次改动自身的安全带 +- 凭据只从文件读(宿主会清洗子进程环境变量,靠 env 传密码不可靠);新增 `safe_config()` + 作为唯一可进日志的视图,`load_config()` 里有明文密码 +- 顺手清掉死代码:`SAFE_CONFIG_KEYS`、`attachment_dir`(Web 时代遗留,全项目无引用) +- `config.example.json` 补上五个可选 URL 键(留空 = 用内置默认);`.env.example` 里的示例赋值 + **全部注释掉**——环境变量优先级最高,整份复制出去就会把 `your-sso-url` 塞进环境真去登录 +- 本地 `scripts/data/config.json` 去掉 7 个陈旧键,只留凭据;改动前备份为 `config.json.bak-20261020` + +测试:`tests/test_config.py` 从 3 例扩到 9 例(文件覆盖默认、env 覆盖文件、占位值忽略、 +strip、凭据不读 env、safe_config 不含密码、坏文件与缺文件不崩、返回副本、示例文件不许再出现 +`your-` 假值)。全部指向 tmp 的假配置,不碰真凭据。`pytest 141 passed`。 diff --git a/.env.example b/.env.example index 6c4656d..2061184 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,17 @@ # 系统 URL 覆盖(可选)——不设则使用 src/config.py 里的内置默认值 -注意:项目已移除 python-dotenv,不会再自动读取 .env 文件。 +取值优先级:环境变量 > scripts/data/config.json > src/config.py 内置默认。 +注意:项目已移除 python-dotenv,不会再自动读取 .env 文件(本文件只是变量名清单)。 这些是真实的进程环境变量名:自己 export,或写进宿主的 MCP server env 配置。 财务账号密码不放这里:DSH 会清洗 stdio 子进程的敏感环境变量,凭据只放 scripts/data/config.json。 -SSO_LOGIN_URL=https://your-sso-url/login -PORTAL_URL=https://your-portal-url/oshall -REIMBURSE_URL=http://your-reimburse-host:8081 -REIMBURSE_PAGE=/expen/common/common?v=4.0 -TRAVEL_PAGE=/expen/travel/travel?v=4.0 +# 全部注释掉:整份复制出去也不会把坏地址塞进环境(环境变量优先级最高,写错就直接生效)。 +# 想用哪一行就去掉注释。左侧是变量名,右侧是内置默认值。 +# SSO_LOGIN_URL=https://tyrz.fynu.edu.cn/sso/login +# PORTAL_URL=https://tyrz.fynu.edu.cn/oshall +# REIMBURSE_URL=http://210.45.32.214:8081 +# REIMBURSE_PAGE=/expen/common/common?v=4.0 +# TRAVEL_PAGE=/expen/travel/travel?v=4.0 # 上传收件器访问码(也可用 --pin 参数或 config.json 的 upload_pin) UPLOAD_PIN= diff --git a/AGENTS.md b/AGENTS.md index df3410b..eba7150 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,9 @@ last_reviewed: 2026-10-19 等,别拍脑袋 `wait_for_timeout`。直达报销 URL 不建立会话,会被踢回「长时间未操作,请重新登录!」 * **多进程共存**:宿主会保留旧代 stdio 子进程,subagent/teammate 也会各起一条 transport—— observed 4 个 PID 并存。 因此任何"独占资源"(浏览器、Office)都要有磁盘锁,别假设只有一个服务器实例 +* **配置优先级**:环境变量 > `scripts/data/config.json` > `src/config.py` 内置默认;含 `your-` 的占位值 + 按未配置处理并告警。改完配置要重连常驻 MCP 子进程才生效(进程内有缓存)。`safe_config()` 才是 + 可以进日志/回显的那份,`load_config()` 里有明文密码 * **敏感信息**:`scripts/data/config.json` 含登录凭据、`cases/` 含身份证号与卡号,两者都已在 `.gitignore`, 绝不要提交,也不要回显密码 * **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;普通发票生成低值易耗品申购单 diff --git a/config.example.json b/config.example.json index 3d6397e..03f02ac 100644 --- a/config.example.json +++ b/config.example.json @@ -4,5 +4,10 @@ "default_name": "默认报销人姓名", "default_card_no": "默认公务卡号", "default_person_id": "默认人员编号", - "upload_pin": "手机上传页访问码,可留空" + "upload_pin": "手机上传页访问码,可留空", + "sso_login_url": "", + "portal_url": "", + "reimburse_url": "", + "reimburse_page": "", + "travel_page": "" } diff --git a/docs/报销操作指南.md b/docs/报销操作指南.md index d27a1d2..6583e9c 100644 --- a/docs/报销操作指南.md +++ b/docs/报销操作指南.md @@ -24,8 +24,10 @@ last_reviewed: 2026-10-19 | 日常报销录入 | `/expen/common/common?v=4.0` | 普通报销目标页 | | 差旅报销 | `/expen/travel/travel?v=4.0` | 差旅报销目标页 | -地址来自 `scripts/data/config.json`,也可用环境变量覆盖(`SSO_LOGIN_URL` 等,见 `.env.example`; -项目**不会**自动读取 `.env` 文件,需要自己导出或写进宿主的 env 配置)。 +取值优先级:**环境变量 > `scripts/data/config.json` > `src/config.py` 内置默认**。 +环境变量名是 `SSO_LOGIN_URL` / `PORTAL_URL` / `REIMBURSE_URL` / `REIMBURSE_PAGE` / `TRAVEL_PAGE` +(见 `.env.example`;项目**不会**自动读 `.env` 文件,要自己 export 或写进宿主的 MCP env)。 +配置文件里这几项**可以不写**;写了占位假值(含 `your-`)会被当成没配并打 WARNING,不会拿去登录。 --- @@ -243,7 +245,8 @@ SSO 需要扫码或验证码时人工完成即可,流程会继续。每次运 | `default_card_no` | 支付方式的「公务卡号」 | | `upload_pin` | 上传收件器访问码(等价 `--pin` 或环境变量 `UPLOAD_PIN`) | -URL 类字段(`sso_login_url`、`portal_url`、`reimburse_url`、`reimburse_page`、`travel_page`)有内置默认值,需要才配。 +URL 类字段(`sso_login_url`、`portal_url`、`reimburse_url`、`reimburse_page`、`travel_page`)有内置默认值, +需要才配;配了就生效(早先版本这里写了也不读,已修)。想临时改地址用环境变量更方便,不用动文件。 凭据不放环境变量:DSH 会清洗 stdio 子进程里含 KEY/PASSWORD/SECRET/TOKEN 的变量。 校验规则在 `config/validation_rules.json`;改规则要同步跑测试(骨架与规则有一致性用例)。 diff --git a/src/config.py b/src/config.py index 1a6b3bd..5c74642 100644 --- a/src/config.py +++ b/src/config.py @@ -1,66 +1,115 @@ -""" -配置加载 +"""配置加载 -从 scripts/data/config.json 读取用户配置,环境变量可覆盖系统 URL 与用户字段。 +优先级:环境变量 > scripts/data/config.json > 内置默认值。 + +两条历史坑,别再犯: + +1. 早期版本 URL 只认环境变量与内置默认,**根本不读 config.json**,而文档写着「地址来自 + config.json」——用户改文件不生效,还留着示例里的占位假值看着像配好了。现在按优先级链真读。 +2. 占位值(`https://your-sso-url/login` 这类从示例抄来的)必须当成「没配」,否则老文件一升级 + 就变成生效的坏地址,登录直接挂。这里显式识别并告警,比让它悄悄生效好。 + +凭据(username/password/…)只从文件读,不走环境变量:DSH 会清洗 stdio 子进程里名字含 +KEY/PASSWORD/SECRET/TOKEN 的变量,指望环境变量传密码是不可靠的。 """ import json import os from pathlib import Path +from . import get_logger + +log = get_logger("config") + # 项目根 = src/config.py 的上上级(曾多写一层 parent,导致解析到项目之外、凭据恒为空) _CONFIG_PATH = Path(__file__).resolve().parent.parent / "scripts" / "data" / "config.json" -# 会话级配置允许覆盖的用户相关字段白名单(含密码) +# URL 类字段:环境变量名 -> 内置默认值。键顺序即 config.json 里推荐的书写顺序 +URL_FIELDS: dict[str, tuple[str, str]] = { + "sso_login_url": ("SSO_LOGIN_URL", "https://tyrz.fynu.edu.cn/sso/login"), + "portal_url": ("PORTAL_URL", "https://tyrz.fynu.edu.cn/oshall"), + "reimburse_url": ("REIMBURSE_URL", "http://210.45.32.214:8081"), + "reimburse_page": ("REIMBURSE_PAGE", "/expen/common/common?v=4.0"), + "travel_page": ("TRAVEL_PAGE", "/expen/travel/travel?v=4.0"), +} -# 前端安全的配置字段白名单(不含密码) -SAFE_CONFIG_KEYS = frozenset( - { - "username", - "default_name", - "default_card_no", - "default_person_id", - } -) +# 用户相关字段只从文件读 +USER_FIELDS = ("username", "password", "default_name", "default_card_no", "default_person_id") -# 模块级配置缓存 -_config_cache: dict[str, str | Path] | None = None +# 环境变量名 -> 配置键(除 URL 外还有这个) +_ENV_ONLY = {"UPLOAD_PIN": "upload_pin"} -def _read_config() -> dict[str, str | Path]: - """读取并合并配置,缺失字段使用默认值""" - raw = {} +def _is_placeholder(value: str) -> bool: + """空值或示例里的占位值都算「没配」。""" + if not value: + return True + low = value.lower() + return "your-" in low or low.startswith("http://your") or "填这里" in value or value.startswith("你的") + + +def _resolve(key: str, raw_value: object) -> str: + """单个 URL 字段按 环境变量 > 文件 > 默认 取值,占位值跳过并告警。""" + env_name, default = URL_FIELDS[key] + env_value = os.environ.get(env_name, "").strip() + if env_value: + return env_value + file_value = str(raw_value).strip() if raw_value is not None else "" + if _is_placeholder(file_value): + if file_value: + log.warning( + "config.json 的 %s 是占位值 %r,已忽略并用内置默认;要改就在这一行填真实地址,或设环境变量 %s", + key, + file_value, + env_name, + ) + return default + return file_value + + +_config_cache: dict[str, str] | None = None + + +def _read_config() -> dict[str, str]: + raw: dict[str, object] = {} if _CONFIG_PATH.exists(): - with open(_CONFIG_PATH, encoding="utf-8") as f: - raw = json.load(f) + try: + loaded = json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception as exc: + log.error("读取 %s 失败,全部退回默认值与环境变量: %s", _CONFIG_PATH, exc) + loaded = {} + if isinstance(loaded, dict): + raw = loaded + else: + log.error("%s 顶层不是对象,忽略整个文件", _CONFIG_PATH) + elif not _CONFIG_PATH.exists(): + log.warning("配置文件不存在: %s(凭据将为空,填报与匹配会失败)", _CONFIG_PATH) - project_root = _CONFIG_PATH.parent - - return { - "sso_login_url": os.environ.get("SSO_LOGIN_URL", "https://tyrz.fynu.edu.cn/sso/login"), - "portal_url": os.environ.get("PORTAL_URL", "https://tyrz.fynu.edu.cn/oshall"), - "reimburse_url": os.environ.get("REIMBURSE_URL", "http://210.45.32.214:8081"), - "reimburse_page": os.environ.get("REIMBURSE_PAGE", "/expen/common/common?v=4.0"), - "travel_page": os.environ.get("TRAVEL_PAGE", "/expen/travel/travel?v=4.0"), - "username": raw.get("username", ""), - "password": raw.get("password", ""), - "default_name": raw.get("default_name", ""), - "default_card_no": raw.get("default_card_no", ""), - "default_person_id": raw.get("default_person_id", ""), - "upload_pin": os.environ.get("UPLOAD_PIN") or raw.get("upload_pin", ""), - "attachment_dir": project_root / "attachments", - } + config: dict[str, str] = {key: _resolve(key, raw.get(key)) for key in URL_FIELDS} + for key in USER_FIELDS: + value = raw.get(key, "") + config[key] = str(value).strip() if value is not None else "" + for env_name, key in _ENV_ONLY.items(): + from_env = os.environ.get(env_name, "").strip() + from_file = str(raw.get(key, "") or "").strip() + config[key] = from_env or from_file + return config -def load_config() -> dict[str, str | Path]: - """加载并合并配置,使用模块级缓存避免重复读取文件""" +def load_config() -> dict[str, str]: + """加载配置(模块级缓存);返回副本,改返回值不会影响缓存。""" global _config_cache if _config_cache is None: _config_cache = _read_config() return dict(_config_cache) +def safe_config() -> dict[str, str]: + """去掉密码的配置,日志与对外展示只能用它。""" + return {k: v for k, v in load_config().items() if k != "password"} + + def clear_config_cache() -> None: - """清除配置缓存(测试或配置变更时调用)""" + """清缓存(测试或改了配置文件后调用;常驻的 MCP 子进程要靠重连才生效)。""" global _config_cache _config_cache = None diff --git a/tests/test_config.py b/tests/test_config.py index 401aa00..0b8fb5a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,19 +1,119 @@ -"""配置模块单元测试""" +"""配置加载测试:优先级链、占位值守卫、凭据只从文件读。 -from src.config import load_config +所有用例都指向 tmp 里的假配置文件,绝不碰 scripts/data/config.json(那是真凭据)。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from src import config as cfg + +URL_KEYS = ("sso_login_url", "portal_url", "reimburse_url", "reimburse_page", "travel_page") +ENV_NAMES = ("SSO_LOGIN_URL", "PORTAL_URL", "REIMBURSE_URL", "REIMBURSE_PAGE", "TRAVEL_PAGE", "UPLOAD_PIN") -class TestLoadConfig: - """配置加载""" +@pytest.fixture() +def cfg_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """把配置文件指到 tmp,清掉干扰断言的环境变量与缓存。""" + path = tmp_path / "config.json" + monkeypatch.setattr(cfg, "_CONFIG_PATH", path) + for name in ENV_NAMES: + monkeypatch.delenv(name, raising=False) + cfg.clear_config_cache() + yield path + cfg.clear_config_cache() - def test_returns_dict(self) -> None: - config = load_config() - assert isinstance(config, dict) - def test_has_username(self) -> None: - config = load_config() - assert "username" in config +def write_cfg(path: Path, data: dict[str, str]) -> None: + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + cfg.clear_config_cache() - def test_has_password(self) -> None: - config = load_config() - assert "password" in config + +def test_file_wins_over_builtin_default(cfg_env: Path) -> None: + write_cfg(cfg_env, {"reimburse_url": "http://10.0.0.9:8081", "username": "u1"}) + config = cfg.load_config() + assert config["reimburse_url"] == "http://10.0.0.9:8081" + assert config["username"] == "u1" + # 文件里没写的字段仍走内置默认,不会因为缺键就变空串 + assert config["sso_login_url"] == cfg.URL_FIELDS["sso_login_url"][1] + + +def test_env_wins_over_file(cfg_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_cfg(cfg_env, {"portal_url": "https://file.example/oshall"}) + monkeypatch.setenv("PORTAL_URL", "https://env.example/oshall") + cfg.clear_config_cache() + assert cfg.load_config()["portal_url"] == "https://env.example/oshall" + + +def test_placeholder_value_is_ignored(cfg_env: Path) -> None: + """示例里抄来的 your-xxx 必须当成没配,否则老文件一升级就把登录打挂。""" + write_cfg(cfg_env, {"sso_login_url": "https://your-sso-url/login", "reimburse_url": ""}) + config = cfg.load_config() + assert config["sso_login_url"] == cfg.URL_FIELDS["sso_login_url"][1] + assert config["reimburse_url"] == cfg.URL_FIELDS["reimburse_url"][1] + + +def test_values_are_trimmed(cfg_env: Path) -> None: + write_cfg(cfg_env, {"reimburse_page": " /expen/x?v=9 ", "username": " abc "}) + config = cfg.load_config() + assert config["reimburse_page"] == "/expen/x?v=9" + assert config["username"] == "abc" + + +def test_credentials_not_read_from_env(cfg_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """凭据只认文件:宿主会清洗子进程环境变量,靠环境变量传密码不可靠。""" + write_cfg(cfg_env, {"username": "from-file", "password": "f"}) + monkeypatch.setenv("username", "from-env") + monkeypatch.setenv("PASSWORD", "from-env") + cfg.clear_config_cache() + config = cfg.load_config() + assert config["username"] == "from-file" + assert config["password"] == "f" + + +def test_safe_config_hides_password(cfg_env: Path) -> None: + write_cfg(cfg_env, {"password": "secret-value"}) + safe = cfg.safe_config() + assert "password" not in safe + assert "secret-value" not in json.dumps(safe, ensure_ascii=False) + + +def test_broken_config_file_falls_back(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = tmp_path / "config.json" + path.write_text("{坏文件", encoding="utf-8") + monkeypatch.setattr(cfg, "_CONFIG_PATH", path) + for name in ENV_NAMES: + monkeypatch.delenv(name, raising=False) + cfg.clear_config_cache() + config = cfg.load_config() + assert config["reimburse_url"] == cfg.URL_FIELDS["reimburse_url"][1] + assert config["username"] == "" + + +def test_missing_file_does_not_crash(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cfg, "_CONFIG_PATH", tmp_path / "nope" / "config.json") + for name in ENV_NAMES: + monkeypatch.delenv(name, raising=False) + cfg.clear_config_cache() + config = cfg.load_config() + assert all(config[k] for k in URL_KEYS) + + +def test_load_config_returns_copy(cfg_env: Path) -> None: + write_cfg(cfg_env, {"username": "a"}) + first = cfg.load_config() + first["username"] = "改了也不影响缓存" + assert cfg.load_config()["username"] == "a" + + +def test_example_has_no_placeholder_urls() -> None: + """示例文件不能再埋 your-xxx:会被当成没配,却看着像配好了。""" + example = Path(__file__).resolve().parents[1] / "config.example.json" + data = json.loads(example.read_text(encoding="utf-8")) + for key in URL_KEYS: + value = str(data.get(key, "")) + assert "your-" not in value, f"config.example.json 的 {key} 又是占位假值: {value}"