Files
zyt/app/research/desktop_architecture.md
T
2026-08-10 17:29:05 +08:00

34 KiB
Raw Blame History

医生桌面端工程架构与打包方案(Windows / macOS

结论先行:采用 Python 3.12 + PySide6 Qt Widgets 构建原生业务界面,以分层的 httpx API client 连接现有后端;会话、权限、离线队列和本地安全存储统一放在 core 层。视频不是整套应用的实现基础,而是独立的可选集成:只有当现有腾讯 TRTC Web 方案无法由原生 SDK 替代时,才在受限的 QWebEngineView 中承载单一视频页面。发布使用 PyInstaller onedirWindows 与 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 推荐分别产出 arm64x86_64 制品。universal2 只有在 Python、PySide6 和所有二进制依赖均提供 universal2 slice,且真实验证签名/视频后再启用;两个单架构制品更易排障且体积更小。

2.2 依赖分档

建立一份代码、两种构建 profile:

  • corePySide6-Essentialshttpxpydanticpydantic-settingsplatformdirskeyringcryptography。包含 QtCore/Gui/Widgets/Network/Sql/Svg/PrintSupport,不包含 WebEngine。
  • video:在 core 上增加与 Essentials 完全相同版本PySide6-Addons,从而获得 QtWebEngineWidgets、WebChannel、Multimedia 等模块。
  • 开发/测试:pytestpytest-qtrespxcoverageruffmypypip-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.lockrequirements-macos-arm64.lock),而不是在发布任务中直接安装“最新版”。

如果所有医生都需要视频,可只发布 video 制品;仍保留 profile 边界,以便定位 WebEngine 问题。若视频是少数场景,可以发布 core 制品并在系统浏览器打开受支持的视频页,避免让每个安装包承担 Chromium 的体积和攻击面。

3. 分层架构

依赖方向固定为:ui -> application -> domaininfrastructure 在 composition root 中实现 domain/application 定义的 port。View 不允许直接调用 httpx、SQLite 或 keyring。

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,直到收到确定结果;请求超时后的状态为“结果未知”,先按键查询/重放,不能直接再创建一条。
  • 业务错误映射为稳定类型:ValidationErrorUnauthenticatedForbiddenConflictRateLimitedMaintenanceTransportErrorUnknownServerError。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 是唯一会话真相,显式状态为:

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.readrecord.writeprescription.sign;具体字符串必须以真实契约为准。

客户端执行三层防误操作:

  1. 路由层:无模块权限时不注册菜单/路由。
  2. ViewModel/command 层:按钮显示与执行前都检查 PermissionGuard.require(...)
  3. API 层:403 统一转为 Forbidden,刷新权限快照并提示“权限已变更”。

这些仅改善体验,真正的 RBAC/ABAC、租户隔离和审计必须由后端再次校验。不能因为客户端隐藏了按钮就省略服务端授权。对开方、签名、删除等高风险操作增加 step-up authentication 或明确二次确认,并把 request id/idempotency key 传给服务端审计。

5. 本地数据、离线与错误态

5.1 数据目录与加密

使用 QStandardPathsplatformdirs 获取每用户目录: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_ATTENTIONDEAD_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*.pakicudtl.dat、V8 snapshot
  • qtwebengine_locales(至少完整验证 zh-CNen-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__.pypathex=["src"];只收集应用 resources 和必要 metadata。WebEngine profile 因 integrations/video/webengine.py 中有显式 import,触发 PyInstaller 官方 PySide6 hook;如通过 feature registry 动态加载,再显式加入这些 hidden imports

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 中写清:

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

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

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.appspctl --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。
  • UIpytest-qt 验证路由权限、loading/empty/error/offline、键盘导航、取消和 late response;不要用脆弱的像素级截图替代行为断言。
  • frozen artifactWindows/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. ruffmypy、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:契约和风险封板(约 35 天)

  • 获取 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 签名密钥都不能硬编码进客户端。

参考资料