feat(channel):允许禁用的渠道进行模型测试操作
This commit is contained in:
1 parent
ee96a5b866
commit
5cae607d19
6 files changed
+59
-95
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 自动禁用 -->
|
||||
|
||||
Reference in new issue
Block a user