14 KiB
DEBUG_MODE 与线上 API 固定策略分析
结论
当前启动配置不是单一的“环境变量 -> AppConfig”链路,而是四层覆盖:
python-dotenv先加载.env,且override=False,所以进程环境变量优先于.env(app/src/doctor_workstation/config.py:113-118)。DOCTOR_API_BASE_URL被规范化并传入AppConfig(config.py:118-132)。- 用户目录中的
preferences.json再覆盖环境配置,因此当前实际上是进程环境/.env < preferences.json(config.py:133-153)。 - 登录页另有一套 Qt
QSettings:server/base_url会覆盖已经合并好的config.api_base_url,保存或正式登录时再通过config_changed写回AppConfig,控制器随后保存preferences.json并重建远端仓库(app/src/doctor_workstation/ui/login.py:917-927, 1026-1054, 1067-1077, 1079-1103;app/src/doctor_workstation/app.py:438-504)。
所以,只在 AppConfig.load() 里把环境变量替换成线上域名是不完整的。DEBUG_MODE=False 时必须同时封住:
- 环境变量 /
.env; preferences.json;- Qt
QSettings的server/base_url; - 登录页运行期
with_updates(api_base_url=...)。
建议目标契约为:
| 模式 | 最终 AppConfig.api_base_url |
本地地址设置 |
|---|---|---|
DEBUG_MODE=False |
始终为 https://admin.zhenyangtang.com.cn/adminapi |
环境、JSON preference、Qt QSettings、登录页编辑均不得改变 |
DEBUG_MODE=True |
保留当前规则:环境/.env 初始化,preferences.json 覆盖,登录页可再次编辑 |
完全保留现有可配置行为 |
仓库内线上地址最直接的证据是 admin/.env.production:1-3,生产管理端使用 https://admin.zhenyangtang.com.cn/;admin/vite.config.ts:54-64 的开发代理也指向同一主机。桌面端的 normalize_api_base_url() 会追加 /adminapi(config.py:67-85),因此建议常量保存主机根地址,最终有效地址由同一个规范化函数产生。TongjiUniApp/main.js:3 当前使用的是 https://xt.zhenyangtang.com.cn/,它属于另一客户端,不能替代管理 API 地址。
精确修改建议
1. 包级发布策略常量
在 app/src/doctor_workstation/__init__.py:1-6 添加两个普通源码常量,并更新 __all__。当前该文件已有未提交的版本升级 1.1.0 -> 1.2.0,实现时必须保留它,只做增量编辑。
建议名称和取值:
DEBUG_MODE = False
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
__all__ = ["__version__", "DEBUG_MODE", "ONLINE_API_BASE_URL"]
DEBUG_MODE 不应来自 DOCTOR_DEBUG_MODE 或其他环境变量,否则已安装程序仍可被本地环境切回调试地址,直接违反需求。它应是发布代码/构建产物内的策略开关。线上常量不要带查询参数、凭据或 fragment;是否在常量里带 /adminapi 均可,但建议只放域名根地址,让 normalize_api_base_url() 保持路径的唯一规范化入口。
config.py 从包根导入这两个常量不会形成循环:包 __init__.py 不导入 config.py;现有 services/app_update.py:21 也已经用相同方式从包根导入 __version__。
2. 在 AppConfig 的两个入口执行同一发布策略
涉及 app/src/doctor_workstation/config.py:17-30, 67-85, 113-153, 167-176。
建议在 normalize_api_base_url() 之后增加一个小型策略函数(名称可调整):
def effective_api_base_url(candidate: str) -> str:
if not DEBUG_MODE:
# 源码常量无效属于发布错误,应显式失败,不要静默退回空地址。
return normalize_api_base_url(ONLINE_API_BASE_URL)
try:
return normalize_api_base_url(candidate)
except ValueError:
return ""
然后在两个入口复用:
AppConfig.load()的api_url必须由该函数生成。这样进程环境和.env在 release 模式下即使含http://127.0.0.1也只会被读取而不会成为有效 API 地址。_merge_preferences()在DEBUG_MODE=False时必须忽略 JSON 中的api_base_url;也可以允许读取后在replace()前强制写回effective_api_base_url(...)。关键是发布策略必须在 preference 合并之后生效。with_updates()在DEBUG_MODE=False时必须把任何传入的api_base_url强制改为线上值,而不是仅做 URL 规范化。登录页运行期正是通过此入口更新配置。
实现上可选择“每个入口调用 effective_api_base_url()”,也可选择一个 _apply_runtime_policy() 在 _merge_preferences() 和 with_updates() 的 replace() 之后统一执行。后者更不容易遗漏,但需要保证两条返回路径都调用它。
不建议用 AppConfig.__post_init__() 强制改写所有直接构造的实例。仓库中大量单元/UI 测试直接构造带 .test 域名的 AppConfig(例如 app/tests/test_ui_contract.py:408-412);发布要求针对真实运行配置入口,没必要破坏依赖注入式测试。若希望更强的防御,可提供显式 apply_runtime_policy(),由 load()、with_updates() 和控制器接收外部 AppConfig 时调用。
3. 封住 Qt QSettings 的第二套本地 preference
涉及 app/src/doctor_workstation/ui/login.py:451-468, 788-851, 917-950, 956-969, 1026-1054。
这是满足“本地 preference 不应把它改回调试地址”的必需修改,不是纯 UI 优化:
_restore_settings():DEBUG_MODE=False时,server_url_edit只能显示config.api_base_url,不得读取self.settings.value("server/base_url", ...);DEBUG_MODE=True时保持现有读取逻辑。_apply_server_settings():DEBUG_MODE=False时使用config.api_base_url作为base_url,不得信任编辑框或旧 QSettings;也不要把旧调试地址重新写入server/base_url。DEBUG_MODE=True时保持现状。- 发布模式下至少将
server_url_edit设为只读。也可隐藏地址编辑入口,但不要无意中一起删除超时设置;是否同时禁止“信任自签名证书”属于另一项发布安全策略。 _credential_scope()当前优先使用地址编辑框(login.py:956-969)。因此必须先确保发布模式下编辑框显示线上地址,否则实际请求虽已被with_updates()锁到线上,密码却可能错误地按旧调试地址做凭据 scope,造成跨环境凭据恢复混乱。
不建议启动时删除旧的 server/base_url QSettings。发布模式忽略它即可,这样将来显式切到 DEBUG_MODE=True 时仍能保留既有调试配置,也避免无必要的数据清理。
4. bootstrap / repository 侧无需另建域名来源
实际启动入口是 app/src/doctor_workstation/__main__.py:5-19 -> app.py:1166-1182。main() 只调用一次 AppConfig.load(),随后 ApplicationController.__init__() 立即执行 _rebuild_remote_repository()(app.py:402-424);后者把 self.config.api_base_url 原样传给 build_repository()(app.py:513-525),再由 ApiClient 规范化为带结尾斜杠的 /adminapi/ 地址(services/factory.py:13-45;services/api_client.py:133-148)。
因此 app.py、factory.py、api_client.py 不应复制线上域名常量。只要 AppConfig 在完成所有合并后保持不变量,这一段无需修改。
可选的纵深防御:ApplicationController._on_config_changed() 在收到一个完整 AppConfig payload 时目前直接接受(app.py:460-489),只有 dict payload 才经过 self.config.with_updates()。若未来可能有第二个发信者,建议让完整 AppConfig 同样经过显式 runtime policy;当前唯一连接来自 LoginWindow(app.py:447),且其正常路径会先调用 with_updates(),所以这不是本次最小改动的阻塞项。
当前覆盖顺序与修改后顺序
当前:
.env --(override=False)--> os.environ
|
v
AppConfig(env)
|
v
preferences.json 覆盖 env
|
v
LoginWindow 的 QSettings/server/base_url 覆盖 config
|
v
with_updates -> save_preferences -> rebuild repository
建议修改后:
DEBUG_MODE=True : 保持上面的完整可配置链路
DEBUG_MODE=False:
env/.env ----------- ignored for api_base_url ---+
preferences.json --- ignored for api_base_url ---+--> ONLINE_API_BASE_URL
QSettings ---------- ignored for api_base_url ---+ |
runtime update ------ clamped for api_base_url ---+ v
build_repository
兼容风险与边界
- Demo 模式仍可覆盖“是否使用远端仓库”。 当前
demo_mode默认True,且仍可被环境和preferences.json覆盖(config.py:93, 126, 140-153);登录页也会保存它(login.py:1002-1006)。本需求只要求固定 API 域名,所以不应顺手强制demo_mode=False。如果产品语义其实是“发布版必须始终连接线上、不能进入 Demo”,需要单独明确并给demo_mode增加相同发布策略。 - TLS 校验仍可被本地 preference 关闭。
verify_ssl当前可由环境、JSON preference 和 QSettings 改为False(config.py:129, 151-152, 174-175;login.py:937-944, 1038-1048)。固定线上域名但允许关闭证书校验仍有中间人风险。建议产品确认是否在DEBUG_MODE=False时也强制verify_ssl=True,但它超出“域名不可改”的最小范围。 - 调试启动脚本不会自动打开源码 DEBUG_MODE。
app/Debug_DoctorWorkstation.bat:16-23只设置独立配置目录、Demo 和日志级别,没有能力改变源码布尔常量。若常量提交为False,脚本仍能跑 Demo,但不能用本地 URL。不要为方便而从环境读取DEBUG_MODE;更安全的方案是开发者本地改为True(不提交),或由明确区分的 debug 构建生成非发布模块。 - 已有 preference 不需要迁移或删除。 发布模式会忽略旧调试 URL;切回 debug 后仍按现有优先级恢复。
save_preferences()当前把完整 dataclass 写入 JSON(config.py:155-165),发布运行后可能把线上 URL写回文件,这是可接受的,但测试应覆盖“旧文件存在时首次启动仍直接得到线上 URL”。 - 凭据按 URL scope 隔离。
LoginWindow._credential_scope()和TokenStore使用 API scope。切到线上后旧调试 token/password 不应被用于线上,这是正确行为;但若只锁AppConfig而不锁登录页 QSettings,可能出现“请求发往线上、密码却按调试 URL scope 保存/恢复”的错配,因此第 3 节不能省略。 - 构建 smoke 环境目前注入 loopback API。 Windows/macOS 构建与安装 smoke 分别在
app/scripts/build_windows.ps1:28-47、build_macos.sh:105-123、smoke_windows_installer.ps1:101-109注入https://127.0.0.1:9。发布策略生效后该变量会被忽略。正常 smoke 不应访问线上:更新检查被DOCTOR_SMOKE_TEST/--smoke-test阻断(ui/dialogs/app_update.py:332-340),且这些脚本设置DOCTOR_DEMO_MODE=true,会阻断 session restore(app.py:530-540)。仍建议增加“smoke 期间没有发起线上请求”的断言,避免未来启动流程变化造成生产流量。 - 不要把线上常量的错误静默转为空地址。 调试环境输入无效时保持当前的空地址降级合理;源码内线上常量无效则应让测试/构建立即失败,否则发布包只会落入
_UnconfiguredRepository(app.py:435-436, 513-525),错误会拖到登录时才暴露。 - 版本读取兼容。 PyInstaller spec 用正则只读取
__version__行(app/packaging/doctor_workstation.spec:27-37)。只要保留当前独立的__version__ = "1.2.0"赋值,新增常量与__all__不影响版本生成。
建议测试
优先在 app/tests/test_config.py 增加以下矩阵:
DEBUG_MODE=False,环境DOCTOR_API_BASE_URL=http://127.0.0.1:8000,无 preference:AppConfig.load().api_base_url == "https://admin.zhenyangtang.com.cn/adminapi"。DEBUG_MODE=False,环境为线上、preferences.json保存调试 URL:最终仍为线上。DEBUG_MODE=False,先AppConfig.load(),再with_updates(api_base_url="http://localhost:8000"):最终仍为线上。DEBUG_MODE=True,环境提供 A、preference 提供 B:最终仍为 B,证明现有“preference 覆盖 env”行为未回归。DEBUG_MODE=True,无 preference,仅环境提供 URL:继续规范化并自动追加/adminapi。- 将
ONLINE_API_BASE_URL临时 monkeypatch 为非法值且DEBUG_MODE=False:应显式抛错,避免发布误配置静默降级。
在 app/tests/test_ui_contract.py 增加:
- 发布模式的 QSettings 预置
server/base_url=http://127.0.0.1:8000,创建LoginWindow后地址框显示线上 URL且不可编辑。 - 发布模式调用
_save_server_settings()/ 非 Demosubmit(),config_changedpayload 的api_base_url仍为线上,远端仓库不会以 QSettings 地址重建。 - 上述场景下
_credential_scope()返回线上 scope,防止凭据落在旧调试 scope。 DEBUG_MODE=True重跑同类场景,确认地址框仍从 QSettings 恢复、保存后仍能切换服务器。
在 bootstrap/构建层增加或保留以下回归:
ApplicationController用AppConfig.load()启动时,传给build_repository()的 release base URL 精确为https://admin.zhenyangtang.com.cn/adminapi。--smoke-test和DOCTOR_SMOKE_TEST=1下,无论 release URL 是否存在,都不执行更新请求或 token restore 网络调用。- 冻结包 smoke 继续通过;原 smoke 脚本中的 loopback
DOCTOR_API_BASE_URL被忽略是预期行为,不应把断言写成“最终 URL 等于 127.0.0.1”。
建议验证命令:
Set-Location D:\web\zyt\app
uv run pytest tests/test_config.py tests/test_ui_contract.py -q
uv run ruff check src/doctor_workstation/__init__.py src/doctor_workstation/config.py src/doctor_workstation/ui/login.py tests/test_config.py tests/test_ui_contract.py
若还修改了 bootstrap 防御或 smoke 契约,再运行相关完整测试和冻结构建门禁;仅本分析任务未修改生产代码、也未执行会连接线上环境的测试。