feat(update):新增快捷升级功能

This commit is contained in:
martin committed 2026-06-07 22:27:01 +08:00
1 parent 5117173b4b
commit 0c05366136
29 files changed
+1912 -12

No files matched your search

+5
View File
@@ -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
+2 -2
View File
@@ -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
+4
View File
@@ -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)
+66
View File
@@ -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"`
}
+1
View File
@@ -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
+2
View File
@@ -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=
+11
View File
@@ -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,
+17
View File
@@ -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 = "当前部署模式不支持自动更新"
)
@@ -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)
}
+156
View File
@@ -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
}
@@ -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.
+1
View File
@@ -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"
)
+192
View File
@@ -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))
}
+57
View File
@@ -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
}
+127
View File
@@ -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
}
+268
View File
@@ -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)
}
+95
View File
@@ -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()
}
+96
View File
@@ -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"`
}
+253
View File
@@ -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)
}
+144
View File
@@ -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:
// <hash> <filename>
// or
// <hash> *<filename>
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)
}
+8
View File
@@ -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)
+8
View File
@@ -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 ()
+12 -6
View File
@@ -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
+1 -1
View File
@@ -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
+3
View File
@@ -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=="],
+1
View File
@@ -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",
+2
View File
@@ -1,5 +1,7 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
+331 -2
View File
@@ -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<any>(null)
const releaseNotes = ref('')
const releaseUrl = ref('')
const updatePollTimer = ref<ReturnType<typeof setInterval> | 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<string, string> = {
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()
})
</script>
@@ -317,10 +444,16 @@ onUnmounted(() => {
<div class="admin-layout">
<!-- Desktop Sidebar -->
<div class="admin-sidebar" :class="{ 'admin-sidebar--collapsed': collapsed }">
<div class="admin-sidebar__logo">
<div class="admin-sidebar__logo" @click="openUpdateModal" style="cursor: pointer;">
<div class="admin-sidebar__logo-icon">T</div>
<Transition name="fade">
<span v-if="!collapsed" class="admin-sidebar__logo-text">Team-API</span>
<span v-if="!collapsed" class="admin-sidebar__logo-text">
Team-API
<span class="admin-sidebar__version">
{{ appVersion === 'dev' ? 'dev' : 'v' + appVersion }}
<span v-if="hasUpdate" class="admin-sidebar__update-dot" title="有新版本可用"></span>
</span>
</span>
</Transition>
</div>
@@ -478,6 +611,123 @@ onUnmounted(() => {
</router-view>
</main>
</div>
<!-- Update Modal -->
<a-modal
v-model:visible="updateModalVisible"
:footer="false"
:closable="true"
:mask-closable="true"
title-align="start"
width="520px"
@cancel="closeUpdateModal"
>
<template #title>
<div style="display: flex; align-items: center; gap: 8px;">
<span>系统更新</span>
<a-tag v-if="isDocker" color="blue" size="small">Docker</a-tag>
<a-tag v-else color="green" size="small">二进制</a-tag>
</div>
</template>
<!-- Version info -->
<div style="display: flex; gap: 16px; margin-bottom: 20px;">
<a-card :bordered="false" class="update-modal-stat" size="small">
<div class="update-modal-stat__title">当前版本</div>
<div class="update-modal-stat__value">v{{ appVersion }}</div>
</a-card>
<a-card :bordered="false" class="update-modal-stat" size="small">
<div class="update-modal-stat__title">最新版本</div>
<div class="update-modal-stat__value" :style="hasUpdate ? { color: '#00b42a' } : {}">
{{ latestVersion ? 'v' + latestVersion : '--' }}
</div>
</a-card>
</div>
<!-- Docker mode hint -->
<template v-if="isDocker">
<a-alert type="info" style="margin-bottom: 16px;">
Docker 部署模式不支持在线自动更新,请手动执行:
</a-alert>
<a-input
model-value="docker compose pull && docker compose up -d"
read-only
style="font-family: monospace; margin-bottom: 16px;"
>
<template #append>
<a-button type="text" size="mini" @click="() => { navigator.clipboard.writeText('docker compose pull && docker compose up -d'); Message.success('已复制') }">复制</a-button>
</template>
</a-input>
</template>
<!-- Update available -->
<template v-if="hasUpdate && !isUpdating && !isFailed && !isComplete && !isDocker">
<a-divider style="margin: 16px 0;" />
<div style="margin-bottom: 12px;">
<a-typography-text bold>更新说明</a-typography-text>
</div>
<div class="update-modal-notes" v-html="releaseNotesHtml"></div>
<a-divider style="margin: 16px 0;" />
<div style="display: flex; justify-content: space-between; align-items: center;">
<a-typography-text type="secondary" style="font-size: 12px;">⚠️ 更新过程中服务将短暂中断</a-typography-text>
<a-button type="primary" :loading="executing" @click="executeUpdate">
{{ executing ? '启动中...' : '立即更新' }}
</a-button>
</div>
</template>
<!-- No update -->
<template v-if="!hasUpdate && !isUpdating">
<a-result>
<template #icon><icon-check-circle-fill style="color: #00b42a; font-size: 32px;" /></template>
<template #title><span style="color: #00b42a; font-size: 14px;">当前已是最新版本</span></template>
</a-result>
</template>
<!-- Update progress -->
<template v-if="isUpdating">
<a-divider style="margin: 16px 0;" />
<a-steps :current="1" size="small" style="margin-bottom: 16px;">
<a-step title="下载" :status="getStepStatus('downloading')" />
<a-step title="校验" :status="getStepStatus('verifying')" />
<a-step title="备份" :status="getStepStatus('backing_up')" />
<a-step title="替换" :status="getStepStatus('replacing')" />
<a-step title="重启" :status="getStepStatus('restarting')" />
</a-steps>
<a-progress
:percent="(progress?.percentage || 0) / 100"
:status="isFailed ? 'danger' : isComplete ? 'success' : 'normal'"
/>
<a-typography-text :type="isFailed ? 'danger' : 'secondary'" style="font-size: 13px;">
{{ progress?.message }}
</a-typography-text>
</template>
<!-- Failed with rollback -->
<template v-if="isFailed">
<a-divider style="margin: 16px 0;" />
<a-alert type="error" style="margin-bottom: 12px;">更新失败:{{ progress?.error || '未知错误' }}</a-alert>
<div v-if="rollbackAvailable" style="text-align: right;">
<a-button status="danger" :loading="rollingBack" @click="doRollback">回滚到 v{{ backupVersion }}</a-button>
</div>
</template>
<!-- Complete with rollback option -->
<template v-if="isComplete && rollbackAvailable">
<a-divider style="margin: 16px 0;" />
<div style="display: flex; justify-content: space-between; align-items: center;">
<a-typography-text type="secondary" style="font-size: 12px;">如遇问题可回滚到 v{{ backupVersion }}</a-typography-text>
<a-button status="warning" :loading="rollingBack" @click="doRollback">回滚</a-button>
</div>
</template>
<template v-if="releaseUrl && hasUpdate">
<a-divider style="margin: 8px 0 0;" />
<div style="text-align: center; padding-top: 8px;">
<a-link :href="releaseUrl" target="_blank" style="font-size: 12px;">在 GitHub 上查看完整发布说明</a-link>
</div>
</template>
</a-modal>
</div>
</template>
@@ -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;
}
</style>
+19 -1
View File
@@ -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/',