diff --git a/internal/logic/admin/channel_testing.go b/internal/logic/admin/channel_testing.go index 4b7bf90..5c11163 100644 --- a/internal/logic/admin/channel_testing.go +++ b/internal/logic/admin/channel_testing.go @@ -15,7 +15,9 @@ import ( "github.com/qianfree/team-api/internal/logic/relay" ) -// TestChannel 测试渠道可用性(发送最小请求验证) +// TestChannel 测试渠道可用性(发送最小请求验证)。 +// 不限制渠道状态:禁用中的渠道也允许手动测试,便于管理员在启用放流量前确认已恢复; +// 自动探测 cron 只查 active 渠道,禁用渠道不会被自动探测。 func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.ChannelTestRes, error) { channelID := req.ID @@ -41,9 +43,6 @@ func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.C if ch == nil { return nil, common.NewNotFoundError("渠道") } - if ch.Status == "disabled" { - return nil, common.NewBadRequestError("渠道已禁用") - } testModel := req.ModelName if testModel == "" { @@ -113,8 +112,12 @@ func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.C result := sendTestRequest(ctx, ch.Type, ch.BaseURL, apiKey, upstreamModel, useProxy) latencyMs := time.Since(startTime).Milliseconds() - // 更新健康度(喂给调度引擎的健康体系;探测失败按瞬时错误轻罚) - dispatchadapter.ReportProbeOutcome(ctx, channelID, upstreamModel, result.Success, float64(latencyMs)) + // 更新健康度(喂给调度引擎的健康体系;探测失败按瞬时错误轻罚)。 + // 仅 active 渠道上报:禁用/测试中渠道不在调度目录,喂健康分只会无意义拉低展示值; + // 渠道启用时 MarkChannelRecovered 会复位熔断,无需在禁用期间预热。 + if ch.Status == "active" { + dispatchadapter.ReportProbeOutcome(ctx, channelID, upstreamModel, result.Success, float64(latencyMs)) + } // 记录测试结果日志 if result.Success { diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index beaad81..67accf2 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -212,9 +212,7 @@ var Registry = []SettingDef{ // ── Channel ── {Key: "channel_auto_test_enabled", Type: SettingTypeBool, Default: "true", Category: "channel", - Label: "渠道自动探测", Description: "定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)"}, - {Key: "channel_auto_test_recovery_enabled", Type: SettingTypeBool, Default: "true", Category: "channel", - Label: "自动恢复探测", Description: "定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启)"}, + Label: "渠道自动探测", Description: "定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)。禁用渠道不自动探测,由管理员手动测试确认后再启用"}, {Key: "channel_auto_disable_enabled", Type: SettingTypeBool, Default: "false", Category: "channel", Label: "渠道自动禁用", Description: "渠道熔断持续超过路由策略 breaker.autoDisableAfterSeconds(默认 10 分钟)未恢复时自动禁用"}, {Key: "channel_routing_policy", Type: SettingTypeJSON, Default: "", Category: "channel", diff --git a/internal/logic/monitor/api_metrics_test.go b/internal/logic/monitor/api_metrics_test.go index 590bc42..8307a2d 100644 --- a/internal/logic/monitor/api_metrics_test.go +++ b/internal/logic/monitor/api_metrics_test.go @@ -2,19 +2,45 @@ package monitor import ( "math" + "reflect" + "strings" "testing" + v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/logic/common" ) -// 断言 list 中某模型的字段(浮点用容差比较,派生指标均四舍五入到 2 位小数)。 -func assertPerfField(t *testing.T, list []map[string]any, model, field string, wantFloat float64, wantInt int64) { - t.Helper() - for _, item := range list { - if item["model_name"] != model { +// perfFieldValue 按 JSON 字段名(snake_case)从结构体取值,整数统一归一为 int64、 +// 浮点统一为 float64,与旧的 []map[string]any 断言口径保持一致。 +func perfFieldValue(item v1.ModelPerformanceSummary, field string) any { + rv := reflect.ValueOf(item) + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + name, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",") + if name != field { continue } - switch v := item[field].(type) { + fv := rv.Field(i) + switch fv.Kind() { + case reflect.Int, reflect.Int64: + return fv.Int() + case reflect.Float64: + return fv.Float() + default: + return fv.Interface() + } + } + return nil +} + +// 断言 list 中某模型的字段(浮点用容差比较,派生指标均四舍五入到 2 位小数)。 +func assertPerfField(t *testing.T, list []v1.ModelPerformanceSummary, model, field string, wantFloat float64, wantInt int64) { + t.Helper() + for _, item := range list { + if item.ModelName != model { + continue + } + switch v := perfFieldValue(item, field).(type) { case int64: if v != wantInt { t.Errorf("%s.%s = %d, want %d", model, field, v, wantInt) @@ -25,11 +51,6 @@ func assertPerfField(t *testing.T, list []map[string]any, model, field string, w t.Errorf("%s.%s = %v, want %v", model, field, v, wantFloat) } return - case string: - if v != model { - t.Errorf("%s.%s = %q", model, field, v) - } - return } } t.Errorf("model %s not found in list or field %s type unexpected", model, field) @@ -199,7 +220,7 @@ func TestBuildModelPerformanceList_FilterUnknownAndEmpty(t *testing.T) { "skip-zero": {Req: 0, Lat: 999}, // 当日请求数为 0 不并入 } list := buildModelPerformanceList(rows, today) - if len(list) != 1 || list[0]["model_name"] != "gpt-4o" { + if len(list) != 1 || list[0].ModelName != "gpt-4o" { t.Fatalf("expected only gpt-4o, got %v", list) } assertPerfField(t, list, "gpt-4o", "request_count", 0, 10) @@ -212,11 +233,11 @@ func TestBuildModelPerformanceList_Empty(t *testing.T) { } } -func assertOrder(t *testing.T, list []map[string]any, first, second string) bool { +func assertOrder(t *testing.T, list []v1.ModelPerformanceSummary, first, second string) bool { t.Helper() pos := map[string]int{} for i, item := range list { - pos[item["model_name"].(string)] = i + pos[item.ModelName] = i } if pos[first] > pos[second] { t.Errorf("expected %s before %s, got positions %v", first, second, pos) @@ -225,12 +246,12 @@ func assertOrder(t *testing.T, list []map[string]any, first, second string) bool return true } -func assertGrade(t *testing.T, list []map[string]any, model, want string) { +func assertGrade(t *testing.T, list []v1.ModelPerformanceSummary, model, want string) { t.Helper() for _, item := range list { - if item["model_name"] == model { - if g, ok := item["grade"].(string); !ok || g != want { - t.Errorf("%s.grade = %v, want %s", model, item["grade"], want) + if item.ModelName == model { + if item.Grade != want { + t.Errorf("%s.grade = %v, want %s", model, item.Grade, want) } return } diff --git a/internal/logic/task/channel_auto_test_cron.go b/internal/logic/task/channel_auto_test_cron.go index 954b50c..c30a8cb 100644 --- a/internal/logic/task/channel_auto_test_cron.go +++ b/internal/logic/task/channel_auto_test_cron.go @@ -8,13 +8,11 @@ import ( v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/dao" - "github.com/qianfree/team-api/internal/dispatchadapter" "github.com/qianfree/team-api/internal/logic/admin" - "github.com/qianfree/team-api/internal/logic/common" - do "github.com/qianfree/team-api/internal/model/do" ) -// AutoTestChannels 自动测试所有活跃渠道,并尝试恢复自动禁用的渠道 +// AutoTestChannels 自动测试所有活跃渠道(禁用渠道不自动探测, +// 由管理员手动测试确认恢复后再启用,避免未经确认的流量进入) func AutoTestChannels(ctx context.Context) { type channelInfo struct { ID int64 `json:"id"` @@ -59,59 +57,4 @@ func AutoTestChannels(ctx context.Context) { } } } - - // 2. 尝试恢复自动禁用的渠道(受独立开关控制) - if common.Config().GetBool(ctx, "channel_auto_test_recovery_enabled") { - testAndRecoverDisabledChannels(ctx) - } -} - -// testAndRecoverDisabledChannels 测试自动禁用的渠道,测试通过则恢复 -func testAndRecoverDisabledChannels(ctx context.Context) int { - var disabledChannels []struct { - ID int64 `json:"id"` - Name string `json:"name"` - } - err := dao.ChnChannels.Ctx(ctx). - Where("status", "disabled"). - Where("auto_disabled", 1). - Where("test_model != ?", ""). - Fields("id, name"). - Scan(&disabledChannels) - if err != nil || len(disabledChannels) == 0 { - return 0 - } - - recovered := 0 - for _, ch := range disabledChannels { - testCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - result, err := admin.New().TestChannel(testCtx, &v1.ChannelTestReq{ID: ch.ID}) - cancel() - - if err != nil || !result.Success { - g.Log().Debugf(ctx, "[Cron] auto-disabled channel %s (%d) recovery test failed", ch.Name, ch.ID) - continue - } - - // 恢复渠道 - _, err = dao.ChnChannels.Ctx(ctx). - Where("id", ch.ID). - Data(do.ChnChannels{ - Status: "active", - AutoDisabled: 0, - }).Update() - if err != nil { - g.Log().Errorf(ctx, "[Cron] recover channel %s (%d) failed: %v", ch.Name, ch.ID, err) - continue - } - - // 复位调度熔断并开启爬坡窗口 + 跨实例目录失效 - dispatchadapter.MarkChannelRecovered(ctx, ch.ID) - dispatchadapter.PublishInvalidate(ctx) - - recovered++ - g.Log().Infof(ctx, "[Cron] auto-disabled channel %s (%d) recovered", ch.Name, ch.ID) - } - - return recovered } diff --git a/internal/logic/update/update_integration_test.go b/internal/logic/update/update_integration_test.go index 926a810..90cc3ac 100644 --- a/internal/logic/update/update_integration_test.go +++ b/internal/logic/update/update_integration_test.go @@ -119,6 +119,13 @@ func startMockReleaseServer(t *testing.T, version string) (*httptest.Server, str // (CheckForUpdate → stageUpdate:下载→校验→备份→替换→写标记), // 验证整个更新链路的磁盘产物,无需 GitHub / PostgreSQL / Redis。 func TestUpdatePipeline_Local(t *testing.T) { + // 自更新流水线面向 Linux 部署:getPlatformAssetName 在 Windows 期望 .zip 资产, + // 而 extractBinary 仅支持 tar.gz,且运行中的 exe 被锁定无法 rename 替换。 + // Windows 开发机跳过,流水线在 Linux CI 上完整验证。 + if runtime.GOOS == "windows" { + t.Skip("self-update pipeline targets Linux deployments (tar.gz asset + rename-based replace)") + } + ctx := gctx.New() version := "0.9.0" // 模拟的"新版本",必须高于当前版本才判定有更新 diff --git a/web/admin/src/views/system/settings/ChannelTab.vue b/web/admin/src/views/system/settings/ChannelTab.vue index 692a033..614f726 100644 --- a/web/admin/src/views/system/settings/ChannelTab.vue +++ b/web/admin/src/views/system/settings/ChannelTab.vue @@ -10,20 +10,12 @@ const values = useFormValues()
自动探测
渠道自动探测 - 定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token) + 定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)。禁用渠道不自动探测,由管理员手动测试确认后再启用
-
- 自动恢复探测 - 定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启) - -