462 lines
34 KiB
Markdown
462 lines
34 KiB
Markdown
# 医生桌面端工程架构与打包方案(Windows / macOS)
|
||
|
||
> 结论先行:采用 **Python 3.12 + PySide6 Qt Widgets** 构建原生业务界面,以分层的 `httpx` API client 连接现有后端;会话、权限、离线队列和本地安全存储统一放在 core 层。视频不是整套应用的实现基础,而是独立的可选集成:只有当现有腾讯 TRTC Web 方案无法由原生 SDK 替代时,才在受限的 `QWebEngineView` 中承载单一视频页面。发布使用 **PyInstaller onedir**,Windows 与 macOS 必须在各自原生 CI runner 上分别构建、签名和验收,不能交叉编译。
|
||
|
||
## 1. 已知上下文、边界与待确认项
|
||
|
||
本结论只对 `admin/package.json` 和环境配置做了最小只读核对,没有审计管理端实现。
|
||
|
||
- 管理端以 `VITE_APP_BASE_URL` 注入后端根地址;示例文件故意留空,开发示例注释仅以 `http://127.0.0.1:8080` 举例。现有环境文件还出现了 `https://css.zhenyangtang.com.cn/`、`https://admin.zhenyangtang.com.cn/` 和 60 秒请求超时,但这些地址可能是网关或前端站点,**不能据此认定为稳定的桌面 API 地址**。
|
||
- 管理端依赖包含 Axios、腾讯 TRTC/Call/Chat UI、`hls.js` 和 COS JS SDK。可以据此判断视频、聊天、流媒体和对象存储是潜在集成面,但不能推断接口路径、认证协议、权限码或 RTC 凭证格式。
|
||
- 桌面端不得读取或复用 Vite 环境变量,不得硬编码管理端 URL,也不得在客户端持有 COS Secret、TRTC SecretKey 或任何服务端签名密钥。
|
||
|
||
编码前必须由后端确认以下契约,并固化为 OpenAPI 或最小接口文档:
|
||
|
||
1. API 的正式 base URL、版本前缀、响应 envelope、错误码、分页和时间格式。
|
||
2. 登录协议(账号密码、短信、SSO/OIDC 或 Cookie)、access/refresh 生命周期、登出和吊销语义。
|
||
3. `/me` 等当前用户接口返回的医生身份、机构/租户、角色与细粒度权限码。
|
||
4. 预约、患者、病历、处方等写操作的幂等键、乐观锁版本号及审计要求。
|
||
5. 聊天的拉取/推送协议、断线续传游标;COS 上传应由后端提供短期预签名 URL 或临时凭证。
|
||
6. TRTC 房间、`userSig` 等凭证必须由后端短时签发;确认现有 Web 页面能否作为受支持的嵌入入口。
|
||
7. 桌面端的 CORS、代理、私有 CA、设备绑定、强制升级和最低版本策略。
|
||
|
||
在这些问题确认前可以完成壳层、接口抽象和模拟服务器,但不应猜测生产 endpoint。
|
||
|
||
## 2. 目标平台与技术选择
|
||
|
||
### 2.1 建议支持矩阵
|
||
|
||
| 项目 | 首发建议 | 说明 |
|
||
| --- | --- | --- |
|
||
| Python | CPython 3.12,固定 patch 版本 | 生命周期长,第三方包成熟;每个平台使用相同 minor |
|
||
| Windows | Windows 10 22H2 / Windows 11,x86-64 | ARM64 可作为后续独立制品,不与 x64 混装 |
|
||
| macOS | macOS 13+,先 arm64,再按客户量增加 x86-64 | 当前 PySide6 wheel 的最低系统版本必须在锁版本时再次核对 |
|
||
| UI | PySide6 Qt Widgets | 医疗表单、表格、快捷键、打印和可访问性更稳定 |
|
||
| 视频 | 可选 PySide6 Addons / QtWebEngineWidgets | 只隔离承载视频页,不用 WebEngine 包住整个应用 |
|
||
| 打包 | PyInstaller onedir | 对 QtWebEngine helper、资源、签名和启动性能最稳妥 |
|
||
|
||
macOS 推荐分别产出 `arm64` 与 `x86_64` 制品。`universal2` 只有在 Python、PySide6 和所有二进制依赖均提供 universal2 slice,且真实验证签名/视频后再启用;两个单架构制品更易排障且体积更小。
|
||
|
||
### 2.2 依赖分档
|
||
|
||
建立一份代码、两种构建 profile:
|
||
|
||
- `core`:`PySide6-Essentials`、`httpx`、`pydantic`、`pydantic-settings`、`platformdirs`、`keyring`、`cryptography`。包含 QtCore/Gui/Widgets/Network/Sql/Svg/PrintSupport,不包含 WebEngine。
|
||
- `video`:在 core 上增加与 Essentials **完全相同版本**的 `PySide6-Addons`,从而获得 QtWebEngineWidgets、WebChannel、Multimedia 等模块。
|
||
- 开发/测试:`pytest`、`pytest-qt`、`respx`、`coverage`、`ruff`、`mypy`、`pip-audit`。
|
||
- 构建:锁定 `PyInstaller` 及其 hooks 版本。以 2026-08-10 可验证组合为基线,可先验证 Python 3.12 + PySide6 Essentials/Addons 6.11.1 + PyInstaller 6.21.0;只有在两端打包 smoke test 通过后才更新锁。
|
||
|
||
不要同时安装 PyQt、PySide2 或系统级 PySide6;必须从干净虚拟环境构建。使用平台专属、带 hash 的锁文件(例如 `requirements-win-x64.lock`、`requirements-macos-arm64.lock`),而不是在发布任务中直接安装“最新版”。
|
||
|
||
如果所有医生都需要视频,可只发布 `video` 制品;仍保留 profile 边界,以便定位 WebEngine 问题。若视频是少数场景,可以发布 core 制品并在系统浏览器打开受支持的视频页,避免让每个安装包承担 Chromium 的体积和攻击面。
|
||
|
||
## 3. 分层架构
|
||
|
||
依赖方向固定为:`ui -> application -> domain`,`infrastructure` 在 composition root 中实现 domain/application 定义的 port。View 不允许直接调用 `httpx`、SQLite 或 keyring。
|
||
|
||
```text
|
||
app/
|
||
├─ pyproject.toml # 项目元数据、依赖分组、工具配置
|
||
├─ requirements/ # 各 OS/架构的发布锁及 hash
|
||
├─ src/
|
||
│ └─ zyt_doctor/
|
||
│ ├─ __main__.py # 极薄入口,只调用 bootstrap.main()
|
||
│ ├─ bootstrap.py # QApplication、配置、DI、异常钩子、主窗口
|
||
│ ├─ build_info.py # 版本、commit、channel;构建时生成
|
||
│ ├─ config/
|
||
│ │ ├─ models.py # 强类型配置及校验
|
||
│ │ └─ loader.py # defaults -> 受管配置 -> 开发环境变量
|
||
│ ├─ domain/
|
||
│ │ ├─ identity.py # Principal、Tenant、Permission
|
||
│ │ ├─ errors.py # 与 UI/HTTP 无关的错误类型
|
||
│ │ └─ ports.py # Repository、Clock、SecretStore 等协议
|
||
│ ├─ application/
|
||
│ │ ├─ commands.py # 写用例、幂等键和确认规则
|
||
│ │ ├─ queries.py # 读用例和缓存策略
|
||
│ │ └─ result.py # Result / Page / OperationState
|
||
│ ├─ core/
|
||
│ │ ├─ api/
|
||
│ │ │ ├─ client.py # httpx client、header、超时、重试
|
||
│ │ │ ├─ auth.py # token/cookie adapter 与 refresh single-flight
|
||
│ │ │ ├─ errors.py # HTTP/业务错误归一化
|
||
│ │ │ ├─ models.py # 公共 DTO
|
||
│ │ │ └─ generated/ # 若有 OpenAPI,生成代码仅放此处
|
||
│ │ ├─ session/
|
||
│ │ │ ├─ manager.py # 会话状态机、锁屏、租户切换、登出清理
|
||
│ │ │ └─ permissions.py # 权限快照与 guard
|
||
│ │ ├─ storage/
|
||
│ │ │ ├─ database.py # SQLite、migration、单写线程
|
||
│ │ │ ├─ secure_store.py # Windows Credential Manager / macOS Keychain
|
||
│ │ │ └─ cache.py # 加密缓存、TTL、容量控制
|
||
│ │ ├─ offline/
|
||
│ │ │ ├─ connectivity.py # 网络状态提示,不作为唯一真相
|
||
│ │ │ ├─ outbox.py # 离线写队列状态机
|
||
│ │ │ └─ sync.py # 重放、冲突和人工处理
|
||
│ │ ├─ jobs.py # QThreadPool 任务、取消、signal 适配
|
||
│ │ ├─ events.py # 进程内 typed event bus
|
||
│ │ ├─ logging.py # 脱敏日志和诊断包
|
||
│ │ └─ paths.py # QStandardPaths/platformdirs;绝不写 bundle
|
||
│ ├─ modules/
|
||
│ │ ├─ auth/
|
||
│ │ ├─ dashboard/
|
||
│ │ ├─ patients/
|
||
│ │ ├─ appointments/
|
||
│ │ ├─ consultations/
|
||
│ │ ├─ medical_records/
|
||
│ │ ├─ prescriptions/
|
||
│ │ ├─ chat/
|
||
│ │ ├─ followups/
|
||
│ │ └─ settings/
|
||
│ │ # 每个模块内含 domain.py、service.py、viewmodel.py、views.py、permissions.py
|
||
│ ├─ integrations/
|
||
│ │ ├─ realtime/ # WebSocket/轮询 adapter,不侵入模块
|
||
│ │ ├─ object_storage/ # 仅消费后端预签名 URL/临时凭证
|
||
│ │ └─ video/
|
||
│ │ ├─ port.py # join/leave/mute 等抽象
|
||
│ │ ├─ external_browser.py
|
||
│ │ └─ webengine.py # 唯一允许 import QtWebEngine 的文件
|
||
│ ├─ ui/
|
||
│ │ ├─ shell/ # 导航、标题栏、全局离线/会话提示
|
||
│ │ ├─ widgets/ # Loading、Empty、Error、PermissionDenied
|
||
│ │ ├─ dialogs/
|
||
│ │ └─ theme/
|
||
│ └─ resources/ # qrc、图标、字体许可、翻译、默认配置
|
||
├─ tests/
|
||
│ ├─ unit/
|
||
│ ├─ contract/
|
||
│ ├─ integration/
|
||
│ ├─ ui/
|
||
│ ├─ packaging/
|
||
│ └─ fixtures/ # 全部为合成数据,禁止生产病患数据
|
||
├─ packaging/
|
||
│ ├─ windows/doctor-core.spec
|
||
│ ├─ windows/doctor-video.spec
|
||
│ ├─ macos/doctor-core.spec
|
||
│ ├─ macos/doctor-video.spec
|
||
│ ├─ macos/entitlements.plist
|
||
│ └─ hooks/
|
||
├─ scripts/ # build、self-check、sign、notarize
|
||
└─ docs/ # API 映射、权限矩阵、发布 runbook
|
||
```
|
||
|
||
每个业务模块只公开一个 facade 和路由描述,例如 `ModuleDescriptor(id, title, permissions, view_factory)`。主壳根据权限注册菜单,模块内再对按钮和 command 做 guard;这样既不会形成一个巨型主窗口,也不会把权限判断散落在控件代码中。
|
||
|
||
## 4. API client、并发和会话
|
||
|
||
### 4.1 API client
|
||
|
||
建议使用一个长生命周期 `httpx.Client`,由 composition root 创建并注入 service。所有同步请求放入 `QThreadPool/QRunnable`,结果通过 Qt signal 回到主线程;严禁 UI 线程阻塞网络。窗口关闭或查询条件变化时取消尚未开始的任务,并忽略带旧 generation id 的晚到响应。
|
||
|
||
Client 的固定行为:
|
||
|
||
- production base URL 只允许 HTTPS;HTTP 仅在 debug profile 且 host 为 localhost 时允许。
|
||
- production 允许的 API、视频和上传 host 必须来自签名/受管配置或内置 allowlist,避免篡改本地配置后窃取 token。
|
||
- 超时拆分为 connect/read/write/pool,不只设一个总数。可从 connect 10 秒、read 60 秒起步,上传/导出另设长超时。
|
||
- 每次请求加入 `Authorization`(若契约采用 bearer)、`X-Request-ID`、客户端版本、平台、时区和租户信息;不得写入日志的 header 列表默认包含 Authorization、Cookie 和所有临时凭证。
|
||
- GET/HEAD 和带服务端认可幂等键的写操作,才可对连接错误、超时、429、502、503、504 做指数退避 + jitter;遵守 `Retry-After`。验证错误、普通 4xx 和未知写请求不自动重试。
|
||
- 所有关键 mutation 生成并持久化 `Idempotency-Key`,直到收到确定结果;请求超时后的状态为“结果未知”,先按键查询/重放,不能直接再创建一条。
|
||
- 业务错误映射为稳定类型:`ValidationError`、`Unauthenticated`、`Forbidden`、`Conflict`、`RateLimited`、`Maintenance`、`TransportError`、`UnknownServerError`。UI 不解析后端文案。
|
||
- 支持 ETag/版本字段做乐观锁。收到 409/412 时进入冲突页,展示服务器版本与本地草稿,不做静默覆盖。
|
||
- 下载/上传流式处理并限制文件大小、MIME 和保存目录。COS 只使用后端签发的预签名 URL或短期临时凭证,绝不打包永久密钥。
|
||
|
||
若后端有 OpenAPI,生成的 models/client 放入 `core/api/generated`,外面再包一层业务 adapter;模块不得直接依赖生成器的数据结构。若无 OpenAPI,先写小而明确的 typed endpoint,不做一个接受任意 path/dict 的“万能客户端”。
|
||
|
||
实时聊天使用独立 adapter:WebSocket 可运行在一个后台 asyncio loop/thread 中,通过 signal 投递事件;实现心跳、指数重连、服务器 sequence/cursor 补拉、重复消息去重和应用休眠恢复。首版若后端没有可靠续传契约,应采用短轮询而不是假装 WebSocket 永不丢消息。
|
||
|
||
### 4.2 Session 状态机
|
||
|
||
`SessionManager` 是唯一会话真相,显式状态为:
|
||
|
||
```text
|
||
SIGNED_OUT -> AUTHENTICATING -> AUTHENTICATED
|
||
AUTHENTICATED -> REFRESHING -> AUTHENTICATED
|
||
AUTHENTICATED/REFRESHING -> LOCKED | EXPIRED | SIGNED_OUT
|
||
```
|
||
|
||
- access token 只保存在内存;需要“保持登录”时,refresh token 或可续期凭证保存在 OS keychain,不能放在 QSettings、SQLite 明文或日志。
|
||
- `keyring` 启动时必须检查实际 backend。没有 Windows Credential Manager/macOS Keychain 等安全 backend 时禁用持久登录,而不是退化到明文文件。
|
||
- 多请求同时遇到 401 时只能有一个 refresh 在飞行,其他请求等待同一个 future;refresh 失败统一切到 `EXPIRED`,避免 401 风暴。
|
||
- 登出、切换医生或切换租户时:取消网络任务、停止实时连接、退出视频、清内存 token、清空 WebEngine profile、关闭并按用户/租户清理本地缓存密钥。
|
||
- 支持工作站空闲自动锁定。解锁方式由后端安全策略决定;锁定界面不得继续显示患者姓名、通知正文或缩略图。
|
||
- 用 `QLocalServer/QLocalSocket` 实现单实例,第二次启动只唤醒现有窗口,避免同一用户同时运行两个 outbox。
|
||
|
||
### 4.3 权限模型
|
||
|
||
权限码由后端返回并作为服务端授权的镜像,例如 `patient.read`、`record.write`、`prescription.sign`;具体字符串必须以真实契约为准。
|
||
|
||
客户端执行三层防误操作:
|
||
|
||
1. 路由层:无模块权限时不注册菜单/路由。
|
||
2. ViewModel/command 层:按钮显示与执行前都检查 `PermissionGuard.require(...)`。
|
||
3. API 层:403 统一转为 `Forbidden`,刷新权限快照并提示“权限已变更”。
|
||
|
||
这些仅改善体验,真正的 RBAC/ABAC、租户隔离和审计必须由后端再次校验。不能因为客户端隐藏了按钮就省略服务端授权。对开方、签名、删除等高风险操作增加 step-up authentication 或明确二次确认,并把 request id/idempotency key 传给服务端审计。
|
||
|
||
## 5. 本地数据、离线与错误态
|
||
|
||
### 5.1 数据目录与加密
|
||
|
||
使用 `QStandardPaths` 或 `platformdirs` 获取每用户目录:Windows 通常位于 `%LOCALAPPDATA%`,macOS 位于 `~/Library/Application Support`。安装目录和 `.app` bundle 始终只读;业务代码不要访问 `sys._MEIPASS`,资源通过 `importlib.resources`/Qt resource system 读取。
|
||
|
||
SQLite 使用 WAL、schema migration 和单写入 worker(或每线程独立 connection),不在线程间共享 `sqlite3.Connection`。默认只缓存必要元数据;如果确需缓存患者/病历或离线草稿:
|
||
|
||
- 使用 `cryptography` 的 AES-GCM 做版本化记录加密,随机 nonce,密钥由 OS keychain 保存;AAD 包含 tenant/user/table/record id,防止记录调包。
|
||
- outbox、cache 和密钥按机构 + 用户分区;退出账号做 crypto-erasure(删除密钥)并清理索引。SSD 上不能承诺可靠覆盖删除,因此不能用“反复覆盖文件”作为安全保证。
|
||
- 设置缓存 TTL、容量上限和最少字段;搜索索引不放诊断正文等敏感内容。
|
||
- QSettings 只保存主题、窗口大小等无敏感偏好。
|
||
- 日志不记录患者姓名、手机号、证件号、病历正文、处方内容、token、Cookie 或 URL query;提供用户确认后的脱敏诊断包。
|
||
|
||
### 5.2 离线策略
|
||
|
||
不要只依赖系统“在线/离线”事件;真正状态以最近请求结果和轻量 health check 综合判定。主壳常驻显示 `在线 / 网络不稳定 / 离线 / 服务维护`,且标明数据最后更新时间。
|
||
|
||
写操作按风险分类:
|
||
|
||
| 类别 | 离线行为 | 恢复后 |
|
||
| --- | --- | --- |
|
||
| 只读列表/详情 | 展示有时间戳的加密缓存,明显标记“可能已过期” | 后台重新验证并原子替换 |
|
||
| 普通草稿、低风险备注 | 可进入 outbox,保存幂等键、base version 和依赖 | 自动重放;冲突转人工处理 |
|
||
| 病历最终提交、开方/签方、医嘱、删除等高风险操作 | 只允许保存为本地草稿,禁止假显示“已提交” | 恢复网络后重新拉取服务端版本,由医生确认再提交 |
|
||
| 视频/实时聊天 | 显示不可用或重连,不能伪造发送成功 | 按 cursor 补拉并去重 |
|
||
|
||
Outbox 状态至少包含 `QUEUED -> SENDING -> SUCCEEDED`,以及 `NEEDS_ATTENTION`、`DEAD_LETTER`。保存 payload schema version、创建人/租户、幂等键、重试次数、next attempt、最后错误和服务端 base version。只自动重放白名单 action;切换用户时绝不重放前一用户队列。
|
||
|
||
### 5.3 统一错误体验
|
||
|
||
所有页面复用下列状态组件,而不是把异常 traceback 或后端原文弹给用户:
|
||
|
||
- 首次加载 skeleton;刷新时保留旧数据并显示非阻塞进度。
|
||
- 真空数据(业务上没有记录)与加载失败严格区分。
|
||
- 离线且有缓存、离线且无缓存、权限不足、登录过期、字段校验、版本冲突、维护中、未知错误各有独立文案和可行动按钮。
|
||
- 未知错误显示 request id、发生时间、“重试/复制诊断编号”,详细堆栈只进入脱敏日志。
|
||
- 全局未捕获异常写入 rotating log 并打开安全错误页;不要自动上传包含医疗数据的 crash dump。
|
||
|
||
## 6. 视频与 QtWebEngine 的可执行边界
|
||
|
||
### 6.1 首选集成顺序
|
||
|
||
1. 先确认腾讯或现有供应商是否提供受支持的 Windows/macOS 原生桌面 SDK 以及 Python 可调用层。如果维护成本可接受,原生 adapter 最可控。
|
||
2. 若现有成熟能力是 TRTC Web 页面,后端提供一个专用、窄功能、HTTPS 的 `/desktop-call` 类入口(实际路径待定),由 `QWebEngineView` 嵌入。
|
||
3. 若 SDK/UA/DRM/屏幕共享在 Qt Chromium 中不受支持,可靠回退是系统默认浏览器,不通过修改 UA 或关闭浏览器安全策略强行兼容。
|
||
|
||
不要把整套 admin 嵌入桌面壳。JS SDK 的版本和构建产物留在专用 Web 页面侧,Python 只持有 `VideoPort(join, leave, mute, device_changed)` 抽象,这样管理端升级 TRTC SDK 不要求桌面二进制同步发版。
|
||
|
||
### 6.2 安全桥接
|
||
|
||
- 桌面从后端申请一次性、短有效期的 call ticket;Web 页面再用 ticket 换房间凭证。禁止把 access token、`userSig` 长期放在 URL、日志或 localStorage。
|
||
- 使用专用 `QWebEngineProfile`。优先 off-the-record;若必须持久化设备选择,也只能保存无认证数据。通话结束时清 Cookie、HTTP cache、permissions 和页面内容。
|
||
- 导航只允许精确的 HTTPS origin/path allowlist;拦截新窗口、任意下载、`file://`、未知 scheme、跨域跳转和证书错误。证书错误 fail closed。
|
||
- 相机/麦克风权限只对当前 allowlisted 通话 origin、活跃通话和明确用户操作放行,结束即撤销。屏幕共享另做显式确认。
|
||
- 若使用 Qt WebChannel,bridge 只暴露少量 typed method/signal,不暴露文件系统、shell、通用 HTTP client、token getter 或任意 Python 调用。每次调用再次校验当前 page origin 和 session/call id。
|
||
- release 不启用 remote debugging,不设置 `QTWEBENGINE_DISABLE_SANDBOX=1`,不使用 `--no-sandbox`。应用也不以管理员/root 身份运行。
|
||
|
||
### 6.3 兼容性与体积现实
|
||
|
||
`PySide6` 顶层 wheel 会同时拉入 Essentials 和 Addons;core profile 应直接依赖 `PySide6-Essentials`。WebEngine 位于 Addons,wheel 和最终制品都会显著增大,不能把它当成“小插件”。最终体积以两端产物为准,不承诺一个固定数字。
|
||
|
||
QtWebEngine 使用多进程 Chromium,发布物必须保留:
|
||
|
||
- `QtWebEngineProcess` helper;
|
||
- QtWebEngineCore/Widgets 库与所需平台插件;
|
||
- `qtwebengine_resources*.pak`、`icudtl.dat`、V8 snapshot;
|
||
- `qtwebengine_locales`(至少完整验证 `zh-CN`、`en-US` 后才可裁剪);
|
||
- macOS framework/helper 的 bundle 结构和 entitlements。
|
||
|
||
H.264/AAC/MP3 等专有 codec 是否可用取决于 QtWebEngine 构建与许可,不能因为 `hls.js` 存在就假设一定能播放。首发验收必须覆盖真实 TRTC/WebRTC、摄像头、麦克风、扬声器切换、屏幕共享(若需求存在)、HLS/录播格式和弱网;若要自行构建启用 proprietary codecs,先完成专利/分发许可评审。
|
||
|
||
## 7. PyInstaller 构建与发布
|
||
|
||
### 7.1 为什么固定 onedir
|
||
|
||
虽然 PyInstaller 支持 onefile,但本项目默认 `onedir`:
|
||
|
||
- onefile 每次启动需解压大体积 Chromium,冷启动慢,易触发杀软且临时目录空间不可控;
|
||
- WebEngine 是多进程,helper、framework、资源和 macOS 签名/沙箱都依赖正确目录结构;
|
||
- onedir 更容易做增量诊断、签名验证和 installer 管理。
|
||
|
||
用户最终仍拿到一个 `.exe` 安装程序或 `.dmg/.pkg`,无需手动管理 onedir 目录。
|
||
|
||
### 7.2 spec 设计原则
|
||
|
||
维护四个薄 spec,公共配置放 `packaging/common.py`。入口始终为 `src/zyt_doctor/__main__.py`,`pathex=["src"]`;只收集应用 resources 和必要 metadata。WebEngine profile 因 `integrations/video/webengine.py` 中有显式 import,触发 PyInstaller 官方 PySide6 hook;如通过 feature registry 动态加载,再显式加入这些 hidden imports:
|
||
|
||
```python
|
||
VIDEO_HIDDEN_IMPORTS = [
|
||
"zyt_doctor.integrations.video.webengine",
|
||
"PySide6.QtWebEngineCore",
|
||
"PySide6.QtWebEngineWidgets",
|
||
"PySide6.QtWebChannel",
|
||
]
|
||
|
||
# core spec 中排除,且 core 构建环境根本不安装 Addons
|
||
CORE_EXCLUDES = [
|
||
"PySide6.QtWebEngineCore",
|
||
"PySide6.QtWebEngineWidgets",
|
||
"PySide6.QtWebEngineQuick",
|
||
]
|
||
```
|
||
|
||
不要手工把整个 `site-packages/PySide6` 复制进 datas,也不要用 `collect_all("PySide6")`;这会拉入无关 Qt 模块并可能破坏 hook 期望的目录。优先依赖当前锁定 PyInstaller 的 Qt hooks,仅对 self-check 证实遗漏的自有动态模块写自定义 hook。
|
||
|
||
macOS `BUNDLE` 至少设置稳定的 bundle id、版本、图标,并在 `Info.plist` 中写清:
|
||
|
||
```python
|
||
info_plist = {
|
||
"CFBundleIdentifier": "com.zhenyangtang.doctor",
|
||
"NSCameraUsageDescription": "用于医生视频问诊",
|
||
"NSMicrophoneUsageDescription": "用于医生视频问诊",
|
||
"NSHighResolutionCapable": True,
|
||
}
|
||
```
|
||
|
||
仅当有实际功能时再加入其他 TCC 权限说明。不得加入放宽 ATS 的全局例外。Windows spec 使用有版本信息的 manifest、`.ico` 和 GUI subsystem,同时保留内部异常日志;开发 smoke build 可临时打开 console。
|
||
|
||
### 7.3 构建命令骨架
|
||
|
||
Windows x64 runner:
|
||
|
||
```powershell
|
||
py -3.12 -m venv .venv-build
|
||
.venv-build\Scripts\python -m pip install --require-hashes -r requirements\video-win-x64.lock
|
||
.venv-build\Scripts\python -m PyInstaller --noconfirm --clean packaging\windows\doctor-video.spec
|
||
dist\DoctorDesktop\DoctorDesktop.exe --self-check
|
||
```
|
||
|
||
macOS arm64 runner:
|
||
|
||
```bash
|
||
python3.12 -m venv .venv-build
|
||
.venv-build/bin/python -m pip install --require-hashes -r requirements/video-macos-arm64.lock
|
||
.venv-build/bin/python -m PyInstaller --noconfirm --clean packaging/macos/doctor-video.spec
|
||
dist/DoctorDesktop.app/Contents/MacOS/DoctorDesktop --self-check
|
||
```
|
||
|
||
PyInstaller 不能从 Windows 生成 macOS `.app`,反之亦然。每次构建从干净环境执行 `--clean`,记录 Python/PySide6/PyInstaller/OS SDK 版本、锁文件 hash、git commit 和产物 SHA-256,保证可追溯。
|
||
|
||
### 7.4 WebEngine 自检
|
||
|
||
应用提供 `--self-check`,不访问患者数据,检查:
|
||
|
||
- build info、只读 resources、可写 data/log/cache 路径;
|
||
- keyring backend 是否安全、SQLite migration 是否可运行;
|
||
- TLS CA、production 配置和 host allowlist;
|
||
- video profile 中通过 `QLibraryInfo` 定位 helper/resources/locales,禁止依赖写死的 `_internal` 路径;
|
||
- release 环境没有 `QTWEBENGINE_DISABLE_SANDBOX`/`--no-sandbox`;
|
||
- GUI smoke 模式实际创建 `QWebEngineView`,加载本地无网络测试页,然后退出;真实视频另由端到端测试覆盖。
|
||
|
||
任何 helper、`.pak`、ICU、snapshot、platform plugin 或 locale 缺失都应让发布流水线失败,不在运行时静默降级。
|
||
|
||
### 7.5 Windows 发布
|
||
|
||
1. 使用固定、受控的 Windows x64 runner 构建;不要在装有多套 Qt/Anaconda 的个人机上发正式包。
|
||
2. QtWebEngine 运行依赖合适的 MSVC runtime。由安装器包含/检查 Microsoft Visual C++ Redistributable(Qt 官方要求的版本下限需按锁定 Qt 再核对),并在干净 VM 验证。
|
||
3. 用组织的 Authenticode 证书和 RFC 3161 时间戳签名主程序及最终 MSI/EXE 安装器;验证 `signtool verify /pa /all`。
|
||
4. 安装到 Program Files,用户数据仍进 LocalAppData;普通用户可运行/升级。可用 WiX Toolset/MSIX 或 Inno Setup,选择后固定 UpgradeCode/AppUserModelID 和回滚策略。
|
||
5. 在 Windows 10/11 干净 VM 上验证安装、升级、卸载后保留/清除用户数据的明确策略、SmartScreen、企业代理、中文路径和非管理员账户。
|
||
|
||
### 7.6 macOS 发布
|
||
|
||
1. 在目标架构的 macOS runner 构建。PyInstaller 修改 Mach-O 后必须重新签名;使用 Developer ID Application 身份,不用 ad-hoc 签名发布。
|
||
2. QtWebEngine helper 是嵌套 app/process。必须保留 framework bundle 结构,并确认 helper 使用 Qt 自带的 `QtWebEngineProcess.entitlements` 所需权限签名;主 app 使用项目的 camera/microphone 权限说明和最小 entitlements。签名顺序由内向外,避免用 `codesign --deep` 掩盖错误。
|
||
3. 执行 `codesign --verify --deep --strict --verbose=2 DoctorDesktop.app` 和 `spctl --assess --type execute`;随后用 `xcrun notarytool submit ... --wait` 公证,staple ticket,再在离线干净 Mac 验证 Gatekeeper。
|
||
4. 用 DMG/PKG 或能保留 symlink 的方式分发。PyInstaller 6+ 的 POSIX bundle 广泛使用 symlink,普通 zip 若不保留 symlink 可能膨胀或破坏运行。
|
||
5. 在 Intel(若支持)与 Apple Silicon 真机上分别验证 keychain 升级连续性、摄像头/麦克风 TCC、休眠唤醒、Retina、多显示器和 WebEngine helper 签名。
|
||
|
||
### 7.7 常见打包坑清单
|
||
|
||
- **Qt binding 混装**:同环境存在 PyQt/PySide2 或系统 Qt,hook 收到冲突库。解决:干净 venv、只安装一种 binding、锁版本。
|
||
- **误用 onefile**:启动慢、helper/沙箱/签名问题更难复现。解决:正式版固定 onedir。
|
||
- **动态 import 未分析**:业务模块或 WebEngine 在 registry 中字符串加载。解决:显式 import 或最小 hiddenimports,并用 frozen smoke test 覆盖。
|
||
- **资源路径错误**:开发机相对路径可用,安装后 CWD 变化。解决:`importlib.resources`/qrc;用户数据用 QStandardPaths。
|
||
- **过度裁剪 Qt**:删除 `.pak`、ICU、snapshot、locale、platform plugin 后只在某些机器崩。解决:先保留 hook 输出,按真实清单与测试有证据地裁剪。
|
||
- **macOS 签名次序/entitlements 错**:QtWebEngineProcess 启动即退出或 TCC 不弹窗。解决:嵌套 helper 真机测试、由内到外签、notarize/staple。
|
||
- **归档破坏 symlink**:`.app` 体积暴涨或 framework 无法加载。解决:DMG/ditto 或明确保留 symlink 的归档工具。
|
||
- **GPU/远程桌面差异**:WebEngine 黑屏。不要默认全局 `--disable-gpu`;收集诊断后提供经验证的软件渲染或外部浏览器 fallback。
|
||
- **codec 误判**:开发机能播 H.264,发布 wheel 不能播。解决:把真实媒体矩阵列入 artifact 验收和许可评审。
|
||
- **杀软与信誉**:大量 DLL/helper 或未签名 nightly 被拦截。解决:正式证书、时间戳、稳定 installer identity、干净 VM/主流安全软件测试。
|
||
- **升级破坏 keychain/数据**:bundle id、签名 identity 或 schema 不稳定。解决:这些值从首版固定,migration 支持备份与回滚。
|
||
|
||
## 8. 安全与隐私基线
|
||
|
||
- TLS 校验永远开启。企业私有 CA 应通过受管安装进入系统 trust store;不得用 `verify=False`。如需兼容企业代理,可用 `truststore` 接入 OS 证书库并做专项测试。
|
||
- 本地配置不能提供任意 production host 重定向;敏感 token 永远不进入 URL query、clipboard、日志、analytics 或 crash report。
|
||
- HTML 病历优先用受限 `QTextBrowser`/原生富文本展示并在服务端净化;不要因为“要展示 HTML”就引入 WebEngine。任何外链由用户确认后交给系统浏览器。
|
||
- 限制剪贴板和通知中的患者信息;自动锁定后遮蔽窗口内容。截图阻止在跨平台上不可靠,不能作为合规控制。
|
||
- 所有高风险业务写操作由服务端保留不可抵赖审计;客户端日志只记录事件名、耗时、状态、request id 和脱敏技术上下文。
|
||
- 发布前完成依赖 SBOM、许可证清单和漏洞扫描。PySide6 采用 LGPLv3/GPLv3 或商业许可,QtWebEngine/Chromium/codec 还包含额外 notices;由法务确认采用的 Qt 许可与分发义务,安装包附第三方 notices。
|
||
- 自动升级若后续实现,更新 manifest 必须签名,制品必须校验 SHA-256 与平台签名,并支持回滚;首版宁可用已签名安装器提示升级,也不要执行未签名下载内容。
|
||
|
||
## 9. 测试与发布门槛
|
||
|
||
### 9.1 自动化测试分层
|
||
|
||
- **unit**:权限 guard、会话状态机、单飞 refresh、重试白名单、错误映射、缓存 TTL、加解密、outbox 状态和冲突决策。用 fake clock/random/secret store,保证确定性。
|
||
- **API contract**:以 OpenAPI schema 或后端 mock 验证字段、错误码、分页、时间和幂等语义;`respx` 模拟超时、断流、401 并发、429/Retry-After、5xx 和结果未知。
|
||
- **integration**:临时 SQLite + fake keyring + staging API,覆盖 migration、损坏缓存、磁盘满、退出清理、租户切换和代理/私有 CA。
|
||
- **UI**:`pytest-qt` 验证路由权限、loading/empty/error/offline、键盘导航、取消和 late response;不要用脆弱的像素级截图替代行为断言。
|
||
- **frozen artifact**:Windows/macOS 各自安装后运行 `--self-check`,启动主窗口、登录 mock/staging、访问资源、写用户目录、升级 migration、卸载。
|
||
- **视频真机**:摄像头/麦克风授权与拒绝、无设备、设备热插拔、回声设备、弱网/断网重连、休眠唤醒、屏幕共享、录播 codec、结束后权限与 Cookie 清理。
|
||
|
||
重点故障用例包括:十个请求同时 401、提交后响应丢失、服务器版本冲突、系统时钟偏差、刷新时退出、缓存被截断、keychain 被锁、两实例竞争、磁盘只读、API 维护、WebEngine helper 被杀、证书过期。测试数据必须是合成数据。
|
||
|
||
### 9.2 CI 矩阵与质量门槛
|
||
|
||
PR 阶段可并行运行 Windows x64 和 macOS arm64 的 lint/type/unit/UI headless 测试。release tag 阶段在原生 runner 生成制品并执行:
|
||
|
||
1. `ruff`、`mypy`、unit/contract/integration 测试全绿,覆盖率阈值重点约束 core 状态机而非 UI 行数。
|
||
2. 依赖 lock、SBOM、license、`pip-audit` 无未批准的高危项。
|
||
3. 两端 frozen self-check 与安装/升级 smoke 通过。
|
||
4. 签名、公证、hash、版本资源和 update channel 验证通过。
|
||
5. video profile 在目标硬件的人工/自动验收清单签字;core profile 证明不会意外收集 QtWebEngine。
|
||
6. staging 完成登录、权限变更、患者查询、一个低风险写操作、一个高风险确认、登出清理和离线恢复闭环。
|
||
|
||
## 10. 实施顺序与验收里程碑
|
||
|
||
### M0:契约和风险封板(约 3–5 天)
|
||
|
||
- 获取 OpenAPI/认证/权限/RTC/对象存储契约;形成 endpoint 与权限矩阵。
|
||
- 在 Windows/macOS 原型中用 QtWebEngine 打开专用测试页,验证 TRTC/WebRTC、设备权限和目标 codec。
|
||
- 决定首发是 core、video 还是“core + 外部浏览器”。
|
||
|
||
**退出条件**:API base 和 auth 不再是假设;视频路线有真实 PoC,而非只证明网页能打开。
|
||
|
||
### M1:可发布骨架
|
||
|
||
- 完成 bootstrap、配置校验、日志/路径、API client、SessionManager、PermissionGuard、shell 和统一状态组件。
|
||
- mock server 下完成登录、`/me`、权限菜单、401 single-flight refresh、登出清理。
|
||
- 两端 onedir unsigned nightly 可安装并通过 self-check。
|
||
|
||
### M2:业务纵切
|
||
|
||
- 先选一个完整纵切(例如预约 -> 患者概要 -> 诊间记录草稿),按 module 结构贯穿 UI、service、API、权限和测试。
|
||
- 再并行扩展患者、病历、处方、随访、聊天;高风险操作必须有后端幂等/审计。
|
||
|
||
### M3:离线与视频
|
||
|
||
- 实现加密 cache/outbox、冲突页、断网/恢复和数据清理。
|
||
- video adapter、受限 profile、一次性 ticket、权限撤销和外部浏览器 fallback 完成。
|
||
|
||
### M4:生产发布
|
||
|
||
- 锁依赖和 runner image,完成 Windows Authenticode、macOS Developer ID/notarization、SBOM/许可证。
|
||
- 干净 VM/真机、企业代理、非管理员、升级/回滚、视频设备矩阵全部通过。
|
||
|
||
## 11. 最终架构决策摘要
|
||
|
||
1. 业务 UI 用原生 Qt Widgets;QtWebEngine 是隔离的视频实现细节,不是应用架构。
|
||
2. 后端契约、服务端授权和服务端审计是权威;桌面端只做强类型 adapter、体验 guard 和安全状态管理。
|
||
3. access token 仅在内存,长期凭证进 OS keychain;敏感离线数据加密且按用户/租户隔离。
|
||
4. 高风险医疗写操作离线时只能保存草稿,恢复后由医生确认;普通 outbox 依赖幂等键和乐观锁。
|
||
5. 发布固定 PyInstaller onedir、平台原生构建、签名和 artifact 级测试;不交叉编译,不依赖开发机“能跑”。
|
||
6. production URL、SDK secret、对象存储密钥和 RTC 签名密钥都不能硬编码进客户端。
|
||
|
||
## 参考资料
|
||
|
||
- [Qt for Python package details](https://doc.qt.io/qtforpython-6.10/package_details.html):Essentials/Addons 拆分和 wheel 内容。
|
||
- [Qt for Python 与 PyInstaller](https://doc.qt.io/qtforpython-6.10/deployment/deployment-pyinstaller.html):官方 PyInstaller 基础部署说明。
|
||
- [Qt WebEngine 部署](https://doc.qt.io/qt-6/qtwebengine-deploying.html):helper、resources、locales、macOS entitlements 等必需项。
|
||
- [Qt WebEngine features](https://doc.qt.io/qt-6/qtwebengine-features.html):WebRTC/媒体能力及专有 codec 许可提醒。
|
||
- [PyInstaller macOS multi-arch 与签名](https://pyinstaller.org/en/stable/feature-notes.html):架构 slice 和 codesign 行为。
|
||
- [PyInstaller symlink/common pitfalls](https://pyinstaller.org/en/stable/common-issues-and-pitfalls.html):PyInstaller 6+ POSIX bundle 的 symlink 分发要求。
|
||
|