FR-167 · 技術規格 · 2026-11-01
主系統「系統設定 → 更新中心」的後端端點、status 回傳形狀、權限、排程、下載與更新單、錯誤碼與事件碼、容器掛載,以及前端畫面怎麼讀它們
這份寫給要改更新中心前後端的工程師。每個欄位與數字都對回程式檔與行號(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。
更新中心是主系統的一頁設定頁,做五件事:問原廠更新站有沒有新版、下載並驗證更新包、寫「更新單」交給主機、顯示主機升級進度、管 Agent 跟進。
🔴 主系統不碰 docker、不跑升級。主系統容器只做兩件事:把更新包放進 incoming/、把更新單寫進 orders/。真正換版的是主機上的 systemd 單元與 guidantai upgrade;主機把進度寫成檔案,主系統每分鐘讀一次回寫紀錄。主機不打主系統 API,主系統也不呼叫主機(單向:主系統寫單、主機寫檔)。
%%{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 -->|"寫 <job_id>.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 就地組 |
| 能力點 | 誰預設有 | 能做什麼 |
|---|---|---|
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(全站排程提醒,只回時間與版號) |
守門分兩層:
@jwt_required → @require_capability(...)(product_update_route.py:31-32)。不掛 require_license——product-update 列在 common/authz/license.py:127 的基礎設施模組,授權過期仍能升級。_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)。
前綴一律 /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。
ProductUpdateStatusRoute(product_update_route.py:37)→ ProductUpdateAppService.status()(product_update_app_service.py:131-151)。這支有副作用:先呼叫 order.after_switch(progress, recheck=False)——主機剛換接完成時,同步紀錄、清掉舊版本那次檢查,不等每分鐘排程(不重問更新站,免得卡住請求)。
| 欄位 | 型別 | 含義 | 來源 |
|---|---|---|---|
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 |
last_check 與 availablelast_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)。
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。
%%{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)。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。
| 項目 | 內容 |
|---|---|
| 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) |
| 項目 | 內容 |
|---|---|
| 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)。
| 項目 | 內容 |
|---|---|
| Request | {"enabled": bool}(必填) |
| Response | {"enabled": bool} |
| 錯誤 | 寫設定失敗 → 500 PRODUCT_UPDATE_500001 |
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):
last_check 存的簽好清單逐段重驗:每段比前一段新、前一段夠格直升它(不信更新站排的順序)。package_downloader.py:41、95-100)。/v1/download-url 拿短效預簽網址,只接受 DOWNLOAD_HOSTS 白名單主機;下到 incoming/guidant-ai-<版>.tar.gz.part,Range 續傳,斷線最多重試 5 次、每次重拿網址(package_downloader.py:39、190-227)。.release.json(簽好的信封原文)與 .tar.gz.sha256,最後才把 .part 改名成 .tar.gz——主機看到 .tar.gz 時旁邊兩檔一定在。ops_sha256 時另下維運指令小檔 guidant-ai-<版>.ops.tar.gz;拿不到不算失敗。failed、包已刪(驗不過)或 .part 保留(斷線)。ready、記實測下載速率(PRODUCT_UPDATE/download_rate),接著:這列是「更新到 X」建的就自動寫更新單(§5.6);否則寫一張 type=preflight 預檢單,就緒卡片就有預檢結果。主機連不到外網時,客戶向原廠取得單一離線檔 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(紀錄頁看得到、畫面給「重新上傳」而不是重試)。成功後同樣寫一張預檢單。
| 欄位 | 型別 | 說明 |
|---|---|---|
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 同欄位):
{"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/ 且用本機印樣驗得過、版號真的比現行新。單上不接受任何路徑。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/ 等)。| 狀況 | 行為 |
|---|---|
單還在 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"}。
只對 rolled_back、skipped 有效(否則 412 412007)。用 incoming/ 裡同一個包從預檢重跑,不重下載;只跑比現行版新的那幾段(跨版第二段失敗停在中間版,重試從中間版接著跑)。沒有剩下的段 → 412005。mode 固定 now,Agent 跟進的勾選沿用上次。
| 端點 | 行為 | 錯誤 |
|---|---|---|
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)。
| 項目 | 內容 |
|---|---|
| 守門 | 只 @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)。
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 請求也會同步紀錄,它先收成終態,排程就看不到變化、永遠不報;所以改看「單是終態且沒報過」。
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)。
| 來源 | 間隔 | 規則 |
|---|---|---|
| 更新中心 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 次 |
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)。
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 條,與後端一一對應)。
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 |
連不到原廠更新站(封閉網路的常態) |
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 記著)。跨版升級只看得到最後一段的步驟。
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(預設空字串=沒設站,只能離線更新)。
建表 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。