102 lines
11 KiB
Markdown
102 lines
11 KiB
Markdown
# 医生工作站集成审查
|
||
|
||
审查时间:2026-08-10。范围为当前工作区中的组合根、登录到主壳链路、Remote/Demo repository、权限门控、退出/视频生命周期及 PyInstaller 入口。审查只读进行;除本报告外未修改项目文件,也未发送网络请求。
|
||
|
||
## 结论
|
||
|
||
入口链 `packaging/doctor_workstation.spec -> doctor_workstation/__main__.py -> app.main()` 和 `build_repository(..., verify=...)` 的构造签名已经对齐,Remote/Demo 的主要 CRUD 与通话方法也具有兼容签名。当前仍有 4 个高严重度和 6 个中严重度集成问题;其中浏览器视频模式目前不能真正发起通话,退出时也存在通话窗口失管和 GUI 阻塞风险。
|
||
|
||
## P1(高)
|
||
|
||
### 1. 浏览器视频模式没有把一次性通话上下文交给伴随页
|
||
|
||
- `src/doctor_workstation/video/window.py:312-326` 在服务端 `start_call` 后仅执行 `webbrowser.open(location.url)`;`VideoCallRequest` 中的 diagnosis、目标用户和 UserSig 均未交给浏览器。
|
||
- `video_companion/src/main.ts:208-236` 只有显式调用 `window.doctorCall.start(config)` 才会初始化 SDK 并呼叫患者,页面自身不获取票据;`video_companion/src/main.ts:281-289` 只是暴露 API 和发送 ready。
|
||
- `README.md:70-77` 又把 browser 声明为正式降级路径,并要求用一次性业务票据传递上下文,和当前实现不一致。
|
||
|
||
系统浏览器不存在 Qt WebChannel,且静态 URL 连 diagnosis_id 都没有,因此页面会一直停在“等待桌面端发起”,而后端通话记录已经开始。浏览器标签关闭也无法回告 Python,`end_call` 只能等显式退出应用。应使用后端签发、单次消费且短有效期的浏览器 handoff ticket(URL 中不能放 UserSig),或受认证的本机 IPC;伴随页确认接收后再调用 `start_call`,并建立可观测的结束回调。
|
||
|
||
### 2. 通话 start/bind/end 的同步 HTTP 被直接放在 Qt GUI 线程执行
|
||
|
||
- `src/doctor_workstation/video/window.py:206-290` 直接调用 repository 的 `start_call`、`bind_call_room`、`end_call`。
|
||
- browser 在 `src/doctor_workstation/video/window.py:312-320` 同步 start;embedded 在 loadFinished 回调 `src/doctor_workstation/video/window.py:437-456` 同步 start,在 bridge/close 回调 `src/doctor_workstation/video/window.py:485-522` 同步 bind/end。
|
||
- Remote 实现最终执行同步 `httpx` POST(`src/doctor_workstation/services/repository.py:377-402`),而配置超时可达 120 秒。
|
||
- 登出和进程退出又在主线程逐个 `call.close()`(`src/doctor_workstation/app.py:298-315`、`src/doctor_workstation/app.py:409-415`)。
|
||
|
||
弱网时打开、挂断、退出都会冻结整个界面;`end_call` 抛错时 embedded 的 `closeEvent` 甚至到不了 `event.accept()`。应把生命周期写操作放入受控 worker,并使用状态机保证单次执行;退出阶段设置短上限、记录未完成结束动作,同时先关闭媒体/UI,不能让网络请求阻塞 Qt 关闭事件。
|
||
|
||
### 3. 同一 diagnosis 的重复发起会覆盖通话句柄,导致退出后仍可能保留旧视频窗口
|
||
|
||
- `src/doctor_workstation/app.py:343-356` 没有 pending/active 去重,用户可在票据请求完成前重复发起。
|
||
- `src/doctor_workstation/app.py:390-396` 以 diagnosis_id 为唯一 key 直接覆盖旧句柄;旧窗口的 destroyed 回调还会无条件 `pop` 同一个 key,可能把较新的句柄移除。
|
||
- 登出/退出只关闭字典当前仍持有的值(`src/doctor_workstation/app.py:298-305`、`src/doctor_workstation/app.py:409-412`)。
|
||
|
||
结果是第一个窗口可能在切回登录页后继续持有摄像头/麦克风;反向关闭旧窗口也会让新窗口失去托管。应对 diagnosis 建立 pending/active 单飞,拒绝或先可靠关闭旧通话;销毁回调必须做对象身份判断后再移除。
|
||
|
||
### 4. WebEngine 对任意来源自动授予音视频权限,且登出没有隔离/清理 profile
|
||
|
||
- `src/doctor_workstation/video/window.py:381-390` 使用 `QWebEngineView` 的共享默认 profile,没有为单次通话建立隔离 profile。
|
||
- `src/doctor_workstation/video/window.py:405-435` 在授权回调中不校验请求 origin、当前页面 URL 或通话状态,只要 feature 名含 audio/video 就 grant。
|
||
- 没有自定义 `acceptNavigationRequest`/origin allowlist;关闭时 `src/doctor_workstation/video/window.py:519-522` 仅挂断,不撤销权限或清理 Cookie、cache、local storage。
|
||
|
||
初始 URL 虽经过 HTTPS 校验,但页面后续导航不受限制;同一进程内切换账号时 WebEngine 状态也可能复用。应使用每通话或每会话的 off-the-record profile、精确 origin/path allowlist,只在活跃通话且当前主文档来源匹配时授权,并在挂断/登出时撤销权限和清理页面/profile。
|
||
|
||
## P2(中)
|
||
|
||
### 5. API code=-1 没有接入应用级会话失效处理
|
||
|
||
- `src/doctor_workstation/services/api_client.py:277-278` 会抛出 `AuthenticationExpiredError`。
|
||
- worker 只把异常交给页面自己的通用 on_error(`src/doctor_workstation/ui/widgets.py:262-293`);组合根只有用户主动点击时才执行 `_logout`(`src/doctor_workstation/app.py:298-315`)。
|
||
- 项目中除异常定义/抛出外没有消费 `AuthenticationExpiredError` 的代码。
|
||
|
||
token 过期后主壳仍展示已加载的患者数据和旧权限快照,各页面只显示错误,用户必须手动退出。应在统一 API/worker 边界发出 session-expired 事件,由 Controller 原子地停止轮询和视频、清 token、销毁 ShellWindow 并回到 LoginWindow。
|
||
|
||
### 6. 页面和动作权限使用过宽的 OR 兜底,并忽略后端 menu
|
||
|
||
- `src/doctor_workstation/services/repository.py:111-127` 已解析 `Session.menu`,但 `src/doctor_workstation/ui/shell.py:248-260` 只按硬编码 `NAVIGATION.permissions` 注册页面,menu 从未参与判断;这与管理端“无有效 menu 即 403”的已确认行为(`research/admin_audit.md:103-109`)不一致。
|
||
- 接诊页可仅凭 `doctor.appointment/reception` 显示,但页面首先请求 `doctor.appointment/lists`(`src/doctor_workstation/ui/shell.py:40-47`、`src/doctor_workstation/ui/pages/reception.py:347-364`)。患者页可仅凭 readonlyDetail 显示,却始终请求 firstvisit lists;问诊页可仅凭 appointment lists 显示,却始终请求 diagnosis lists(`src/doctor_workstation/ui/shell.py:62-75`)。
|
||
- 通知医助和视频按钮又把基础 lists 当动作权限兜底(`src/doctor_workstation/ui/pages/reception.py:321-332`、`src/doctor_workstation/ui/pages/consultations.py:88-96`),所以缺少 videoQr/notifyAssistant 的账号仍看到按钮。
|
||
|
||
服务端仍是最终边界,但当前 UI 会显示必然 403 的页面/动作,也可能绕过管理员对 desktop 页面入口的隐藏意图。页面应同时受后端 menu/capability 和实际列表接口权限约束;动作只接受对应动作码或经过确认的同义码,不能用 lists 兜底。
|
||
|
||
### 7. 已开处方审核状态筛选在 UI 适配层被改成 Remote 不识别的字段
|
||
|
||
- UI 传入 status,`src/doctor_workstation/ui/widgets.py:205-208` 将其改写为 `audit_status`。
|
||
- Remote 只在收到 `status` 时转换成后端需要的 `audit_filter=pending|passed|rejected`(`src/doctor_workstation/services/repository.py:284-303`),因此实际请求会原样携带数值 `audit_status`。
|
||
- Demo 特意兼容了 `audit_status`(`src/doctor_workstation/services/mock_repository.py:374-399`),所以演示验收不会暴露生产差异。
|
||
|
||
结果是生产环境的“待审核/已通过/已驳回”筛选可能无效或被后端拒绝。应只在 repository 内做一次 UI 值到 API DTO 的映射,并给 Remote 增加 request-parameter 合同测试。
|
||
|
||
### 8. 登录 token 在 Session 校验成功前落盘,失败路径不回滚
|
||
|
||
- `src/doctor_workstation/services/repository.py:82-90` 在调用 `auth.admin/mySelf` 前就设置并持久化 token。
|
||
- mySelf 网络失败、协议错误或 code=10 时 LoginWindow 只显示错误(`src/doctor_workstation/ui/login.py:385-390`),不会调用 repository.logout。
|
||
|
||
这会在用户从未进入有效 Session 的情况下留下内存和 keyring/JSON token,后续登录还会带着该 token 请求 login/account。应先把 token 暂存在内存,完整构建并校验 Session 后再持久化;任何异常都清理 client/token store。
|
||
|
||
### 9. “记住账号”未控制 TokenStore 中的账号落盘,且持久 token 没有启动恢复闭环
|
||
|
||
- `src/doctor_workstation/services/repository.py:87-88` 每次成功账号密码登录都把 account 传给 TokenStore,与复选框无关。
|
||
- TokenStore 会把 account 写入 JSON 元数据(`src/doctor_workstation/services/token_store.py:83-108`),而 LoginWindow 的复选框只增删 QSettings(`src/doctor_workstation/ui/login.py:374-379`)。
|
||
- `ApplicationController.start()` 始终显示登录页(`src/doctor_workstation/app.py:172-176`),未调用已实现的 `restore_session`;普通关闭只 close client,不清 token(`src/doctor_workstation/app.py:409-415`)。
|
||
|
||
因此取消“记住账号”仍会在磁盘留下账号;成功 token 则持续保存但下次启动完全不使用。应让 remember-account 明确控制所有账号元数据,并二选一:启动时安全验证/恢复 token,或不持久化并在关闭时清除。
|
||
|
||
### 10. 当前冻结产物落后于最新 companion,构建脚本也没有执行入口 smoke test
|
||
|
||
- 最新 `video_companion/dist/index.html:8` 引用 `index-le5ZH3pL.js`(包含 roomId/bindCallRoom 桥接),现有 `dist/DoctorWorkstation/_internal/video_companion_dist/index.html:8` 仍引用旧的 `index-BrQGJzsD.js`。
|
||
- 应用已经提供 `--smoke-test`(`src/doctor_workstation/app.py:436-451`),但 Windows 构建只检查 helper/pak/index 文件存在(`scripts/build_windows.ps1:30-43`),macOS 同样只做静态文件和 codesign 检查(`scripts/build_macos.sh:22-37`),都不启动冻结入口。
|
||
|
||
所以当前 `dist/DoctorWorkstation` 不包含刚合并的 room binding;未来即使入口 import/bootstrap 失败,构建也可能仍打印成功。交付前应重新执行 PyInstaller,并在隔离用户目录、无真实网络的环境下运行冻结程序 `--smoke-test`,同时校验退出码和日志中无未捕获异常。
|
||
|
||
## 已核对且未发现签名断点
|
||
|
||
- `ApiClient(base_url, ..., verify=...)`、`build_repository(..., verify=...)` 与 `app.py` 调用一致。
|
||
- Remote/Demo 的 login 均返回 `Session`;列表、接诊、处方库 CRUD、患者/问诊列表及 `get_call_ticket/start_call/bind_call_room/end_call` 的 UI 所需参数基本对齐。
|
||
- PyInstaller spec 的入口、`src` pathex、resources 和 `video_companion_dist` 目标路径与 `resources.py` 的 `_MEIPASS` 查找规则一致。
|
||
- 最新 source companion 已产生 roomId 并调用 `bind_call_room`;本报告未把此前已修复的“问诊页交换 appointment/diagnosis ID”或“未绑定 room”列为当前问题。
|
||
|
||
## 验证限制
|
||
|
||
本轮没有重新执行 Python 测试:工作区 `.venv/Scripts/python.exe` 指向当前机器上不存在的 uv Python 基础解释器;为保持只读审查,没有重建虚拟环境。现有 `research/ui_acceptance.md` 记录的最近一次完整测试为 46 passed,但上述多项是未被现有单元测试覆盖的跨层行为。
|