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

15 KiB
Raw Blame History

医生工作站版本发布配置与更新 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_versiondata 下的顶层字段。
  • platformarch 是检测请求参数,同时在 data 下回显规范化后的值;它们不保存在发布配置中。
  • 安装包对象叫 package
  • 安装包类型叫 package.type,不是顶层或同级的 package_type
  • 下载地址叫 package.url,不是 download_url
  • 哈希叫 package.sha256
  • 如果截图或线上响应实际出现的是扁平 package_typedownload_url,当前 app 不会读取这些别名。这不是当前仓库 server 的输出,优先怀疑线上后端/代理为另一版本或只部署了部分提交。

当前工作区还存在一个已确认的发布物版本风险:运行时版本源已经是 1.2.0app/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,每个包固定包含 urlsha256sizefilenametype;其中 type 只允许 archive | inno_setupadmin/src/api/setting/desktop_workstation.ts:3-24)。保存接口是 POST /setting.desktop_workstation/setConfig(同文件 :27-34)。

页面的 Windows 区块来自平台键 windows_x64admin/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.typeEXE 为 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-256index.vue:292-315)。上传成功后只把上传接口返回的 data.uri(次选 data.url)写入包的 url:318-328)。最终点击保存时,页面显式组装三个平台的完整 packages 对象,而不是上传后自动发布(:330-345)。

虽然 API 封装调用写成 request.post({ params }),拦截器会在 POST 且没有 data 时把 params 移入 JSON bodyadmin/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)。因此数据库中的逻辑形态是:

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-280server/app/common/service/FileService.php:42-59, 69-78)。本地 uploads/... 文件还会在缺失/无效时由 server 计算哈希、大小和文件名(DesktopWorkstationLogic.php:309-331)。

3. 检测 API 如何选择包和序列化响应

app 请求的端点是 setting.desktop_workstation/checkapp/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_x64DesktopWorkstationLogic.php:125-153)。也就是说,platform / arch 不是管理端发布字段,而是由客户端运行环境发给检测接口、用于选择 packages.windows_x64 的请求维度。

server 的检测响应由 evaluate() 直接组成(DesktopWorkstationLogic.php:75-103):

  • latest_version 来自已保存的配置并正规化。
  • platformarch 是请求值正规化后的回显。
  • package 是匹配平台的单个包,只有 urlsha256 都非空才返回,否则为 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-60server/app/common/service/JsonService.php:71-91)。app 的 ApiClientcode == 1 返回 envelope 中的 dataapp/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_setupserver/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. 先重新打 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 部分:

{
  "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 不消费该形态。