15 KiB
医生工作站版本发布配置与更新 API 诊断
结论
当前仓库中,admin、server、app 三端的现行契约是一致的,但字段名不是扁平的
package_type / download_url。正式 wire contract 是:
{
"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 配置项保存:
enabledlatest_versionmin_versionforce_updatetitlenotespackages
证据为 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)。因此数据库中的逻辑形态是:
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)。
客户端发送:
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)。本轮实跑:
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 代码。如果截图中的实际网络响应是例如:
{"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.2.0 正式包再发布。 保持
app/src/doctor_workstation/__init__.py、EXE 的 FileVersion/ProductVersion、安装包文件名、管理端latest_version四者全部为1.2.0;从新生成的SHA256SUMS.txt复制 EXE 对应哈希,不要复用 1.1.0 的值。 - 直接抓线上 check 响应。 用与 app 一样的参数请求:
GET /adminapi/setting.desktop_workstation/check?current_version=1.1.0&platform=windows&arch=x64。确认有效数据位于data,并且字段精确为data.package.url/type/sha256。 - 如果看到
download_url/package_type,统一契约。 首选修 server 采用当前仓库的嵌套结构并整体部署;若必须兼容历史服务,可在 app 解析层短期接受别名,但 canonical 输出仍应只有package.{url,type,...},并补契约测试。 - 如果
package.type缺失,做完整部署并清 OPcache。 同时部署 admin 静态资源、PHP controller/logic/validator 和新 app;不要只替换管理页面。 - 核验 URL 与哈希。
package.url必须是客户端可达的 HTTPS 绝对地址;下载文件 SHA-256 必须与package.sha256完全一致,size 若填写也必须一致。 - 补一条跨端端到端 fixture。 固化一个 Windows
inno_setup响应,既让 PHPevaluate()产出 JSON,也让 Pythonparse_update_offer()消费同一 fixture;另外增加扁平别名必须被拒绝(或在决定兼容后明确接受)的测试,避免字段名再次漂移。
最小正确发布样例
管理端保存体中的 Windows 部分:
{
"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 不消费该形态。