---
title: "更新中心 API 規格 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 技術規格 · 2026-11-01"
h1: "更新中心 API"
subtitle: "主系統「系統設定 → 更新中心」的後端端點、status 回傳形狀、權限、排程、下載與更新單、錯誤碼與事件碼、容器掛載，以及前端畫面怎麼讀它們"
lede: "這份寫給要改更新中心前後端的工程師。每個欄位與數字都對回程式檔與行號（BE `api/product_update/`、`app/product_update/service/`、`core/scheduler_update.py`；FE `src/views/product-update/`）。Agent 跟進的細節另見 `agent-rollout-spec.md`，主機上的升級指令另見 `upgrade-runner-spec.md`。"
---

## 1. 範圍與分工 {#scope nav="範圍"}

更新中心是主系統的一頁設定頁，做五件事：問原廠更新站有沒有新版、下載並驗證更新包、寫「更新單」交給主機、顯示主機升級進度、管 Agent 跟進。

🔴 **主系統不碰 docker、不跑升級**。主系統容器只做兩件事：把更新包放進 `incoming/`、把更新單寫進 `orders/`。真正換版的是主機上的 systemd 單元與 `guidantai upgrade`；主機把進度寫成檔案，主系統每分鐘讀一次回寫紀錄。主機不打主系統 API，主系統也不呼叫主機（單向：主系統寫單、主機寫檔）。

```{.mermaid cap="圖 1 — 更新中心的資料通道：主系統只寫 incoming/ 與 orders/，主機只寫進度檔"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
    FE["FE 更新中心頁<br/>UpdateCenter.vue"] -->|"REST /system/product-update/*"| API["guidant-api"]
    API -->|"入列 product_update_download"| WK["guidant-worker"]
    SITE["原廠更新站<br/>/v1/check · /v1/download-url · /v1/report"]
    API -->|"立即檢查"| SITE
    WK -->|"預簽網址下載"| SITE
    WK -->|"驗章＋雜湊後改名"| INC[("upgrade/incoming/<br/>容器 rw")]
    API -->|"寫 &lt;job_id&gt;.json"| ORD[("upgrade/orders/<br/>容器 rw、730")]
    ORD -->|"PathChanged"| UNIT["guidant-upgrade-order.path<br/>→ guidantai upgrade --from-order"]
    UNIT -->|"讀包"| INC
    UNIT -->|"寫"| PROG[("upgrade/upgrade-progress.json<br/>draining · last-switch.json · diag/")]
    PROG -->|"唯讀掛載，每分鐘同步"| API
    PROG -->|"維護頁直讀"| NGX["guidant-fe nginx"]
```

| 層 | 檔案 | 職責 |
|---|---|---|
| Route | `api/product_update/__init__.py:28-47`、`api/product_update/routes/product_update_route.py` | 18 個 Resource、能力點守門、解 request、序列化 |
| Schema | `api/product_update/serializers/product_update.py` | request／response 欄位（下文各表照此） |
| App service | `app/product_update/service/product_update_app_service.py` | 守門（SaaS 要平台管理員）、組 status、合併紀錄 |
| 發現與下載 | `app/product_update/service/product_update_flow.py` | 檢查、通知、背景下載、取消、離線上傳 |
| 更新單 | `app/product_update/service/upgrade_order.py` | 立即／排程／取消／重試／預檢／診斷包、進度檔回寫、回報更新站、補寫 system_logs |
| 驗章與下載 | `package_verifier.py`、`package_downloader.py`、`offline_bundle.py`、`update_site_client.py` | 發布清單驗章、續傳、離線檔拆解、更新站呼叫 |
| 排程 | `core/scheduler_update.py` | 每 6 小時檢查＋啟動一次；每分鐘同步進度檔 |
| Domain／Infra | `domain/product_update/`、`infra/product_update/` | `product_update_jobs`、`agent_update_rollouts` 兩表 |
| DI | `di_containers/product_update/product_update_containers.py` | `ProductUpdateAppService` 只注入 job domain service 與 identity；Agent 跟進的 service 由 `core/plugins/_agent_update.py:19` 就地組 |

## 2. 權限 {#authz nav="權限"}

| 能力點 | 誰預設有 | 能做什麼 |
|---|---|---|
| `system.product-update.read` | 所有 `is_admin=1` 的非系統、非範本角色（migration `2026-10-29-fr167-product-update-capabilities.sql`） | 進頁、`GET status`／`history`／`agents` |
| `system.product-update.update` | 同上 | 其餘所有寫入端點與 `GET diag/<id>` |
| （只驗登入） | 任何登入者 | `GET notice`（全站排程提醒，只回時間與版號） |

守門分兩層：

1. **Route**：`@jwt_required → @require_capability(...)`（`product_update_route.py:31-32`）。不掛 `require_license`——`product-update` 列在 `common/authz/license.py:127` 的基礎設施模組，授權過期仍能升級。
2. **App service**：所有寫入先 `_assert_may_write()`（`product_update_app_service.py:102-105`）——非落地版（SaaS）一律要平台管理員，因為換版是整台機器的事，任何租戶都不准換掉全機共用的版本。

`status.can_update`（`product_update_app_service.py:107-116`）＝持 update 能力點 **且** 通過上面第 2 層。前端用它藏寫入鈕；按鈕本身另掛 `v-can="CAP.SYSTEM_PRODUCT_UPDATE_UPDATE"`（`src/config/capabilities.js:212-213`）。

選單：`ui_routes.name='product-update'`、`/system/product-update`、掛在 `group-system-config` 下、sort 43；`route_capabilities` 綁 read（ALL）與 update（ANY）。

## 3. 端點總覽 {#endpoints nav="端點總覽"}

前綴一律 `/api/1.0`。信封照全站：成功 `{"status": true, "data": …}`；失敗 HTTP 4xx／5xx、`{"error_code", "msg"}`。

