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:
+88
-39
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user