diff --git a/cloud_version.go b/cloud_version.go new file mode 100644 index 0000000..19de0c1 --- /dev/null +++ b/cloud_version.go @@ -0,0 +1,52 @@ +// cloud_version.go — t103 daemon 雲端版本偵測。 +// 每輪 GET {cypher_url}/health 取 bundle_version,比對最低相容日期, +// 過舊時 status.json 記錄,托盤顯示更新入口。 +package main + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +// minCloudBuilt 是雲端最低相容建置日期(YYYY-MM-DD)。 +// 雲端契約變更(新 API/schema 上線)時手動升此常數;daemon 下一輪自動偵測並提示更新。 +const minCloudBuilt = "2026-07-28" + +// fetchCloudVersion 可在測試中替換為 stub,避免真實網路呼叫拖慢測試。 +var fetchCloudVersion = fetchBundleVersion + +// fetchBundleVersion GET {cypherURL}/health 取 bundle_version。 +// 5s timeout;失敗(逾時、連不上、非 JSON)靜默回 ("", false)——呼叫端不判定過舊。 +func fetchBundleVersion(cypherURL string) (version string, ok bool) { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(strings.TrimSuffix(cypherURL, "/") + "/health") + if err != nil { + return "", false + } + defer resp.Body.Close() + var payload struct { + BundleVersion string `json:"bundle_version"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&payload); err != nil { + return "", false + } + return payload.BundleVersion, true +} + +// cloudVersionStale 判斷雲端是否需要更新。 +// checkOK=false(/health 不可達)回 false(靜默不判定)。 +// bundle_version 空(老實例)或日期部分 < minCloudBuilt 回 true。 +func cloudVersionStale(version string, checkOK bool) bool { + if !checkOK { + return false + } + if version == "" { + return true + } + // bundle_version 格式:YYYY-MM-DD+(或純 YYYY-MM-DD) + date := strings.SplitN(version, "+", 2)[0] + return date < minCloudBuilt +} diff --git a/cloud_version_test.go b/cloud_version_test.go new file mode 100644 index 0000000..22b295b --- /dev/null +++ b/cloud_version_test.go @@ -0,0 +1,50 @@ +// cloud_version_test.go — t103 雲端版本判定函式 4 案單元測試。 +package main + +import ( + "os" + "testing" +) + +// TestMain 在所有測試執行前把 fetchCloudVersion 換成 stub(回 ("", false)), +// 避免各測試的假 httptest.Server 或離線環境因真實 /health 呼叫炸掉測試。 +// TestCloudVersionStale 直接測 cloudVersionStale 邏輯,不受此 stub 影響。 +func TestMain(m *testing.M) { + fetchCloudVersion = func(string) (string, bool) { return "", false } + os.Exit(m.Run()) +} + +// TestCloudVersionStale 驗證四種判定情境:空版本/過舊/正常/解析失敗。 +func TestCloudVersionStale(t *testing.T) { + cases := []struct { + name string + version string + checkOK bool + wantStale bool + }{ + { + // 老實例:/health 可達但 bundle_version 為空字串 → 需要更新 + name: "空版本(老實例)", version: "", checkOK: true, wantStale: true, + }, + { + // 日期早於 minCloudBuilt → 需要更新 + name: "過舊日期", version: "2026-07-27+abc1234", checkOK: true, wantStale: true, + }, + { + // 日期等於 minCloudBuilt → 正常(剛好達標) + name: "正常(剛好達標)", version: "2026-07-28+abc1234", checkOK: true, wantStale: false, + }, + { + // /health 不可達(解析失敗/逾時)→ 靜默不判定 + name: "解析失敗(/health 不可達)", version: "", checkOK: false, wantStale: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := cloudVersionStale(tc.version, tc.checkOK) + if got != tc.wantStale { + t.Errorf("cloudVersionStale(%q, %v) = %v,want %v", tc.version, tc.checkOK, got, tc.wantStale) + } + }) + } +} diff --git a/cmd/arcrun-tray/main.go b/cmd/arcrun-tray/main.go index 985f787..952d460 100644 --- a/cmd/arcrun-tray/main.go +++ b/cmd/arcrun-tray/main.go @@ -90,6 +90,28 @@ type traySyncStatus struct { Failures []trayExtFail `json:"failures,omitempty"` ExtractorOK bool `json:"extractor_ok"` ExtractorError string `json:"extractor_error,omitempty"` + // t103:雲端版本偵測 + CloudVersion string `json:"cloud_version,omitempty"` // collector 每輪 GET /health 取得的 bundle_version + CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才 true;false 不判定(靜默) +} + +// ── t103 雲端版本偵測 ────────────────────────────────────────────────────────── + +// trayMinCloudBuilt 是雲端最低相容建置日期;與 collector/cloud_version.go 的 minCloudBuilt 保持同值。 +// 雲端契約變更時兩處一起升。 +const trayMinCloudBuilt = "2026-07-28" + +// trayCloudVersionStale 判斷雲端是否需要更新(同 collector/cloudVersionStale 邏輯)。 +// checkOK=false(/health 不可達)回 false(靜默不判定)。 +func trayCloudVersionStale(version string, checkOK bool) bool { + if !checkOK { + return false + } + if version == "" { + return true + } + date := strings.SplitN(version, "+", 2)[0] + return date < trayMinCloudBuilt } type trayExtFail struct { @@ -665,6 +687,14 @@ func main() { }) items = append(items, failItem) } + // t103:雲端版本過舊(或老實例)時提示用戶更新,點了開瀏覽器到安裝頁。 + if trayCloudVersionStale(sync.CloudVersion, sync.CloudCheckOK) { + items = append(items, fyne.NewMenuItem("⚠ 知識庫需要更新(點我)", func() { + if u, err := neturl.Parse("https://install.arcrun.dev/"); err == nil { + _ = a.OpenURL(u) + } + })) + } items = append(items, fyne.NewMenuItemSeparator()) for _, f := range cfg.Folders() { folder := f // capture diff --git a/direct.go b/direct.go index 2329f43..a35c6dd 100644 --- a/direct.go +++ b/direct.go @@ -355,10 +355,14 @@ func RunDirectOnce(cfg *DirectConfig, dryRun bool) ([]DirectResult, int, *Trigge // t91:每輪寫狀態檔(只有 extractor 模式才有意義的計數;direct 雲端萃模式 extracted_ok=0)。 if !dryRun && cfg.Manifest != "" { + // t103:每輪順手 GET /health 取雲端版本(5s timeout,失敗靜默)。 + cloudVer, cloudOK := fetchCloudVersion(cfg.CypherURL) st := SyncStatus{ LastSync: time.Now().Format(time.RFC3339), ExtractorOK: extractorOK, ExtractorError: extractorError, + CloudVersion: cloudVer, + CloudCheckOK: cloudOK, } if cfg.Extractor != "" { for _, r := range results { diff --git a/sync_status.go b/sync_status.go index d8c2552..4c15b9f 100644 --- a/sync_status.go +++ b/sync_status.go @@ -17,6 +17,9 @@ type SyncStatus struct { Failures []ExtractFail `json:"failures,omitempty"` // 失敗清單(路徑+白話原因) ExtractorOK bool `json:"extractor_ok"` // 萃取器本身是否就緒(預檢) ExtractorError string `json:"extractor_error,omitempty"` // 未就緒的白話原因 + // t103:雲端版本偵測(每輪 GET /health) + CloudVersion string `json:"cloud_version,omitempty"` // bundle_version 回傳值(空=老實例) + CloudCheckOK bool `json:"cloud_check_ok"` // /health 可達才為 true;false 代表網路失敗,不判定過舊 } // ExtractFail 記一筆萃取失敗(路徑+白話原因)。