| # | 方法 路徑 | 能力點 | FE 常數（`src/config/api/api.js:550-566`） |
|---|---|---|---|
| 1 | `GET /system/product-update/status` | read | `PRODUCT_UPDATE_STATUS` |
| 2 | `GET /system/product-update/history` | read | `PRODUCT_UPDATE_HISTORY` |
| 3 | `POST /system/product-update/check` | update | `PRODUCT_UPDATE_CHECK` |
| 4 | `PUT /system/product-update/auto-check` | update | （FE 目前沒有呼叫） |
| 5 | `POST /system/product-update/download` | update | `PRODUCT_UPDATE_DOWNLOAD` |
| 6 | `DELETE /system/product-update/download` | update | 同上 |
| 7 | `POST /system/product-update/uploads` | update | `PRODUCT_UPDATE_UPLOADS` |
| 8 | `PUT /system/product-update/uploads/<upload_id>/chunks/<index>` | update | `PRODUCT_UPDATE_UPLOAD_CHUNK` |
| 9 | `POST /system/product-update/uploads/<upload_id>/complete` | update | `PRODUCT_UPDATE_UPLOAD_COMPLETE` |
| 10 | `POST /system/product-update/apply` | update | `PRODUCT_UPDATE_APPLY` |
| 11 | `DELETE /system/product-update/apply` | update | 同上 |
| 12 | `POST /system/product-update/retry` | update | `PRODUCT_UPDATE_RETRY` |
| 13 | `POST /system/product-update/diag` | update | `PRODUCT_UPDATE_DIAG` |
| 14 | `GET /system/product-update/diag/<diag_id>` | update | `PRODUCT_UPDATE_DIAG_FILE` |
| 15 | `GET /system/product-update/notice` | 只驗登入 | `PRODUCT_UPDATE_NOTICE` |
| 16～20 | `GET agents`、`POST agents/pause`、`agents/resume`、`agents/push`、`agents/remind-later` | read／update | `PRODUCT_UPDATE_AGENTS*` |

16～20 是 Agent 跟進，契約寫在 `agent-rollout-spec.md` §5。

## 4. GET status {#status nav="status"}

`ProductUpdateStatusRoute`（`product_update_route.py:37`）→ `ProductUpdateAppService.status()`（`product_update_app_service.py:131-151`）。這支有副作用：先呼叫 `order.after_switch(progress, recheck=False)`——主機剛換接完成時，同步紀錄、清掉舊版本那次檢查，不等每分鐘排程（不重問更新站，免得卡住請求）。

### 4.1 頂層欄位

| 欄位 | 型別 | 含義 | 來源 |
|---|---|---|---|
| `current_version` | string\|null | 主系統現行版號 | `get_version_info()` |
| `last_check` | object\|null | 最近一次檢查紀錄（§4.2），已拿掉簽好的清單原文 | ROOT 租戶 `system_configs` `PRODUCT_UPDATE/last_check`，`product_update_flow.py:272-278` |
| `available` | object\|null | 以**目前版本**問到的新版；沒有新版、或那次檢查是升級前做的回 null | `product_update_flow.py:281-289` |
| `download` | object\|null | 下載中才有 `{version, bytes_done, bytes_total}`；`bytes_total` 不知道為 null | `product_update_flow.py:299-306` |
| `auto_check_enabled` | bool | 自動檢查開關，預設 true；false 時畫面要一直提醒 | `product_update_flow.py:93-98` |
| `latest_job` | object\|null | `product_update_jobs` 最新一列（§4.3），`status` 已換成畫面狀態 | `upgrade_order.display_status` |
| `progress` | object\|null | 主機進度檔 `upgrade-progress.json` 原樣；沒有、超過 256KB、不是 JSON 物件都回 null | `product_update_app_service.py:44-64` |
| `draining` | bool | 主機放了停接新工作旗標 `upgrade/draining`；worker 不撿新單、Agent 不派新任務 | `core/maintenance_gate.py:23` |
| `in_flight` | object\|null | 只在 `draining=true` 時有：`{background_jobs, agent_tasks}`，取自進度檔第 2 步的 `running_jobs`／`running_agent_tasks`（主機以超管身分數，不在這裡重查 DB 免得被 RLS 少算） | `product_update_app_service.py:74-82` |
| `can_update` | bool | 見 §2 | |
| `scheduled_at` | string\|null | 最新一列是 `scheduled` 時的 `not_before`（ISO 帶時區） | `product_update_app_service.py:67-71` |
| `last_upgraded_at` | datetime\|null | 最近一筆 `succeeded` 的 `ended_at` | `product_update_app_service.py:153-156` |
| `agent_rollout` | object | `{host_version, counts{pending,dispatched,succeeded,failed,paused}, total, paused}`，只算現行版那一組 | `agent_rollout_service.py:694-697` |

### 4.2 `last_check` 與 `available`

`last_check`（`product_update_flow.py:241-269`）：

| 欄位 | 含義 |
|---|---|
| `at`、`by` | 檢查時間與觸發者（排程為 `system`） |
| `current_version` | 檢查當下的現行版；與 `current_version` 不同時 `available` 一律回 null |
| `ok` | 這次有沒有成功問到 |
| `status` | `none`（沒有新版）／`available`／`error`／`cleared`（換版後被清掉） |
| `error`、`error_code` | 失敗原因白話與錯誤碼（`PRODUCT_UPDATE_412001`、`412003`、`503001`、`400001`），只寫在這裡、不當 HTTP 錯誤拋 |
| `latest`、`path[]` | 最新版與必經版本路徑 |
| `releases[]` | 每段 `{version, size, has_security_fix, min_base_version, issued_at, notes[], security_impact, security_action}` |
| `last_success_at` | 最近一次成功檢查時間（失敗時沿用前一次） |
| `notified_version` | 已寄過新版通知的版號；同一版不重寄 |

