Files
zyt/app/research/client_update_diagnosis.md
2026-08-28 18:24:37 +08:00

159 lines
14 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 x64 客户端更新 `can_install=False` 诊断
## 结论
“已识别最新版本,但按钮显示「暂不可安装」并提示后台尚未配置安装包”并不等价于只有一种后台配置错误。当前链路把多种拒绝原因压缩为同一个 `UpdateOffer.can_install=False`,而对话框的兜底文案统一归因为“后台未配置”。
对标准 Windows x64 客户端,最值得按以下顺序检查:
1. **服务端没有在 `packages.windows_x64` 取到同时非空的 `url` 和 `sha256`。** 最新版本是全局字段,安装包是按平台另行选择,因此完全可能 `has_update=True``can_install=False`
2. **Windows Inno Setup 包使用了 HTTP(非 localhost)地址,或本机关闭了 HTTPS 证书校验。** 前者会被解析器拒绝;后者会在 UI session 中把一个原本可安装的 offer 二次降级为不可安装。
3. **服务端返回了非 64 位十六进制 SHA-256。** 当前 PHP evaluate 只检查 SHA 是否非空,Python 客户端则做严格格式校验,两端判定可能不一致。
4. **`package` 结构或 `package.type` 不符合客户端契约。** 客户端只接受对象形式的 `package`,类型只接受 `archive` / `inno_setup`;不过当前第一方 PHP 后端会把未知类型归一为 `archive`,当前管理端也只提供这两个选项,所以这通常只发生在旧服务、手工响应或绕过当前保存链路的配置中。
如果更新对话框确实已经出现,则单纯的版本、`enabled`、响应平台/架构不匹配通常可以排除:`AppUpdateSession._on_offer()``offer.has_update=False` 时直接返回,不会展示更新对话框(`app/src/doctor_workstation/ui/dialogs/app_update.py:360-368`)。
## 端到端链路与证据
### 1. 客户端发送的身份
- Windows 被映射为 `windows``app/src/doctor_workstation/services/app_update.py:78-83`)。
- `AMD64``x86_64``x64` 都被映射为 `x64``app/src/doctor_workstation/services/app_update.py:86-92`)。
- 检查请求固定发往 `setting.desktop_workstation/check`,携带 `current_version``platform``arch``app/src/doctor_workstation/services/app_update.py:218-243`)。
- `ApiClient` 会解开 `{code, data}` 信封,`code == 1` 时把 `data` 直接交给更新解析器(`app/src/doctor_workstation/services/api_client.py:515-542`)。
因此标准 64 位 Windows 的请求应为:
```text
GET /adminapi/setting.desktop_workstation/check
?current_version=<当前版本>&platform=windows&arch=x64
```
### 2. 服务端先决定是否有对应平台安装包
- 服务端只声明三个包槽位:`windows_x64``macos_arm64``macos_x64``server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:28-32`)。
- `windows`/`win32`/`win64` 会归一为 `windows``x64`/`amd64`/`x86_64` 会归一为 `x64`,然后拼成 `windows_x64`(同文件 `:125-153`)。
- evaluate 从 `config.packages[windows_x64]` 取包;服务端 `canInstall` 只要求 `url !== '' && sha256 !== ''`(同文件 `:75-86`)。
- `hasUpdate` 独立由启用状态和版本比较决定(`:86-88`),响应中只有 `canInstall` 为真才返回 `package`,最终 `can_install = hasUpdate && canInstall``:90-103`)。
这直接解释了核心现象:`latest_version` 配置正确会让客户端看到新版本,但 `packages.windows_x64.url``packages.windows_x64.sha256` 任一为空,响应仍会是 `has_update: true``package: null``can_install: false`
管理端保存的真实字段是嵌套结构 `packages.windows_x64.{url,sha256,size,filename,type}``admin/src/api/setting/desktop_workstation.ts:5-24``admin/src/views/setting/desktop_workstation/index.vue:330-344`),而不是把 Windows 包放在 macOS 槽位或任意自定义键下。管理页默认 Windows 类型为 `inno_setup`Vue 文件 `:198-218`),上传 `.exe` 也会设置为 `inno_setup` 并在浏览器计算 SHA-256`:292-315`)。
当前服务端校验允许整行安装包为空:空值/空行会继续通过(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:83-110`),所以“自动检测已启用、最新版本有效、Windows 包未完整配置”是被允许保存的状态。外部 URL 缺 SHA 会被拒绝,但站内相对 URL 对应文件不存在且 SHA 为空的情形仍可能保存;服务端只会在本地文件确实存在时自动补 SHA、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
### 3. Python 客户端会再做一轮更严格的判定
`parse_update_offer()` 的规则位于 `app/src/doctor_workstation/services/app_update.py:140-215`
- `package` 必须是字典;`url` 必须非空(`:152-167`)。
- `type` 缺省为 `archive`,只接受 `archive` / `inno_setup``inno_setup` 只允许 Windows`:158-167`)。
- 响应平台、架构必须与请求时的期望值完全一致;同时必须满足服务端 `has_update``enabled`、合法且更高的版本(`:175-184`)。
- SHA-256 必须恰好 64 个十六进制字符(`:185-189`)。
- `inno_setup` URL 必须是 HTTPS,唯一例外是 HTTP localhost/loopback`:190-194`,具体 URL 规则在 `:371-376`)。
- 最终 `can_install` 是服务端 `can_install`、有效 package、有效 SHA、安全安装器传输、`has_update` 五者的合取(`:195-201`)。判失败后返回对象会清除 `package`,并把 `force` 一并降为 false`:202-215`)。
因此若原始 API 返回 `can_install: true`,客户端仍可能因以下字段得到 false:
| 字段/状态 | 拒绝条件 | Windows x64 症状是否吻合 |
|---|---|---|
| `package` | `null`、数组、字符串等非对象 | 是 |
| `package.url` | 空字符串 | 是 |
| `package.sha256` | 空、长度不是 64、包含非十六进制字符 | 是 |
| `package.type` | 非 `archive` / `inno_setup` | 是,但当前第一方后端通常会归一为 `archive` |
| `package.type=inno_setup` + URL | 非 localhost 的 `http://` 或相对 URL | 是 |
| `package.filename` | 空或扩展名不匹配 | **不会在 offer 阶段令 `can_install=False`**;可能在下载/应用阶段失败 |
| `package.size` | 空、0、不可转整数 | **不会在 offer 阶段令 `can_install=False`**;解析为 0 |
| 缺少 `package.type` | 默认 `archive` | **不会单独导致 false**EXE 被误当 archive 会在稍后解压失败 |
一个重要的不一致是:PHP evaluate 目前只检查 SHA 非空(`DesktopWorkstationLogic.php:85`),Python 检查完整格式(`app_update.py:185-189`)。管理端正常保存会校验 64 位十六进制(`DesktopWorkstationValidate.php:147-151`),但旧数据、直接写配置或绕过校验的导入仍可能造成“后端说可安装、客户端说不可安装”。
### 4. UI session 还会因本机 TLS 设置二次降级
即使 `fetch_update_offer()` 返回的 Inno Setup offer 已经 `can_install=True``AppUpdateSession._on_offer()` 仍会以本机 `config.verify_ssl` 调用安装器下载策略;失败时把 `force=False``package=None``can_install=False``app/src/doctor_workstation/ui/dialogs/app_update.py:371-392`)。
本机配置默认 `verify_ssl=True``app/src/doctor_workstation/config.py:88-97``:124-130`),但登录页勾选“信任自签名证书(仅内网调试)”会把它反转为 false(`app/src/doctor_workstation/ui/login.py:831-844``:937-944``:1041-1053`)。`validate_installer_download_policy()` 明确拒绝 `verify_ssl=False`,也拒绝非安全的 Inno Setup URL`app/src/doctor_workstation/services/app_update.py:379-385`)。
这是最容易被误判为“后台没包”的非后台原因。诊断时应比较两个时点:
1. `fetch_update_offer()` 刚返回时是否 `can_install=True`
2. `_on_offer()` 传给 `_present()` 时是否已经变成 false。
若只有第 2 个时点为 false,按当前代码唯一的正常降级入口就是 Inno Setup 下载策略,优先检查 `verify_ssl`
审阅时工作树中已存在一项并非本文创建的未提交改善:`UpdateOffer` 增加 `install_unavailable_reason`,TLS 策略降级时生成具体原因,对话框优先展示该原因(`app_update.py` service `:53-67`UI `:200-206``:379-391`)。兜底文案仍用于服务端/解析阶段没有原因的 `can_install=False`,所以根因判别和补测仍有必要。
### 5. 为什么平台或版本通常不是这个弹窗的根因
- 客户端要求响应 `platform`/`arch` 与请求期望值精确相等,错配会让 `has_update=False``app_update.py:175-184`)。
- session 对 `has_update=False` 直接显示“当前已是最新版本”或静默返回,不创建更新对话框(UI `:360-368`)。
- 当前第一方后端会把 Windows/x64 常见别名归一为响应中的 `windows`/`x64``DesktopWorkstationLogic.php:125-153`)。
所以对于已经出现该对话框的标准 Windows x64 客户端,优先查 `packages.windows_x64`,而不是先怀疑 `AMD64``x64` 名称差异。例外是非标准/旧后端没有按当前契约归一,或实际机器是 Windows ARM64;服务端没有 `windows_arm64` 包槽位,后者会天然没有对应包。
同理,`enabled=false`、最新版本无效、当前版本不低于最新版本都会使 `has_update=False`,与“更新弹窗出现但不可安装”不吻合。源码运行也不是该兜底文案的成因:源码模式只会取消强制属性,点击安装后才显示“当前为源码运行”(UI `:393-394``:401-410`)。
## 最短现场排查路径
1. 用发生问题的当前版本请求实际 API,并保留解包后的 `data`
```text
/adminapi/setting.desktop_workstation/check?current_version=<version>&platform=windows&arch=x64
```
2. 若响应已经是 `can_install:false` 且 `package:null`,读取管理端配置并核对 `packages.windows_x64.url` 与 `.sha256` 是否同时非空;确认包没有误填到 `macos_x64`,也没有只保存最新版本而未保存包。
3. 若响应是 `can_install:true`,核对 `package` 是否为对象、SHA 是否 64 位十六进制、`type` 是否精确为 `archive` 或 `inno_setup`。若为 Inno SetupURL 应为 HTTPS。
4. 若解析后 offer 为 true、弹窗前变成 false,检查客户端 `preferences.json` 中的 `verify_ssl`,以及登录页“信任自签名证书”是否被勾选。
5. 对 Windows 安装程序,期望响应至少应类似:
```json
{
"has_update": true,
"enabled": true,
"platform": "windows",
"arch": "x64",
"can_install": true,
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
"sha256": "<64 lowercase hex chars>",
"size": 123456789,
"filename": "DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
"type": "inno_setup"
}
}
```
## 现有测试覆盖与缺口
已有客户端测试覆盖:
- 缺 SHA 会拒绝安装(`app/tests/test_app_update.py:43-63`)。
- 合法 HTTPS Inno Setup 会接受(`:66-89`)。
- HTTP Inno Setup 会拒绝(`:92-115`)。
- 未知类型会拒绝(`:118-139`)。
- 旧版本/错误平台响应不会成为 update(`:142-163`)。
- 检查接口会发送 `platform=windows`、`arch=x64``:166-208`)。
- UI 的可选/强制升级基本行为,以及“给定 policy reason 时展示该 reason”(`app/tests/test_app_update_ui.py:49-96`)。
已有 PHP 契约测试覆盖 `win32 + amd64 -> windows_x64`,以及完整 Windows Inno 包可安装(`server/tests/DesktopWorkstationUpdateContractTest.php:17-62`);缺包测试只覆盖 macOS 槽位(`:74-77`)。
建议新增以下测试:
1. **Windows x64 服务端缺字段矩阵(最高优先级)**:分别让 `packages.windows_x64.url` 为空、`sha256` 为空、整个键缺失;断言 `has_update=true`、`package=null`、`can_install=false`。这会直接固化本次症状。
2. **服务端/客户端 SHA 契约一致性**:给 evaluate 一个“非空但不是 64 位十六进制”的 SHA。期望服务端也返回不可安装,或至少用共享 fixture 明确当前由客户端拒绝;避免两端一个 true、一个 false。
3. **`AppUpdateSession` TLS 二次降级**:构造合法 HTTPS `inno_setup` offer,分别设置 `verify_ssl=True/False`,截获 `_present()`true 时保持可安装,false 时断言 `can_install=False` 且原因明确指向证书策略而非后台缺包。
4. **UI 兜底分支**:构造 `can_install=False` 且无 reason 的 offer,断言按钮禁用并展示后台/平台包缺失文案;与已有“注入 policy reason”的测试形成两条独立路径。
5. **解析字段矩阵**:补充 `package=null`、非对象、空 URL、63 位 SHA、含非 hex SHA、缺少 type 默认 archive、Windows archive 使用 HTTP 仍可解析等边界测试。现有测试覆盖了部分,但没有把每个判定条件与原因一一锁定。
6. **跨层契约 fixture**:把 PHP `check` 的 Windows x64 JSON 响应作为 Python `parse_update_offer()` 输入,验证 canonical `windows/x64`、包类型、SHA 和 `can_install` 不发生语义漂移。
7. **管理端 payload 测试**:确认保存时始终发送 `packages.windows_x64` 嵌套对象,上传 `.exe` 后 `type=inno_setup` 且 URL、SHA、文件名、大小均落在同一槽位。
长期看,最稳妥的可观测性是让 `can_install=False` 同时带结构化原因(例如 `missing_package`、`invalid_digest`、`unsupported_type`、`insecure_installer_url`、`tls_verification_disabled`),并在客户端保留原因而不是立即清除所有包信息。这样 UI 不必用一个“后台未配置”文案覆盖所有安全门禁。
## 验证记录
- 根目录 `.trellis/` 不存在;本次按根 `AGENTS.md` 执行,只读检查生产代码,仅新增本文档。
- `app/.venv/Scripts/python.exe -m pytest tests/test_app_update.py tests/test_app_update_ui.py -q`:通过。
- 收集结果:`test_app_update.py` 21 项、`test_app_update_ui.py` 3 项,共 24 项。
- `php tests/DesktopWorkstationUpdateContractTest.php`(工作目录 `server/`):`Desktop workstation update contract: OK`。