234 lines
15 KiB
Markdown
234 lines
15 KiB
Markdown
# 医生工作站版本发布配置与更新 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-256,SHA-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 不消费该形态。
|