`available` 形狀：`{latest, path[], releases[], found_at}`，`releases` 同上。

更新站回的每一段清單都要用本機印樣（`/etc/guidant/release-keys`）驗過才採信，並檢查每段都能從前一段直升（`min_base_version`）。任一段驗不過整個不認，記 `error_code=PRODUCT_UPDATE_400001`（`product_update_flow.py:217-231`）。

### 4.3 `latest_job`（紀錄列）

欄位照 `ProductUpdateJobSchema`（`serializers/product_update.py:8-42`）：`uid`、`job_id`、`from_version`、`to_version`、`path[]`、`status`、`source`（`online`／`offline`）、`downloaded_by`／`applied_by`／`cancelled_by`（login）與對應 `*_name`（暱稱）、`*_at`、`not_before`、`started_at`、`ended_at`、`error`、`with_agents`、`agent_uids[]`、審計四欄與 `*_name`。

`status` 的畫面換算（`upgrade_order.py:192-204`）：DB 是 `scheduled`、時間已到、主機還沒為這個 `job_id` 寫進度檔時，回應換成 `submitted`。**只換回應不寫 DB**，取消與重複按的擋法照舊認 `scheduled`。

```{.mermaid cap="圖 2 — product_update_jobs.status 的狀態流轉（submitted 只出現在回應）"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
    [*] --> downloading: POST download／apply（還沒下載）
    [*] --> ready: 離線上傳 complete
    downloading --> ready: worker 驗章＋雜湊通過
    downloading --> failed: 下載失敗／驗不過（包已刪）
    downloading --> cancelled: DELETE download
    ready --> cancelled: DELETE download
    ready --> scheduled: apply／下載完自動寫單
    scheduled --> ready: DELETE apply（第 0～2 步前）
    scheduled --> running: 進度檔 status=running
    running --> succeeded: 最後一段 succeeded
    running --> rolled_back: failed＋result=rolled_back
    running --> failed: failed／timeout
    running --> skipped: 主機這次不更新（包還在）
    rolled_back --> scheduled: POST retry
    skipped --> scheduled: POST retry
```

- `checking` 在 DB CHECK 內但程式不寫入。
- `failed` 與 `skipped` 的分界：`failed`＝包已刪、下一步是重新下載；`skipped`＝這次沒動、包還在、可重試（`product_update_flow.py:49-51`）。
- 跨版時每段各寫一份進度檔、`job_id` 相同；中間段的 `succeeded` 仍當 `running`，直到 `to` 等於最後一段（`upgrade_order.py:432-447`）。
- 已回 `ready` 的列（管理員取消）不會被主機事後寫的 `skipped` 蓋掉（`upgrade_order.py:450-472`）。

### 4.4 `progress`（主機進度檔）

原樣轉交，欄位由主機 `scripts/installer/lib/progress.sh:116` 決定：`job_id`、`from`、`to`、`path`、`segment{n,of}`（跨版第幾段，非跨版為 null）、`triggered_by{kind,user,mode,at}`、`started_at`、`status`（`running`／`succeeded`／`failed`／`timeout`／`skipped`／`preflight`）、`db`、`prev_images`、`result`（`upgraded`／`rolled_back`／`rollback_failed`／`unchanged`／`passed`／`failed`）、`error`、`rolled_back_at`、`updated_at`、`steps[]`。步驟編號與名稱見 `upgrade-runner-spec.md`。

## 5. 各端點契約 {#endpoint-detail nav="端點細節"}

### 5.1 GET history

| 項目 | 內容 |
|---|---|
| Query | `page`（預設 1，≥1）、`page_size`（預設 20，1～100，超過回 400，不靜默夾小） |
| Response | `data[]`＝紀錄列（§4.3 欄位）＋`target`（`host`／`agent`）；`target=agent` 多 `agent_total`／`agent_succeeded`／`agent_failed`、`host_version`。`meta` 為標準分頁 |
| 合併規則 | 主系統列在 DB 分頁，取前 `page×page_size` 筆；Agent 列（同 `host_version`、同 `created_at` 的一次推送彙整成一列，`agent_rollout_service.py:284-313`）全取；兩邊依 `created_at` 新到舊合併（沒時區當 UTC）後切這一頁。`total`＝主系統總數＋Agent 彙整列數（`product_update_app_service.py:158-165`） |
| Agent 列狀態 | 還有 pending／dispatched → `running`；有 paused → `paused`；有 failed → `failed`；全成功 → `succeeded`（`agent_rollout_service.py:274-281`） |

### 5.2 POST check

| 項目 | 內容 |
|---|---|
| Request | 無 body |
| 行為 | 同步打更新站 `/v1/check`（連線 10 秒、讀取 30 秒，`update_site_client.py:20-21`），驗清單後寫 `last_check`；有新版且 `latest ≠ notified_version` 就寄信給 ROOT 租戶管理員（收件人沿用授權到期通知的 `TenantAdminDirectory`），至少寄出一封才記 `notified_version` |
| Response | `last_check`（§4.2） |
| 錯誤 | 連不到站、沒設站、沒授權、清單驗不過都**不拋**，寫進 `last_check.error_code` |

送出的身分：有更新站身分票只送 `{ticket, fingerprint, current_version}`；沒有才送授權檔原文（`update_site_client.py:99-104`）。

### 5.3 PUT auto-check

| 項目 | 內容 |
|---|---|
| Request | `{"enabled": bool}`（必填） |
| Response | `{"enabled": bool}` |
| 錯誤 | 寫設定失敗 → 500 `PRODUCT_UPDATE_500001` |

### 5.4 POST／DELETE download

