diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e020fbf..36d6136 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,13 +42,16 @@ jobs: id: vars run: | VERSION=${GITHUB_REF_NAME} + # 资产文件名统一去掉 v 前缀,与 Go 侧 getPlatformAssetName 的命名约定一致 + # (内置版本号 VERSION 保留原始 tag,如 v0.2.0;仅文件名用 ASSET_VERSION) + ASSET_VERSION=${VERSION#v} BINARY=team-api if [ "${{ matrix.goos }}" = "windows" ]; then BINARY=team-api.exe fi echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "binary=${BINARY}" >> $GITHUB_OUTPUT - echo "artifact=team-api-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}" >> $GITHUB_OUTPUT + echo "artifact=team-api-${ASSET_VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}" >> $GITHUB_OUTPUT - name: Build env: diff --git a/README.md b/README.md index b71e149..6add65b 100644 --- a/README.md +++ b/README.md @@ -411,11 +411,13 @@ make migrate-status # 查看迁移状态 ## 主线路线图 -1. 完善大模型支持,特别是图像和视频的支持 -2. 支付模块(Easy Pay) -3. 插件功能(定制功能通过插件实现,确保不与主线代码冲突) -4. 开放平台(对接OA的能力,方便企业对接管理) -5. 在线升级 +1. ~~在线升级(已完成)~~ +2. ~~支付模块(Easy Pay 已支持)~~ +3. ~~图片生成同步转异步~~ +4. 完善大模型支持,特别是图像和视频的支持 +5. 角色管理,方便运营团队内部协作,预设角色(管理员,销售,技术支持) +6. 插件功能(定制功能通过插件实现,确保不与主线代码冲突) +7. 开放平台(对接OA的能力,方便企业对接管理) ## 参与贡献 @@ -452,5 +454,4 @@ Copyright (C) 2026 qianfree. 本项目采用 [GNU Affero General Public License - [GoFrame](https://goframe.org/) — Go 应用开发框架 - [new-api](https://github.com/Calcium-Ion/new-api) — AI 网关参考实现 -- [sub2api](https://github.com/sub2api/sub2api) — 监控与亲和性参考实现 - [Naive UI](https://www.naiveui.com/) — Vue 3 组件库 diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index d914a5f..1db0c80 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -731,3 +731,23 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { **正确做法**:测试中需要真实走 ghttp 请求链路(中间件、handler、hook)时,一律用 `testutil.StartGFServer(t, s)` 启动真实 server;**禁止**把 `g.Server` 直接塞进 `httptest.NewServer`——那只会执行 `ServeHTTP` 路径而不执行 `Start()` 的初始化(session manager、graceful listener 等),属于未定义用法。`httptest.NewServer` 仅用于包装自建的普通 `http.Handler`。 排查信号:`go test -v` 输出中出现成段的 `http: panic serving` + `gsession.(*Session).Close` 栈。 + +### 2026-08-14(五):更新接口非指针 int 字段被零值无条件覆写(渠道 weight/priority 被洗成 0) + +**问题**:渠道创建后数据库 `chn_channels.weight` 是 0,但创建表单明明提交了 100。前端"仅切换状态"或"仅更新 API Key"的 PUT 请求会把已有渠道的 weight/priority 洗成 0。 + +**原因**:`ChannelUpdateReq.Weight/Priority` 是非指针 `int`,JSON 里不带该字段时解析为 Go 零值 0,无法区分"未提交"与"显式提交 0";而 `UpdateChannel` 里 `data.Weight = req.Weight` 无条件写入,`do` 结构体的非 nil 字段(含 0)都会进 UPDATE SET 列表,于是 0 覆盖了库里的 100。前端曾在多处用"提交时带上当前 weight"打补丁,但详情页的状态切换/Key 更新漏带,且编辑表单 `weight || 100` 的 falsy 兜底把数据损坏掩盖成"显示正常"。 + +**修复**: + +1. `ChannelUpdateReq.Weight/Priority` 改为 `*int`(与同结构体 `MaxConcurrency`、`Type` 等"留空不更新"字段风格一致),Logic 里仅在非 nil 时赋值; +2. 删除前端"必须携带当前 priority/weight"的 workaround(ChannelsPage 的状态切换/行内权重/行内层级三处),只提交变更字段; +3. 详情页编辑表单去掉 `detail.weight || 100` 兜底,显示真实库值,避免再次掩盖数据异常。 + +**正确做法(通用规则)**: + +- **更新接口的"可选更新"字段一律用指针类型**(`*int`/`*string`/`*bool`),`nil` = 不更新,非 nil(含零值)= 显式更新;非指针字段意味着"调用方必须每次全量携带",要在 API 注释里写明并让所有前端调用点遵守。 +- **do 结构体字段是 `interface{}`**:nil 才会被 ORM 过滤,非 nil 的 0/""/false 都会写入——"零值是否合法的业务值"必须在 Req 层用指针区分,不能指望 ORM 帮你跳过。 +- **前端编辑表单慎用 `x || 默认值` 兜底**:对数值字段,0 会被 falsy 规则吞掉,既掩盖真实数据,又可能在保存时把合法的 0 改写为默认值。 + +排查信号:某字段在库里莫名变成 0/空串,而创建时明明写过合法值——查该表所有 UPDATE 路径中是否存在非指针字段的无条件赋值。 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index eb2a3b1..dd0dffc 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -286,9 +286,14 @@ var ( // 1) 先排空任务池(StopSyncImageWorkers / StopAsyncPolling)——它们收尾时会写 // 用量日志 / 审计 / 结算;此时异步 Writer 必须仍存活。 // 2) 再 flush 关闭异步 Writer。 - // 3) 最后关 webhook / plugin。 + // 3) 最后关 webhook / 调度后台任务 / plugin。 // 因此把两个 task.Stop* 注册在最后(最先执行),Writer 关闭注册在前(后执行)。 + // 0) MaybeExecRestart 注册在最前、最后执行:在线更新/回滚换壳后,用 + // syscall.Exec 原地换壳为新版本(同 PID 继续运行,不依赖外部进程管理器); + // 正常退出时是空操作。必须在所有任务池/Writer 排空之后执行。 + defer update.MaybeExecRestart(ctx) defer plugin.Shutdown(ctx) + defer dispatchadapter.Shutdown() defer tenant.ShutdownWebhookDispatcher() defer common.CloseAuditLogWriter() defer common.CloseChannelErrorWriter() diff --git a/internal/cmd/cmd_aggregate_usage.go b/internal/cmd/cmd_aggregate_usage.go index f0aa62c..57855f5 100644 --- a/internal/cmd/cmd_aggregate_usage.go +++ b/internal/cmd/cmd_aggregate_usage.go @@ -16,7 +16,7 @@ import ( var aggregateUsageCmd = gcmd.Command{ Name: "aggregate-usage", Usage: "aggregate-usage --from --to ", - Brief: "回填 bil_usage_daily 用量日汇总(历史数据)", + Brief: "回填 bil_usage_daily 用量日汇总(历史数据), 一般用不到", Description: ` 将 bil_usage_logs 的历史用量按月分块聚合写入 bil_usage_daily,供流量流向桑基图与趋势分析使用。 按月分块以避免单次扫描全部分区表。--from/--to 均为包含的日期边界;--to 缺省为昨天。 diff --git a/internal/logic/admin/channel_testing.go b/internal/logic/admin/channel_testing.go index ab92409..4b7bf90 100644 --- a/internal/logic/admin/channel_testing.go +++ b/internal/logic/admin/channel_testing.go @@ -8,6 +8,7 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/dispatchadapter" "github.com/qianfree/team-api/internal/logic/common" @@ -87,7 +88,7 @@ func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.C Limit(1). Scan(&keyInfo) if err != nil || keyInfo == nil || keyInfo.EncryptedKey == "" { - return nil, common.NewNotFoundError("渠道没有可用的 API Key") + return nil, common.NewBusinessError(consts.CodeNotFound, "渠道没有可用的 API Key") } encKey := relay.GetEncryptionKey() diff --git a/internal/logic/admin/update.go b/internal/logic/admin/update.go index abcc1b7..d488e55 100644 --- a/internal/logic/admin/update.go +++ b/internal/logic/admin/update.go @@ -3,6 +3,7 @@ package admin import ( "context" "fmt" + "runtime" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" @@ -102,6 +103,11 @@ func (s *sAdmin) UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (* return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotSupported, consts.MsgUpdateNotSupported, nil)) } + // Windows 不支持在线更新:无 exec 换壳语义,且运行中二进制无法被重命名 + if runtime.GOOS == "windows" { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotSupported, "Windows 平台不支持在线更新,请手动更新(下载最新版本替换当前程序)", nil)) + } + // Check if already updating if update.GetManager().IsUpdating() { return nil, gerror.NewCode(gcode.New(consts.CodeUpdateAlreadyRunning, consts.MsgUpdateAlreadyRunning, nil)) @@ -146,6 +152,11 @@ func (s *sAdmin) UpdateRollback(ctx context.Context, req *v1.UpdateRollbackReq) return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotSupported, consts.MsgUpdateNotSupported, nil)) } + // Windows 不支持在线更新(与 UpdateExecute 保持一致) + if runtime.GOOS == "windows" { + return nil, gerror.NewCode(gcode.New(consts.CodeUpdateNotSupported, "Windows 平台不支持在线更新,请手动更新(下载最新版本替换当前程序)", nil)) + } + if err := update.Rollback(ctx); err != nil { return nil, gerror.NewCode(gcode.New(consts.CodeUpdateRollbackFailed, consts.MsgUpdateRollbackFailed+": "+err.Error(), nil)) } diff --git a/internal/logic/tenant/agreement.go b/internal/logic/tenant/agreement.go index 75e0dd7..1327647 100644 --- a/internal/logic/tenant/agreement.go +++ b/internal/logic/tenant/agreement.go @@ -75,7 +75,7 @@ func (s *sTenant) GetCurrentAgreementByCode(ctx context.Context, req *v1.Agreeme return nil, err } if detail == nil { - return nil, common.NewNotFoundError("协议不存在") + return nil, common.NewNotFoundError("协议") } return &v1.AgreementCurrentGetRes{ diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 1db6cea..4851b61 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strconv" "strings" "time" @@ -1065,7 +1066,35 @@ func (s *sTenant) ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApi }, nil } +// 成员导出显示文案,与前端成员列表页保持一致 +var memberRoleLabels = map[string]string{ + "owner": "所有者", + "admin": "管理员", + "member": "成员", +} + +var memberStatusLabels = map[string]string{ + "active": "已激活", + "invited": "已邀请", + "disabled": "已禁用", +} + +var memberPeriodLabels = map[string]string{ + "day": "按天", + "week": "按周", + "month": "按月", +} + +// formatMemberUSD 格式化金额为 $ 前缀、最多两位小数(去掉末尾 0),与前端 formatMoney(precision: 2) 输出一致 +func formatMemberUSD(v float64) string { + s := strconv.FormatFloat(v, 'f', 2, 64) + s = strings.TrimRight(s, "0") + s = strings.TrimSuffix(s, ".") + return "$" + s +} + // ExportMembers exports the tenant member list as CSV or Excel. +// 导出列与成员列表页表格保持一致(用户/角色/状态/额度限制/可用模型/本月消费/加入时间/最后更新)。 func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportReq) (*v1.TenantMemberExportRes, error) { role := middleware.GetUserRole(ctx) if role != "owner" && role != "admin" { @@ -1077,13 +1106,16 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR tenantID := middleware.GetTenantID(ctx) columns := []export.Column{ - {Field: "id", Header: "ID"}, {Field: "username", Header: "用户名"}, - {Field: "email", Header: "邮箱"}, {Field: "display_name", Header: "显示名称"}, + {Field: "email", Header: "邮箱"}, {Field: "role", Header: "角色"}, {Field: "status", Header: "状态"}, - {Field: "created_at", Header: "创建时间"}, + {Field: "quota", Header: "额度限制"}, + {Field: "model_count", Header: "可用模型"}, + {Field: "month_cost", Header: "本月消费(USD)"}, + {Field: "created_at", Header: "加入时间"}, + {Field: "updated_at", Header: "最后更新"}, } config := export.Config{ @@ -1106,27 +1138,92 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR } var users []struct { - Id int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + Id int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + QuotaType string `json:"quota_type"` + QuotaLimit float64 `json:"quota_limit"` + QuotaPeriod string `json:"quota_period"` + UpdatedAt string `json:"updated_at"` } err := model.OrderDesc("id").Limit(1000).Offset(offset).Scan(&users) if err = common.IgnoreScanNoRows(err); err != nil { return } + + // 与列表页口径一致:批量聚合模型范围与本月消费,避免逐行 N+1 查询 + userIds := make([]int64, len(users)) + for i, u := range users { + userIds[i] = u.Id + } + modelCountMap := make(map[int64]int) + hasScopeMap := make(map[int64]bool) + monthCostMap := make(map[int64]float64) + if len(userIds) > 0 { + var scopeRows []struct { + UserId int64 `json:"user_id"` + ModelId int64 `json:"model_id"` + } + if err = dao.TntMemberModelScopes.Ctx(ctx). + Where("tenant_id", tenantID). + Where("user_id", userIds). + Scan(&scopeRows); err == nil { + for _, r := range scopeRows { + hasScopeMap[r.UserId] = true + if r.ModelId > 0 { + modelCountMap[r.UserId]++ + } + } + } + + monthStart := time.Now().Format("2006-01") + "-01 00:00:00" + var costRows []struct { + UserId int64 `json:"user_id"` + Cost float64 `json:"cost"` + } + if err = dao.BilUsageLogs.Ctx(ctx). + Fields("user_id, COALESCE(SUM(total_cost), 0) as cost"). + Where("tenant_id", tenantID). + Where("user_id", userIds). + Where("created_at >= ?", monthStart). + Group("user_id"). + Scan(&costRows); err == nil { + for _, c := range costRows { + monthCostMap[c.UserId] = c.Cost + } + } + } + for _, u := range users { + // 额度限制:不限制 / 金额(周期性追加周期文案),与列表页渲染一致 + quota := "不限制" + if u.QuotaType != "" && u.QuotaType != "none" { + quota = formatMemberUSD(u.QuotaLimit) + if u.QuotaType == "periodic" && u.QuotaPeriod != "" { + quota += "(" + memberPeriodLabels[u.QuotaPeriod] + ")" + } + } + // 可用模型:无任何范围记录 = 不限,否则输出授权数量 + modelCount := "不限" + if hasScopeMap[u.Id] { + modelCount = strconv.Itoa(modelCountMap[u.Id]) + } + if !yield(map[string]any{ - "id": u.Id, "username": u.Username, - "email": u.Email, "display_name": u.DisplayName, - "role": u.Role, - "status": u.Status, + "email": u.Email, + "role": memberRoleLabels[u.Role], + "status": memberStatusLabels[u.Status], + "quota": quota, + "model_count": modelCount, + "month_cost": formatMemberUSD(monthCostMap[u.Id]), "created_at": u.CreatedAt, + "updated_at": u.UpdatedAt, }) { return } diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 6c7e82f..b59f29e 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -45,11 +45,13 @@ type tenantModelPriceRow struct { // groupPriceRow 分组模型的 base 价格查询结果 type groupPriceRow struct { - ModelID int64 `json:"model_id"` - BaseBillingMode string `json:"base_billing_mode"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` - BasePerRequestPrice *float64 `json:"base_per_request_price"` + ModelID int64 `json:"model_id"` + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + BaseCacheReadPrice float64 `json:"base_cache_read_price"` + BaseCacheCreationPrice float64 `json:"base_cache_creation_price"` + BasePerRequestPrice *float64 `json:"base_per_request_price"` } // baseTierRow 阶梯定价查询结果 @@ -89,10 +91,12 @@ type priceInfo struct { // groupPriceInfo 分组模型的价格信息 type groupPriceInfo struct { - BaseBillingMode string - BaseInputPrice float64 - BaseOutputPrice float64 - BasePerRequestPrice *float64 + BaseBillingMode string + BaseInputPrice float64 + BaseOutputPrice float64 + BaseCacheReadPrice float64 + BaseCacheCreationPrice float64 + BasePerRequestPrice *float64 } // ListAvailableModels 获取租户可用的模型列表 @@ -167,7 +171,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab err = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", groupDBIDs). Where("min_tokens", 0). - Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, per_request_price AS base_per_request_price"). + Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, cache_read_price AS base_cache_read_price, cache_creation_price AS base_cache_creation_price, per_request_price AS base_per_request_price"). Scan(&groupPrices) if err != nil { return nil, err @@ -175,10 +179,12 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab for _, gp := range groupPrices { groupPriceMap[gp.ModelID] = &groupPriceInfo{ - BaseBillingMode: gp.BaseBillingMode, - BaseInputPrice: gp.BaseInputPrice, - BaseOutputPrice: gp.BaseOutputPrice, - BasePerRequestPrice: gp.BasePerRequestPrice, + BaseBillingMode: gp.BaseBillingMode, + BaseInputPrice: gp.BaseInputPrice, + BaseOutputPrice: gp.BaseOutputPrice, + BaseCacheReadPrice: gp.BaseCacheReadPrice, + BaseCacheCreationPrice: gp.BaseCacheCreationPrice, + BasePerRequestPrice: gp.BasePerRequestPrice, } } } @@ -272,9 +278,9 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab list = append(list, item) } else { - // group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 阶梯明细) + // group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 缓存价 / 阶梯明细) billingMode := "token" - var inputPrice, outputPrice, perRequestPrice *float64 + var inputPrice, outputPrice, cacheReadPrice, cacheCreationPrice, perRequestPrice *float64 var baseInputPrice, baseOutputPrice float64 gp, ok := groupPriceMap[m.ModelDBID] if ok { @@ -286,23 +292,27 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab baseOutputPrice = gp.BaseOutputPrice inputPrice = effectivePrice(nil, baseInputPrice) outputPrice = effectivePrice(nil, baseOutputPrice) + cacheReadPrice = effectivePrice(nil, gp.BaseCacheReadPrice) + cacheCreationPrice = effectivePrice(nil, gp.BaseCacheCreationPrice) perRequestPrice = gp.BasePerRequestPrice } item := v1.TenantAvailableModelItem{ - ID: m.ModelDBID, - ModelId: m.ModelId, - ModelName: m.ModelName, - Category: m.Category, - MaxContext: m.MaxContextTokens, - MaxOutput: m.MaxOutputTokens, - Description: m.Description, - Tags: m.Tags, - Capabilities: m.Capabilities, - BillingMode: &billingMode, - PerRequestPrice: perRequestPrice, - InputPrice: inputPrice, - OutputPrice: outputPrice, + ID: m.ModelDBID, + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &billingMode, + PerRequestPrice: perRequestPrice, + InputPrice: inputPrice, + OutputPrice: outputPrice, + CacheReadPrice: cacheReadPrice, + CacheCreationPrice: cacheCreationPrice, } if billingMode == "tiered" && ok { item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID]) diff --git a/internal/logic/tenant/open_platform.go b/internal/logic/tenant/open_platform.go index 6f34b80..f406547 100644 --- a/internal/logic/tenant/open_platform.go +++ b/internal/logic/tenant/open_platform.go @@ -456,7 +456,7 @@ func (s *sTenant) WebhookConfigDelete(ctx context.Context, req *v1.WebhookConfig return nil, err } if count == 0 { - return nil, common.NewNotFoundError("Webhook 配置不存在") + return nil, common.NewNotFoundError("Webhook 配置") } // 级联删除:投递日志 → 事件 → 配置 diff --git a/internal/logic/update/checker.go b/internal/logic/update/checker.go index 75c9a8b..be0756b 100644 --- a/internal/logic/update/checker.go +++ b/internal/logic/update/checker.go @@ -35,8 +35,8 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) { } } - // Call GitHub API - release, etag, err := fetchLatestRelease(ctx) + // Call GitHub API(force 时不带 ETag 条件请求,见 fetchLatestRelease 注释) + release, etag, err := fetchLatestRelease(ctx, !force) if err != nil { return nil, fmt.Errorf("failed to fetch latest release: %w", err) } @@ -87,7 +87,8 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) { var assetSize int64 for _, asset := range release.Assets { - if asset.Name == assetName { + // 资产名可能带/不带 v 前缀(历史 release 命名不一致),归一化后比较 + if normalizeAssetName(asset.Name) == assetName { downloadURL = asset.BrowserDownloadURL assetSize = asset.Size } @@ -120,9 +121,21 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) { 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" +// updateAPIBase 返回更新检查的 API 基地址。默认 GitHub 官方 API; +// 可通过配置 update.api_base 覆盖(自建发布服务器 / 本地测试 / 国内镜像)。 +func updateAPIBase(ctx context.Context) string { + if base := g.Cfg().MustGet(ctx, "update.api_base").String(); base != "" { + return strings.TrimSuffix(base, "/") + } + return githubAPIBase +} + +// fetchLatestRelease calls the GitHub Releases API. +// conditional 为 true 时携带 If-None-Match ETag 做条件请求(省配额); +// 强制检测(force)必须传 false:GitHub /releases/latest 有 CDN 缓存,刚发布新版本后 +// 条件请求可能返回过期的 304 Not Modified,导致强制检测仍拿不到最新发行版。 +func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease, string, error) { + url := updateAPIBase(ctx) + "/releases/latest" req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -133,9 +146,11 @@ func fetchLatestRelease(ctx context.Context) (*GitHubRelease, string, error) { 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) + if conditional { + if etagVal := manager.lastETag.Load(); etagVal != nil { + if etag, ok := etagVal.(string); ok && etag != "" { + req.Header.Set("If-None-Match", etag) + } } } @@ -170,8 +185,17 @@ func fetchLatestRelease(ctx context.Context) (*GitHubRelease, string, error) { return &release, etag, nil } -// getCheckCache reads cached check result from Redis -func getCheckCache(ctx context.Context) *CheckResult { +// getCheckCache reads cached check result from Redis. +// 缓存是纯优化(TTL 24h),Redis 未配置/不可用时应降级为无缓存、走实时检查, +// 绝不能因 g.Redis() 客户端创建失败而 panic(见 goframe-conventions.md 已修复记录)。 +func getCheckCache(ctx context.Context) (out *CheckResult) { + defer func() { + if r := recover(); r != nil { + g.Log().Warningf(ctx, "update check cache read skipped: %v", r) + out = nil + } + }() + val, err := g.Redis().Do(ctx, "GET", redisCacheKey) if err != nil || val.IsNil() || val.IsEmpty() { return nil @@ -184,8 +208,14 @@ func getCheckCache(ctx context.Context) *CheckResult { return &result } -// setCheckCache stores check result in Redis +// setCheckCache stores check result in Redis. 与 getCheckCache 同理,失败仅降级不 panic。 func setCheckCache(ctx context.Context, result *CheckResult) { + defer func() { + if r := recover(); r != nil { + g.Log().Warningf(ctx, "update check cache write skipped: %v", r) + } + }() + data, err := json.Marshal(result) if err != nil { return diff --git a/internal/logic/update/checker_test.go b/internal/logic/update/checker_test.go new file mode 100644 index 0000000..4619cfd --- /dev/null +++ b/internal/logic/update/checker_test.go @@ -0,0 +1,80 @@ +package update + +import ( + "fmt" + "runtime" + "strings" + "testing" +) + +func TestCompareVersions(t *testing.T) { + cases := []struct { + name string + current string + latest string + want bool + wantErr bool + }{ + {"落后于最新版", "1.0.0", "1.0.1", true, false}, + {"小幅落后大版本", "1.9.9", "2.0.0", true, false}, + {"版本相同", "1.0.0", "1.0.0", false, false}, + {"当前更新", "1.2.0", "1.1.0", false, false}, + {"latest 带 v 前缀", "1.0.0", "v1.0.1", true, false}, + {"dev 构建保守提示更新", "dev", "0.0.1", true, false}, + {"当前版本无法解析按 dev 处理", "garbage", "0.0.1", true, false}, + {"git describe 版本与基础版本等价", "0.2.0_39", "0.2.0", false, false}, + {"git describe 版本落后于新版", "0.2.0_39", "0.2.1", true, false}, + {"latest 无法解析报错", "1.0.0", "not-a-version", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := compareVersions(c.current, c.latest) + if c.wantErr { + if err == nil { + t.Fatalf("latest=%q 无法解析时应返回错误", c.latest) + } + return + } + if err != nil { + t.Fatalf("比较失败: %v", err) + } + if got != c.want { + t.Errorf("compareVersions(%q, %q) = %v, want %v", c.current, c.latest, got, c.want) + } + }) + } +} + +func TestNormalizeSemver(t *testing.T) { + cases := []struct{ in, want string }{ + {"0.2.0_39", "0.2.0+39"}, // Makefile git describe 版本:下划线转构建元数据 + {"v1.2.3", "1.2.3"}, // v 前缀剥离 + {"1.2.3", "1.2.3"}, // 原样保留 + {"dev", "dev"}, + } + for _, c := range cases { + if got := normalizeSemver(c.in); got != c.want { + t.Errorf("normalizeSemver(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestGetPlatformAssetName(t *testing.T) { + ext := "tar.gz" + if runtime.GOOS == "windows" { + ext = "zip" + } + + // v 前缀应被剥离 + got := getPlatformAssetName("v1.2.3") + want := fmt.Sprintf("team-api-1.2.3-%s-%s.%s", runtime.GOOS, runtime.GOARCH, ext) + if got != want { + t.Errorf("getPlatformAssetName(v1.2.3) = %q, want %q", got, want) + } + + // 无前缀原样使用 + if got := getPlatformAssetName("2.0.0"); !strings.Contains(got, "team-api-2.0.0-") { + t.Errorf("getPlatformAssetName(2.0.0) = %q, 应包含 team-api-2.0.0-", got) + } +} diff --git a/internal/logic/update/docker_test.go b/internal/logic/update/docker_test.go new file mode 100644 index 0000000..5c70030 --- /dev/null +++ b/internal/logic/update/docker_test.go @@ -0,0 +1,44 @@ +package update + +import "testing" + +func TestContainsDockerIndicator(t *testing.T) { + cases := []struct { + name string + content string + want bool + }{ + {"docker cgroup 路径", "0::/docker/abc123", true}, + {"docker- 前缀", "12:pids:/docker-abc", true}, + {"包含 docker 字样", "1:name=systemd/docker.slice", true}, + {"k8s 环境", "0::/kubepods/pod123", false}, + {"普通 cgroup", "0::/user.slice/user-1000.slice", false}, + {"空内容", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := containsDockerIndicator(c.content); got != c.want { + t.Errorf("containsDockerIndicator(%q) = %v, want %v", c.content, got, c.want) + } + }) + } +} + +func TestContains(t *testing.T) { + cases := []struct { + s, sub string + want bool + }{ + {"hello docker world", "docker", true}, + {"hello docker world", "world", true}, + {"hello", "hello", true}, // 与自身相等 + {"abc", "abcd", false}, // 子串比源串长 + {"", "", true}, // 双空串 + {"abc", "", true}, // 空子串视为匹配(containsDockerIndicator 已另行过滤空内容) + } + for _, c := range cases { + if got := contains(c.s, c.sub); got != c.want { + t.Errorf("contains(%q, %q) = %v, want %v", c.s, c.sub, got, c.want) + } + } +} diff --git a/internal/logic/update/exec_unix.go b/internal/logic/update/exec_unix.go new file mode 100644 index 0000000..754dd68 --- /dev/null +++ b/internal/logic/update/exec_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +package update + +import ( + "os" + "syscall" +) + +// execSelf 用 syscall.Exec 将当前进程原地替换为 bin 指向的新版本二进制。 +// 成功时进程镜像被替换、永不返回(PID 不变,外部进程管理器感知不到重启); +// 失败返回 false,由调用方决定退出。 +func execSelf(bin string) bool { + return syscall.Exec(bin, os.Args, os.Environ()) == nil +} diff --git a/internal/logic/update/exec_windows.go b/internal/logic/update/exec_windows.go new file mode 100644 index 0000000..692d1cf --- /dev/null +++ b/internal/logic/update/exec_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package update + +// execSelf Windows 无 exec 语义(在线更新仅支持 Linux),返回 false 由调用方退出。 +func execSelf(bin string) bool { + return false +} diff --git a/internal/logic/update/graceful_exit.go b/internal/logic/update/graceful_exit.go new file mode 100644 index 0000000..1ff36f2 --- /dev/null +++ b/internal/logic/update/graceful_exit.go @@ -0,0 +1,82 @@ +package update + +import ( + "context" + "os" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +const ( + // exitResponseGrace 让 HTTP 响应先写出的等待时间 + exitResponseGrace = 500 * time.Millisecond + // gracefulExitTimeout 优雅关闭链路的最长等待。需覆盖 cmd.go defer 链中 + // sync-image 任务池的收尾等待(3 分钟),再留余量;超时则强制换壳/退出兜底, + // 防止个别长连接把退出拖到无限长。 + gracefulExitTimeout = 4 * time.Minute +) + +// gracefulExit 更新/回滚完成后的进程退出入口。 +// +// 不能直接 os.Exit:那会跳过 cmd.go 中 s.Run() 之后的 defer 链(排空任务池、 +// flush 用量/审计异步 Writer、退款在途任务),导致计费数据丢失。 +// 正确做法是向自身发送 SIGTERM,走 GoFrame 的信号处理链路: +// +// SIGTERM → ghttp 关闭监听并等待在途请求 → s.Run() 返回 +// → cmd.go defer 链执行(LIFO,排空任务池/Writer) +// → 末尾 MaybeExecRestart 用 syscall.Exec 原地换壳为新版本 +// (同 PID 继续运行,不依赖外部进程管理器拉起) +// → 若没有待重启版本则自然退出 +// +// Windows 无自信号机制(且在线更新仅支持 Linux),退化为直接退出。 +func gracefulExit(ctx context.Context, reason string) { + go func() { + time.Sleep(exitResponseGrace) + g.Log().Infof(ctx, "%s: requesting graceful shutdown", reason) + + if !signalSelfTerm() { + // 平台不支持自信号:无优雅链路可用,直接退出(不阻塞调用方) + os.Exit(0) + } + + // 优雅链路正常情况下会走完 cmd.go defer 链末尾的 MaybeExecRestart 换壳; + // 若超过上限仍未完成(如 SSE 流长时间不结束),defer 链不会执行, + // 这里直接原地换壳/退出兜底,防止服务停机。 + time.Sleep(gracefulExitTimeout) + g.Log().Warningf(ctx, "%s: graceful shutdown not finished within %v, restarting directly", + reason, gracefulExitTimeout) + restartOrExit(ctx) + }() +} + +// MaybeExecRestart 是 cmd.go defer 链的最后一步(注册在最前、最后执行)。 +// 若存在待重启的新版本(在线更新/回滚已完成换壳),用 syscall.Exec 原地换壳: +// 进程 PID 不变,不依赖外部进程管理器拉起;正常退出(无待重启版本)时是空操作, +// 不干预普通关闭流程。 +func MaybeExecRestart(ctx context.Context) { + bin := manager.ConsumeRestartBinary() + if bin == "" { + return + } + g.Log().Infof(ctx, "Restarting into %s", bin) + if !execSelf(bin) { + g.Log().Errorf(ctx, "Failed to exec %s, exiting", bin) + os.Exit(1) + } + // exec 成功时进程镜像已被替换,永不返回 +} + +// restartOrExit 超时兜底入口:此时 cmd.go defer 链尚未执行,直接原地换壳; +// 无待重启版本则直接退出。 +func restartOrExit(ctx context.Context) { + bin := manager.ConsumeRestartBinary() + if bin == "" { + os.Exit(0) + } + g.Log().Infof(ctx, "Restarting into %s (timeout fallback)", bin) + if !execSelf(bin) { + g.Log().Errorf(ctx, "Failed to exec %s, exiting", bin) + os.Exit(1) + } +} diff --git a/internal/logic/update/graceful_exit_test.go b/internal/logic/update/graceful_exit_test.go new file mode 100644 index 0000000..94ed584 --- /dev/null +++ b/internal/logic/update/graceful_exit_test.go @@ -0,0 +1,34 @@ +package update + +import ( + "context" + "testing" +) + +// TestSetAndConsumeRestartBinary 校验待重启二进制路径的写入/读取语义: +// 读取即失效,保证换壳只发生一次(防止 update 与 rollback、超时兜底与正常退出重复换壳)。 +func TestSetAndConsumeRestartBinary(t *testing.T) { + m := &UpdateManager{} + + m.SetRestartBinary("/opt/team-api/team-api") + if got := m.ConsumeRestartBinary(); got != "/opt/team-api/team-api" { + t.Fatalf("ConsumeRestartBinary() = %q, want %q", got, "/opt/team-api/team-api") + } + + // 二次读取应为空:换壳只允许发生一次 + if got := m.ConsumeRestartBinary(); got != "" { + t.Fatalf("second ConsumeRestartBinary() = %q, want empty", got) + } +} + +// TestMaybeExecRestartNoop 无待重启版本时 MaybeExecRestart 必须是空操作 +// (不 exec、不退出),保证普通关闭路径不被在线更新逻辑干扰。 +func TestMaybeExecRestartNoop(t *testing.T) { + // 清空单例可能残留的待重启状态,确保进入空操作分支 + if manager.ConsumeRestartBinary() != "" { + t.Fatal("manager 单例残留了待重启状态,测试前置清理失败") + } + + // 不应 panic、不应触发 exec/退出 + MaybeExecRestart(context.Background()) +} diff --git a/internal/logic/update/graceful_exit_unix.go b/internal/logic/update/graceful_exit_unix.go new file mode 100644 index 0000000..21959e4 --- /dev/null +++ b/internal/logic/update/graceful_exit_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package update + +import ( + "syscall" +) + +// signalSelfTerm 向自身发送 SIGTERM,触发 GoFrame 的优雅关闭信号处理 +func signalSelfTerm() bool { + return syscall.Kill(syscall.Getpid(), syscall.SIGTERM) == nil +} diff --git a/internal/logic/update/graceful_exit_windows.go b/internal/logic/update/graceful_exit_windows.go new file mode 100644 index 0000000..41abd2b --- /dev/null +++ b/internal/logic/update/graceful_exit_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package update + +// signalSelfTerm Windows 无自信号机制,返回 false 由调用方直接退出 +func signalSelfTerm() bool { + return false +} diff --git a/internal/logic/update/replacer.go b/internal/logic/update/replacer.go index 023ff93..bf5e0eb 100644 --- a/internal/logic/update/replacer.go +++ b/internal/logic/update/replacer.go @@ -19,6 +19,10 @@ import ( "github.com/qianfree/team-api/internal/consts" ) +// executablePath 获取当前二进制路径。默认 os.Executable;本地集成测试可覆盖为 +// 指向临时目录的假二进制,避免真实替换测试进程自身(见 update_integration_test.go)。 +var executablePath = os.Executable + // 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 @@ -32,6 +36,13 @@ func ExecuteUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL return fmt.Errorf("auto-update is not supported in Docker mode") } + // Windows 不支持在线更新:无 exec 换壳语义(见 exec_windows.go), + // 且 Windows 上运行中的二进制无法被重命名,替换必然失败,显式拒绝 + if runtime.GOOS == "windows" { + manager.updating.Store(false) + return fmt.Errorf("auto-update is not supported on Windows") + } + // Check download URL if downloadURL == "" { manager.updating.Store(false) @@ -67,41 +78,45 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL } }() + if _, err := stageUpdate(ctx, targetVersion, downloadURL, checksumURL, assetSize); err != nil { + return // stageUpdate 已设置失败状态并记录日志 + } + + // 触发重启:SIGTERM 优雅关闭 → cmd.go defer 链排空任务池/flush 日志 + // → MaybeExecRestart 用 syscall.Exec 原地换壳为新版本(同 PID,不依赖进程管理器) + manager.setProgress(PhaseRestarting, "正在重启服务...", 90) + g.Log().Info(ctx, "Update complete, restarting...") + gracefulExit(ctx, "update complete") +} + +// stageUpdate 执行下载→校验→备份→替换→写标记的完整流水线,返回替换后的新二进制路径。 +// 与 performUpdate 分离,便于本地集成测试直接驱动生产流水线而不触发进程重启(exec)。 +func stageUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL string, assetSize int64) (string, error) { // 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 + return "", err } - // Step 2: Verify + // Step 2: Verify(校验失败一律中止,不放行未通过完整性校验的更新包) if err := VerifyChecksum(ctx, dlResult.FilePath, checksumURL, assetName); err != nil { - manager.setProgressError("文件校验失败", err.Error()) + manager.setProgressError("文件校验失败,更新包可能已破损", err.Error()) g.Log().Errorf(ctx, "Checksum verification failed: %v", err) _ = os.Remove(dlResult.FilePath) - return + return "", err } - // 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() + // Step 3: 定位当前二进制路径 + // 新二进制必须提取到与当前二进制相同的目录(同一文件系统), + // 否则后续 os.Rename 会因跨设备(EXDEV)失败,如 /tmp 与部署目录不在同一分区 + currentExe, err := executablePath() if err != nil { manager.setProgressError("获取当前程序路径失败", err.Error()) - _ = os.Remove(newBinaryPath) - return + _ = os.Remove(dlResult.FilePath) + return "", err } // Resolve symlinks currentExe, err = filepath.EvalSymlinks(currentExe) @@ -109,11 +124,25 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL g.Log().Warningf(ctx, "Failed to resolve symlink: %v", err) } + // Step 4: Extract binary from tarball(提取到当前二进制同目录) + manager.setProgress(PhaseBackingUp, "正在提取二进制文件...", 10) + newBinaryPath, err := extractBinary(ctx, dlResult.FilePath, currentExe+".new") + if err != nil { + manager.setProgressError("提取文件失败", err.Error()) + g.Log().Errorf(ctx, "Extract failed: %v", err) + _ = os.Remove(dlResult.FilePath) + return "", err + } + _ = os.Remove(dlResult.FilePath) // clean up tarball + + // Step 5: Backup current binary + manager.setProgress(PhaseBackingUp, "正在备份当前版本...", 30) + 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 + return "", err } g.Log().Infof(ctx, "Backed up current binary to %s", backupPath) @@ -128,35 +157,41 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL g.Log().Warningf(ctx, "Failed to save rollback info: %v", err) } - // Step 5: Replace binary + // Step 6: 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 + return "", err } - // Step 6: Write pending verification marker + // Step 7: Write pending verification marker pendingData, _ := json.Marshal(struct { - Version string `json:"version"` - OldBinary string `json:"old_binary"` + Version string `json:"version"` + OldBinary string `json:"old_binary"` + OldVersion string `json:"old_version"` }{ - Version: targetVersion, - OldBinary: backupPath, + Version: targetVersion, + // 旧版本被 replaceBinary 重命名为 {exe}.old 的实际路径:自检成功后据此清理; + // 回滚仍走 rollback.json 的 .backup.* 副本,不受该清理影响 + OldBinary: currentExe + ".old", + // 记录升级前版本号:自检完成后展示 rollback 入口时需要显示版本而非备份路径 + OldVersion: consts.Version, }) _ = 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 + // 记录待重启的新版本路径:cmd.go 退出链末尾用 syscall.Exec 原地换壳为新版本, + // 同 PID 继续运行,不依赖外部进程管理器拉起(见 gracefulExit 注释) + manager.SetRestartBinary(currentExe) - os.Exit(0) + return currentExe, nil } -// extractBinary extracts the team-api binary from a tar.gz archive -func extractBinary(ctx context.Context, tarballPath string) (string, error) { +// extractBinary extracts the team-api binary from a tar.gz archive to outputPath. +// outputPath 必须与被替换的二进制位于同一文件系统(通常为同目录), +// 否则 replaceBinary 中的 os.Rename 会因跨设备(EXDEV)失败 +func extractBinary(ctx context.Context, tarballPath, outputPath string) (string, error) { f, err := os.Open(tarballPath) if err != nil { return "", err @@ -185,8 +220,6 @@ func extractBinary(ctx context.Context, tarballPath string) (string, error) { // 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 diff --git a/internal/logic/update/replacer_test.go b/internal/logic/update/replacer_test.go new file mode 100644 index 0000000..720a67d --- /dev/null +++ b/internal/logic/update/replacer_test.go @@ -0,0 +1,200 @@ +package update + +import ( + "archive/tar" + "compress/gzip" + "context" + "os" + "path/filepath" + "runtime" + "testing" +) + +// createTarball 在临时目录生成包含指定文件的 tar.gz,返回其路径。 +// files 的 key 为归档内路径,value 为文件内容。 +func createTarball(t *testing.T, files map[string]string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "asset.tar.gz") + + f, err := os.Create(path) + if err != nil { + t.Fatalf("创建 tarball 失败: %v", err) + } + defer f.Close() + + gzw := gzip.NewWriter(f) + defer gzw.Close() + + tw := tar.NewWriter(gzw) + defer tw.Close() + + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + }); err != nil { + t.Fatalf("写 tar header 失败: %v", err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatalf("写 tar 内容失败: %v", err) + } + } + return path +} + +func TestExtractBinary_正常提取(t *testing.T) { + tarball := createTarball(t, map[string]string{ + "team-api-1.0.0-linux-amd64/team-api": "new-binary-content", + "team-api-1.0.0-linux-amd64/README.md": "docs", + }) + + outPath := filepath.Join(t.TempDir(), "team-api.new") + got, err := extractBinary(context.Background(), tarball, outPath) + if err != nil { + t.Fatalf("提取二进制失败: %v", err) + } + if got != outPath { + t.Errorf("返回路径应为 %s,实际 %s", outPath, got) + } + + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("读取提取结果失败: %v", err) + } + if string(data) != "new-binary-content" { + t.Errorf("提取内容不匹配: %q", string(data)) + } + + // 提取出的二进制必须带可执行权限(Windows 不支持 Unix 权限位,跳过) + if runtime.GOOS != "windows" { + info, err := os.Stat(outPath) + if err != nil { + t.Fatalf("stat 提取结果失败: %v", err) + } + if info.Mode().Perm()&0100 == 0 { + t.Errorf("提取的二进制缺少可执行权限: %v", info.Mode().Perm()) + } + } +} + +func TestExtractBinary_归档中无二进制(t *testing.T) { + tarball := createTarball(t, map[string]string{ + "team-api-1.0.0-linux-amd64/README.md": "docs", + }) + + outPath := filepath.Join(t.TempDir(), "team-api.new") + if _, err := extractBinary(context.Background(), tarball, outPath); err == nil { + t.Fatal("归档中不含 team-api 二进制时应返回错误") + } +} + +func TestReplaceBinary_正常替换(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "team-api") + newBin := filepath.Join(dir, "team-api.new") + + if err := os.WriteFile(current, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newBin, []byte("new"), 0755); err != nil { + t.Fatal(err) + } + + if err := replaceBinary(context.Background(), current, newBin); err != nil { + t.Fatalf("替换失败: %v", err) + } + + // 新内容应已就位 + data, _ := os.ReadFile(current) + if string(data) != "new" { + t.Errorf("替换后二进制内容应为 new,实际 %q", string(data)) + } + // 旧版本应被挪到 .old + oldData, _ := os.ReadFile(current + ".old") + if string(oldData) != "old" { + t.Errorf("旧二进制应保留在 .old,实际 %q", string(oldData)) + } + // 替换后必须可执行(Windows 不支持 Unix 权限位,跳过) + if runtime.GOOS != "windows" { + info, _ := os.Stat(current) + if info.Mode().Perm()&0100 == 0 { + t.Errorf("替换后的二进制缺少可执行权限: %v", info.Mode().Perm()) + } + } +} + +func TestReplaceBinary_残留old文件被清理(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "team-api") + newBin := filepath.Join(dir, "team-api.new") + + for path, content := range map[string]string{current: "old", newBin: "new", current + ".old": "stale"} { + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + t.Fatal(err) + } + } + + if err := replaceBinary(context.Background(), current, newBin); err != nil { + t.Fatalf("替换失败: %v", err) + } + + oldData, _ := os.ReadFile(current + ".old") + if string(oldData) != "old" { + t.Errorf("残留 .old 应被清理并被当前旧版本覆盖,实际 %q", string(oldData)) + } +} + +func TestReplaceBinary_失败时自救恢复(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "team-api") + + if err := os.WriteFile(current, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + + // newBinary 不存在:第二步 rename 失败,应把 current 从 .old 恢复回来 + missing := filepath.Join(dir, "not-exist.new") + if err := replaceBinary(context.Background(), current, missing); err == nil { + t.Fatal("新二进制缺失时替换应返回错误") + } + + data, _ := os.ReadFile(current) + if string(data) != "old" { + t.Errorf("替换失败后当前二进制应恢复原内容,实际 %q", string(data)) + } + if _, err := os.Stat(current + ".old"); !os.IsNotExist(err) { + t.Error("自救恢复后 .old 不应残留") + } +} + +func TestCopyFile_内容与权限一致(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + if err := os.WriteFile(src, []byte("binary-bytes"), 0755); err != nil { + t.Fatal(err) + } + + if err := copyFile(src, dst); err != nil { + t.Fatalf("复制失败: %v", err) + } + + data, _ := os.ReadFile(dst) + if string(data) != "binary-bytes" { + t.Errorf("复制内容不匹配: %q", string(data)) + } + srcInfo, _ := os.Stat(src) + dstInfo, _ := os.Stat(dst) + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + t.Errorf("复制后权限不一致: src=%v dst=%v", srcInfo.Mode().Perm(), dstInfo.Mode().Perm()) + } +} + +func TestCopyFile_源文件不存在(t *testing.T) { + if err := copyFile(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil { + t.Fatal("源文件不存在时应返回错误") + } +} diff --git a/internal/logic/update/rollback.go b/internal/logic/update/rollback.go index 77b0332..c4c636a 100644 --- a/internal/logic/update/rollback.go +++ b/internal/logic/update/rollback.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "github.com/gogf/gf/v2/frame/g" @@ -18,6 +19,11 @@ func Rollback(ctx context.Context) error { return fmt.Errorf("rollback is not supported in Docker mode") } + // Windows 无 exec 换壳语义,且运行中二进制无法被重命名,显式拒绝 + if runtime.GOOS == "windows" { + return fmt.Errorf("rollback is not supported on Windows") + } + if manager.IsUpdating() { return fmt.Errorf("cannot rollback while update is in progress") } @@ -62,15 +68,21 @@ func Rollback(ctx context.Context) error { _ = os.Chmod(currentExe, 0755) + // 记录待重启的旧版本路径:退出链末尾用 syscall.Exec 原地换壳回到旧版本, + // 同 PID 继续运行,不依赖外部进程管理器拉起(见 gracefulExit 注释) + manager.SetRestartBinary(currentExe) + // 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) + g.Log().Infof(ctx, "Rollback to %s complete, scheduling restart...", info.BackupVersion) - // Exit — process supervisor will restart with the old binary - os.Exit(0) + // 走 SIGTERM 优雅退出(见 gracefulExit 注释):先让 handler 把"回滚已启动" + // 响应写出去,再触发优雅关闭链路排空任务池/异步 Writer 后自然退出, + // 由进程管理器(systemd/supervisor 等)拉起旧版本 + gracefulExit(ctx, "rollback complete") return nil } diff --git a/internal/logic/update/types.go b/internal/logic/update/types.go index 689eee5..8f6b10b 100644 --- a/internal/logic/update/types.go +++ b/internal/logic/update/types.go @@ -3,6 +3,7 @@ package update import ( "sync" "sync/atomic" + "time" "github.com/gogf/gf/v2/os/gtime" ) @@ -65,6 +66,15 @@ type Progress struct { Message string `json:"message"` Percentage int `json:"percentage"` Error string `json:"error,omitempty"` + // FinishedAt 终态(complete/failed)产生时间。终态进度只在内存中保留一段 + // 时间供发起升级的管理员查看,超过 progressExpire 后 GetStatus 不再返回, + // 避免上次升级留下的快照永久残留误导后续状态判断 + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// isTerminalPhase 判断进度是否处于终态(complete/failed),只有终态会过期 +func isTerminalPhase(phase string) bool { + return phase == PhaseComplete || phase == PhaseFailed } // RollbackInfo holds information needed for rollback (persisted to disk) @@ -82,6 +92,27 @@ type UpdateManager struct { mu sync.Mutex // serialize update operations checkResult atomic.Value // *CheckResult lastETag atomic.Value // string - GitHub API ETag for conditional requests + + restartMu sync.Mutex // 保护 restartBin + restartBin string // 待重启的二进制路径(在线更新/回滚换壳后由 exec 原地换入) +} + +// SetRestartBinary 记录待重启的二进制路径。在线更新/回滚已完成换壳、即将触发 +// 优雅退出前调用;cmd.go 退出链末尾的 MaybeExecRestart 会消费该路径执行换壳。 +func (m *UpdateManager) SetRestartBinary(bin string) { + m.restartMu.Lock() + defer m.restartMu.Unlock() + m.restartBin = bin +} + +// ConsumeRestartBinary 读取并清空待重启二进制路径。读取即失效(取一次即删), +// 保证换壳只发生一次,避免 update 与 rollback、超时兜底与正常退出路径重复执行。 +func (m *UpdateManager) ConsumeRestartBinary() string { + m.restartMu.Lock() + defer m.restartMu.Unlock() + bin := m.restartBin + m.restartBin = "" + return bin } // Status holds the full update status diff --git a/internal/logic/update/types_test.go b/internal/logic/update/types_test.go new file mode 100644 index 0000000..d4ca3eb --- /dev/null +++ b/internal/logic/update/types_test.go @@ -0,0 +1,157 @@ +package update + +import ( + "testing" + "time" +) + +// 使用独立实例而非全局单例,避免测试间状态污染 +func newTestManager() *UpdateManager { + return &UpdateManager{} +} + +func TestUpdateManager_默认状态(t *testing.T) { + m := newTestManager() + + status := m.GetStatus() + if status.Updating { + t.Error("初始状态不应处于更新中") + } + if status.Progress != nil { + t.Errorf("初始状态不应有进度信息,实际 %+v", status.Progress) + } + if m.IsUpdating() { + t.Error("初始 updating 锁应为 false") + } + if m.GetCheckResult() != nil { + t.Error("初始检查结果应为空") + } +} + +func TestUpdateManager_进度状态机(t *testing.T) { + m := newTestManager() + + m.setProgress(PhaseDownloading, "正在下载...", 50) + status := m.GetStatus() + if !status.Updating { + t.Error("设置进度后应处于更新中") + } + if status.Progress == nil || status.Progress.Phase != PhaseDownloading || status.Progress.Percentage != 50 { + t.Errorf("进度未正确记录: %+v", status.Progress) + } + + // 多次 setProgress 应覆盖同一份状态快照 + m.setProgress(PhaseVerifying, "正在校验...", 80) + if p := m.GetStatus().Progress; p.Phase != PhaseVerifying || p.Percentage != 80 { + t.Errorf("进度覆盖失败: %+v", p) + } +} + +func TestUpdateManager_失败收尾(t *testing.T) { + m := newTestManager() + + m.setProgress(PhaseDownloading, "正在下载...", 50) + m.setProgressError("文件校验失败,更新包可能已破损", "SHA256 校验不一致") + + status := m.GetStatus() + if status.Updating { + t.Error("失败后 Updating 应复位为 false") + } + if status.Progress == nil || status.Progress.Phase != PhaseFailed { + t.Fatalf("失败后进度应标记 PhaseFailed: %+v", status.Progress) + } + if status.Progress.Message != "文件校验失败,更新包可能已破损" { + t.Errorf("失败提示未记录: %q", status.Progress.Message) + } + if status.Progress.Error == "" { + t.Error("失败详情未记录") + } + if m.IsUpdating() { + t.Error("失败后 updating 锁应释放,允许再次发起更新") + } +} + +func TestUpdateManager_更新锁互斥(t *testing.T) { + m := newTestManager() + + if !m.updating.CompareAndSwap(false, true) { + t.Fatal("首次抢锁应成功") + } + if m.updating.CompareAndSwap(false, true) { + t.Fatal("更新进行中再次抢锁应失败") + } + if !m.IsUpdating() { + t.Error("抢锁后 IsUpdating 应为 true") + } + + m.updating.Store(false) + if m.IsUpdating() { + t.Error("释放后 IsUpdating 应为 false") + } +} + +func TestGetStatus_终态进度过期自动失效(t *testing.T) { + m := newTestManager() + + // 上次升级留下的过期 complete 快照:不应再下发 + stale := time.Now().Add(-(progressExpire + time.Minute)) + m.status.Store(&Status{ + CurrentVersion: "0.2.7", + Updating: false, + Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100, FinishedAt: &stale}, + }) + + status := m.GetStatus() + if status.Progress != nil { + t.Errorf("过期的终态进度不应返回: %+v", status.Progress) + } + if status.CurrentVersion != "0.2.7" { + t.Errorf("剔除过期进度不应影响其余状态字段: %+v", status) + } + + // 过期的 failed 同样失效 + m.status.Store(&Status{ + Updating: false, + Progress: &Progress{Phase: PhaseFailed, Message: "失败", FinishedAt: &stale}, + }) + if got := m.GetStatus().Progress; got != nil { + t.Errorf("过期的 failed 进度不应返回: %+v", got) + } +} + +func TestGetStatus_终态进度窗口内保留(t *testing.T) { + m := newTestManager() + + // 刚完成的 complete:管理员仍在轮询确认窗口内,应正常返回 + fresh := time.Now().Add(-1 * time.Minute) + m.status.Store(&Status{ + Updating: false, + Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100, FinishedAt: &fresh}, + }) + + status := m.GetStatus() + if status.Progress == nil || status.Progress.Phase != PhaseComplete { + t.Errorf("窗口内的终态进度应保留: %+v", status.Progress) + } +} + +func TestGetStatus_非终态进度不过期(t *testing.T) { + m := newTestManager() + + // 进行中的进度(无 FinishedAt)不受过期机制影响 + m.status.Store(&Status{ + Updating: true, + Progress: &Progress{Phase: PhaseDownloading, Message: "正在下载...", Percentage: 50}, + }) + + status := m.GetStatus() + if status.Progress == nil || status.Progress.Phase != PhaseDownloading { + t.Errorf("进行中的进度不应被剔除: %+v", status.Progress) + } + + // setProgressError 写入的 failed 带时间戳,窗口内可读 + m.setProgressError("失败", "boom") + if got := m.GetStatus().Progress; got == nil || got.Phase != PhaseFailed || got.FinishedAt == nil { + t.Errorf("setProgressError 应写入带时间戳的 failed 进度: %+v", got) + } +} diff --git a/internal/logic/update/update.go b/internal/logic/update/update.go index ab9d999..66a1ca3 100644 --- a/internal/logic/update/update.go +++ b/internal/logic/update/update.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "fmt" + "net" "os" "path/filepath" "runtime" + "strconv" + "strings" "time" "github.com/gogf/gf/v2/frame/g" @@ -14,15 +17,19 @@ import ( "github.com/qianfree/team-api/internal/consts" ) +// updateDir is the temp directory for update operations(本地集成测试可覆盖为临时目录) +var updateDir = "/tmp/team-api-update" + 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 + // progressExpire 终态进度(complete/failed)的保留时长: + // 覆盖一次升级完成后管理员轮询确认的窗口,超时即不再下发 + progressExpire = 30 * time.Minute ) // manager is the singleton UpdateManager @@ -49,6 +56,29 @@ func InitManager(ctx context.Context) { g.Log().Info(ctx, "Update manager initialized") } +// selfHealthURL 构造升级后自检用的本机健康检查地址。 +// 端口优先取 ghttp 运行时实际监听端口(GetListenedPort),服务尚未完成监听时 +// 从配置 server.address 兜底解析,避免部署自定义端口后仍探测默认的 18888。 +// 始终探测 127.0.0.1(GetListenedAddress 可能是 0.0.0.0/[::] 这类通配地址, +// 不能直接作为连接目标)。 +func selfHealthURL() string { + if port := g.Server().GetListenedPort(); port > 0 { + return fmt.Sprintf("http://127.0.0.1:%d/api/health", port) + } + + // 兜底:服务未开始监听(理论上自检在启动 5 秒后才开始,很难走到这里) + if addr := g.Cfg().MustGet(context.Background(), "server.address").String(); addr != "" { + if _, portStr, err := net.SplitHostPort(addr); err == nil { + if port, err := strconv.Atoi(portStr); err == nil && port > 0 { + return fmt.Sprintf("http://127.0.0.1:%d/api/health", port) + } + } + } + + // 最终兜底:默认端口 + return "http://127.0.0.1:18888/api/health" +} + // CheckPendingVerification checks if this process just started after an update // and verifies the new version is healthy func CheckPendingVerification(ctx context.Context) { @@ -59,8 +89,9 @@ func CheckPendingVerification(ctx context.Context) { } var info struct { - Version string `json:"version"` - OldBinary string `json:"old_binary"` + Version string `json:"version"` + OldBinary string `json:"old_binary"` + OldVersion string `json:"old_version"` } if err := json.Unmarshal(data, &info); err != nil { _ = os.Remove(pendingPath) @@ -73,14 +104,24 @@ func CheckPendingVerification(ctx context.Context) { go func() { time.Sleep(5 * time.Second) - // Check own health endpoint + // 回滚入口展示用的版本号:优先取 pending 标记里的旧版本号; + // 旧版升级流程写的标记没有该字段,回落读 rollback.json + backupVersion := info.OldVersion + if backupVersion == "" { + if rb, rbErr := loadRollbackInfo(); rbErr == nil && rb != nil { + backupVersion = rb.BackupVersion + } + } + + verifiedAt := time.Now() + // Check own health endpoint(端口取运行时实际监听端口,见 selfHealthURL 注释) client := g.Client() - resp, err := client.Get(ctx, "http://127.0.0.1:18888/api/health") + resp, err := client.Get(ctx, selfHealthURL()) 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") + resp, err = client.Get(ctx, selfHealthURL()) } if err == nil && resp != nil { @@ -94,15 +135,16 @@ func CheckPendingVerification(ctx context.Context) { CurrentVersion: consts.Version, DeploymentMode: GetDeploymentMode(), Updating: false, - Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100}, + Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100, FinishedAt: &verifiedAt}, RollbackAvailable: info.OldBinary != "", - BackupVersion: info.OldBinary, + BackupVersion: backupVersion, } manager.status.Store(status) - // Clean up .old file + // Clean up .old file(旧版本被 replaceBinary 重命名为 {exe}.old; + // 成功验证后移除该残留。回滚依赖的 .backup.* 副本不受影响) if info.OldBinary != "" { - _ = os.Remove(info.OldBinary + ".old") + _ = os.Remove(info.OldBinary) } return } @@ -115,12 +157,13 @@ func CheckPendingVerification(ctx context.Context) { DeploymentMode: GetDeploymentMode(), Updating: false, Progress: &Progress{ - Phase: PhaseFailed, - Message: "更新后健康检查失败,建议回滚", - Error: "health check failed after update", + Phase: PhaseFailed, + Message: "更新后健康检查失败,建议回滚", + Error: "health check failed after update", + FinishedAt: &verifiedAt, }, RollbackAvailable: info.OldBinary != "", - BackupVersion: info.OldBinary, + BackupVersion: backupVersion, } manager.status.Store(status) }() @@ -141,7 +184,10 @@ func BackgroundCheck(ctx context.Context) error { return nil } -// GetStatus returns the current update status +// GetStatus returns the current update status. +// 终态进度(complete/failed)超过 progressExpire 后视为过期,返回时剔除, +// 避免上次升级留下的快照在进程内永久残留(快照本身保留在内存中不影响, +// 下一次 setProgress 会以无进度的状态重建) func (m *UpdateManager) GetStatus() *Status { val := m.status.Load() if val == nil { @@ -151,7 +197,15 @@ func (m *UpdateManager) GetStatus() *Status { Updating: false, } } - return val.(*Status) + status := val.(*Status) + + if p := status.Progress; p != nil && isTerminalPhase(p.Phase) && + p.FinishedAt != nil && time.Since(*p.FinishedAt) > progressExpire { + filtered := *status + filtered.Progress = nil + return &filtered + } + return status } // GetCheckResult returns the cached check result @@ -184,10 +238,12 @@ func (m *UpdateManager) setProgress(phase, message string, percentage int) { func (m *UpdateManager) setProgressError(message, errMsg string) { status := m.GetStatus() status.Updating = false + failedAt := time.Now() status.Progress = &Progress{ - Phase: PhaseFailed, - Message: message, - Error: errMsg, + Phase: PhaseFailed, + Message: message, + Error: errMsg, + FinishedAt: &failedAt, } m.status.Store(status) m.updating.Store(false) @@ -211,7 +267,7 @@ func cleanOldBackups(ctx context.Context) { for _, entry := range entries { name := entry.Name() - // Match pattern: {binary}.backup.{timestamp} or {binary}.old + // Match pattern: {binary}.backup.{timestamp} if len(name) > len(base) && name[:len(base)] == base { suffix := name[len(base):] if len(suffix) > 8 && suffix[:8] == ".backup." { @@ -226,6 +282,10 @@ func cleanOldBackups(ctx context.Context) { } } } + + // 清理残留的 {binary}.old:正常链路在自检成功后已移除, + // 这里兜底处理进程异常终止(自检未执行)留下的旧版本副本 + _ = os.Remove(filepath.Join(dir, base+".old")) } // GetDeploymentMode returns the current deployment mode @@ -251,3 +311,13 @@ func getPlatformAssetName(version string) string { } return fmt.Sprintf("team-api-%s-%s-%s.%s", v, osName, arch, ext) } + +// normalizeAssetName 将资产名归一化到"无 v 前缀"的规范形式,用于兼容历史上 +// 以 "team-api-v{version}-..." 命名的旧资产(release.yml 早期版本未去掉 v)。 +// 例:team-api-v0.2.0-linux-amd64.tar.gz → team-api-0.2.0-linux-amd64.tar.gz +func normalizeAssetName(name string) string { + if strings.HasPrefix(name, "team-api-v") { + return "team-api-" + strings.TrimPrefix(name, "team-api-v") + } + return name +} diff --git a/internal/logic/update/update_integration_test.go b/internal/logic/update/update_integration_test.go new file mode 100644 index 0000000..926a810 --- /dev/null +++ b/internal/logic/update/update_integration_test.go @@ -0,0 +1,216 @@ +package update + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/gogf/gf/v2/os/gcfg" + "github.com/gogf/gf/v2/os/gctx" +) + +// buildReleaseTarGz 构造符合 getPlatformAssetName 命名的 release 资产 tar.gz, +// 内部结构 {assetDir}/team-api(与 extractBinary 的查找约定一致)。 +func buildReleaseTarGz(t *testing.T, dir, version string, content []byte) string { + t.Helper() + assetDir := fmt.Sprintf("team-api-%s-%s-%s", version, runtime.GOOS, runtime.GOARCH) + path := filepath.Join(dir, assetDir+".tar.gz") + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + + _ = tw.WriteHeader(&tar.Header{Name: assetDir + "/", Typeflag: tar.TypeDir, Mode: 0755}) + if err := tw.WriteHeader(&tar.Header{ + Name: assetDir + "/team-api", Typeflag: tar.TypeReg, + Size: int64(len(content)), Mode: 0755, + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func sha256File(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + t.Fatal(err) + } + return hex.EncodeToString(h.Sum(nil)) +} + +func fileSize(t *testing.T, path string) int64 { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Size() +} + +// startMockReleaseServer 启动一个模拟 GitHub release 的本地 HTTP 服务: +// /releases/latest 返回版本信息,/assets/ 下发 tar.gz 与 checksums。 +// 返回服务地址与工作目录(含资产文件)。 +func startMockReleaseServer(t *testing.T, version string) (*httptest.Server, string) { + t.Helper() + workDir := t.TempDir() + tarPath := buildReleaseTarGz(t, workDir, version, []byte("FAKE-NEW-BINARY-"+version)) + assetName := filepath.Base(tarPath) + checksums := fmt.Sprintf("%s %s\n", sha256File(t, tarPath), assetName) + + var tsURL string + mux := http.NewServeMux() + mux.HandleFunc("/releases/latest", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"tag_name":"v%s","name":"v%s",`+ + `"assets":[{"name":%q,"browser_download_url":%q,"size":%d},`+ + `{"name":"checksums-sha256.txt","browser_download_url":%q}]}`, + version, version, + assetName, tsURL+"/assets/"+assetName, fileSize(t, tarPath), + tsURL+"/assets/checksums-sha256.txt") + }) + mux.HandleFunc("/assets/", func(w http.ResponseWriter, r *http.Request) { + switch strings.TrimPrefix(r.URL.Path, "/assets/") { + case assetName: + http.ServeFile(w, r, tarPath) + case "checksums-sha256.txt": + _, _ = io.WriteString(w, checksums) + default: + http.NotFound(w, r) + } + }) + + ts := httptest.NewServer(mux) + tsURL = ts.URL + t.Cleanup(ts.Close) + return ts, workDir +} + +// TestUpdatePipeline_Local 在本地用模拟 release 服务器驱动生产流水线 +// (CheckForUpdate → stageUpdate:下载→校验→备份→替换→写标记), +// 验证整个更新链路的磁盘产物,无需 GitHub / PostgreSQL / Redis。 +func TestUpdatePipeline_Local(t *testing.T) { + ctx := gctx.New() + version := "0.9.0" // 模拟的"新版本",必须高于当前版本才判定有更新 + + ts, _ := startMockReleaseServer(t, version) + + // 模拟"当前二进制":stageUpdate 会把它备份/替换(executablePath seam 指向这里, + // 避免真实操作测试进程自身的可执行文件) + work := t.TempDir() + currentBin := filepath.Join(work, "team-api") + if err := os.WriteFile(currentBin, []byte("FAKE-CURRENT-0.2.0"), 0755); err != nil { + t.Fatal(err) + } + + origExec := executablePath + executablePath = func() (string, error) { return currentBin, nil } + t.Cleanup(func() { executablePath = origExec }) + + // 更新目录指向临时目录,避免污染全局 /tmp/team-api-update + origUpdateDir := updateDir + updateDir = t.TempDir() + t.Cleanup(func() { updateDir = origUpdateDir }) + + // 更新源指向本地 mock。Redis 缓存未配置时已被 getCheckCache/setCheckCache 防御性 + // recover 降级,无需在测试里配置 Redis。 + adapter, err := gcfg.NewAdapterContent(fmt.Sprintf("update:\n api_base: %s\n", ts.URL)) + if err != nil { + t.Fatal(err) + } + gcfg.Instance().SetAdapter(adapter) + + // 1) 版本检查应命中 mock:判定有更新,下载地址指向本地 + res, err := CheckForUpdate(ctx, true) + if err != nil { + t.Fatalf("CheckForUpdate failed: %v", err) + } + if !res.HasUpdate { + t.Fatalf("expected HasUpdate=true, got false (current=%q latest=%q)", res.CurrentVersion, res.LatestVersion) + } + if !strings.HasPrefix(res.DownloadURL, ts.URL) { + t.Fatalf("DownloadURL %q does not point at local mock %s", res.DownloadURL, ts.URL) + } + if res.LatestVersion != version { + t.Fatalf("LatestVersion=%q, want %q", res.LatestVersion, version) + } + + // 2) 驱动真实生产流水线(stageUpdate,不触发进程重启)。 + // stageUpdate 内部会 EvalSymlinks(如 macOS 上 /var → /private/var), + // 返回值是解析后的绝对路径,断言前同样解析。 + resolvedBin, err := filepath.EvalSymlinks(currentBin) + if err != nil { + t.Fatal(err) + } + newExe, err := stageUpdate(ctx, version, res.DownloadURL, res.ChecksumURL, res.AssetSize) + if err != nil { + t.Fatalf("stageUpdate failed: %v", err) + } + if newExe != resolvedBin { + t.Fatalf("stageUpdate returned %q, want %q", newExe, resolvedBin) + } + + // 3) 校验磁盘产物 + got, err := os.ReadFile(currentBin) + if err != nil { + t.Fatal(err) + } + if string(got) != "FAKE-NEW-BINARY-"+version { + t.Fatalf("current binary not replaced, content=%q", string(got)) + } + if _, err := os.Stat(currentBin + ".old"); err != nil { + t.Fatalf("expected .old file: %v", err) + } + + entries, _ := os.ReadDir(work) + hasBackup := false + for _, e := range entries { + if strings.HasPrefix(e.Name(), "team-api.backup.") { + hasBackup = true + } + } + if !hasBackup { + t.Fatal("expected a team-api.backup.* file") + } + + if _, err := os.Stat(filepath.Join(updateDir, rollbackFile)); err != nil { + t.Fatalf("rollback.json not written: %v", err) + } + if _, err := os.Stat(filepath.Join(updateDir, pendingVerificationFile)); err != nil { + t.Fatalf("pending_verification not written: %v", err) + } + + // 4) 待重启的新版本路径已记录(供退出链 exec 换壳) + if got := manager.ConsumeRestartBinary(); got != resolvedBin { + t.Fatalf("restart binary = %q, want %q", got, resolvedBin) + } +} diff --git a/internal/logic/update/update_test.go b/internal/logic/update/update_test.go new file mode 100644 index 0000000..c12e3a9 --- /dev/null +++ b/internal/logic/update/update_test.go @@ -0,0 +1,21 @@ +package update + +import "testing" + +// TestNormalizeAssetName 校验资产名归一化:兼容历史上带 v 前缀的资产命名。 +func TestNormalizeAssetName(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"team-api-0.2.0-linux-amd64.tar.gz", "team-api-0.2.0-linux-amd64.tar.gz"}, // 规范名:不变 + {"team-api-v0.2.0-linux-amd64.tar.gz", "team-api-0.2.0-linux-amd64.tar.gz"}, // 历史带 v:去掉 + {"team-api-v1.2.3-darwin-arm64.zip", "team-api-1.2.3-darwin-arm64.zip"}, // Windows/Darwin zip 同理 + {"checksums-sha256.txt", "checksums-sha256.txt"}, // 非资产名:不变 + } + for _, c := range cases { + if got := normalizeAssetName(c.in); got != c.want { + t.Fatalf("normalizeAssetName(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/logic/update/verifier.go b/internal/logic/update/verifier.go index eb82e0a..c264641 100644 --- a/internal/logic/update/verifier.go +++ b/internal/logic/update/verifier.go @@ -28,23 +28,22 @@ func VerifyChecksum(ctx context.Context, filePath, checksumURL, expectedFilename 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 + return fmt.Errorf("release 未提供 checksums-sha256.txt,无法校验更新包完整性,已中止更新") } // 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 + // 拉不到校验文件同样不放行,防止跳过校验把破损/篡改的包替换上线 + return fmt.Errorf("获取校验文件失败: %w", err) } manager.setProgress(PhaseVerifying, "正在对比校验值...", 80) if !strings.EqualFold(actualHash, expectedHash) { - return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash) + return fmt.Errorf("SHA256 校验不一致(期望 %s,实际 %s),下载的文件可能已破损或被篡改,已中止更新", expectedHash, actualHash) } manager.setProgress(PhaseVerifying, "文件校验通过", 100) @@ -134,7 +133,8 @@ func fetchExpectedHash(ctx context.Context, checksumURL, filename string) (strin if len(parts) >= 2 { hash := parts[0] file := strings.TrimPrefix(parts[1], "*") - if file == baseFilename || file == filename { + // 兼容历史资产命名(checksums 文件中的文件名可能带/不带 v 前缀) + if normalizeAssetName(file) == normalizeAssetName(baseFilename) { return strings.ToLower(hash), nil } } diff --git a/internal/logic/update/verifier_test.go b/internal/logic/update/verifier_test.go new file mode 100644 index 0000000..9d1501f --- /dev/null +++ b/internal/logic/update/verifier_test.go @@ -0,0 +1,126 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeTempFile 在临时目录写入内容并返回路径 +func writeTempFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("写入临时文件失败: %v", err) + } + return path +} + +// serveChecksums 启动一个返回指定内容的测试 HTTP 服务 +func serveChecksums(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestVerifyChecksum_成功(t *testing.T) { + content := "fake-binary-content" + filePath := writeTempFile(t, "team-api-1.0.0-linux-amd64.tar.gz", content) + + sum := sha256.Sum256([]byte(content)) + hash := hex.EncodeToString(sum[:]) + + srv := serveChecksums(t, http.StatusOK, hash+" team-api-1.0.0-linux-amd64.tar.gz\n") + + if err := VerifyChecksum(context.Background(), filePath, srv.URL, "team-api-1.0.0-linux-amd64.tar.gz"); err != nil { + t.Fatalf("校验应通过,实际失败: %v", err) + } +} + +func TestVerifyChecksum_哈希不一致须报错(t *testing.T) { + filePath := writeTempFile(t, "asset.tar.gz", "actual-content") + + wrongHash := strings.Repeat("0", 64) + srv := serveChecksums(t, http.StatusOK, wrongHash+" asset.tar.gz\n") + + err := VerifyChecksum(context.Background(), filePath, srv.URL, "asset.tar.gz") + if err == nil { + t.Fatal("哈希不一致必须返回错误,不允许更新") + } + if !strings.Contains(err.Error(), "SHA256 校验不一致") { + t.Errorf("错误信息应说明校验不一致,实际: %v", err) + } +} + +func TestVerifyChecksum_缺少校验文件地址须拒绝(t *testing.T) { + filePath := writeTempFile(t, "asset.tar.gz", "content") + + err := VerifyChecksum(context.Background(), filePath, "", "asset.tar.gz") + if err == nil { + t.Fatal("无 checksum URL 时必须返回错误,不允许跳过校验放行") + } + if !strings.Contains(err.Error(), "未提供") { + t.Errorf("错误信息应说明缺少校验文件,实际: %v", err) + } +} + +func TestVerifyChecksum_校验文件拉取失败须拒绝(t *testing.T) { + filePath := writeTempFile(t, "asset.tar.gz", "content") + + // 服务返回 500,模拟校验文件不可用 + srv := serveChecksums(t, http.StatusInternalServerError, "boom") + + err := VerifyChecksum(context.Background(), filePath, srv.URL, "asset.tar.gz") + if err == nil { + t.Fatal("校验文件拉取失败时必须返回错误,不允许跳过校验放行") + } +} + +func TestFetchExpectedHash_解析格式(t *testing.T) { + content := "aaa file-a.tar.gz\nbbb *file-b.tar.gz\n" + srv := serveChecksums(t, http.StatusOK, content) + ctx := context.Background() + + // 标准格式(两个空格分隔) + if got, err := fetchExpectedHash(ctx, srv.URL, "file-a.tar.gz"); err != nil || got != "aaa" { + t.Errorf("双空格格式解析失败: got=%q err=%v", got, err) + } + // 二进制格式(* 前缀) + if got, err := fetchExpectedHash(ctx, srv.URL, "file-b.tar.gz"); err != nil || got != "bbb" { + t.Errorf("* 前缀格式解析失败: got=%q err=%v", got, err) + } + // 传入带路径的文件名应按 basename 匹配 + if got, err := fetchExpectedHash(ctx, srv.URL, "/some/dir/file-a.tar.gz"); err != nil || got != "aaa" { + t.Errorf("basename 匹配失败: got=%q err=%v", got, err) + } + // 文件名不存在应报错 + if _, err := fetchExpectedHash(ctx, srv.URL, "not-exist.tar.gz"); err == nil { + t.Error("校验文件中不存在目标文件时应返回错误") + } +} + +func TestComputeSHA256(t *testing.T) { + content := "hello team-api" + filePath := writeTempFile(t, "bin", content) + + sum := sha256.Sum256([]byte(content)) + want := hex.EncodeToString(sum[:]) + + got, err := computeSHA256(context.Background(), filePath) + if err != nil { + t.Fatalf("计算 SHA256 失败: %v", err) + } + if got != want { + t.Errorf("SHA256 不匹配: got=%s want=%s", got, want) + } +} diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index 42923b8..12c1a6d 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -85,3 +85,10 @@ demo: # Channel proxy (for accessing Google/Anthropic etc. from China) channel_proxy_url: "" + +# System update source override +# Default: GitHub official API (https://api.github.com/repos/qianfree/team-api). +# Override to point at a self-hosted release server / local mock release (see +# scripts/mock-release.sh) or a China mirror. Leave empty to use GitHub. +update: + api_base: "" diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 6d93f7f..f541311 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -58,6 +58,7 @@ const updateModalVisible = ref(false) const updateLoading = ref(false) const executing = ref(false) const rollingBack = ref(false) +const checking = ref(false) const updateStatus = ref(null) const releaseNotes = ref('') const releaseUrl = ref('') @@ -129,6 +130,16 @@ function openUpdateModal() { checkUpdate() // 打开弹窗时刷新版本检查,确保当前版本/最新版本为最新 } +// 立即检测:force=true 跳过后端检查缓存,强制向上游拉取最新版本信息 +async function manualCheck() { + checking.value = true + try { + await Promise.all([fetchUpdateStatus(), checkUpdate(true)]) + } finally { + checking.value = false + } +} + function closeUpdateModal() { updateModalVisible.value = false stopPolling() @@ -697,7 +708,10 @@ onUnmounted(() => { - -