# 医生工作站版本发布配置与更新 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 不消费该形态。