**POST**：只下最近一次檢查驗過、且是以目前版本問到的那條路徑。建一列 `downloading`、入列 `product_update_download` 背景工作（`max_attempts=1`，斷線重試在 handler 內做）。回 `{uid, version, path[], status}`（`product_update_flow.py:406-418`）。

| 前置 | 錯誤 |
|---|---|
| `last_check.status=available` 且 `current_version` 相符 | 412 `PRODUCT_UPDATE_412002` |
| 沒有 `downloading`／`ready` 的列 | 409 `PRODUCT_UPDATE_409001` |

**DELETE**：`downloading` 或 `ready`（還沒寫單）的列改 `cancelled`、刪 `incoming/` 裡這條路徑的所有檔。worker 每收 64MB 看一次狀態，看到 `cancelled` 自己停手刪檔。回 `{uid, status:"cancelled"}`。沒有可取消的 → 404 `PRODUCT_UPDATE_404001`。

**下載流程**（worker，`product_update_flow.py:474-513`）：

1. 以 `last_check` 存的簽好清單逐段重驗：每段比前一段新、前一段夠格直升它（不信更新站排的順序）。
2. 空間檢查：剩餘總大小＋2GB（`package_downloader.py:41`、`95-100`）。
3. 每段向 `/v1/download-url` 拿短效預簽網址，只接受 `DOWNLOAD_HOSTS` 白名單主機；下到 `incoming/guidant-ai-<版>.tar.gz.part`，Range 續傳，斷線最多重試 5 次、每次重拿網址（`package_downloader.py:39`、`190-227`）。
4. 下完驗 sha256 與大小；先寫 `.release.json`（簽好的信封原文）與 `.tar.gz.sha256`，最後才把 `.part` 改名成 `.tar.gz`——主機看到 `.tar.gz` 時旁邊兩檔一定在。
5. 清單有 `ops_sha256` 時另下維運指令小檔 `guidant-ai-<版>.ops.tar.gz`；拿不到不算失敗。
6. 驗不過、連線用盡、授權缺 → 列改 `failed`、包已刪（驗不過）或 `.part` 保留（斷線）。
7. 成功 → 列改 `ready`、記實測下載速率（`PRODUCT_UPDATE/download_rate`），接著：這列是「更新到 X」建的就自動寫更新單（§5.6）；否則寫一張 `type=preflight` 預檢單，就緒卡片就有預檢結果。

### 5.5 離線上傳（uploads 三端點）

主機連不到外網時，客戶向原廠取得單一離線檔 `guidant-ai-<版>-offline.tar`（檔內恰好是同版號的更新包與發布清單兩個檔）上傳。

| 端點 | Request | Response | 錯誤 |
|---|---|---|---|
| `POST uploads` | `{total_size ≥1, total_chunks ≥1, manifest?}`；`manifest` 不送＝單一離線檔；送 `release.json` 原文（≤64KB）＝兩檔送法，先驗清單才收 | `{upload_id, version, chunk_max_bytes}` | 400001／400003／400004（清單驗不過）、400006（`total_size` 不等於清單記的大小）、412 `412004`（空間不足）、409 `409001`（已有下載中或就緒） |
| `PUT uploads/<id>/chunks/<n>` | body＝原始位元組，`n` 從 0 起；同一塊可重送覆蓋 | `{index, size}` | 400 `400007`（編號或大小不對）、404 `404002`（上傳不存在） |
| `POST uploads/<id>/complete` | 無 | `{uid, version, status:"ready"}` | 404002、400 `400008`（還有塊沒傳）、400005（雜湊不符）、400 `400012`（不是原廠離線檔）、驗章類 400001／3／4、409001 |

限制（`product_update_flow.py:569-573`）：單塊 ≤50MB、總量 ≤8GB、塊數上限 `ceil(8GB/50MB)`。暫存在 `incoming/.uploads/pkg-*`。空間：兩檔送法要 1 倍包大小，單一離線檔要 2 倍（合併檔與拆出的包同時在磁碟上），再加 2GB 餘裕。

單一離線檔（`product_update_flow.py:660-680`）：先只讀檔內清單驗章，過了才把包抽到系統自產的檔名，再走跟線上下載同一段核雜湊。檔內附的維運指令小檔不符只丟掉它、不拒整份。上傳完但驗不過會留一列 `failed`（紀錄頁看得到、畫面給「重新上傳」而不是重試）。成功後同樣寫一張預檢單。

### 5.6 POST apply（寫更新單）

| 欄位 | 型別 | 說明 |
|---|---|---|
| `mode` | `now`\|`scheduled` | 必填 |
| `at` | AwareDateTime | `scheduled` 必帶，要有時區，1 分鐘後到 60 天內（`upgrade_order.py:53-55`、`134-143`） |
| `with_agents` | bool | 預設 true：主系統升完接著更新 Agent |
| `agent_uids` | string[]（每個 1～50 字、最多 200 個） | `with_agents=true` 時只跟這幾台；空＝全部落後的 |

Response（`ProductUpdateOrderSchema`）：`{uid, job_id, version, path[], status, mode, not_before, with_agents, agent_uids}`。

判斷順序（`upgrade_order.py:279-307`）：

| 最新一列 | 行為 | 錯誤 |
|---|---|---|
| `ready`、由「更新到 X」建、還沒寫單、沒取消沒錯誤 | 擋：worker 正要自動寫單 | 412 `412013` |
| `ready` | 驗包 → 改 `scheduled` 記 `job_id`／`applied_by` → 寫單；寫單失敗改回 `ready` | 412006、400001／3／4／5、500 `500002` |
| `downloading`／`scheduled`／`running` | 擋 | 412 `412005` |
| 其他（沒下載過） | 有可用新版就先建下載單並記下誰按的、排程時間、Agent 勾選，就緒後 worker 接著寫單；排程時間要晚於「現在＋剩餘大小÷最近一次下載速率」（預設 1MB/s） | 412005（沒新版）、412 `412011`（排太近）、409001 |
| — | `at` 不合法 | 400 `400009` |

