diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a36fc3c..d55948b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,9 +72,14 @@ jobs: tar -czf ${ARTIFACT}.tar.gz $ARTIFACT fi + - name: Generate checksums + run: | + sha256sum team-api-*.tar.gz team-api-*.zip > checksums-sha256.txt + - name: Upload release asset uses: softprops/action-gh-release@v2 with: files: | team-api-*.zip team-api-*.tar.gz + checksums-sha256.txt diff --git a/Makefile b/Makefile index 7bf4cf0..9feefaf 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ROOT_DIR = $(CURDIR) DEPLOY_NAME = "team-api" DOCKER_NAME = "team-api" -VERSION ?= $(strip $(file < VERSION)) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || cat VERSION 2>/dev/null || echo "dev") LDFLAGS = -X github.com/qianfree/team-api/internal/consts.Version=$(VERSION) # Mirror acceleration (override for non-China regions) @@ -77,7 +77,7 @@ migrate-reset: # Docker commands docker-build: - cd manifest/docker && VERSION=$(VERSION) docker compose build + cd manifest/docker && VERSION=$(VERSION) docker compose build --build-arg VERSION=$(VERSION) docker-up: cd manifest/docker && docker compose up -d diff --git a/api/admin/admin.go b/api/admin/admin.go index 487e849..041ace0 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -233,6 +233,10 @@ type IAdminV1 interface { TicketAssign(ctx context.Context, req *v1.TicketAssignReq) (res *v1.TicketAssignRes, err error) TicketReply(ctx context.Context, req *v1.TicketReplyReq) (res *v1.TicketReplyRes, err error) TicketStatusUpdate(ctx context.Context, req *v1.TicketStatusUpdateReq) (res *v1.TicketStatusUpdateRes, err error) + UpdateCheck(ctx context.Context, req *v1.UpdateCheckReq) (res *v1.UpdateCheckRes, err error) + UpdateStatus(ctx context.Context, req *v1.UpdateStatusReq) (res *v1.UpdateStatusRes, err error) + UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (res *v1.UpdateExecuteRes, err error) + UpdateRollback(ctx context.Context, req *v1.UpdateRollbackReq) (res *v1.UpdateRollbackRes, err error) UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (res *v1.UsageLogCleanupCreateRes, err error) UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (res *v1.UsageLogCleanupListRes, err error) UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (res *v1.UsageLogCleanupCancelRes, err error) diff --git a/api/admin/v1/update.go b/api/admin/v1/update.go new file mode 100644 index 0000000..b8dc5e4 --- /dev/null +++ b/api/admin/v1/update.go @@ -0,0 +1,66 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// === 系统在线升级(管理后台) === + +// UpdateCheck 检查系统更新 +type UpdateCheckReq struct { + g.Meta `path:"/update/check" method:"get" mime:"json" tags:"管理后台-系统更新" summary:"检查系统更新"` + Force bool `json:"force" in:"query" d:"false" dc:"是否强制跳过缓存检查"` +} + +type UpdateCheckRes struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + HasUpdate bool `json:"has_update"` + ReleaseNotes string `json:"release_notes"` + ReleaseURL string `json:"release_url"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` + CheckedAt *gtime.Time `json:"checked_at"` + DeploymentMode string `json:"deployment_mode"` // binary | docker +} + +// UpdateStatus 获取更新状态(升级进行中时前端轮询) +type UpdateStatusReq struct { + g.Meta `path:"/update/status" method:"get" mime:"json" tags:"管理后台-系统更新" summary:"获取更新状态"` +} + +type UpdateStatusRes struct { + CurrentVersion string `json:"current_version"` + DeploymentMode string `json:"deployment_mode"` // binary | docker + Updating bool `json:"updating"` + LastCheck *UpdateCheckRes `json:"last_check,omitempty"` + UpdateProgress *UpdateProgress `json:"update_progress,omitempty"` + RollbackAvailable bool `json:"rollback_available"` + BackupVersion string `json:"backup_version,omitempty"` +} + +type UpdateProgress struct { + Phase string `json:"phase"` // downloading | verifying | backing_up | replacing | restarting | complete | failed + Message string `json:"message"` // 当前操作描述 + Percentage int `json:"percentage"` // 0-100 + Error string `json:"error,omitempty"` // 失败时的错误信息 +} + +// UpdateExecute 触发系统升级 +type UpdateExecuteReq struct { + g.Meta `path:"/update/execute" method:"post" mime:"json" tags:"管理后台-系统更新" summary:"触发系统升级"` + Version string `json:"version" v:"required" dc:"目标版本号"` +} + +type UpdateExecuteRes struct { + Message string `json:"message"` +} + +// UpdateRollback 回滚到上一版本(仅二进制模式) +type UpdateRollbackReq struct { + g.Meta `path:"/update/rollback" method:"post" mime:"json" tags:"管理后台-系统更新" summary:"回滚到上一版本"` +} + +type UpdateRollbackRes struct { + Message string `json:"message"` +} diff --git a/go.mod b/go.mod index 42f6f46..19ac5b5 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( require ( github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect diff --git a/go.sum b/go.sum index 89cbac1..3438882 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 2fa572c..4054d1f 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -25,6 +25,7 @@ import ( relayLogic "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/logic/task" "github.com/qianfree/team-api/internal/logic/tenant" + "github.com/qianfree/team-api/internal/logic/updat "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/response" @@ -165,8 +166,18 @@ var ( scheduler.GetGlobalAffinity().CleanExpired() return nil }) + cs.Register("update_check", "0 */6 * * *", func(ctx context.Context) error { + if common.Config().GetBool(ctx, "update_auto_check_enabled") { + return update.BackgroundCheck(ctx) + } + return nil + }) cs.StartBackground(ctx) + // Initialize update manager + update.InitManager(ctx) + update.CheckPendingVerification(ctx) + // Initialize plugin system (must be after CronScheduler init) pluginApp := &plugin.App{ Server: s, diff --git a/internal/consts/consts.go b/internal/consts/consts.go index ee454fb..c8043a0 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -184,6 +184,15 @@ const ( CodePluginAlreadyEnabled = 10408 CodePluginDependencyMissing = 10409 CodePluginInstallFailed = 10410 + + // Update errors (10411+) + CodeUpdateAlreadyRunning = 10411 + CodeUpdateNotAvailable = 10412 + CodeUpdateDownloadFailed = 10413 + CodeUpdateVerifyFailed = 10414 + CodeUpdateReplaceFailed = 10415 + CodeUpdateRollbackFailed = 10416 + CodeUpdateNotSupported = 10417 ) // Common error messages @@ -309,4 +318,12 @@ const ( MsgPluginAlreadyEnabled = "插件已启用" MsgPluginDependencyMissing = "插件依赖未满足" MsgPluginInstallFailed = "插件安装失败" + // Update errors + MsgUpdateAlreadyRunning = "系统更新正在进行中" + MsgUpdateNotAvailable = "当前已是最新版本" + MsgUpdateDownloadFailed = "下载更新文件失败" + MsgUpdateVerifyFailed = "更新文件校验失败" + MsgUpdateReplaceFailed = "替换二进制文件失败" + MsgUpdateRollbackFailed = "回滚失败" + MsgUpdateNotSupported = "当前部署模式不支持自动更新" ) diff --git a/internal/controller/admin/admin_v1_update.go b/internal/controller/admin/admin_v1_update.go new file mode 100644 index 0000000..22550c2 --- /dev/null +++ b/internal/controller/admin/admin_v1_update.go @@ -0,0 +1,21 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) UpdateCheck(ctx context.Context, req *v1.UpdateCheckReq) (res *v1.UpdateCheckRes, err error) { + return service.Admin().UpdateCheck(ctx, req) +} +func (c *ControllerV1) UpdateStatus(ctx context.Context, req *v1.UpdateStatusReq) (res *v1.UpdateStatusRes, err error) { + return service.Admin().UpdateStatus(ctx, req) +} +func (c *ControllerV1) UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (res *v1.UpdateExecuteRes, err error) { + return service.Admin().UpdateExecute(ctx, req) +} +func (c *ControllerV1) UpdateRollback(ctx context.Context, req *v1.UpdateRollbackReq) (res *v1.UpdateRollbackRes, err error) { + return service.Admin().UpdateRollback(ctx, req) +} diff --git a/internal/logic/admin/update.go b/internal/logic/admin/update.go new file mode 100644 index 0000000..abcc1b7 --- /dev/null +++ b/internal/logic/admin/update.go @@ -0,0 +1,156 @@ +package admin + +import ( + "context" + "fmt" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/logic/update" +) + +// CheckUpdate checks GitHub Releases for a newer version +func (s *sAdmin) UpdateCheck(ctx context.Context, req *v1.UpdateCheckReq) (*v1.UpdateCheckRes, error) { + result, err := update.CheckForUpdate(ctx, req.Force) + if err != nil { + return nil, gerror.NewCode(gcode.New(500, err.Error(), nil)) + } + + return &v1.UpdateCheckRes{ + CurrentVersion: result.CurrentVersion, + LatestVersion: result.LatestVersion, + HasUpdate: result.HasUpdate, + ReleaseNotes: result.ReleaseNotes, + ReleaseURL: result.ReleaseURL, + PublishedAt: result.PublishedAt, + CheckedAt: result.CheckedAt, + DeploymentMode: result.DeploymentMode, + }, nil +} + +// GetUpdateStatus returns the current update status +func (s *sAdmin) UpdateStatus(ctx context.Context, req *v1.UpdateStatusReq) (*v1.UpdateStatusRes, error) { + mgr := update.GetManager() + status := mgr.GetStatus() + + res := &v1.UpdateStatusRes{ + CurrentVersion: status.CurrentVersion, + DeploymentMode: status.DeploymentMode, + Updating: status.Updating, + RollbackAvailable: status.RollbackAvailable, + BackupVersion: status.BackupVersion, + } + + if status.LastCheck != nil { + res.LastCheck = &v1.UpdateCheckRes{ + CurrentVersion: status.LastCheck.CurrentVersion, + LatestVersion: status.LastCheck.LatestVersion, + HasUpdate: status.LastCheck.HasUpdate, + ReleaseNotes: status.LastCheck.ReleaseNotes, + ReleaseURL: status.LastCheck.ReleaseURL, + PublishedAt: status.LastCheck.PublishedAt, + CheckedAt: status.LastCheck.CheckedAt, + DeploymentMode: status.LastCheck.DeploymentMode, + } + } + + // Also check for cached check result + if res.LastCheck == nil { + cached := mgr.GetCheckResult() + if cached != nil { + res.LastCheck = &v1.UpdateCheckRes{ + CurrentVersion: cached.CurrentVersion, + LatestVersion: cached.LatestVersion, + HasUpdate: cached.HasUpdate, + ReleaseNotes: cached.ReleaseNotes, + ReleaseURL: cached.ReleaseURL, + PublishedAt: cached.PublishedAt, + CheckedAt: cached.CheckedAt, + DeploymentMode: cached.DeploymentMode, + } + } + } + + // Check rollback availability from disk + if !res.RollbackAvailable { + info, err := update.GetRollbackInfo() + if err == nil && info != nil { + res.RollbackAvailable = true + res.BackupVersion = info.BackupVersion + } + } + + if status.Progress != nil { + res.UpdateProgress = &v1.UpdateProgress{ + Phase: status.Progress.Phase, + Message: status.Progress.Message, + Percentage: status.Progress.Percentage, + Error: status.Progress.Error, + } + } + + return res, nil +} + +// ExecuteUpdate triggers the system update +func (s *sAdmin) UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (*v1.UpdateExecuteRes, error) { + // Docker mode check + if update.IsDocker() { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotSupported, consts.MsgUpdateNotSupported, nil)) + } + + // Check if already updating + if update.GetManager().IsUpdating() { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateAlreadyRunning, consts.MsgUpdateAlreadyRunning, nil)) + } + + // Get check result to find download URL + cached := update.GetManager().GetCheckResult() + if cached == nil { + // Force a check first + result, err := update.CheckForUpdate(ctx, true) + if err != nil { + return nil, gerror.New("检查更新失败: " + err.Error()) + } + cached = result + } + + if !cached.HasUpdate { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotAvailable, consts.MsgUpdateNotAvailable, nil)) + } + + if cached.LatestVersion != req.Version { + return nil, gerror.New(fmt.Sprintf("请求的版本 %s 与最新版本 %s 不匹配", req.Version, cached.LatestVersion)) + } + + if cached.DownloadURL == "" { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotSupported, "当前平台没有可用的更新包", nil)) + } + + // Execute update + if err := update.ExecuteUpdate(ctx, req.Version, cached.DownloadURL, cached.ChecksumURL, cached.AssetSize); err != nil { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateDownloadFailed, err.Error(), nil)) + } + + return &v1.UpdateExecuteRes{ + Message: "系统更新已启动", + }, nil +} + +// RollbackUpdate rolls back to the previous version +func (s *sAdmin) UpdateRollback(ctx context.Context, req *v1.UpdateRollbackReq) (*v1.UpdateRollbackRes, error) { + if update.IsDocker() { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotSupported, consts.MsgUpdateNotSupported, nil)) + } + + if err := update.Rollback(ctx); err != nil { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateRollbackFailed, consts.MsgUpdateRollbackFailed+": "+err.Error(), nil)) + } + + return &v1.UpdateRollbackRes{ + Message: "回滚已启动,系统将重启", + }, nil +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 03cd57a..0db7299 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -45,6 +45,7 @@ var Categories = []SettingCategory{ {Key: "channel", Label: "渠道配置", Icon: "channel", Order: 9}, {Key: "storage", Label: "存储配置", Icon: "cloud", Order: 10}, {Key: "data_governance", Label: "数据治理", Icon: "database", Order: 11}, + {Key: "update", Label: "系统更新", Icon: "sync", Order: 12}, } // Registry is the central definition of all configuration items. @@ -252,6 +253,14 @@ var Registry = []SettingDef{ Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"}, {Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance", Label: "启用文件保留期检查"}, + + // Update settings + {Key: "update_auto_check_enabled", Type: SettingTypeBool, Default: "true", Category: "update", + Label: "自动检查更新", Description: "启用后系统会定期检查是否有新版本"}, + {Key: "update_check_interval_hours", Type: SettingTypeInt, Default: "24", Category: "update", + Label: "检查间隔(小时)", Description: "自动检查更新的时间间隔", Validation: "min:1,max:168"}, + {Key: "update_channel", Type: SettingTypeString, Default: "stable", Category: "update", + Label: "更新通道", Description: "选择更新通道", Validation: "enum:stable,beta"}, } // registryMap is a lookup index for fast access by key. diff --git a/internal/logic/logic.go b/internal/logic/logic.go index 5f93c65..d12d136 100644 --- a/internal/logic/logic.go +++ b/internal/logic/logic.go @@ -16,4 +16,5 @@ import ( _ "github.com/qianfree/team-api/internal/logic/settings" _ "github.com/qianfree/team-api/internal/logic/task" _ "github.com/qianfree/team-api/internal/logic/tenant" + _ "github.com/qianfree/team-api/internal/logic/update" ) diff --git a/internal/logic/update/checker.go b/internal/logic/update/checker.go new file mode 100644 index 0000000..4a8dd9c --- /dev/null +++ b/internal/logic/update/checker.go @@ -0,0 +1,192 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Masterminds/semver/ + "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) + if cached != nil { + return cached, nil + } + } + + // Dev builds skip update check + if consts.Version == "dev" { + return &CheckResult{ + CurrentVersion: consts.Version, + LatestVersion: consts.Version, + HasUpdate: false, + CheckedAt: gtime.Now(), + DeploymentMode: GetDeploymentMode(), + }, nil + } + + // Call GitHub API + release, etag, err := fetchLatestRelease(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch latest release: %w", err) + } + + if release == nil { + // 304 Not Modified — use cached result + cached := getCheckCache(ctx) + if cached != nil { + return cached, nil + } + return &CheckResult{ + CurrentVersion: consts.Version, + LatestVersion: consts.Version, + HasUpdate: false, + CheckedAt: gtime.Now(), + DeploymentMode: GetDeploymentMode(), + }, nil + } + + // Parse versions + currentVer, err := semver.NewVersion(strings.TrimPrefix(consts.Version, "v")) + if err != nil { + return nil, fmt.Errorf("invalid current version %q: %w", consts.Version, err) + } + + latestStr := strings.TrimPrefix(release.TagName, "v") + latestVer, err := semver.NewVersion(latestStr) + if err != nil { + return nil, fmt.Errorf("invalid latest version %q: %w", release.TagName, err) + } + + // Find matching asset for current platform + assetName := getPlatformAssetName(latestStr) + var downloadURL, checksumURL string + var assetSize int64 + + for _, asset := range release.Assets { + if 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: latestVer.GreaterThan(currentVer), + 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 + setCheckCache(ctx, result) + manager.checkResult.Store(result) + + return result, nil +} + +// fetchLatestRelease calls the GitHub Releases API with conditional request support +func fetchLatestRelease(ctx context.Context) (*GitHubRelease, string, error) { + url := githubAPIBase + "/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 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 +} + +// getCheckCache reads cached check result from Redis +func getCheckCache(ctx context.Context) *CheckResult { + 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 + } + return &result +} + +// setCheckCache stores check result in Redis +func setCheckCache(ctx context.Context, result *CheckResult) { + 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)) +} diff --git a/internal/logic/update/docker.go b/internal/logic/update/docker.go new file mode 100644 index 0000000..f13209c --- /dev/null +++ b/internal/logic/update/docker.go @@ -0,0 +1,57 @@ +package update + +import ( + "os" +) + +// IsDocker detects if the application is running inside a Docker container +func IsDocker() bool { + // Check /.dockerenv file (standard Docker indicator) + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Check environment variable (set by our docker-compose) + if os.Getenv("TEAM_API_DEPLOYMENT") == "docker" { + return true + } + + // Check for Docker-specific cgroup indicator + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + content := string(data) + if containsDockerIndicator(content) { + return true + } + } + + return false +} + +// containsDockerIndicator checks if cgroup content indicates Docker +func containsDockerIndicator(content string) bool { + dockerIndicators := []string{ + "/docker/", + "/docker-", + "docker", + } + for _, indicator := range dockerIndicators { + if len(content) > 0 && contains(content, indicator) { + return true + } + } + return false +} + +// contains is a simple string contains check +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/logic/update/downloader.go b/internal/logic/update/downloader.go new file mode 100644 index 0000000..e30b1ea --- /dev/null +++ b/internal/logic/update/downloader.go @@ -0,0 +1,127 @@ +package update + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +const ( + downloadTimeout = 5 * time.Minute +) + +// DownloadResult holds the result of a download operation +type DownloadResult struct { + FilePath string + Size int64 +} + +// DownloadFile downloads a file from the given URL to the update temp directory +// and reports progress via the manager +func DownloadFile(ctx context.Context, url, filename string, expectedSize int64) (*DownloadResult, error) { + if url == "" { + return nil, fmt.Errorf("download URL is empty") + } + + // Ensure update directory exists + _ = os.MkdirAll(updateDir, 0755) + + // Create temp file + filePath := filepath.Join(updateDir, filename) + f, err := os.Create(filePath) + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + defer f.Close() + + // Create HTTP request with context + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{Timeout: downloadTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download returned status %d", resp.StatusCode) + } + + // Determine content size + contentSize := expectedSize + if contentSize == 0 { + contentSize = resp.ContentLength + } + + // Download with progress tracking + written, err := downloadWithProgress(ctx, f, resp.Body, contentSize) + if err != nil { + _ = os.Remove(filePath) + return nil, fmt.Errorf("download failed: %w", err) + } + + g.Log().Infof(ctx, "Downloaded %s (%d bytes)", filename, written) + + return &DownloadResult{ + FilePath: filePath, + Size: written, + }, nil +} + +// downloadWithProgress copies from reader to writer and updates progress +func downloadWithProgress(ctx context.Context, dst *os.File, src io.Reader, totalSize int64) (int64, error) { + buf := make([]byte, 32*1024) // 32KB buffer + var written int64 + var lastReport time.Time + + for { + nr, err := src.Read(buf) + if nr > 0 { + nw, err := dst.Write(buf[:nr]) + if err != nil { + return written, err + } + if nr != nw { + return written, io.ErrShortWrite + } + written += int64(nw) + } + + if err == io.EOF { + break + } + if err != nil { + return written, err + } + + // Update progress every 500ms + now := time.Now() + if now.Sub(lastReport) > 500*time.Millisecond { + lastReport = now + percentage := 0 + if totalSize > 0 { + percentage = int(written * 100 / totalSize) + if percentage > 99 { + percentage = 99 + } + } + sizeMB := float64(written) / 1024 / 1024 + manager.setProgress(PhaseDownloading, + fmt.Sprintf("正在下载更新文件 (%.1f MB)...", sizeMB), + percentage, + ) + } + } + + return written, nil +} diff --git a/internal/logic/update/replacer.go b/internal/logic/update/replacer.go new file mode 100644 index 0000000..14c88b5 --- /dev/null +++ b/internal/logic/update/replacer.go @@ -0,0 +1,268 @@ +package update + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "github.com/qianfree/team-api/internal/consts" +) + +// ExecuteUpdate runs the full update process in a goroutine +func ExecuteUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL string, assetSize int64) error { + // Check if already updating + if !manager.updating.CompareAndSwap(false, true) { + return fmt.Errorf("update already in progress") + } + + // Docker mode check + if IsDocker() { + manager.updating.Store(false) + return fmt.Errorf("auto-update is not supported in Docker mode") + } + + // Check download URL + if downloadURL == "" { + manager.updating.Store(false) + return fmt.Errorf("no download URL available for this platform (%s/%s)", runtime.GOOS, runtime.GOARCH) + } + + // Initialize status + manager.status.Store(&Status{ + CurrentVersion: consts.Version, + DeploymentMode: DeploymentBinary, + Updating: true, + Progress: &Progress{Phase: PhaseDownloading, Message: "正在准备下载...", Percentage: 0}, + }) + + // Run update in background + go performUpdate(ctx, targetVersion, downloadURL, checksumURL, assetSize) + + return nil +} + +// performUpdate is the main update routine +func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL string, assetSize int64) { + defer func() { + if r := recover(); r != nil { + g.Log().Errorf(ctx, "Update panic: %v", r) + manager.setProgressError("更新过程中发生异常", fmt.Sprintf("%v", r)) + } + }() + + // Step 1: Download + assetName := getPlatformAssetName(targetVersion) + dlResult, err := DownloadFile(ctx, downloadURL, assetName, assetSize) + if err != nil { + manager.setProgressError("下载更新文件失败", err.Error()) + g.Log().Errorf(ctx, "Download failed: %v", err) + return + } + + // Step 2: Verify + if err := VerifyChecksum(ctx, dlResult.FilePath, checksumURL, assetName); err != nil { + manager.setProgressError("文件校验失败", err.Error()) + g.Log().Errorf(ctx, "Checksum verification failed: %v", err) + _ = os.Remove(dlResult.FilePath) + return + } + + // Step 3: Extract binary from tarball + manager.setProgress(PhaseBackingUp, "正在提取二进制文件...", 10) + newBinaryPath, err := extractBinary(ctx, dlResult.FilePath) + if err != nil { + manager.setProgressError("提取文件失败", err.Error()) + g.Log().Errorf(ctx, "Extract failed: %v", err) + _ = os.Remove(dlResult.FilePath) + return + } + _ = os.Remove(dlResult.FilePath) // clean up tarball + + // Step 4: Backup current binary + manager.setProgress(PhaseBackingUp, "正在备份当前版本...", 30) + currentExe, err := os.Executable() + if err != nil { + manager.setProgressError("获取当前程序路径失败", err.Error()) + _ = os.Remove(newBinaryPath) + return + } + // Resolve symlinks + currentExe, err = filepath.EvalSymlinks(currentExe) + if err != nil { + g.Log().Warningf(ctx, "Failed to resolve symlink: %v", err) + } + + backupPath := fmt.Sprintf("%s.backup.%s", currentExe, gtime.Now().Format("YmdHis")) + if err := copyFile(currentExe, backupPath); err != nil { + manager.setProgressError("备份当前版本失败", err.Error()) + _ = os.Remove(newBinaryPath) + return + } + g.Log().Infof(ctx, "Backed up current binary to %s", backupPath) + + // Save rollback info + rollbackInfo := &RollbackInfo{ + BackupPath: backupPath, + OriginalPath: currentExe, + BackupVersion: consts.Version, + Timestamp: gtime.Now().Format("Y-m-d H:i:s"), + } + if err := saveRollbackInfo(rollbackInfo); err != nil { + g.Log().Warningf(ctx, "Failed to save rollback info: %v", err) + } + + // Step 5: Replace binary + manager.setProgress(PhaseReplacing, "正在替换程序文件...", 60) + if err := replaceBinary(ctx, currentExe, newBinaryPath); err != nil { + manager.setProgressError("替换程序文件失败", err.Error()) + g.Log().Errorf(ctx, "Replace failed: %v", err) + _ = os.Remove(newBinaryPath) + return + } + + // Step 6: Write pending verification marker + pendingData, _ := json.Marshal(struct { + Version string `json:"version"` + OldBinary string `json:"old_binary"` + }{ + Version: targetVersion, + OldBinary: backupPath, + }) + _ = os.WriteFile(filepath.Join(updateDir, pendingVerificationFile), pendingData, 0644) + + // Step 7: Restart + manager.setProgress(PhaseRestarting, "正在重启服务...", 90) + g.Log().Info(ctx, "Update complete, exiting for restart...") + time.Sleep(500 * time.Millisecond) // give status API a moment to respond + + os.Exit(0) +} + +// extractBinary extracts the team-api binary from a tar.gz archive +func extractBinary(ctx context.Context, tarballPath string) (string, error) { + f, err := os.Open(tarballPath) + if err != nil { + return "", err + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("gzip open failed: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + // Look for the binary inside the tarball + // Expected structure: team-api-{version}-{os}-{arch}/team-api + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("tar read failed: %w", err) + } + + // Look for the binary file (not directory) + baseName := filepath.Base(hdr.Name) + if baseName == "team-api" && hdr.Typeflag == tar.TypeReg { + // Extract to temp file + outputPath := filepath.Join(updateDir, "team-api.new") + outf, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return "", err + } + defer outf.Close() + + if _, err := io.Copy(outf, tr); err != nil { + _ = os.Remove(outputPath) + return "", fmt.Errorf("extract failed: %w", err) + } + + return outputPath, nil + } + } + + return "", fmt.Errorf("binary not found in tarball") +} + +// replaceBinary replaces the current binary with the new one using the rename strategy +func replaceBinary(ctx context.Context, currentExe, newBinary string) error { + // Strategy: + // 1. rename current → current.old (safe because Linux allows renaming running binary) + // 2. rename new → current + // 3. chmod new binary + oldPath := currentExe + ".old" + + // Remove any leftover .old file + _ = os.Remove(oldPath) + + // Rename current binary to .old + if err := os.Rename(currentExe, oldPath); err != nil { + return fmt.Errorf("rename current to .old failed: %w", err) + } + + // Rename new binary to current path + if err := os.Rename(newBinary, currentExe); err != nil { + // Attempt rollback: rename .old back + _ = os.Rename(oldPath, currentExe) + return fmt.Errorf("rename new to current failed: %w", err) + } + + // Ensure executable permission + if err := os.Chmod(currentExe, 0755); err != nil { + g.Log().Warningf(ctx, "Failed to chmod binary: %v", err) + } + + g.Log().Infof(ctx, "Binary replaced: %s -> %s", oldPath, currentExe) + return nil +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + // Get source file permissions + srcInfo, err := srcFile.Stat() + if err != nil { + return err + } + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + _ = os.Remove(dst) + return err + } + + return nil +} + +// saveRollbackInfo persists rollback metadata to disk +func saveRollbackInfo(info *RollbackInfo) error { + data, err := json.Marshal(info) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(updateDir, rollbackFile), data, 0644) +} diff --git a/internal/logic/update/rollback.go b/internal/logic/update/rollback.go new file mode 100644 index 0000000..77b0332 --- /dev/null +++ b/internal/logic/update/rollback.go @@ -0,0 +1,95 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/internal/consts" +) + +// Rollback rolls back to the previous version +func Rollback(ctx context.Context) error { + if IsDocker() { + return fmt.Errorf("rollback is not supported in Docker mode") + } + + if manager.IsUpdating() { + return fmt.Errorf("cannot rollback while update is in progress") + } + + // Read rollback info + info, err := loadRollbackInfo() + if err != nil { + return fmt.Errorf("no rollback information available: %w", err) + } + + // Verify backup file exists + if _, err := os.Stat(info.BackupPath); err != nil { + return fmt.Errorf("backup file not found: %s", info.BackupPath) + } + + g.Log().Infof(ctx, "Rolling back from %s to %s using backup %s", + consts.Version, info.BackupVersion, info.BackupPath) + + // Current executable path + currentExe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get current executable path: %w", err) + } + currentExe, err = filepath.EvalSymlinks(currentExe) + if err != nil { + g.Log().Warningf(ctx, "Failed to resolve symlink: %v", err) + } + + // Replace current binary with backup + oldPath := currentExe + ".old" + _ = os.Remove(oldPath) + + if err := os.Rename(currentExe, oldPath); err != nil { + return fmt.Errorf("failed to rename current binary: %w", err) + } + + if err := os.Rename(info.BackupPath, currentExe); err != nil { + // Try to restore + _ = os.Rename(oldPath, currentExe) + return fmt.Errorf("failed to restore backup: %w", err) + } + + _ = os.Chmod(currentExe, 0755) + + // Clean up + _ = os.Remove(filepath.Join(updateDir, rollbackFile)) + _ = os.Remove(filepath.Join(updateDir, pendingVerificationFile)) + _ = os.Remove(oldPath) + + g.Log().Infof(ctx, "Rollback to %s complete, exiting for restart...", info.BackupVersion) + + // Exit — process supervisor will restart with the old binary + os.Exit(0) + return nil +} + +// loadRollbackInfo reads rollback metadata from disk +func loadRollbackInfo() (*RollbackInfo, error) { + data, err := os.ReadFile(filepath.Join(updateDir, rollbackFile)) + if err != nil { + return nil, err + } + + var info RollbackInfo + if err := json.Unmarshal(data, &info); err != nil { + return nil, err + } + + return &info, nil +} + +// GetRollbackInfo returns the current rollback information (if any) +func GetRollbackInfo() (*RollbackInfo, error) { + return loadRollbackInfo() +} diff --git a/internal/logic/update/types.go b/internal/logic/update/types.go new file mode 100644 index 0000000..689eee5 --- /dev/null +++ b/internal/logic/update/types.go @@ -0,0 +1,96 @@ +package update + +import ( + "sync" + "sync/atomic" + + "github.com/gogf/gf/v2/os/gtime" +) + +// Phase constants for update progress +const ( + PhaseIdle = "" + PhaseDownloading = "downloading" + PhaseVerifying = "verifying" + PhaseBackingUp = "backing_up" + PhaseReplacing = "replacing" + PhaseRestarting = "restarting" + PhaseComplete = "complete" + PhaseFailed = "failed" +) + +// Deployment modes +const ( + DeploymentBinary = "binary" + DeploymentDocker = "docker" +) + +// GitHubRelease represents a GitHub release from the API +type GitHubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + PublishedAt *gtime.Time `json:"published_at"` + Assets []GitHubAsset `json:"assets"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +// GitHubAsset represents a single asset in a GitHub release +type GitHubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// CheckResult holds the result of a version check +type CheckResult struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + HasUpdate bool `json:"has_update"` + ReleaseNotes string `json:"release_notes"` + ReleaseURL string `json:"release_url"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` + CheckedAt *gtime.Time `json:"checked_at"` + DeploymentMode string `json:"deployment_mode"` + DownloadURL string `json:"download_url"` // matched asset URL + ChecksumURL string `json:"checksum_url"` // checksums-sha256.txt URL + AssetSize int64 `json:"asset_size"` +} + +// Progress holds the current update progress +type Progress struct { + Phase string `json:"phase"` + Message string `json:"message"` + Percentage int `json:"percentage"` + Error string `json:"error,omitempty"` +} + +// RollbackInfo holds information needed for rollback (persisted to disk) +type RollbackInfo struct { + BackupPath string `json:"backup_path"` + OriginalPath string `json:"original_path"` + BackupVersion string `json:"backup_version"` + Timestamp string `json:"timestamp"` +} + +// UpdateManager is the singleton that manages update state +type UpdateManager struct { + status atomic.Value // *Status + updating atomic.Bool // update lock + mu sync.Mutex // serialize update operations + checkResult atomic.Value // *CheckResult + lastETag atomic.Value // string - GitHub API ETag for conditional requests +} + +// Status holds the full update status +type Status struct { + CurrentVersion string `json:"current_version"` + DeploymentMode string `json:"deployment_mode"` + Updating bool `json:"updating"` + LastCheck *CheckResult `json:"last_check,omitempty"` + Progress *Progress `json:"progress,omitempty"` + RollbackAvailable bool `json:"rollback_available"` + BackupVersion string `json:"backup_version,omitempty"` +} diff --git a/internal/logic/update/update.go b/internal/logic/update/update.go new file mode 100644 index 0000000..ab9d999 --- /dev/null +++ b/internal/logic/update/update.go @@ -0,0 +1,253 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/internal/consts" +) + +const ( + // updateDir is the temp directory for update operations + updateDir = "/tmp/team-api-update" + // rollbackFile stores rollback metadata + rollbackFile = "rollback.json" + // pendingVerificationFile marks a just-updated process + pendingVerificationFile = "pending_verification" + // backupMaxAge is how long old backups are kept + backupMaxAge = 7 * 24 * time.Hour +) + +// manager is the singleton UpdateManager +var manager *UpdateManager + +func init() { + manager = &UpdateManager{} + manager.updating.Store(false) +} + +// GetManager returns the singleton UpdateManager +func GetManager() *UpdateManager { + return manager +} + +// InitManager initializes the update manager and cleans old backups +func InitManager(ctx context.Context) { + // Ensure update directory exists + _ = os.MkdirAll(updateDir, 0755) + + // Clean old backups + cleanOldBackups(ctx) + + g.Log().Info(ctx, "Update manager initialized") +} + +// CheckPendingVerification checks if this process just started after an update +// and verifies the new version is healthy +func CheckPendingVerification(ctx context.Context) { + pendingPath := filepath.Join(updateDir, pendingVerificationFile) + data, err := os.ReadFile(pendingPath) + if err != nil { + return // no pending verification + } + + var info struct { + Version string `json:"version"` + OldBinary string `json:"old_binary"` + } + if err := json.Unmarshal(data, &info); err != nil { + _ = os.Remove(pendingPath) + return + } + + g.Log().Infof(ctx, "Detected pending update verification for version %s", info.Version) + + // Verify health in background + go func() { + time.Sleep(5 * time.Second) + + // Check own health endpoint + client := g.Client() + resp, err := client.Get(ctx, "http://127.0.0.1:18888/api/health") + if err != nil || resp == nil { + g.Log().Warningf(ctx, "Update verification health check failed: %v", err) + // Wait and retry + time.Sleep(10 * time.Second) + resp, err = client.Get(ctx, "http://127.0.0.1:18888/api/health") + } + + if err == nil && resp != nil { + defer resp.Close() + if resp.StatusCode == 200 { + g.Log().Infof(ctx, "Update to %s verified successfully", info.Version) + _ = os.Remove(pendingPath) + + // Update status in Redis + status := &Status{ + CurrentVersion: consts.Version, + DeploymentMode: GetDeploymentMode(), + Updating: false, + Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100}, + RollbackAvailable: info.OldBinary != "", + BackupVersion: info.OldBinary, + } + manager.status.Store(status) + + // Clean up .old file + if info.OldBinary != "" { + _ = os.Remove(info.OldBinary + ".old") + } + return + } + } + + // Health check failed after retries + g.Log().Errorf(ctx, "Update to %s verification failed - health check not passing", info.Version) + status := &Status{ + CurrentVersion: consts.Version, + DeploymentMode: GetDeploymentMode(), + Updating: false, + Progress: &Progress{ + Phase: PhaseFailed, + Message: "更新后健康检查失败,建议回滚", + Error: "health check failed after update", + }, + RollbackAvailable: info.OldBinary != "", + BackupVersion: info.OldBinary, + } + manager.status.Store(status) + }() +} + +// BackgroundCheck performs an update check (called by cron) +func BackgroundCheck(ctx context.Context) error { + result, err := CheckForUpdate(ctx, false) + if err != nil { + g.Log().Warningf(ctx, "Background update check failed: %v", err) + return err + } + + if result != nil && result.HasUpdate { + g.Log().Infof(ctx, "New version available: %s (current: %s)", result.LatestVersion, result.CurrentVersion) + } + + return nil +} + +// GetStatus returns the current update status +func (m *UpdateManager) GetStatus() *Status { + val := m.status.Load() + if val == nil { + return &Status{ + CurrentVersion: consts.Version, + DeploymentMode: GetDeploymentMode(), + Updating: false, + } + } + return val.(*Status) +} + +// GetCheckResult returns the cached check result +func (m *UpdateManager) GetCheckResult() *CheckResult { + val := m.checkResult.Load() + if val == nil { + return nil + } + return val.(*CheckResult) +} + +// IsUpdating returns whether an update is in progress +func (m *UpdateManager) IsUpdating() bool { + return m.updating.Load() +} + +// setProgress updates the current progress +func (m *UpdateManager) setProgress(phase, message string, percentage int) { + status := m.GetStatus() + status.Updating = true + status.Progress = &Progress{ + Phase: phase, + Message: message, + Percentage: percentage, + } + m.status.Store(status) +} + +// setProgressError marks the update as failed +func (m *UpdateManager) setProgressError(message, errMsg string) { + status := m.GetStatus() + status.Updating = false + status.Progress = &Progress{ + Phase: PhaseFailed, + Message: message, + Error: errMsg, + } + m.status.Store(status) + m.updating.Store(false) +} + +// cleanOldBackups removes backup files older than backupMaxAge +func cleanOldBackups(ctx context.Context) { + exe, err := os.Executable() + if err != nil { + return + } + + dir := filepath.Dir(exe) + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + base := filepath.Base(exe) + now := time.Now() + + for _, entry := range entries { + name := entry.Name() + // Match pattern: {binary}.backup.{timestamp} or {binary}.old + if len(name) > len(base) && name[:len(base)] == base { + suffix := name[len(base):] + if len(suffix) > 8 && suffix[:8] == ".backup." { + info, err := entry.Info() + if err != nil { + continue + } + if now.Sub(info.ModTime()) > backupMaxAge { + _ = os.Remove(filepath.Join(dir, name)) + g.Log().Debugf(ctx, "Cleaned old backup: %s", name) + } + } + } + } +} + +// GetDeploymentMode returns the current deployment mode +func GetDeploymentMode() string { + if IsDocker() { + return DeploymentDocker + } + return DeploymentBinary +} + +// getPlatformAssetName constructs the expected asset name for the current platform +func getPlatformAssetName(version string) string { + osName := runtime.GOOS + arch := runtime.GOARCH + ext := "tar.gz" + if osName == "windows" { + ext = "zip" + } + // Strip 'v' prefix if present + v := version + if len(v) > 0 && v[0] == 'v' { + v = v[1:] + } + return fmt.Sprintf("team-api-%s-%s-%s.%s", v, osName, arch, ext) +} diff --git a/internal/logic/update/verifier.go b/internal/logic/update/verifier.go new file mode 100644 index 0000000..eb82e0a --- /dev/null +++ b/internal/logic/update/verifier.go @@ -0,0 +1,144 @@ +package update + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// VerifyChecksum verifies the SHA256 checksum of the downloaded file +func VerifyChecksum(ctx context.Context, filePath, checksumURL, expectedFilename string) error { + manager.setProgress(PhaseVerifying, "正在校验文件完整性...", 50) + + // Compute SHA256 of the downloaded file + actualHash, err := computeSHA256(ctx, filePath) + if err != nil { + return fmt.Errorf("failed to compute SHA256: %w", err) + } + + g.Log().Debugf(ctx, "Computed SHA256: %s", actualHash) + + // If no checksum URL, skip verification (log warning) + if checksumURL == "" { + g.Log().Warning(ctx, "No checksum URL available, skipping verification") + return nil + } + + // Download checksums file + expectedHash, err := fetchExpectedHash(ctx, checksumURL, expectedFilename) + if err != nil { + g.Log().Warningf(ctx, "Failed to fetch checksum file, skipping verification: %v", err) + return nil + } + + manager.setProgress(PhaseVerifying, "正在对比校验值...", 80) + + if !strings.EqualFold(actualHash, expectedHash) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash) + } + + manager.setProgress(PhaseVerifying, "文件校验通过", 100) + g.Log().Info(ctx, "File checksum verified successfully") + + return nil +} + +// computeSHA256 computes the SHA256 hash of a file with progress reporting +func computeSHA256(ctx context.Context, filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return "", err + } + + hash := sha256.New() + buf := make([]byte, 64*1024) // 64KB buffer + var processed int64 + var lastReport time.Time + + for { + nr, err := f.Read(buf) + if nr > 0 { + hash.Write(buf[:nr]) + processed += int64(nr) + } + + if err == io.EOF { + break + } + if err != nil { + return "", err + } + + // Update progress + now := time.Now() + if now.Sub(lastReport) > 500*time.Millisecond { + lastReport = now + percentage := 30 + if info.Size() > 0 { + percentage = int(processed * 30 / info.Size()) + if percentage > 29 { + percentage = 29 + } + } + manager.setProgress(PhaseVerifying, "正在计算文件校验值...", percentage) + } + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// fetchExpectedHash downloads the checksums file and finds the hash for the target filename +func fetchExpectedHash(ctx context.Context, checksumURL, filename string) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", checksumURL, nil) + if err != nil { + return "", err + } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("checksum download returned %d", resp.StatusCode) + } + + // Parse checksums file format: + // + // or + // * + scanner := bufio.NewScanner(resp.Body) + baseFilename := filepath.Base(filename) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + parts := strings.Fields(line) + if len(parts) >= 2 { + hash := parts[0] + file := strings.TrimPrefix(parts[1], "*") + if file == baseFilename || file == filename { + return strings.ToLower(hash), nil + } + } + } + + return "", fmt.Errorf("checksum for %s not found in checksums file", filename) +} diff --git a/internal/service/admin.go b/internal/service/admin.go index 8a12525..3c3b0a3 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -411,6 +411,14 @@ type ( ReplyToTicketAdmin(ctx context.Context, req *v1.TicketReplyReq) (*v1.TicketReplyRes, error) // UpdateTicketStatus 更新工单状态 UpdateTicketStatus(ctx context.Context, req *v1.TicketStatusUpdateReq) (*v1.TicketStatusUpdateRes, error) + // CheckUpdate checks GitHub Releases for a newer version + UpdateCheck(ctx context.Context, req *v1.UpdateCheckReq) (*v1.UpdateCheckRes, error) + // GetUpdateStatus returns the current update status + UpdateStatus(ctx context.Context, req *v1.UpdateStatusReq) (*v1.UpdateStatusRes, error) + // ExecuteUpdate triggers the system update + UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (*v1.UpdateExecuteRes, error) + // RollbackUpdate rolls back to the previous version + UpdateRollback(ctx context.Context, req *v1.UpdateRollbackReq) (*v1.UpdateRollbackRes, error) UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (*v1.UsageLogCleanupCreateRes, error) UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (*v1.UsageLogCleanupListRes, error) UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (*v1.UsageLogCleanupCancelRes, error) diff --git a/internal/service/update.go b/internal/service/update.go new file mode 100644 index 0000000..3d70438 --- /dev/null +++ b/internal/service/update.go @@ -0,0 +1,8 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +type () diff --git a/manifest/docker/Dockerfile b/manifest/docker/Dockerfile index d434791..4ad94b8 100644 --- a/manifest/docker/Dockerfile +++ b/manifest/docker/Dockerfile @@ -10,6 +10,7 @@ FROM oven/bun:1-alpine AS frontend ARG BUN_CONFIG_REGISTRY=https://registry.npmmirror.com +ARG VERSION=dev ENV BUN_CONFIG_REGISTRY=${BUN_CONFIG_REGISTRY} WORKDIR /build/web @@ -20,11 +21,12 @@ COPY web/tenant/package.json web/tenant/bun.lock* ./tenant/ RUN cd admin && bun install --frozen-lockfile && \ cd ../tenant && bun install --frozen-lockfile -COPY VERSION /build/VERSION -COPY web/admin ./admin -COPY web/tenant ./tenant - -RUN cd admin && bun run build && \ +# Resolve version: build arg → VERSION file → dev +RUN if [ "$VERSION" = "dev" ]; then \ + VERSION=$(cat /build/VERSION 2>/dev/null || echo "dev"); \ + fi && \ + echo "Building frontend with version: ${VERSION}" && \ + cd admin && VITE_APP_VERSION=$VERSION bun run build && \ cd ../tenant && bun run build # Stage 2: Go build @@ -48,7 +50,11 @@ COPY --from=frontend /build/web/tenant/dist ./web/tenant/dist ARG VERSION=dev ARG TARGETARCH -RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ +RUN if [ "$VERSION" = "dev" ]; then \ + VERSION=$(cat /build/VERSION 2>/dev/null || echo "dev"); \ + fi && \ + echo "Building Go binary with version: ${VERSION}" && \ + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ go build -tags embedweb -ldflags="-s -w -X github.com/qianfree/team-api/internal/consts.Version=${VERSION}" \ -o /app/team-api main.go diff --git a/manifest/docker/docker-compose.yaml b/manifest/docker/docker-compose.yaml index 2b1fb57..c4a713e 100644 --- a/manifest/docker/docker-compose.yaml +++ b/manifest/docker/docker-compose.yaml @@ -65,7 +65,7 @@ services: # Team-API 应用 # ----------------------------------------------------------------------------- app: - image: team-api:${VERSION:-v0.1.0} + image: team-api:${VERSION:-latest} build: context: ../.. dockerfile: manifest/docker/Dockerfile diff --git a/web/admin/bun.lock b/web/admin/bun.lock index 0cfbd56..1309cc7 100644 --- a/web/admin/bun.lock +++ b/web/admin/bun.lock @@ -9,6 +9,7 @@ "@tailwindcss/vite": "^4.2.2", "axios": "^1.15.0", "echarts": "^5.6.0", + "marked": "^18.0.5", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", "vue": "^3.5.32", @@ -258,6 +259,8 @@ "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/web/admin/package.json b/web/admin/package.json index 06d2ef7..959c3fc 100644 --- a/web/admin/package.json +++ b/web/admin/package.json @@ -19,6 +19,7 @@ "@tailwindcss/vite": "^4.2.2", "axios": "^1.15.0", "echarts": "^5.6.0", + "marked": "^18.0.5", "pinia": "^3.0.4", "tailwindcss": "^4.2.2", "vue": "^3.5.32", diff --git a/web/admin/src/env.d.ts b/web/admin/src/env.d.ts index 323c78a..0c94e0a 100644 --- a/web/admin/src/env.d.ts +++ b/web/admin/src/env.d.ts @@ -1,5 +1,7 @@ /// +declare const __APP_VERSION__: string + declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent<{}, {}, any> diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index c83f0ea..aab0cb1 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -3,6 +3,8 @@ import { ref, reactive, computed, onMounted, onUnmounted, provide } from 'vue' import { useRouter, useRoute } from 'vue-router' import { Message } from '@arco-design/web-vue' import axios from 'axios' +import { marked } from 'marked' +import request from '@/utils/request' import { useWatermark } from '@/composables/useWatermark' import { IconDashboard, @@ -43,6 +45,127 @@ const isMobile = ref(false) const demoMode = ref(false) const demoMessage = ref('') +// --- Version & Update --- +const appVersion = ref(__APP_VERSION__ || 'dev') +const hasUpdate = ref(false) +const latestVersion = ref('') +const updateModalVisible = ref(false) +const updateLoading = ref(false) +const executing = ref(false) +const rollingBack = ref(false) +const updateStatus = ref(null) +const releaseNotes = ref('') +const releaseUrl = ref('') +const updatePollTimer = ref | null>(null) + +const releaseNotesHtml = computed(() => { + if (!releaseNotes.value) return '' + return marked(releaseNotes.value) as string +}) + +const isUpdating = computed(() => updateStatus.value?.updating === true) +const isFailed = computed(() => updateStatus.value?.update_progress?.phase === 'failed') +const isComplete = computed(() => updateStatus.value?.update_progress?.phase === 'complete') +const isDocker = computed(() => updateStatus.value?.deployment_mode === 'docker') +const progress = computed(() => updateStatus.value?.update_progress) +const rollbackAvailable = computed(() => updateStatus.value?.rollback_available === true) +const backupVersion = computed(() => updateStatus.value?.backup_version || '') + +async function checkUpdate(force = false) { + try { + const res = await request.get('/admin/update/check', { params: { force } }) + const data = res.data?.data + if (data) { + hasUpdate.value = data.has_update + latestVersion.value = data.latest_version || '' + releaseNotes.value = data.release_notes || '' + releaseUrl.value = data.release_url || '' + } + } catch { + // silent + } +} + +async function fetchUpdateStatus() { + try { + const res = await request.get('/admin/update/status') + updateStatus.value = res.data?.data + } catch { + // silent + } +} + +function openUpdateModal() { + updateModalVisible.value = true + fetchUpdateStatus() +} + +function closeUpdateModal() { + updateModalVisible.value = false + stopPolling() +} + +function startPolling() { + stopPolling() + updatePollTimer.value = setInterval(fetchUpdateStatus, 2000) +} + +function stopPolling() { + if (updatePollTimer.value) { + clearInterval(updatePollTimer.value) + updatePollTimer.value = null + } +} + +async function executeUpdate() { + if (!latestVersion.value) return + executing.value = true + try { + await request.post('/admin/update/execute', { version: latestVersion.value }) + Message.success('系统更新已启动') + startPolling() + } catch (e: any) { + Message.error(e.response?.data?.message || '启动更新失败') + } finally { + executing.value = false + } +} + +async function doRollback() { + rollingBack.value = true + try { + await request.post('/admin/update/rollback') + Message.success('回滚已启动,系统将重启...') + startPolling() + } catch (e: any) { + Message.error(e.response?.data?.message || '回滚失败') + } finally { + rollingBack.value = false + } +} + +function getPhaseLabel(phase: string): string { + const map: Record = { + downloading: '下载中', verifying: '校验中', backing_up: '备份中', + replacing: '替换中', restarting: '重启中', complete: '完成', failed: '失败', + } + return map[phase] || phase +} + +function getStepStatus(phase: string): string { + if (!progress.value) return 'wait' + const phases = ['downloading', 'verifying', 'backing_up', 'replacing', 'restarting'] + const currentIdx = phases.indexOf(progress.value.phase) + const stepIdx = phases.indexOf(phase) + if (isFailed.value) return stepIdx < currentIdx ? 'finish' : stepIdx === currentIdx ? 'error' : 'wait' + if (isComplete.value) return 'finish' + if (stepIdx < currentIdx) return 'finish' + if (stepIdx === currentIdx) return 'process' + return 'wait' +} + +// --- End Version & Update --- + provide('demoMode', demoMode) const { mount: mountWatermark, unmount: unmountWatermark } = useWatermark(demoMessage) @@ -293,6 +416,9 @@ onMounted(() => { updateMobile() window.addEventListener('resize', updateMobile) + // Check for updates on mount + checkUpdate() + axios.get('/api/settings/public').then((res) => { const settings = res.data?.data?.settings if (settings) { @@ -309,6 +435,7 @@ onUnmounted(() => { document.removeEventListener('click', handleClickOutside) window.removeEventListener('resize', updateMobile) clearTimeout(popupHideTimer!) + stopPolling() unmountWatermark() }) @@ -317,10 +444,16 @@ onUnmounted(() => {
- + + + + + + +
+ +
当前版本
+
v{{ appVersion }}
+
+ +
最新版本
+
+ {{ latestVersion ? 'v' + latestVersion : '--' }} +
+
+
+ + + + + + + + + + + + + + + + + + + + +
@@ -930,4 +1180,83 @@ onUnmounted(() => { display: flex; } } + +/* ===== Version Badge & Update Dot ===== */ +.admin-sidebar__version { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 500; + color: rgba(255, 255, 255, 0.55); + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 1px 7px; + margin-left: 6px; + line-height: 18px; + letter-spacing: 0.02em; +} + +.admin-sidebar__update-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #00b42a; + margin-left: 4px; + vertical-align: middle; + position: relative; + top: -1px; + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { box-shadow: 0 0 0 0 rgba(0, 180, 42, 0.4); } + 50% { box-shadow: 0 0 0 4px rgba(0, 180, 42, 0); } +} + +/* ===== Update Modal ===== */ +.update-modal-stat { + flex: 1; + background: var(--ta-bg-secondary); +} + +.update-modal-stat__title { + font-size: 12px; + color: var(--ta-text-tertiary); + margin-bottom: 4px; +} + +.update-modal-stat__value { + font-size: 18px; + font-weight: 600; + color: var(--ta-text-primary); +} + +.update-modal-notes { + max-height: 260px; + overflow-y: auto; + line-height: 1.8; + font-size: 13px; + color: var(--ta-text-secondary); +} + +.update-modal-notes :deep(h1), +.update-modal-notes :deep(h2), +.update-modal-notes :deep(h3) { + margin-top: 12px; + margin-bottom: 6px; + font-size: 15px; +} + +.update-modal-notes :deep(ul), +.update-modal-notes :deep(ol) { + padding-left: 20px; +} + +.update-modal-notes :deep(code) { + background: var(--ta-fill-2); + padding: 1px 4px; + border-radius: 3px; + font-size: 12px; +} diff --git a/web/admin/vite.config.ts b/web/admin/vite.config.ts index 7777b7a..07bea30 100644 --- a/web/admin/vite.config.ts +++ b/web/admin/vite.config.ts @@ -3,9 +3,27 @@ import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' import { fileURLToPath, URL } from 'node:url' import { readFileSync } from 'node:fs' +import { execSync } from 'node:child_process' import { resolve } from 'node:path' -const version = readFileSync(resolve(__dirname, '../../VERSION'), 'utf-8').trim() +function getVersion(): string { + if (process.env.NODE_ENV === 'development') return 'dev' + // 1. 环境变量注入(Docker 构建时通过 VITE_APP_VERSION 传入) + if (process.env.VITE_APP_VERSION) return process.env.VITE_APP_VERSION + // 2. git tag + try { + return execSync('git describe --tags --always', { encoding: 'utf-8' }).trim() + } catch { + // 3. VERSION 文件 + try { + return readFileSync(resolve(__dirname, '../../VERSION'), 'utf-8').trim() + } catch { + return 'dev' + } + } +} + +const version = getVersion() export default defineConfig({ base: '/admin/',