11 KiB
医生工作站集成审查
审查时间: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 实现最终执行同步
httpxPOST(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 的入口、
srcpathex、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,但上述多项是未被现有单元测试覆盖的跨层行为。