下載途中排定時間已過，不改成立即：包留在就緒、`error` 記原因讓管理員重選（`upgrade_order.py:342-365`）。

**更新單**（`upgrade_order.py:83-95`，主機端 `scripts/installer/lib/upgrade_order.sh:13-14` 同欄位）：

```json
{"job_id": "20261101-1430-a1b2", "type": "upgrade", "mode": "now",
 "version": "1.27.11", "path": ["1.27.11"], "not_before": null,
 "requested_by": "admin", "requested_at": "2026-11-01T06:30:00+00:00"}
```

- 寫到 `upgrade/orders/<job_id>.json`，先寫 `.<名>.tmp` 再 `os.replace`——path 單元一看到檔就觸發，不能讓主機讀到半份。
- `job_id` 形狀 `YYYYmmdd-HHMM-<4～8 位 hex>`，主機 `ord_valid_job_id` 認同一個樣式。
- `type`：`upgrade`／`preflight`／`diag`。
- 主機只信三件事：`job_id` 與版號的格式、包在本機 `incoming/` 且用本機印樣驗得過、版號真的比現行新。單上不接受任何路徑。
- 主機端單檔上限 8192 bytes（`ORD_MAX_BYTES`），超過不讀。
- `orders/` 對容器是 730（`root:1000`）：能建檔、能刪知道名字的檔，但列不出目錄——取消只能靠紀錄上的 `job_id` 拼檔名。
- 主機 `guidant-upgrade-order.path`（`PathChanged=<資料目錄>/upgrade/orders`）觸發 `guidant-upgrade-order.service` → `guidantai upgrade --from-order`。立即單撿到就刪單、開跑；排程單用 `systemd-run --on-calendar` 排一次性 timer，到點以 `--run` 回來（單留在 `orders/` 等）。

### 5.7 DELETE apply（取消）

| 狀況 | 行為 |
|---|---|
| 單還在 `orders/`（主機還沒撿） | 刪單 |
| 單不在、主機進度檔這個 `job_id` 的最大步驟 < 3 | 寫取消旗標 `orders/<job_id>.cancel`；主機每秒看一次，看到就 TERM runner，進度檔寫 `skipped`／`unchanged` |
| 已到第 3 步（停服務、複製資料庫）以後 | 409 `PRODUCT_UPDATE_409002` |
| 最新一列不是 `scheduled`／`running` | 404 `404001` |

成功後列改 `ready`、記 `cancelled_by`，包保留可重排。回 `{uid, status:"ready"}`。

### 5.8 POST retry

只對 `rolled_back`、`skipped` 有效（否則 412 `412007`）。用 `incoming/` 裡同一個包從預檢重跑，不重下載；只跑比現行版新的那幾段（跨版第二段失敗停在中間版，重試從中間版接著跑）。沒有剩下的段 → 412005。`mode` 固定 `now`，Agent 跟進的勾選沿用上次。

### 5.9 診斷包

| 端點 | 行為 | 錯誤 |
|---|---|---|
| `POST diag` | 寫 `type=diag` 更新單，回 `{diag_id}`（＝`job_id`） | 500002 |
| `GET diag/<diag_id>` | 主機把 `guidantai diag` 產到 `upgrade/diag/<job_id>/guidant-diag-*.tar.gz` 並 chmod 644；有檔回 `application/gzip` 附件，還沒好回 **HTTP 202** `{"ready": false}` | `diag_id` 不是 job_id 形狀 → 404 `404003`；檔是 root 600 讀不到 → 412 `412012` |

FE 每 3 秒問一次、最多 100 次（`src/composables/useProductUpdate.js:14-15`）。

### 5.10 GET notice（全站提醒）

| 項目 | 內容 |
|---|---|
| 守門 | 只 `@jwt_required`，不掛能力點——同 `/version` 是全站公開的系統狀態 |
| Response | `{scheduled_at, version, remind_minutes:[30,5]}`；沒排程時前兩欄 null |
| 陷阱 | 在 `system_context` 內讀最新一列，不可包 `@transaction`：session 開在外面就沿用登入者的 RLS，子租戶的人看不到排在根租戶的那一列（`product_update_app_service.py:228-239`） |

FE `UpdateScheduleReminder.vue` 每 60 秒問一次，開始前 30 分與 5 分各提醒一次（`src/views/product-update/updateNoticeState.js:34-48`）。

## 6. 排程 {#schedule nav="排程"}

`core/scheduler_update.py`。gunicorn `preload_app=True`，排程在 master fork 前建立，只跑一份。

| job id | 觸發 | 做什麼 |
|---|---|---|
| `product_update_check` | 每 6 小時（`CHECK_INTERVAL_HOURS=6`，`:10`） | `run_scheduled_check()`：開關關了或沒設 `UPDATE_SITE_URL` 就不問、不寫 `last_check`；其餘同立即檢查（`check_now("system")`）。只問有沒有新版、有就通知，**不下載** |
| `product_update_check_startup` | 啟動時跑一次（date trigger 不給 run_date） | 同上；新裝或改了更新站網址重啟後當下就有結果 |
| `product_update_order_sync` | 每分鐘 | 依序：`sync_from_progress`（進度檔結果寫回紀錄）→ `after_switch(recheck=True)`（換接後清舊檢查並以新版重查）→ `report_final_once`（終態回報更新站一次）→ `log_steps_once`（每步補寫 system_logs）→ Agent 跟進 `tick` 與回報。各段各自 try，一段出錯不擋其他段（`:39-108`） |

ROOT 租戶 `system_configs` 群組 `PRODUCT_UPDATE` 的鍵：

