Merge branch 'main' into feat/responses

# Conflicts:
#	docs/reference/goframe-conventions.md
#	internal/logic/admin/channel.go
#	web/tenant/src/views/service/ApiKeysPage.vue
This commit is contained in:
qian committed 2026-08-18 23:02:33 +08:00
commit 8a7f0f2760
47 files changed
+1619 -298

No files matched your search

+4 -1
View File
@@ -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:
+7 -6
View File
@@ -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 组件库
+20
View File
@@ -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 路径中是否存在非指针字段的无条件赋值。
+6 -1
View File
@@ -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()
+1 -1
View File
@@ -16,7 +16,7 @@ import (
var aggregateUsageCmd = gcmd.Command{
Name: "aggregate-usage",
Usage: "aggregate-usage --from <YYYY-MM-DD> --to <YYYY-MM-DD>",
Brief: "回填 bil_usage_daily 用量日汇总(历史数据)",
Brief: "回填 bil_usage_daily 用量日汇总(历史数据), 一般用不到",
Description: `
将 bil_usage_logs 的历史用量按月分块聚合写入 bil_usage_daily,供流量流向桑基图与趋势分析使用。
按月分块以避免单次扫描全部分区表。--from/--to 均为包含的日期边界;--to 缺省为昨天。
+2 -1
View File
@@ -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()
+11
View File
@@ -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))
}
+1 -1
View File
@@ -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{
+111 -14
View File
@@ -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
}
+39 -29
View File
@@ -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])
+1 -1
View File
@@ -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 配置")
}
// 级联删除:投递日志 → 事件 → 配置
+42 -12
View File
@@ -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
+80
View File
@@ -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)
}
}
+44
View File
@@ -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)
}
}
}
+15
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
//go:build windows
package update
// execSelf Windows 无 exec 语义(在线更新仅支持 Linux),返回 false 由调用方退出。
func execSelf(bin string) bool {
return false
}
+82
View File
@@ -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)
}
}
@@ -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())
}
@@ -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
}
@@ -0,0 +1,8 @@
//go:build windows
package update
// signalSelfTerm Windows 无自信号机制,返回 false 由调用方直接退出
func signalSelfTerm() bool {
return false
}
+70 -37
View File
@@ -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
+200
View File
@@ -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("源文件不存在时应返回错误")
}
}
+15 -3
View File
@@ -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
}
+31
View File
@@ -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
+157
View File
@@ -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)
}
}
+91 -21
View File
@@ -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
}
@@ -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)
}
}
+21
View File
@@ -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)
}
}
}
+7 -7
View File
@@ -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
}
}
+126
View File
@@ -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)
}
}
+7
View File
@@ -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: ""
+21 -2
View File
@@ -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<any>(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(() => {
</template>
<!-- Update available -->
<template v-if="canUpdate && !isUpdating && !isFailed && !isComplete && !isDocker">
<!-- 注意不能被 isFailed/isComplete 挡住:后端的 complete/failed 进度快照在进程内
不会过期(上次升级留下的状态会一直挂着),否则新版本出现后更新入口被吞掉、
失败后也无法重试。更新进行中(isUpdating)才是真正的互斥条件 -->
<template v-if="canUpdate && !isUpdating && !isDocker">
<a-divider style="margin: 16px 0;" />
<div style="margin-bottom: 12px;">
<a-typography-text bold>更新说明</a-typography-text>
@@ -717,6 +731,9 @@ onUnmounted(() => {
<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>
<template #extra>
<a-button size="small" :loading="checking" @click="manualCheck">立即检测</a-button>
</template>
</a-result>
</template>
@@ -749,7 +766,9 @@ onUnmounted(() => {
</template>
<!-- Complete with rollback option -->
<template v-if="isComplete && rollbackAvailable">
<!-- 有新版本可更新时隐藏:这个 complete 是历史升级留下的常驻状态,
不应与新版本入口同时出现 -->
<template v-if="isComplete && rollbackAvailable && !canUpdate">
<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>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, h } from 'vue'
import { NForm, NFormItem, NInput, NSelect, NDatePicker, NInputNumber, NButton, NDrawer, NDrawerContent } from 'naive-ui'
import { NForm, NFormItem, NInput, NSelect, NDatePicker, NInputNumber, NButton, NDrawer, NDrawerContent, NDropdown } from 'naive-ui'
import Icon from './Icon.vue'
import MobileRangeFilter from './MobileRangeFilter.vue'
@@ -49,9 +49,17 @@ const emit = defineEmits<{
// 表单数据(由父组件通过 v-model 绑定)
const modelValue = defineModel<Record<string, any>>({ required: true })
const showExportDropdown = ref(false)
const showFilterDrawer = ref(false)
// 导出格式下拉选项(NDropdown 默认 teleport 到 body,避免被 .card 的 backdrop-filter
// stacking context 困住而被下方表格面板遮挡)
const exportDropdownOptions = computed(() =>
props.exportFormats.map(format => ({
label: format === 'csv' ? '导出 CSV' : '导出 Excel',
key: format,
}))
)
// 检测是否为移动端
const isMobile = ref(false)
const checkMobile = () => {
@@ -104,9 +112,8 @@ function handleReset() {
emit('reset')
}
function handleExport(format: 'csv' | 'xlsx') {
showExportDropdown.value = false
emit('export', format)
function handleExport(format: string | number) {
emit('export', format as 'csv' | 'xlsx')
}
// 日期时间范围默认快捷选项
@@ -243,39 +250,19 @@ const renderField = (field: FilterField) => {
<div class="flex items-center gap-2">
<!-- 导出按钮 -->
<div v-if="showExport" class="relative">
<n-button
:loading="exporting"
:disabled="exporting"
@click="showExportDropdown = !showExportDropdown"
>
<n-dropdown
v-if="showExport"
trigger="click"
:options="exportDropdownOptions"
@select="handleExport"
>
<n-button :loading="exporting" :disabled="exporting">
<template #icon>
<Icon name="download" size="sm" />
</template>
导出
</n-button>
<div
v-if="showExportDropdown"
class="absolute right-0 z-50 mt-2 w-36 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
<button
v-if="exportFormats.includes('csv')"
type="button"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
@click="handleExport('csv')"
>
导出 CSV
</button>
<button
v-if="exportFormats.includes('xlsx')"
type="button"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
@click="handleExport('xlsx')"
>
导出 Excel
</button>
</div>
</div>
</n-dropdown>
<!-- 自定义额外按钮插槽 -->
<slot name="extra-actions" />
@@ -356,12 +343,12 @@ const renderField = (field: FilterField) => {
<!-- 导出按钮 -->
<template v-if="showExport">
<span class="mx-1 h-6 w-px bg-gray-200" aria-hidden="true"></span>
<div class="relative">
<n-button
:loading="exporting"
:disabled="exporting"
@click="showExportDropdown = !showExportDropdown"
>
<n-dropdown
trigger="click"
:options="exportDropdownOptions"
@select="handleExport"
>
<n-button :loading="exporting" :disabled="exporting">
<template #icon>
<Icon name="download" size="sm" />
</template>
@@ -370,28 +357,7 @@ const renderField = (field: FilterField) => {
<Icon name="chevronDown" size="xs" />
</template>
</n-button>
<div
v-if="showExportDropdown"
class="absolute right-0 z-50 mt-2 w-36 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
<button
v-if="exportFormats.includes('csv')"
type="button"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
@click="handleExport('csv')"
>
导出 CSV
</button>
<button
v-if="exportFormats.includes('xlsx')"
type="button"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
@click="handleExport('xlsx')"
>
导出 Excel
</button>
</div>
</div>
</n-dropdown>
</template>
<!-- 自定义额外按钮插槽 -->
@@ -86,7 +86,6 @@ onBeforeUnmount(() => {
background: rgba(255, 255, 255, 0.66);
box-shadow: var(--shadow-glass-sm), inset 0 1px 0 rgba(255, 255, 255, 0.94);
backdrop-filter: blur(20px) saturate(1.16);
-webkit-backdrop-filter: blur(20px) saturate(1.16);
}
.auth-card {
-9
View File
@@ -262,13 +262,11 @@ body.modal-open {
.glass {
background: var(--glass-bg-subtle);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
}
/* 液态玻璃卡片:弱透明底 + 模糊 + 高光描边 + 冷投影 + 内高光 */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-glass), var(--glass-highlight);
@@ -286,7 +284,6 @@ body.modal-open {
border-radius: var(--radius-card);
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-card);
}
@@ -305,7 +302,6 @@ body.modal-open {
.card-glass {
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass);
}
@@ -343,7 +339,6 @@ body.modal-open {
border-radius: var(--radius-card);
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-card);
}
@@ -389,7 +384,6 @@ body.modal-open {
0 10px 30px rgba(75, 91, 137, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
backdrop-filter: blur(22px) saturate(1.2);
-webkit-backdrop-filter: blur(22px) saturate(1.2);
scrollbar-width: thin;
scrollbar-color: rgba(20, 184, 166, 0.28) transparent;
}
@@ -661,7 +655,6 @@ body.modal-open {
@apply origin-top-right animate-scale-in;
background: var(--glass-bg-strong);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--glass-highlight);
}
@@ -747,7 +740,6 @@ body.modal-open {
linear-gradient(115deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.035) 42%, rgba(255, 255, 255, 0.08)),
rgba(238, 246, 252, 0.2);
backdrop-filter: blur(20px) saturate(1.38) brightness(1.04);
-webkit-backdrop-filter: blur(20px) saturate(1.38) brightness(1.04);
border: 1px solid rgba(255, 255, 255, 0.38);
box-shadow:
0 16px 38px rgba(35, 48, 68, 0.12),
@@ -868,7 +860,6 @@ body.modal-open {
.sidebar {
background: rgba(247, 249, 253, 0.94);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
@media (prefers-contrast: more) {
-1
View File
@@ -600,7 +600,6 @@ onBeforeUnmount(() => {
.user-dropdown {
background: #fff;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.fade-enter-active,
-1
View File
@@ -544,7 +544,6 @@ async function handleOAuthLogin(provider: string) {
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 6px 16px rgba(76, 91, 142, 0.06);
backdrop-filter: blur(18px) saturate(1.14);
-webkit-backdrop-filter: blur(18px) saturate(1.14);
}
.login-mode-indicator {
+17 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton } from 'naive-ui'
import { NButton, NDropdown } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, formatMoney, tableScrollX } from '@/utils/renderUtils'
@@ -15,7 +15,16 @@ const pageSize = ref(20)
const total = ref(0)
const statusFilter = ref('')
const showExportDropdown = ref(false)
// 导出格式下拉选项(NDropdown 默认 teleport 到 body,避免被 .card 的 backdrop-filter
// stacking context 困住而被下方表格面板遮挡)
const exportDropdownOptions = [
{ label: '导出 CSV', key: 'csv' },
{ label: '导出 Excel', key: 'xlsx' },
]
function handleExport(format: string | number) {
exportFile(format as 'csv' | 'xlsx')
}
const { exporting, exportFile } = useExport({
url: '/tenant/orders/export',
getFilters: () => ({
@@ -162,17 +171,14 @@ onMounted(() => {
<p class="page-description">查看所有订阅和充值订单</p>
</div>
<!-- Export dropdown -->
<div class="relative inline-block">
<button class="btn btn-secondary btn-sm" :disabled="exporting" @click="showExportDropdown = !showExportDropdown">
<svg v-if="!exporting" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
<n-dropdown trigger="click" :options="exportDropdownOptions" @select="handleExport">
<button type="button" class="btn btn-secondary btn-sm" :disabled="exporting">
<Icon v-if="exporting" name="refresh" size="sm" class="animate-spin" />
<Icon v-else name="download" size="sm" />
导出
<Icon name="chevronDown" size="xs" />
</button>
<div v-if="showExportDropdown" class="absolute right-0 mt-2 w-36 bg-white rounded-xl border shadow-lg py-1 z-50">
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</div>
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</div>
</div>
</div>
</n-dropdown>
</div>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NInput, NInputNumber, NSelect } from 'naive-ui'
import { NInput, NInputNumber, NSelect, NDropdown } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
@@ -32,8 +32,16 @@ const filters = ref({
model: '',
})
// 导出下拉菜单
const showExportDropdown = ref(false)
// 导出格式下拉选项(NDropdown 默认 teleport 到 body,避免被 .card 的 backdrop-filter
// stacking context 困住而被下方表格面板遮挡)
const exportDropdownOptions = [
{ label: '导出 CSV', key: 'csv' },
{ label: '导出 Excel', key: 'xlsx' },
]
function handleExport(format: string | number) {
exportFile(format as 'csv' | 'xlsx')
}
const { exporting, exportFile } = useExport({
url: '/tenant/wallet/transactions/export',
@@ -231,18 +239,14 @@ onMounted(fetchTransactions)
刷新
</button>
<span class="mx-1 h-6 w-px bg-gray-200" aria-hidden="true"></span>
<div class="relative">
<button type="button" class="btn btn-secondary btn-sm" :disabled="exporting || loading" @click="showExportDropdown = !showExportDropdown">
<n-dropdown trigger="click" :options="exportDropdownOptions" @select="handleExport">
<button type="button" class="btn btn-secondary btn-sm" :disabled="exporting || loading">
<Icon v-if="exporting" name="refresh" size="sm" class="animate-spin" />
<Icon v-else name="download" size="sm" />
导出
<Icon name="chevronDown" size="xs" />
</button>
<div v-if="showExportDropdown" class="absolute right-0 z-50 mt-2 w-36 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<button type="button" class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</button>
<button type="button" class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</button>
</div>
</div>
</n-dropdown>
</div>
</form>
</div>
+4 -4
View File
@@ -590,7 +590,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78
justify-content: center; width: fit-content; margin: 0 auto 1.5rem;
border-radius: 14px;
background: rgba(241, 245, 249, 0.8);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
@@ -634,7 +634,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78
height: 440px;
border-radius: 1.5rem; padding: 2rem 2.25rem;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(28px) saturate(1.18); -webkit-backdrop-filter: blur(28px) saturate(1.18);
backdrop-filter: blur(28px) saturate(1.18);
border: 1px solid rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 55px rgba(76, 91, 142, 0.11), inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
@@ -835,7 +835,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78
.announce-backdrop {
position: absolute; inset: 0;
background: rgba(15, 23, 42, 0.35);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
/* Card */
@@ -843,7 +843,7 @@ const mockChartHeights = [45, 62, 38, 71, 55, 82, 67, 48, 73, 58, 90, 65, 52, 78
position: relative; width: 100%; max-width: 860px;
border-radius: 1.5rem; overflow: hidden;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(28px) saturate(1.18); -webkit-backdrop-filter: blur(28px) saturate(1.18);
backdrop-filter: blur(28px) saturate(1.18);
border: 1px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 32px 80px -16px rgba(15, 23, 42, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.95);
animation: announceEnter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
@@ -623,7 +623,6 @@ useSeo({
border-bottom: 1px solid rgba(220, 229, 225, 0.82);
background: rgba(247, 250, 248, 0.88);
backdrop-filter: blur(18px) saturate(1.2);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
}
.header-inner,
@@ -2101,7 +2100,6 @@ useSeo({
border-bottom: 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.header-inner {
@@ -2146,7 +2144,6 @@ useSeo({
background: rgba(241, 245, 249, 0.58);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.header-nav a {
@@ -2211,7 +2208,6 @@ useSeo({
box-shadow: 0 5px 16px rgba(76, 91, 142, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.9);
color: #475569;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.secondary-button:hover {
@@ -2263,7 +2259,6 @@ useSeo({
background: rgba(255, 255, 255, 0.64);
box-shadow: 0 18px 55px rgba(76, 91, 142, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.9);
backdrop-filter: blur(28px) saturate(1.16);
-webkit-backdrop-filter: blur(28px) saturate(1.16);
}
.category-panel {
@@ -2422,7 +2417,6 @@ useSeo({
box-shadow: 0 18px 55px rgba(76, 91, 142, 0.11), inset 0 1px 0 rgba(255, 255, 255, 0.92);
color: #111827;
backdrop-filter: blur(28px) saturate(1.18);
-webkit-backdrop-filter: blur(28px) saturate(1.18);
}
.cta-icon {
@@ -2471,7 +2465,6 @@ useSeo({
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 24px 80px rgba(76, 91, 142, 0.24), inset 0 1px 0 #fff;
backdrop-filter: blur(28px) saturate(1.16);
-webkit-backdrop-filter: blur(28px) saturate(1.16);
}
.detail-hero {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NDropdown } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import BasePagination from '@/components/common/BasePagination.vue'
import request from '@/utils/request'
@@ -23,7 +24,16 @@ const pageSize = ref(20)
const total = ref(0)
const activeTab = ref<'all' | 'unread'>('all')
const showExportDropdown = ref(false)
// 导出格式下拉选项(NDropdown 默认 teleport 到 body,避免被 .card 的 backdrop-filter
// stacking context 困住而被下方表格面板遮挡)
const exportDropdownOptions = [
{ label: '导出 CSV', key: 'csv' },
{ label: '导出 Excel', key: 'xlsx' },
]
function handleExport(format: string | number) {
exportFile(format as 'csv' | 'xlsx')
}
const { exporting, exportFile } = useExport({
url: '/tenant/notifications/export',
})
@@ -125,17 +135,14 @@ onMounted(() => {
</div>
<div class="flex items-center gap-2">
<!-- Export dropdown -->
<div class="relative inline-block">
<button class="btn btn-secondary" :disabled="exporting" @click="showExportDropdown = !showExportDropdown">
<svg v-if="!exporting" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
<n-dropdown trigger="click" :options="exportDropdownOptions" @select="handleExport">
<button type="button" class="btn btn-secondary" :disabled="exporting">
<Icon v-if="exporting" name="refresh" size="sm" class="animate-spin" />
<Icon v-else name="download" size="sm" />
导出
<Icon name="chevronDown" size="xs" />
</button>
<div v-if="showExportDropdown" class="absolute right-0 mt-2 w-36 bg-white rounded-xl border shadow-lg py-1 z-50">
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</div>
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</div>
</div>
</div>
</n-dropdown>
<button class="btn btn-secondary" @click="markAllRead" :disabled="unreadCount === 0">
<Icon name="check" size="sm" />
全部已读
@@ -355,6 +355,10 @@ onMounted(fetchModels)
<span class="text-gray-400">输出</span>
<span class="ml-1 font-semibold text-gray-800">{{ formatPrice(m.output_price) }}</span>
</span>
<span v-if="m.cache_read_price">
<span class="text-gray-400">缓存读取</span>
<span class="ml-1 font-semibold text-gray-800">{{ formatPrice(m.cache_read_price) }}</span>
</span>
<span class="text-gray-300">/1M tokens</span>
</div>
</template>
+15 -10
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NTag, NInput } from 'naive-ui'
import { NButton, NTag, NInput, NDropdown } from 'naive-ui'
import { useRoute } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
@@ -57,7 +57,16 @@ const filterPlatform = ref('')
const filterTaskId = ref('')
const filterStartDate = ref(todayStart())
const filterEndDate = ref(todayEnd())
const showExportDropdown = ref(false)
// 导出格式下拉选项(NDropdown 默认 teleport 到 body,避免被 .card 的 backdrop-filter
// stacking context 困住而被下方表格面板遮挡)
const exportDropdownOptions = [
{ label: '导出 CSV', key: 'csv' },
{ label: '导出 Excel', key: 'xlsx' },
]
function handleExport(format: string | number) {
exportFile(format as 'csv' | 'xlsx')
}
const showDetail = ref(false)
const detailLoading = ref(false)
@@ -261,7 +270,7 @@ onMounted(() => {
<template>
<div class="viewport-table-page space-y-6">
<!-- Filters -->
<div class="relative z-20 overflow-visible card">
<div class="card">
<div class="card-body !p-4">
<form class="flex flex-wrap items-center gap-x-3 gap-y-3" @submit.prevent="applyFilters">
<DateTimeRangePicker
@@ -288,18 +297,14 @@ onMounted(() => {
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
<span class="mx-1 h-6 w-px bg-gray-200" aria-hidden="true"></span>
<div class="relative">
<button type="button" class="btn btn-secondary btn-sm" :disabled="exporting || loading" @click="showExportDropdown = !showExportDropdown">
<n-dropdown trigger="click" :options="exportDropdownOptions" @select="handleExport">
<button type="button" class="btn btn-secondary btn-sm" :disabled="exporting || loading">
<Icon v-if="exporting" name="refresh" size="sm" class="animate-spin" />
<Icon v-else name="download" size="sm" />
导出
<Icon name="chevronDown" size="xs" />
</button>
<div v-if="showExportDropdown" class="absolute right-0 z-50 mt-2 w-36 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<button type="button" class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</button>
<button type="button" class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</button>
</div>
</div>
</n-dropdown>
</div>
</form>
</div>
+14 -24
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NInput, NSelect } from 'naive-ui'
import { NButton, NInput, NSelect, NDropdown } from 'naive-ui'
import { useRouter } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import BaseModal from '@/components/common/BaseModal.vue'
@@ -11,10 +11,6 @@ import request from '@/utils/request'
import { tableScrollX } from '@/utils/renderUtils'
import { useExport } from '@/composables/useExport'
// 日期辅助(native,避免引入 dayjs 依赖)
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
// timestamp(ms) ↔ 后端字符串 YYYY-MM-DD HH:mm:ss
function tsToStr(ts: number): string {
const d = new Date(ts)
@@ -49,8 +45,16 @@ const filters = ref({
requestType: '',
})
// 导出下拉菜单
const showExportDropdown = ref(false)
// 导出格式下拉选项(NDropdown 默认 teleport 到 body,避免被 .card 的 backdrop-filter
// stacking context 困住而被下方表格面板遮挡)
const exportDropdownOptions = [
{ label: '导出 CSV', key: 'csv' },
{ label: '导出 Excel', key: 'xlsx' },
]
function handleExport(format: string | number) {
exportFile(format as 'csv' | 'xlsx')
}
const { exporting, exportFile } = useExport({
url: '/tenant/usage-logs/export',
@@ -210,16 +214,6 @@ function viewAuditLog(requestId: string, taskId?: string) {
router.push({ name: 'TenantRequestAuditLogs', query })
}
function hasCacheTokens(log: any): boolean {
return (log.cache_creation_tokens > 0) || (log.cache_read_tokens > 0) ||
(log.cache_creation_5m_tokens > 0) || (log.cache_creation_1h_tokens > 0)
}
function hasExtraTokens(log: any): boolean {
return (log.reasoning_tokens > 0) || (log.audio_input_tokens > 0) ||
(log.audio_output_tokens > 0) || (log.image_output_tokens > 0)
}
function hasUpstreamModel(log: any): boolean {
return log.upstream_model && log.upstream_model !== log.model_name && log.upstream_model !== ''
}
@@ -514,18 +508,14 @@ onMounted(() => {
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
<span class="mx-1 h-6 w-px bg-gray-200" aria-hidden="true"></span>
<div class="relative">
<button type="button" class="btn btn-secondary btn-sm" :disabled="exporting || loading" @click="showExportDropdown = !showExportDropdown">
<n-dropdown trigger="click" :options="exportDropdownOptions" @select="handleExport">
<button type="button" class="btn btn-secondary btn-sm" :disabled="exporting || loading">
<Icon v-if="exporting" name="refresh" size="sm" class="animate-spin" />
<Icon v-else name="download" size="sm" />
导出
<Icon name="chevronDown" size="xs" />
</button>
<div v-if="showExportDropdown" class="absolute right-0 z-50 mt-2 w-36 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
<button type="button" class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</button>
<button type="button" class="block w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</button>
</div>
</div>
</n-dropdown>
</div>
</form>
</div>
+17 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed, h } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NInput } from 'naive-ui'
import { NInput, NDropdown } from 'naive-ui'
import BaseModal from '@/components/common/BaseModal.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import Icon from '@/components/common/Icon.vue'
@@ -37,7 +37,16 @@ const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const showExportDropdown = ref(false)
// 导出格式下拉选项(NDropdown 默认 teleport 到 body,避免被 .card 的 backdrop-filter
// stacking context 困住而被下方表格面板遮挡)
const exportDropdownOptions = [
{ label: '导出 CSV', key: 'csv' },
{ label: '导出 Excel', key: 'xlsx' },
]
function handleExport(format: string | number) {
exportFile(format as 'csv' | 'xlsx')
}
const { exporting, exportFile } = useExport({
url: '/tenant/tickets/export',
})
@@ -248,17 +257,14 @@ onMounted(() => {
</div>
<div class="flex items-center gap-2">
<!-- Export dropdown -->
<div class="relative inline-block">
<button class="btn btn-secondary" :disabled="exporting" @click="showExportDropdown = !showExportDropdown">
<svg v-if="!exporting" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
<n-dropdown trigger="click" :options="exportDropdownOptions" @select="handleExport">
<button type="button" class="btn btn-secondary" :disabled="exporting">
<Icon v-if="exporting" name="refresh" size="sm" class="animate-spin" />
<Icon v-else name="download" size="sm" />
导出
<Icon name="chevronDown" size="xs" />
</button>
<div v-if="showExportDropdown" class="absolute right-0 mt-2 w-36 bg-white rounded-xl border shadow-lg py-1 z-50">
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</div>
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</div>
</div>
</div>
</n-dropdown>
<button class="btn btn-primary" @click="showCreateModal = true">
<Icon name="plus" size="sm" />
新建工单
@@ -35,7 +35,6 @@ const filterFields: FilterField[] = [
},
]
const showExportDropdown = ref(false)
const showMoreMenu = ref(false)
const { exporting, exportFile } = useExport({
url: '/tenant/members/export',