# 医生桌面端工程架构与打包方案(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 分发要求。