| key | 用途 |
|---|---|
| `auto_check_enabled` | 自動檢查開關 |
| `last_check` | 最近一次檢查（§4.2，含簽好的清單原文） |
| `download_rate` | `{bytes_per_sec, at}`，估排程夠不夠下完 |
| `last_reported_job` | 已回報更新站的 `job_id`；送不到每分鐘重試，結束超過 1 天放棄 |
| `upgrade_steps_logged_job` | 已補寫 system_logs 的 `job_id` |
| `agent_remind` | Agent 稍後提醒 `{version, until}` |

回報更新站（`upgrade_order.py:486-512`）：`POST /v1/report`，body 為身分＋`{result, from_version, to_version}`，`result` 只收 `succeeded`／`rolled_back`／`failed`／`skipped`。不掛在「狀態有變」那一刻——畫面輪詢的 status 請求也會同步紀錄，它先收成終態，排程就看不到變化、永遠不報；所以改看「單是終態且沒報過」。

## 7. 前端畫面對應 {#fe nav="前端對應"}

### 7.1 `deriveView` 的畫面狀態

`src/views/product-update/productUpdateState.js:73-91`，由上往下第一個成立的勝出：

| view | 後端狀態組合 | 畫面 |
|---|---|---|
| `loading` | 還沒拿到 status | 載入中 |
| `rollback_failed` | `progress.result=rollback_failed` | 紅色失敗，顯示 `sudo guidantai recover`，停輪詢 |
| `running` | `progress.status=running`；或 `latest_job.status=running` 且進度檔沒收手（不是 failed／timeout／skipped） | 「更新中：{from} → {to}」＋步驟列，排空中顯示進行中件數 |
| `submitted` | `latest_job.status=scheduled` 且 `not_before` 已到（後端回 `submitted` 也落這裡）；或 `ready`＋`applied_by` 有值＋`job_id` 空＋沒 `error`＋沒 `cancelled_at`（下載完 worker 正要寫單） | 「正在準備更新到 {to}」五格：已送出 → 驗證更新包 → 已交給主機 → 主機驗包 → 開始升級 |
| `downloading`／`ready`／`scheduled`／`checking` | `latest_job.status` 原值 | 下載進度條（`download`）／就緒卡＋預檢結果／排程時間 |
| `available` | `available.latest` 有值且不等於 `current_version` | 版本說明、「更新到 X」「預先下載」 |
| `up_to_date` | 其餘 | 已是最新，顯示 `last_upgraded_at` |

`effectiveJob`（`:58-64`）：紀錄還寫 `scheduled`／`submitted`／`running`、但同 `job_id` 的進度檔已是 failed／timeout／skipped 的那一分鐘，以進度檔為準換成結果，畫面不會卡在轉圈。成功不在前端換（跨版中間段也寫 succeeded）。

畫面另有一層 `screen`（`UpdateCenter.vue:55-58`）：按下更新後、後端回應前是 `sending`。

失敗另判、不佔主狀態：`lastFailure` 回最新一列是 `rolled_back`／`skipped`／`failed` 的那筆，頁首放失敗說明；`canRetry` 只對 `rolled_back`、`skipped`、或 `failed` 且進度檔 `result=unchanged` 給重試鈕（`:104-117`）。

### 7.2 輪詢

| 來源 | 間隔 | 規則 |
|---|---|---|
| 更新中心 status | `pollIntervalMs`（`productUpdateState.js:30-35`） | 沒有紀錄或紀錄已終態（succeeded／failed／rolled_back／cancelled／skipped）→ 0（不輪詢）；`scheduled` 且 `not_before` 在未來 → 30 秒；其餘 3 秒。看「有沒有沒結束的單」不看畫面狀態——下載完那一刻後端先寫 ready、一秒後才自己寫單，看畫面會停在下載完那格 |
| 主機忙時連不到 | — | view 屬 `scheduled`／`submitted`／`running` 時請求靜默；拿到 502／503／504 或無回應就整頁重載，讓前門送維護頁（`useProductUpdate.js:47-75`） |
| Agent 區 | 3 秒 | 有任一台 `pending`／`dispatched`（`agentRolloutState.js:20`、`useAgentRollout.js:9`） |
| 全站提醒 | 60 秒 | `notice` 端點 |
| 診斷包 | 3 秒 × 100 次 | |

### 7.3 步驟名

`STEP_NAMES`（`productUpdateState.js:15`）＝進度檔步驟 0～7：`self_replace`、`preflight`、`drain`、`db_copy`、`apply_on_copy`、`health`、`switch`、`cleanup`；第 8 步 `rollback` 插在失敗那步之後。白話名走 i18n `lang.product_update.step_<代號>`，與後端 `upgrade_order.STEP_EVENTS`、主機 `progress_step_label` 是同一套，改要三邊一起改。可取消：view 為 `scheduled`／`submitted`，或 `running` 且目前步驟在 `db_copy` 之前（`:212-216`）。

### 7.4 i18n key 對照（`src/config/locales/i18n/zh-tw/product-update.json`）

| 畫面文字 | key |
|---|---|
| 頁首標籤：更新中／更新失敗／有新版本／已是最新 | `tag_updating`／`tag_failed`／`tag_has_update`／`tag_latest` |
| 連不到更新站的提示 | `check_failed` |
| 下載 {version} 中、{version} 已就緒、已排程在 {at} 更新 | `downloading_title`、`ready_title`、`scheduled_title` |
| 正在準備更新到 {to}、更新中：{from} → {to} | `submitted_title`、`running_title` |
| 更新到 {version}、預先下載 | `btn_update_to`、`btn_predownload` |
| 確認視窗「接著更新 Agent」 | `with_agents_label`、`with_agents_on`、`with_agents_off` |
| 必須更新 Agent 的頁首警示 | `required_banner` |

