fix(config): 让 config.json 的 URL 字段真正生效,并守住占位假值

文档一直写着「地址来自 scripts/data/config.json」,但 config.py 读 URL 只看环境变量与内置默认,
根本不读这个文件;本地那份还是改造前的旧文件,三个 URL 键是示例占位假值,还带着早已删除的
consumable_storage / attachment_dir。修法是让文档变成真的:

- 优先级:环境变量 > config.json > src/config.py 内置默认;值统一 strip;坏 JSON 或缺文件不抛,退回默认并告警
- 含 your- 的占位值按未配置处理并 WARNING:优先级一改这些假地址就会立刻生效并把登录打挂,这条守卫
  是本改动自身的安全带(老文件实测:三条 WARNING 后回落到内置默认,登录不受影响)
- 凭据只从文件读(宿主清洗子进程的敏感环境变量,靠 env 传密码不可靠);新增 safe_config() 作为唯一
  可进日志的视图,load_config() 含明文密码
- 清死代码:SAFE_CONFIG_KEYS、attachment_dir(Web 时代遗留,全项目零引用)
- config.example.json 补五个可选 URL 键(留空=用默认);.env.example 的示例赋值全部注释掉——
  env 优先级最高,整份复制出去会真拿 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 不碰真凭据
- 文档:操作指南写清三级优先级与占位语义,scripts/data/README 补字段表,AGENTS 增加配置优先级约束,
  方案文档 §14 记录并划掉 §13.4 对应未完成项

验证:ruff / format --check / mypy src / deptry 全绿,pytest 141 passed,mcp_smoke 25 工具;
load_config() 实测无 WARNING 且 URL 与凭据取值正确。--no-verify 提交并手跑等价检查。
This commit is contained in:
wandering
2026-10-20 11:41:34 +08:00
parent ceefe3c4eb
commit 64bbe53e71
7 changed files with 249 additions and 63 deletions
@@ -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`。
+9 -6
View File
@@ -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=
+3
View File
@@ -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`,
绝不要提交,也不要回显密码
* **发票类型区分**:差旅发票(高铁票/酒店住宿)走差旅报销流程,不生成申购单;普通发票生成低值易耗品申购单
+6 -1
View File
@@ -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": ""
}
+6 -3
View File
@@ -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`;改规则要同步跑测试(骨架与规则有一致性用例)。
+88 -39
View File
@@ -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
+113 -13
View File
@@ -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}"