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

234 lines
15 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.
# 医生工作站版本发布配置与更新 API 诊断
## 结论
当前仓库中,admin、server、app 三端的**现行契约是一致的**,但字段名不是扁平的
`package_type` / `download_url`。正式 wire contract 是:
```json
{
"code": 1,
"data": {
"has_update": true,
"force": false,
"enabled": true,
"current_version": "1.1.0",
"latest_version": "1.2.0",
"min_version": "",
"title": "...",
"notes": "...",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://.../DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"sha256": "64 位十六进制值",
"size": 123,
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"type": "inno_setup"
},
"can_install": true
}
}
```
因此:
- `latest_version``data` 下的顶层字段。
- `platform``arch` 是检测请求参数,同时在 `data` 下回显规范化后的值;它们不保存在发布配置中。
- 安装包对象叫 `package`
- 安装包类型叫 `package.type`,不是顶层或同级的 `package_type`
- 下载地址叫 `package.url`,不是 `download_url`
- 哈希叫 `package.sha256`
- 如果截图或线上响应实际出现的是扁平 `package_type``download_url`,当前 app 不会读取这些别名。这不是当前仓库 server 的输出,优先怀疑线上后端/代理为另一版本或只部署了部分提交。
当前工作区还存在一个已确认的发布物版本风险:运行时版本源已经是 `1.2.0``app/src/doctor_workstation/__init__.py:5-6`),但 `app/dist/SHA256SUMS.txt:1-2` 只登记了 `1.1.0` 的 EXE/ZIP,且本地 `DoctorWorkstation.exe` 文件版本也是 `1.1.0`。如果管理端把 `latest_version` 设为 `1.2.0`,却填入当前 `1.1.0` 安装包,客户端安装后仍会报告 `1.1.0`,下次启动会再次发现 `1.2.0`,形成重复升级提示。打包脚本明确从同一个 `__version__` 读取版本(`app/scripts/package_windows.ps1:68-76`),并用它生成 EXE/ZIP 名称(`:169-174`)及两者哈希(`:234-244`);发布前必须重新生成 `1.2.0` 产物。
> 本轮只读诊断没有修改生产代码。根目录 `AGENTS.md` 已读取;仓库中没有 `.trellis/` 目录。
## 1. Windows 64 位安装包字段如何进入保存请求
管理端类型定义把 Windows 包放在 `packages.windows_x64`,每个包固定包含
`url``sha256``size``filename``type`;其中 `type` 只允许
`archive | inno_setup``admin/src/api/setting/desktop_workstation.ts:3-24`)。保存接口是
`POST /setting.desktop_workstation/setConfig`(同文件 `:27-34`)。
页面的 Windows 区块来自平台键 `windows_x64``admin/src/views/setting/desktop_workstation/index.vue:196-224`),默认类型是 `inno_setup``:198-218`)。UI 字段与请求体的对应关系如下:
| 截图/UI 字段 | 保存请求字段 | 证据 |
|---|---|---|
| Windows 64 位安装包 | `packages.windows_x64` | `index.vue:221-224, 339-343` |
| 安装包类型 | `packages.windows_x64.type`EXE 为 `inno_setup` | `index.vue:96-104, 305-309` |
| 安装包地址 | `packages.windows_x64.url` | `index.vue:113-123, 318-324` |
| SHA-256 | `packages.windows_x64.sha256` | `index.vue:144-150, 292-315` |
| 文件名 | `packages.windows_x64.filename` | `index.vue:152-162, 300-304` |
| 文件大小(字节) | `packages.windows_x64.size` | `index.vue:164-174, 300-304` |
| 最新版本号 | 顶层 `latest_version` | `index.vue:34-42, 330-344` |
选择文件后,页面在浏览器本地读取原始文件名和字节数,`.exe` 自动切换为
`inno_setup`,并用 Web Crypto 计算 SHA-256`index.vue:292-315`)。上传成功后只把上传接口返回的 `data.uri`(次选 `data.url`)写入包的 `url``:318-328`)。最终点击保存时,页面显式组装三个平台的完整 `packages` 对象,而不是上传后自动发布(`:330-345`)。
虽然 API 封装调用写成 `request.post({ params })`,拦截器会在 POST 且没有 `data` 时把
`params` 移入 JSON body`admin/src/utils/request/index.ts:20-38`),所以 PHP 收到的是上述嵌套 JSON,而不是查询字符串。
## 2. 后端如何校验和持久化
控制器用 POST 校验器接收请求,再交给逻辑层保存(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:38-46`)。
正常管理端保存时的关键约束:
- 版本号需是纯数字分段格式(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:48-57`)。
- 包类型只允许 `archive` / `inno_setup`,且 `inno_setup` 只允许 Windows x64`:118-137`)。
- Inno Setup 若填写文件名,必须以 `.exe` 结尾(`:138-140`)。
- 显式 `http://` 的 Inno Setup 地址会被拒绝(`:141-143`)。
- 外部 http(s) 地址必须带 SHA-256SHA-256 若非空必须是 64 位十六进制(`:144-152`)。
- 文件名最长 180 字节,size 必须是非负数(`:153-158`)。
逻辑层将标量分别保存为配置项,把所有平台包作为一个 `packages` 配置项保存:
- `enabled`
- `latest_version`
- `min_version`
- `force_update`
- `title`
- `notes`
- `packages`
证据为 `server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:45-55`。其中
`latest_version` / `min_version` 会被正规化为三段版本,包则逐平台正规化
`url/sha256/size/filename/type``:180-205, 226-254`)。
`ConfigService::set()` 对数组执行 `json_encode(..., JSON_UNESCAPED_UNICODE)` 后写入 Config 模型的 `value` 字段;标量直接写入(`server/app/common/service/ConfigService.php:32-50`)。因此数据库中的逻辑形态是:
```text
type = desktop_workstation, name = latest_version, value = "1.2.0"
type = desktop_workstation, name = packages, value =
{"windows_x64":{"url":"...","sha256":"...","size":...,"filename":"...","type":"inno_setup"},...}
```
读取时,`ConfigService::get()` 会对合法 JSON 自动 `json_decode(..., true)`(同文件
`:65-85`),所以 `packages` 回到 PHP 数组。保存 URL 时会去掉当前站点/当前存储域名,读取给 API 时再补回绝对域名(`DesktopWorkstationLogic.php:231-252, 261-280``server/app/common/service/FileService.php:42-59, 69-78`)。本地 `uploads/...` 文件还会在缺失/无效时由 server 计算哈希、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
## 3. 检测 API 如何选择包和序列化响应
app 请求的端点是 `setting.desktop_workstation/check``app/src/doctor_workstation/services/app_update.py:29-35`)。控制器把 `check` 放进免登录列表(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:26-29`),并把 GET 参数直接交给逻辑层(`:48-55`)。
客户端发送:
```text
current_version=<当前运行时版本>&platform=windows&arch=x64
```
证据为 `app_update.py:217-243`。server 将 `windows/win/win32/win64` 统一成
`windows`,把 `amd64/x86_64/x64` 统一成 `x64`,拼成配置键
`windows_x64``DesktopWorkstationLogic.php:125-153`)。也就是说,`platform` / `arch`
不是管理端发布字段,而是由客户端运行环境发给检测接口、用于选择
`packages.windows_x64` 的请求维度。
server 的检测响应由 `evaluate()` 直接组成(`DesktopWorkstationLogic.php:75-103`):
- `latest_version` 来自已保存的配置并正规化。
- `platform``arch` 是请求值正规化后的回显。
- `package` 是匹配平台的单个包,只有 `url``sha256` 都非空才返回,否则为 `null`
- 包对象的键为 `url/sha256/size/filename/type``:272-280`)。
- `can_install = has_update && url 非空 && sha256 非空`
- `force` 只有存在更新、命中强制策略并且有可安装包时才为 true。
控制器的 `data()` 最终封装为 `{code, show, msg, data}``server/app/common/controller/BaseLikeAdminController.php:50-60``server/app/common/service/JsonService.php:71-91`)。app 的 `ApiClient``code == 1` 返回 envelope 中的 `data``app/src/doctor_workstation/services/api_client.py:504-542`),因此 `parse_update_offer()` 收到的就是上面列出的 `data` 对象,而不是整个 envelope。
## 4. 与 app 客户端契约逐字段对照
| 语义 | server 实际输出 | app 实际读取 | 是否一致 |
|---|---|---|---|
| 最新版本 | `latest_version` | `data.get("latest_version")` | 一致(`DesktopWorkstationLogic.php:95`; `app_update.py:174-181` |
| 平台 | `platform` | `data.get("platform")` | 一致(server `:99`; app `:147-150, 176-183` |
| 架构 | `arch` | `data.get("arch")` | 一致(server `:100`; app `:147-150, 176-183` |
| 安装包 | `package` object/null | `data.get("package")` | 一致(server `:101`; app `:151-153` |
| 下载地址 | `package.url` | `package_payload.get("url")` | 一致(server `:275`; app `:154, 166-173` |
| SHA-256 | `package.sha256` | `package_payload.get("sha256")` | 一致(server `:276`; app `:155, 184-188` |
| 包类型 | `package.type` | `package_payload.get("type")` | 一致(server `:279`; app `:157-173` |
| 文件大小 | `package.size` | `package_payload.get("size")` | 一致(server `:277`; app `:158-173` |
| 文件名 | `package.filename` | `package_payload.get("filename")` | 一致(server `:278`; app `:156-173` |
| 可安装 | `can_install` | `data.get("can_install")` + 客户端二次校验 | 一致但客户端更严格(server `:85-102`; app `:184-200` |
客户端只认可 `archive` / `inno_setup`,且 Inno 只允许 Windows;它还要求 SHA-256
严格为 64 位小写十六进制、响应平台/架构必须与请求一致(`app_update.py:162-200`)。对于
`inno_setup`,下载 URL 还必须是 HTTPS(localhost 调试例外),随后下载内容要通过 SHA-256、size、`.exe` 后缀和 PE `MZ` 头校验(`:287-300, 360-397`)。UI 根据 `package.type` 分流:`inno_setup` 直接走 Windows 安装器,`archive` 则按 ZIP 解压(`app/src/doctor_workstation/ui/dialogs/app_update.py:423-449`)。
现有自动化也明确锁定了这个嵌套契约:server contract test 要求 Windows 包返回
`package.type == inno_setup``server/tests/DesktopWorkstationUpdateContractTest.php:34-62`);app test 用
`package.{url,sha256,size,filename,type}` 构造响应并验证接收(`app/tests/test_app_update.py:66-90`)。本轮实跑:
```text
php server/tests/DesktopWorkstationUpdateContractTest.php PASS
uv run pytest app/tests/test_app_update.py -q PASS (21 tests)
```
## 5. 根因候选(按优先级)
### A. `latest_version` 与实际安装包版本不一致(当前工作区已有直接证据)
当前版本源是 `1.2.0`,但现有 EXE/ZIP、SHA256SUMS 和冻结 exe 都是 `1.1.0`。如果截图中的管理端配置已经把最新版本发布为 `1.2.0`,当前 `1.1.0` 包不能作为它的安装包。表现为下载、安装可能成功,但应用重启后仍是旧版本并再次提示更新。
### B. 线上响应使用 `package_type` / `download_url` 扁平字段
当前 app 没有这两个 wire key 的兼容读取,仓库内也没有生成它们的 server 代码。如果截图中的实际网络响应是例如:
```json
{"latest_version":"1.2.0","package_type":"inno_setup","download_url":"...","sha256":"..."}
```
app 会因为没有 `package.url` 而得到 `package=None`,最终 `can_install=false`;即使把包放在
`package` 中但只给 `package_type`,客户端也会默认当成 `archive`,对 EXE 执行 ZIP 解压并失败。该情形应视为明确的协议不一致。
### C. admin / server / app 部署版本分叉,或 PHP OPcache 未刷新
Git 历史显示提交 `43e5411b6a8d2e625140c5dca8ddeb8492ba7daa` 才同步把
`type=inno_setup` 加入 admin、server 和 app。它之前的 server 会在保存/读取包时丢掉 `type`
因此“管理页面已有 Inno Setup 下拉框,但 check 响应没有 `package.type`”最符合部分部署或旧 PHP 代码仍在运行,而不是当前源码的逻辑错误。
### D. SHA-256 非空但无效,server 与 app 的可安装判定强度不同
`evaluate()` 只检查 URL/哈希非空;app 要求恰好 64 位十六进制。通过正常管理端保存不会发生,因为 validator 会拦截;但旧数据、手工改库、另一服务写入配置时,可能出现 server 返回 `can_install=true`、app 最终降级为不可安装。
### E. 相对上传路径在 server 输出时被扩成 HTTP
validator 只对输入字符串显式以 `http://` 开头的 Inno URL 拒绝;`uploads/...` 相对路径可通过。响应时 `FileService::getFileUrl()``request()->domain()` 补域名。如果生产位于 HTTPS 反向代理后但 PHP 未正确识别代理协议,响应可能变成 `http://...`。app 会安全地拒绝自动执行这个 EXE。若截图中的 `package.url` 为 HTTP,应核对反向代理的 forwarded proto / trusted proxy 配置,而不是放宽客户端安全校验。
## 6. 建议修复与验证顺序
1. **先重新打 1.2.0 正式包再发布。** 保持 `app/src/doctor_workstation/__init__.py`、EXE 的 FileVersion/ProductVersion、安装包文件名、管理端 `latest_version` 四者全部为 `1.2.0`;从新生成的 `SHA256SUMS.txt` 复制 EXE 对应哈希,不要复用 1.1.0 的值。
2. **直接抓线上 check 响应。** 用与 app 一样的参数请求:
`GET /adminapi/setting.desktop_workstation/check?current_version=1.1.0&platform=windows&arch=x64`。确认有效数据位于 `data`,并且字段精确为 `data.package.url/type/sha256`
3. **如果看到 `download_url/package_type`,统一契约。** 首选修 server 采用当前仓库的嵌套结构并整体部署;若必须兼容历史服务,可在 app 解析层短期接受别名,但 canonical 输出仍应只有 `package.{url,type,...}`,并补契约测试。
4. **如果 `package.type` 缺失,做完整部署并清 OPcache。** 同时部署 admin 静态资源、PHP controller/logic/validator 和新 app;不要只替换管理页面。
5. **核验 URL 与哈希。** `package.url` 必须是客户端可达的 HTTPS 绝对地址;下载文件 SHA-256 必须与 `package.sha256` 完全一致,size 若填写也必须一致。
6. **补一条跨端端到端 fixture。** 固化一个 Windows `inno_setup` 响应,既让 PHP `evaluate()` 产出 JSON,也让 Python `parse_update_offer()` 消费同一 fixture;另外增加扁平别名必须被拒绝(或在决定兼容后明确接受)的测试,避免字段名再次漂移。
## 最小正确发布样例
管理端保存体中的 Windows 部分:
```json
{
"enabled": 1,
"latest_version": "1.2.0",
"min_version": "",
"force_update": 0,
"title": "医生工作站 1.2.0",
"notes": "...",
"packages": {
"windows_x64": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"sha256": "<新 1.2.0 EXE 的 64 位 SHA-256>",
"size": 0,
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"type": "inno_setup"
},
"macos_arm64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"},
"macos_x64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"}
}
}
```
不要把同一内容改名为顶层 `download_url` / `package_type`;当前 app 不消费该形态。