錯誤碼訊息在 `src/config/locales/i18n/zh-tw/error-code.json`（`PRODUCT_UPDATE_*` 共 34 條，與後端一一對應）。

## 8. 錯誤碼 {#errors nav="錯誤碼"}

`common/code/product_update_error_code.py`。HTTP 欄寫「—」的只出現在 `last_check.error_code`，不當 HTTP 錯誤回。

| 代碼 | HTTP | 常數 | 白話 |
|---|---|---|---|
| `PRODUCT_UPDATE_400001` | 400 | `BAD_SIGNATURE` | 發布清單無法用原廠印章驗過（壞檔或不是原廠的）；也記在 `last_check` 表示更新站回的清單驗不過 |
| `PRODUCT_UPDATE_400002` | — | `MANIFEST_EXPIRED` | 保留不拋（發布清單不設有效期），碼不可挪用 |
| `PRODUCT_UPDATE_400003` | 400 | `NOT_NEWER` | 版本沒比目前新，不可降版或重裝；或路徑缺了必經版本 |
| `PRODUCT_UPDATE_400004` | 400 | `MAJOR_MISMATCH` | 主版號不同，換主版號要重新授權 |
| `PRODUCT_UPDATE_400005` | 400 | `CHECKSUM_MISMATCH` | 包的內容與清單記的雜湊不符 |
| `PRODUCT_UPDATE_400006` | 400 | `UPLOAD_SIZE_MISMATCH` | 上傳宣告的大小與清單不符（選錯版的包） |
| `PRODUCT_UPDATE_400007` | 400 | `UPLOAD_CHUNK_INVALID` | 分塊編號或大小不對 |
| `PRODUCT_UPDATE_400008` | 400 | `UPLOAD_INCOMPLETE` | 還有分塊沒傳完就按完成 |
| `PRODUCT_UPDATE_400009` | 400 | `SCHEDULE_INVALID` | 主系統排程時間沒帶時區，或不在 1 分鐘後～60 天內 |
| `PRODUCT_UPDATE_400010` | 400 | `AGENT_NONE_LAGGING` | 勾選的 Agent 都已最新或更新中，或一次超過 200 台 |
| `PRODUCT_UPDATE_400011` | 400 | `AGENT_SCHEDULE_INVALID` | Agent 排程沒帶時區，或不在 10 分鐘後～60 天內 |
| `PRODUCT_UPDATE_400012` | 400 | `OFFLINE_BUNDLE_INVALID` | 上傳的不是原廠離線檔（檔內要恰好是同版號的包與清單） |
| `PRODUCT_UPDATE_404001` | 404 | `NOTHING_TO_CANCEL` | 沒有可取消的下載或更新 |
| `PRODUCT_UPDATE_404002` | 404 | `UPLOAD_NOT_FOUND` | 上傳不存在或已過期 |
| `PRODUCT_UPDATE_404003` | 404 | `DIAG_NOT_FOUND` | 診斷包編號不對 |
| `PRODUCT_UPDATE_404004` | 404 | `AGENT_BUNDLE_NOT_FOUND` | 這台 Agent 現在沒有要下載的包（Agent 下載端點，不區分「沒有」與「不准」） |
| `PRODUCT_UPDATE_409001` | 409 | `BUSY` | 已有下載中或就緒的包，要先取消 |
| `PRODUCT_UPDATE_409002` | 409 | `TOO_LATE_TO_CANCEL` | 服務已停、正在升級，不能取消（失敗會自動退回） |
| `PRODUCT_UPDATE_412001` | — | `SITE_NOT_CONFIGURED` | 沒設原廠更新站，請改用離線更新 |
| `PRODUCT_UPDATE_412002` | 412 | `NO_AVAILABLE_RELEASE` | 沒有可下載的新版，請先檢查 |
| `PRODUCT_UPDATE_412003` | — | `LICENSE_MISSING` | 這台主機還沒開通落地版授權，無法問更新站 |
| `PRODUCT_UPDATE_412004` | 412 | `NO_SPACE` | 更新包存放位置空間不足 |
| `PRODUCT_UPDATE_412005` | 412 | `NOT_READY` | 沒有已就緒的包；或正在下載／排程／升級中 |
| `PRODUCT_UPDATE_412006` | 412 | `PACKAGE_MISSING` | 就緒的包或清單在 `incoming/` 找不到（被刪了） |
| `PRODUCT_UPDATE_412007` | 412 | `NOTHING_TO_RETRY` | 只有退回或這次不更新的能重試 |
| `PRODUCT_UPDATE_412008` | 412 | `AGENT_BUNDLE_MISSING` | 現行版主系統包沒附 Agent 安裝包，無法推 |
| `PRODUCT_UPDATE_412009` | 412 | `AGENT_NOTHING_TO_PAUSE` | 沒有正在推送的 Agent |
| `PRODUCT_UPDATE_412010` | 412 | `AGENT_NOTHING_TO_RESUME` | 沒有已暫停且仍落後的 Agent |
| `PRODUCT_UPDATE_412011` | 412 | `SCHEDULE_TOO_SOON` | 排程太近，依最近下載速率估算來不及下載完 |
| `PRODUCT_UPDATE_412012` | 412 | `DIAG_UNREADABLE` | 診斷包產好了但檔案權限讀不到，請在主機 `sudo guidantai diag` |
| `PRODUCT_UPDATE_412013` | 412 | `ALREADY_SUBMITTING` | 下載完正在自動寫單，請稍候 |
| `PRODUCT_UPDATE_500001` | 500 | `SETTING_SAVE_FAILED` | 更新中心設定（自動檢查、稍後提醒）存不進去 |
| `PRODUCT_UPDATE_500002` | 500 | `ORDER_WRITE_FAILED` | 更新單寫不進 `orders/`，請查主機目錄權限 |
| `PRODUCT_UPDATE_503001` | — | `SITE_UNREACHABLE` | 連不到原廠更新站（封閉網路的常態） |

