Files
xuetang/app/research/desktop_architecture.md
2026-09-08 11:40:15 +08:00

462 lines
34 KiB
Markdown
Raw Permalink 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.
# 医生桌面端工程架构与打包方案(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 11x86-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 只允许 HTTPSHTTP 仅在 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 ticketWeb 页面再用 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 WebChannelbridge 只暴露少量 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 和 Addonscore profile 应直接依赖 `PySide6-Essentials`。WebEngine 位于 Addonswheel 和最终制品都会显著增大,不能把它当成“小插件”。最终体积以两端产物为准,不承诺一个固定数字。
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++ RedistributableQt 官方要求的版本下限需按锁定 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 或系统 Qthook 收到冲突库。解决:干净 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 WidgetsQtWebEngine 是隔离的视频实现细节,不是应用架构。
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 分发要求。