feat(channel):允许禁用的渠道进行模型测试操作

This commit is contained in:
qian committed 2026-08-20 01:03:48 +08:00
1 parent ee96a5b866
commit 5cae607d19
6 files changed
+59 -95

No files matched your search

+9 -6
View File
@@ -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 {
+1 -3
View File
@@ -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",
+39 -18
View File
@@ -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
}
+2 -59
View File
@@ -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
}
@@ -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" // 模拟的"新版本",必须高于当前版本才判定有更新
@@ -10,20 +10,12 @@ const values = useFormValues()
<div class="section-title">自动探测</div>
<div class="switch-row">
<span class="switch-label">渠道自动探测</span>
<span class="switch-desc">定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)</span>
<span class="switch-desc">定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)。禁用渠道不自动探测,由管理员手动测试确认后再启用</span>
<ASwitch
:model-value="!!values['channel_auto_test_enabled']"
@change="(v: string | number | boolean) => values['channel_auto_test_enabled'] = v"
/>
</div>
<div class="switch-row" style="margin-top: 12px">
<span class="switch-label">自动恢复探测</span>
<span class="switch-desc">定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启)</span>
<ASwitch
:model-value="!!values['channel_auto_test_recovery_enabled']"
@change="(v: string | number | boolean) => values['channel_auto_test_recovery_enabled'] = v"
/>
</div>
</div>
<!-- 自动禁用 -->