## 9. system_logs 事件碼 {#events nav="事件碼"}

`common/enum/event_code.py:239-251`。

| 碼 | 名稱 | 何時寫 | 寫在哪 |
|---|---|---|---|
| 6237 | `AGENT_UPDATE_BUNDLE_FETCH` | Agent 每次成功取用 Agent 包（agent_uid、version、size_bytes） | `core/plugins/_agent_update.py:101` |
| 6238 | `AGENT_UPDATE_ROLLOUT_CHANGED` | 管理員暫停／繼續／推選定／稍後提醒（`action`＝`pause`／`resume`／`push`／`remind_later`、actor、結果） | `product_update_app_service.py:275-282` |
| 6239～6247 | `PRODUCT_UPDATE_STEP_SELF_REPLACE`、`_PREFLIGHT`、`_DRAIN`、`_DB_COPY`、`_APPLY_ON_COPY`、`_HEALTH`、`_SWITCH`、`_CLEANUP`、`_ROLLBACK` | 主系統升級每一步一筆（狀態、秒數、起訖、說明），順序＝進度檔步驟 0～8 | `upgrade_order.py:518-559` |
| 6248 | `PRODUCT_UPDATE_RESULT` | 整次升級結果（job_id、from → to、結果、主機狀態、觸發者、錯誤） | `upgrade_order.py:580-583` |

6239～6248 不是即時寫：升級期間服務是停的，主機的 log 只在主機檔案裡。服務回來後由每分鐘排程在紀錄收成終態時補寫，每個 `job_id` 只寫一次（`upgrade_steps_logged_job` 記著）。跨版升級只看得到最後一段的步驟。

## 10. 容器掛載 {#mounts nav="容器掛載"}

`docker/production/docker-compose.yml`。api、socketio、worker 共用 `x-guidant-common`（`:151`），volumes 清單無法在單一 service 追加，所以三個都掛（socketio 掛了不用）。

| 主機路徑 | 容器路徑 | api／socketio／worker | fe | 用途 |
|---|---|---|---|---|
| `${GUIDANT_DATA_DIR}/upgrade` | `/guidant/upgrade` | ro（`:252`） | ro（`:525`） | 進度檔、`draining` 旗標、`last-switch.json`、`bundles/`、`diag/`；fe 的維護頁直讀 `/upgrade-progress.json` |
| `${GUIDANT_DATA_DIR}/upgrade/orders` | `/guidant/upgrade/orders` | rw（`:253`） | — | 更新單；主機目錄 730 `root:1000` |
| `${GUIDANT_DATA_DIR}/upgrade/incoming` | `/guidant/upgrade/incoming` | rw（`:255`） | — | 下載與上傳落點；主機目錄 750 `1000:1000` |
| `/etc/guidant/release-keys` | 同路徑 | ro（`:257`） | — | 發布印樣；主系統驗清單只信這裡 |

🔴 `upgrade:ro` 與 `orders:rw` 兩行都要在：只掛 `orders/` 時 api 讀不到進度檔與旗標，且不報錯（狀態永遠 null、永遠不停接單）。目錄由主機 `scripts/installer/lib/systemd_units.sh:20-29` 建。

設定（`config/config.py:204-209`）：`GUIDANT_UPGRADE_DIR`（預設 `/guidant/upgrade`）、`GUIDANT_RELEASE_KEYS_DIR`（預設 `/etc/guidant/release-keys`）、`UPDATE_SITE_URL`（預設空字串＝沒設站，只能離線更新）。

## 11. 資料表 product_update_jobs {#table nav="資料表"}

建表 `scripts/sql/2026-10-29-fr167-product-update-jobs.sql`，加 `failed` 狀態 `2026-10-29-fr167-product-update-jobs-failed-status.sql`，加 Agent 跟進欄 `2026-10-30-fr167-agent-follow-optional.sql`。ORM `infra/product_update/model/product_update_job.py`。

| 欄位 | 型別 | 說明 |
|---|---|---|
| `id`／`uid` | bigserial／varchar(36) | |
| `tenant_id`／`org_unit_id` | int | RLS 前綴比對＋超管放行，FORCE；紀錄列建在按下載那個人的租戶 |
| `job_id` | varchar(64) unique | 寫更新單時才有；用來把進度檔結果對回這一列 |
| `from_version`／`to_version` | varchar(32) | |
| `path` | jsonb | 必經版本路徑，最後一個＝`to_version`。🔴 entity 預設 `[]`，更新時一定要帶回原值，否則會被蓋成空清單（`product_update_flow.py:426-428`） |
| `status` | varchar(16) | CHECK：`checking`／`downloading`／`ready`／`scheduled`／`running`／`succeeded`／`rolled_back`／`skipped`／`cancelled`／`failed` |
| `source` | varchar(16) | `online`／`offline` |
| `downloaded_by/at`、`applied_by/at`、`cancelled_by/at` | | 各動作的 login 與時間 |
| `not_before`、`started_at`、`ended_at`、`error` | | `error` 取自進度檔，不含密碼與金鑰 |
| `with_agents` | bool，預設 true | |
| `agent_uids` | jsonb，預設 `[]` | |

`cm_app` 只有 SELECT／INSERT／UPDATE，沒有 DELETE。

## 相關文件 {#refs nav="相關"}

- [設計文件](./design.html)：定案與取捨（本文件與 design 不一致時以程式碼與本文件為準）
- [Agent 跟進規格](./agent-rollout-spec.html)
- [主機升級指令規格](./upgrade-runner-spec.html)
- [原廠更新站規格](./update-site-spec.html)
- [發布與簽章規格](./release-signing-spec.html)
- [狀態機](./state-machines.html)
- [時序圖](./sequence-diagrams.html)
