Files

277 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package update
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/consts"
)
const (
githubRepo = "qianfree/team-api"
githubAPIBase = "https://api.github.com/repos/" + githubRepo
redisCacheKey = "update:check_result"
redisCacheTTL = 24 * time.Hour // default TTL, overridden by config
)
// CheckForUpdate checks GitHub Releases for a newer version
func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) {
// Try Redis cache first (unless force)
if !force {
cached := getCheckCache(ctx)
// 缓存里的 current_version 与当前二进制版本不一致时(通常发生在刚升级后),
// 视为失效并 fallthrough 重算,避免旧缓存里的 has_update 在"相同版本"下误报更新
if cached != nil && cached.CurrentVersion == consts.Version {
return cached, nil
}
}
// Call GitHub API(force 时不带 ETag 条件请求,见 fetchLatestRelease 注释)
release, etag, err := fetchLatestRelease(ctx, !force)
if err != nil {
return nil, fmt.Errorf("failed to fetch latest release: %w", err)
}
if release == nil {
// 304 Not Modified — GitHub 最新发行版未变化,复用缓存的发行版信息
cached := getCheckCache(ctx)
if cached != nil {
// 二进制版本与缓存一致:直接返回缓存
if cached.CurrentVersion == consts.Version {
return cached, nil
}
// 二进制刚升级(缓存里仍是旧版本):基于当前版本重算 hasUpdate 并覆盖缓存,
// 否则旧缓存里 has_update=true 会让小圆点在"相同版本"下也误亮
refreshed := *cached
refreshed.CurrentVersion = consts.Version
hasUpdate, cmpErr := compareVersions(consts.Version, cached.LatestVersion)
if cmpErr != nil {
// latest_version 无法解析(理论上不会发生,缓存写入时已校验),保守沿用旧值
hasUpdate = cached.HasUpdate
}
refreshed.HasUpdate = hasUpdate
refreshed.CheckedAt = gtime.Now()
setCheckCache(ctx, &refreshed)
manager.checkResult.Store(&refreshed)
return &refreshed, nil
}
return &CheckResult{
CurrentVersion: consts.Version,
LatestVersion: consts.Version,
HasUpdate: false,
CheckedAt: gtime.Now(),
DeploymentMode: GetDeploymentMode(),
}, nil
}
latestStr := strings.TrimPrefix(release.TagName, "v")
// 判断是否有更新:dev 构建或无法解析的当前版本保守按"有更新"处理,避免漏报
hasUpdate, err := compareVersions(consts.Version, latestStr)
if err != nil {
return nil, err
}
// Find matching asset for current platform
assetName := getPlatformAssetName(latestStr)
var downloadURL, checksumURL string
var assetSize int64
for _, asset := range release.Assets {
// 资产名可能带/不带 v 前缀(历史 release 命名不一致),归一化后比较
if normalizeAssetName(asset.Name) == assetName {
downloadURL = asset.BrowserDownloadURL
assetSize = asset.Size
}
if asset.Name == "checksums-sha256.txt" {
checksumURL = asset.BrowserDownloadURL
}
}
result := &CheckResult{
CurrentVersion: consts.Version,
LatestVersion: latestStr,
HasUpdate: hasUpdate,
ReleaseNotes: release.Body,
ReleaseURL: release.HTMLURL,
PublishedAt: release.PublishedAt,
CheckedAt: gtime.Now(),
DeploymentMode: GetDeploymentMode(),
DownloadURL: downloadURL,
ChecksumURL: checksumURL,
AssetSize: assetSize,
}
// Store ETag for conditional requests
manager.lastETag.Store(etag)
// Cache result。「有更新但没匹配到平台安装包」的结果不写 Redis:TTL 24h 太长,
// 会挡住发布窗口期之后的检查拿到完整资产列表(内存结果照常存,供状态展示)
if !HasUpdateButNoAsset(result) {
setCheckCache(ctx, result)
}
manager.checkResult.Store(result)
return result, nil
}
// updateAPIBase 返回更新检查的 API 基地址。默认 GitHub 官方 API;
// 可通过配置 update.api_base 覆盖(自建发布服务器 / 本地测试 / 国内镜像)。
func updateAPIBase(ctx context.Context) string {
if base := g.Cfg().MustGet(ctx, "update.api_base").String(); base != "" {
return strings.TrimSuffix(base, "/")
}
return githubAPIBase
}
// fetchLatestRelease calls the GitHub Releases API.
// conditional 为 true 时携带 If-None-Match ETag 做条件请求(省配额);
// 强制检测(force)必须传 false:GitHub /releases/latest 有 CDN 缓存,刚发布新版本后
// 条件请求可能返回过期的 304 Not Modified,导致强制检测仍拿不到最新发行版。
func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease, string, error) {
url := updateAPIBase(ctx) + "/releases/latest"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
// Add ETag for conditional request
if conditional {
if etagVal := manager.lastETag.Load(); etagVal != nil {
if etag, ok := etagVal.(string); ok && etag != "" {
req.Header.Set("If-None-Match", etag)
}
}
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
etag := resp.Header.Get("ETag")
if resp.StatusCode == http.StatusNotModified {
return nil, etag, nil // 304 — no change
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, etag, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body))
}
var release GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, etag, fmt.Errorf("failed to decode release: %w", err)
}
// Skip drafts and prereleases
if release.Draft || release.Prerelease {
return nil, etag, nil
}
return &release, etag, nil
}
// HasUpdateButNoAsset 判断检查结果是否为「有更新但当前平台没匹配到安装包」。
// 典型成因:发行版刚发布、CI 尚未上传资产的窗口期拉到的结果(资产几分钟后才就位)。
// 这类结果不可信:不能写缓存,已入库的脏缓存读到后按无效处理,
// 执行更新时则强制重查,而不是直接报「没有可用的更新包」。
func HasUpdateButNoAsset(r *CheckResult) bool {
return r != nil && r.HasUpdate && r.DownloadURL == ""
}
// getCheckCache reads cached check result from Redis.
// 缓存是纯优化(TTL 24h),Redis 未配置/不可用时应降级为无缓存、走实时检查,
// 绝不能因 g.Redis() 客户端创建失败而 panic(见 goframe-conventions.md 已修复记录)。
func getCheckCache(ctx context.Context) (out *CheckResult) {
defer func() {
if r := recover(); r != nil {
g.Log().Warningf(ctx, "update check cache read skipped: %v", r)
out = nil
}
}()
val, err := g.Redis().Do(ctx, "GET", redisCacheKey)
if err != nil || val.IsNil() || val.IsEmpty() {
return nil
}
var result CheckResult
if err := json.Unmarshal(val.Bytes(), &result); err != nil {
return nil
}
// 「有更新但没匹配到平台安装包」的脏缓存(发布窗口期写入,含旧版本遗留数据)按无效处理,
// 让调用方 fallthrough 到实时检查拿完整资产列表
if HasUpdateButNoAsset(&result) {
return nil
}
return &result
}
// setCheckCache stores check result in Redis. 与 getCheckCache 同理,失败仅降级不 panic。
func setCheckCache(ctx context.Context, result *CheckResult) {
defer func() {
if r := recover(); r != nil {
g.Log().Warningf(ctx, "update check cache write skipped: %v", r)
}
}()
data, err := json.Marshal(result)
if err != nil {
return
}
ttl := int64(redisCacheTTL.Seconds())
// TODO: read from config update_check_interval_hours
g.Redis().Do(ctx, "SETEX", redisCacheKey, ttl, string(data))
}
// normalizeSemver 将构建期注入的版本字符串规整为合法 semver。
// Makefile 通过 git describe 生成的版本形如 "0.2.0_39"(v0.2.0 之后 39 个提交),
// 其中的下划线在 semver 中非法,此处转为构建元数据 "+"("0.2.0+39"),
// 比较时与基础版本等价,不会误报更新。
func normalizeSemver(v string) string {
v = strings.TrimPrefix(v, "v")
if i := strings.IndexByte(v, '_'); i > 0 {
v = v[:i] + "+" + v[i+1:]
}
return v
}
// compareVersions 判断 currentVersion 是否落后于 latestVersion(即是否有新版本可用)。
// 判定规则与历史行为保持一致:
// - dev 构建,或当前版本无法解析为合法 semver 时,保守返回 (true, nil):
// 只要有远端发行版即提示更新,宁可误报也不漏报。
// - latestVersion 无法解析时返回错误(正常情况下缓存写入时已校验过),
// 调用方决定降级策略。
func compareVersions(currentVersion, latestVersion string) (bool, error) {
if currentVersion == "dev" {
return true, nil
}
currentVer, err := semver.NewVersion(normalizeSemver(currentVersion))
if err != nil {
// 当前版本无法解析,按开发构建处理
return true, nil
}
latestVer, err := semver.NewVersion(strings.TrimPrefix(latestVersion, "v"))
if err != nil {
return false, fmt.Errorf("invalid latest version %q: %w", latestVersion, err)
}
return latestVer.GreaterThan(currentVer), nil
}