Files
zyt/app/research/debug_mode_login_analysis.md
T
2026-08-28 18:24:37 +08:00

144 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 登录页 DEBUG_MODE 门禁分析
## 结论
当前实现不存在 `DEBUG_MODE`(项目内唯一含 `debug_mode` 的命中只是一个测试函数名)。登录页始终创建并展示“演示模式”和“服务器设置”入口;`AppConfig.demo_mode` 默认又是 `True`,且 `preferences.json` 会覆盖环境配置。因此,仅对两个控件调用 `hide()` 不能满足目标:隐藏的 checkbox 仍可能保持 checked,普通登录仍会自动读取/写回残留 `QSettings`,控制器也会接受伪造或残留的 demo 状态。
建议把 `DEBUG_MODE` 设计成**非用户偏好、不可由 `QSettings``preferences.json` 覆盖的单一运行时门禁**,并在配置加载、LoginWindow 行为和 ApplicationController 三层同时收口:
- `DEBUG_MODE=True`:显示且允许演示仓库切换和登录页服务器设置,保留现有调试行为。
- `DEBUG_MODE=False`:隐藏完整 UI 区块,强制 effective demo 为 `False`,忽略残留服务器 QSettings,登录只能使用 composition root 提供的远程仓库;直接调用槽函数、设置隐藏 checkbox、发信号或构造 demo payload 也不能绕过。
`demo_mode` 只能表示 DEBUG 模式下的默认选择/当前选择,不能再充当“是否有权使用 demo”的授权位。
## 当前实现与风险点
### 1. 配置与持久化
| 位置 | 当前行为 | DEBUG_MODE=False 的风险 |
| --- | --- | --- |
| `src/doctor_workstation/config.py:88-99` | `AppConfig.demo_mode` 默认 `True`,没有 debug gate | 直接构造 `AppConfig()` 就默认允许 demo |
| `config.py:113-133` | `DOCTOR_DEMO_MODE` 未设置时也按 `True` 加载,然后调用 `_merge_preferences()` | 生产未显式注入环境变量时默认 demo;即使环境设为 false,后续偏好仍可覆盖 |
| `config.py:135-153` | `preferences.json` 中所有 dataclass 字段均会合并,包括 `demo_mode``api_base_url``request_timeout``verify_ssl` | 旧 debug profile 的 demo/server 值可覆盖本次受控配置 |
| `config.py:155-165` | `save_preferences()``asdict(self)` 保存完整配置 | demo 切换和服务器设置会持续残留在 JSON 中 |
| `config.py:167-176` | `with_updates()` 可随时把 demo/服务器字段改回调试值 | UI 隐藏后仍可从信号/直接调用修改 |
需要特别区分两套持久化:demo 当前**不写 QSettings**,它通过 `config_changed -> ApplicationController._on_config_changed -> save_preferences()` 写入 `preferences.json`;服务器地址、超时和证书校验先写 `QSettings`,随后同一信号链又写入 `preferences.json`。相关位置是 `ui/login.py:1038-1054``app.py:460-491`
当前 `Debug_DoctorWorkstation.bat:16-24` 只是设置 `DOCTOR_DEMO_MODE=true``DOCTOR_LOG_LEVEL=DEBUG`,没有提供独立 debug capability。若新门禁来自环境,调试启动器应显式设置专用值(例如 `DOCTOR_DEBUG_MODE=true`);普通/冻结启动不得设置。若门禁是构建期常量,则无需让用户偏好或 `.env` 参与。无论采用哪种来源,都不要把它作为普通 `AppConfig` dataclass 字段写入 `preferences.json`
### 2. LoginWindow 组件和信号
| 位置 | 组件/信号链 | 当前行为与缺口 |
| --- | --- | --- |
| `ui/login.py:444-449` | `server_settings_changed(dict)``config_changed(object)``demo_mode_changed(bool)` | `server_settings_changed` 目前仅测试监听;另外两个信号由 controller 监听。所有发射点都无 debug gate |
| `login.py:451-468` | 构造参数、`demo_repository``active_repository` | 只要传入 demo repository 就保留可切换能力;controller 当前总会传入 |
| `login.py:739-755` | “记住密码”行和 `demo_check` | checkbox 始终加入布局;只有 repository 为空时 disabled,不会隐藏 |
| `login.py:770-855` | “或”分隔线、`server_toggle``server_panel` 及 URL/timeout/self-signed/save 子控件 | toggle 始终显示,panel 只是在初始时折叠。若只隐藏 toggle,“或”分隔线和固定 spacing 仍会残留 |
| `login.py:917-948` | `_restore_settings()` | 始终从 QSettings 恢复三项 server 值;只看 `config.demo_mode` 就勾选 demo。`setChecked(True)` 会触发已连接的 `_on_demo_toggled()` |
| `login.py:956-969` | `_credential_scope()` | 优先读取 `server_url_edit`;即便控件隐藏,残留 QSettings URL 仍可改变凭据读取/保存 scope |
| `login.py:1002-1013` | `demo_check.toggled -> _on_demo_toggled()` | 切换 `active_repository`,发射 `demo_mode_changed`,再经 `_emit_config_update` 发射 `config_changed`;没有权限判断 |
| `login.py:1015-1027` | `server_toggle.clicked`、save button | 方法可被直接调用,隐藏控件并不能阻止 panel 展开或保存 |
| `login.py:1029-1054` | `_apply_server_settings()` | 会持久化 QSettings、发射两个配置相关信号;没有权限判断 |
| `login.py:1079-1126` | `submit()` | 直接以隐藏 checkbox 的 checked 状态决定 demo;非 demo 登录会**无条件自动应用服务器控件当前值**,所以旧 QSettings 即便不展开 panel 也会生效 |
| `login.py:1140-1159` | `_set_loading()` | loading 结束会按 `demo_repository is not None` 重新 enable demo,并重新 enable server 子控件;需把 debug gate 合入 enable 条件 |
| `login.py:1177-1216` | 登录成功与凭据保存 | `payload["demo_mode"]` 决定是否保存密码;凭据 scope 又可能来自隐藏的 server edit |
| `login.py:1224-1234` | 证书错误 | 会直接勾选 toggle 并展开 panel;生产隐藏后仍可被错误路径重新显示 |
证书错误文案还在 `ui/widgets.py:376-383` 明确引导用户展开服务器设置、关闭证书校验。非 debug 模式必须改为不引用隐藏入口的运维提示,否则 UI 和文案契约矛盾。
### 3. ApplicationController 与登录可信边界
| 位置 | 当前行为 | 需要的防线 |
| --- | --- | --- |
| `app.py:402-423` | 总是实例化 `DemoDoctorRepository()``current_demo_mode=config.demo_mode` | 非 debug 不创建/不暴露 demo repository,并强制 current demo false |
| `app.py:438-455` | 总把 demo repository 传给 LoginWindow;复用窗口时信任 `demo_check` | 传递显式 gate;非 debug 复用时重置 checkbox/active repository |
| `app.py:460-506` | 接受 `demo_mode` 及全部 server 字段,保存 preferences 并重建 repository | 非 debug 拒绝 debug-only changes,避免伪造 `config_changed` 绕过 UI |
| `app.py:508-511` | 任意 `demo_mode_changed(True)` 都会设置 current demo 并取消 session restore | 非 debug 忽略/纠正 true |
| `app.py:530-540` | `config.demo_mode=True` 会跳过生产 token restore | 必须基于经过门禁归一化的 effective demo;残留 preference 不能阻止 restore |
| `app.py:653-681` | 信任成功 payload 中的 `demo_mode` 与 repository | 非 debug 必须拒绝 demo payload/repository,或无条件把 effective demo 归零;这是 UI 之外的最后可信边界 |
| `app.py:782-792` | `current_demo_mode` 决定是否打开离线 demo 视频窗 | 前述边界不收口时,伪造状态还会扩散到登录后的功能 |
## 精确修改建议
### A. 建立单一、不可持久化的 capability
`src/doctor_workstation/config.py` 定义唯一 `DEBUG_MODE`(或等价只读函数),由受控构建/专用调试启动器决定。不要从 `QSettings` 读取,不要让 `preferences.json` 覆盖,也不要随 `asdict(AppConfig)` 保存。
配置加载完成后必须做一次最终归一化:`effective_demo_mode = DEBUG_MODE and requested_demo_mode`。在 `DEBUG_MODE=False` 时,`_merge_preferences()` 至少忽略 `demo_mode`;若“服务器设置不可用”意味着生产连接完全由受控环境提供,还应同时忽略偏好中的 `api_base_url``request_timeout``verify_ssl`,否则旧登录页设置虽然 UI 不可见,仍会从 JSON 生效。`with_updates()` 也应拒绝或丢弃非 debug 下对这些 debug-only 字段的修改。
推荐把 debug capability 显式传给 `ApplicationController`/`LoginWindow` 或保存为只读实例属性,便于测试 True/False 两条路径。不要在多个模块各自复制一个可 monkeypatch 的常量,否则测试或运行时可能出现 config 判 false、UI 判 true 的分裂状态。
### B. LoginWindow:可见性和行为同时门禁
`ui/login.py:451-468` 记录 `self.debug_mode`,并把 `self.demo_repository` 设为 `demo_repository if debug_mode else None`。建议仍构造具名控件以保持测试和代码引用稳定,但所有状态转换都使用 `self.debug_mode` 判断。
UI 结构建议:
1. `demo_check` 仅在 debug 时 visible,并且 enabled 条件为 `debug_mode and demo_repository is not None and not loading`
2.`login.py:770-855` 的“或”分隔线、server toggle、panel 和上下 spacing 包进一个 `self.debug_server_section` QWidget;整个 section 仅在 debug 时 visible。单独隐藏 `server_toggle` 会留下“或”和空白。
3. panel 初始仍折叠;debug true 时保持现有 toggle 行为。
行为防线建议:
1. `_restore_settings()`:非 debug 不读取 `server/*` QSettings,不恢复 demo,明确令 demo unchecked、active repository 为 production repository;服务器控件若仍构造,只从受控 `config` 填充。是否删除旧键是迁移策略,**忽略它们才是安全要求**。
2. `_credential_scope()`:非 debug 始终从受控 config URL 取 scope,不读取隐藏的 `server_url_edit`
3. `_on_demo_toggled(True)`:非 debug 立即用 signal blocker 恢复 unchecked/production repository,然后 return;不得发 `demo_mode_changed``config_changed`
4. `_toggle_server_panel()``_save_server_settings()``_apply_server_settings()`:非 debug 强制 panel 关闭且不写 QSettings、不发 `server_settings_changed/config_changed`。直接调用也必须无效。
5. `submit()`:用 `demo_mode = self.debug_mode and self.demo_check.isChecked()`,并从这个 effective 值选择 repository。非 debug 跳过 `_apply_server_settings()`,只使用 composition root 已构造的 remote repository;否则会再次应用隐藏控件中的旧值。
6. `_set_loading()`:所有 demo/server enabled 状态与 `self.debug_mode` 做 AND,防止 loading 完成后重新激活。
7. `_on_login_error()`:仅 debug 时自动展开 certificate panel;非 debug 保持 section 隐藏,并显示“请联系管理员检查受控服务器/证书配置”之类不提供绕过证书校验的文案。
### C. Controller:不要信任 UI 状态或 payload
`app.py:402-423` 以同一 capability 计算 effective state;非 debug 最好根本不实例化 `DemoDoctorRepository``_show_login()` 显式传 gate,窗口复用时不要读取隐藏 checkbox 决定 repository。
`_on_config_changed()` 必须再次过滤 demo/server debug-only 字段;`_on_demo_mode_changed()` 非 debug 不接受 true`_begin_session_restore()` 不得因未经门禁的旧 `config.demo_mode` 跳过;`_on_login_succeeded()` 应把 demo capability 作为可信边界,非 debug 收到 `demo_mode=True` 或 demo repository 时拒绝进入 shell并清理 session,而不是静默接受 payload。这样即使未来有其他代码直接调用槽函数,也不能重新开启演示路径。
## `tests/test_ui_contract.py` 现状与调整
实际文件是 `app/tests/test_ui_contract.py``app/tests` 下没有 `conftest.py`;这里使用的 `tmp_path`/`monkeypatch` 是 pytest 内置 fixture,相关 helper 都定义在测试函数内。
现有相关契约:
- `test_ui_contract.py:230-273`:真实 demo 登录,证明 `config.demo_mode=True` 会勾选 checkbox、使用空账号密码登录 demo,并发出 demo payload;未覆盖 debug capability。
- `test_ui_contract.py:276-337`:同一 QSettings 跨窗口恢复账号/密码;不涉及 demo。
- `test_ui_contract.py:340-375`:服务器 panel 在最小窗口的布局。
- `test_ui_contract.py:378-399`:函数名虽含 `debug_mode`,实际仅验证 self-signed 值写入 QSettings,没有任何 `DEBUG_MODE` 判断。
- `test_ui_contract.py:402-455`:普通登录前自动应用 server 值、经 `config_changed` 换成新 repository。
- `test_ui_contract.py:458-463`:证书错误文案指向服务器设置。
- `test_ui_contract.py:495-513`:证书错误会自动展开服务器 panel;这个契约只应在 debug true 成立。
引入 gate 后,`230``340``378``402``495` 这几组依赖 demo/server 的测试都应显式运行在 `DEBUG_MODE=True`,避免它们因测试默认值偶然通过。不要新增“把 demo 写入 QSettings”的契约;当前 demo 的持久化源是 AppConfig/preferences,目标反而要求非 debug 忽略该残留值。
## 建议回归测试矩阵
### `tests/test_ui_contract.py`
1. **debug true 可见且可用**show 窗口后断言 demo checkbox、完整 server section/toggle 可见;原 demo 登录、panel 几何、自签名保存、登录前应用服务器设置均继续通过。
2. **debug false 无视觉残件**:断言 demo checkbox、`debug_server_section`(包括“或”分隔线)、toggle、panel 都不可见;demo unchecked`active_repository is remote_repository`
3. **残留 QSettings 不生效**:预写 `server/base_url=旧地址``server/read_timeout``server/verify_ssl=false`,用 debug false 构造窗口;断言 credential scope/实际登录 repository 使用受控 configQSettings 值未被 `_apply_server_settings()` 写回或发射成配置更新。
4. **直接调用不能绕过**debug false 下程序化 `demo_check.setChecked(True)``_on_demo_toggled(True)``_toggle_server_panel(True)``_save_server_settings()`;断言仍 unchecked、production repository、panel hidden`demo_mode_changed``server_settings_changed``config_changed` 均无 debug 更新。
5. **提交强制 production**:给 debug false 窗口同时传 remote 和 demo repository,并让 stale config 的 `demo_mode=True`;输入账号密码后立即执行 worker,断言只有 remote `login()` 被调用,payload `demo_mode=False`
6. **证书错误分模式**:debug true 仍自动展开并给出 self-signed 指引;debug false 不展开/不显示 section,错误文案不再提隐藏的“服务器设置”或关闭证书校验。
7. **loading 不重启入口**debug false 执行 `_set_loading(True)``_set_loading(False)`,断言 demo/server 控件持续 hidden + disabled。
### `tests/test_config.py`
1. 在隔离 `DOCTOR_CONFIG_DIR` 写入旧 `preferences.json`(至少 `demo_mode:true`);DEBUG false 加载后必须 `demo_mode is False`,即使 `DOCTOR_DEMO_MODE=true` 也不能越权。
2. DEBUG true 时确认 `DOCTOR_DEMO_MODE`/允许的 demo preference 仍能选择默认 demo 状态。
3. 若生产服务器配置要求环境权威,再写入旧 JSON server 字段,断言 DEBUG false 仍采用环境的 URL/timeout/verify_ssl。
4. `with_updates(demo_mode=True)` 在 DEBUG false 下不能产生 effective demo true;同理覆盖 server debug-only 更新的策略。
### `tests/test_ui_contract.py` 中的 controller 边界(或拆到 controller 专属测试)
1. DEBUG false 时 `_begin_session_restore()` 不因 stale `config.demo_mode=True` 而跳过远程恢复。
2. DEBUG false 时直接调用 `_on_demo_mode_changed(True)` 不改变 `current_demo_mode`
3. DEBUG false 时把 `demo_mode=True`/demo repository 的伪造 payload 传给 `_on_login_succeeded()`,断言不能创建 ShellWindow。
若采用专用 `DOCTOR_DEBUG_MODE` 环境变量,还应在 `tests/test_one_click_entrypoints.py:102-108` 增加调试启动器显式开启、普通启动器/打包入口不开启的静态契约,并同步 `.env.example:8-17``README.md:56-72`,避免继续把 `DOCTOR_DEMO_MODE=true` 描述成足以启用演示能力。
## 最小验收标准
非 debug 模式应同时满足以下可观察结果:登录页看不到 demo、服务器入口、“或”分隔线或相关空白;旧 demo preference 不能阻止远程 token restore;旧 server QSettings 不能改变 URL、timeout、TLS 校验或凭据 scope;程序化调用隐藏控件/槽函数/信号也不能切换仓库或进入 demo shell。只有这四层都成立,才不是单纯的视觉隐藏。