feat(update):新增快捷升级功能
This commit is contained in:
1 parent
5117173b4b
commit
0c05366136
29 files changed
+1912
-12
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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/',
|
||||
|
||||
Reference in new issue
Block a user