package admin import ( "context" "encoding/json" "time" "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" "github.com/qianfree/team-api/internal/logic/relay" ) // TestChannel 测试渠道可用性(发送最小请求验证)。 // 不限制渠道状态:禁用中的渠道也允许手动测试,便于管理员在启用放流量前确认已恢复; // 自动探测 cron 只查 active 渠道,禁用渠道不会被自动探测。 func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.ChannelTestRes, error) { channelID := req.ID // 获取渠道信息 type channelRow struct { ID int64 `json:"id"` Name string `json:"name"` Type int `json:"type"` BaseURL string `json:"base_url"` TestModel string `json:"test_model"` Status string `json:"status"` Settings string `json:"settings"` } var ch *channelRow err := dao.ChnChannels.Ctx(ctx). Where("id", channelID). Fields("id, name, type, base_url, test_model, status, settings"). Scan(&ch) if err != nil { return nil, err } if ch == nil { return nil, common.NewNotFoundError("渠道") } testModel := req.ModelName if testModel == "" { testModel = ch.TestModel } if testModel == "" { return nil, common.NewBadRequestError("请指定测试模型名") } // 将系统内模型名称转换为该渠道配置的上游模型名称 // chn_abilities 表存储了每个渠道每个模型的上游模型映射 // 例如:平台标准名 "deepseek-v4-flash" → 上游实际名 "DeepSeek-V4-Flash" type abilityRow struct { UpstreamModel string `json:"upstream_model"` } var abilityInfo abilityRow upstreamModel := testModel // 查询该渠道配置的上游模型名称 err = dao.ChnAbilities.Ctx(ctx). Where("channel_id", channelID). Where("model_name", testModel). Where("enabled", true). Fields("upstream_model"). Limit(1). Scan(&abilityInfo) if abilityInfo.UpstreamModel != "" { upstreamModel = abilityInfo.UpstreamModel } // 获取渠道的 API Key type keyRow struct { EncryptedKey string `json:"encrypted_key"` } var keyInfo *keyRow err = dao.ChnChannelKeys.Ctx(ctx). Where("channel_id", channelID). Where("status", "active"). Fields("encrypted_key"). OrderAsc("last_used_at"). Limit(1). Scan(&keyInfo) if err != nil || keyInfo == nil || keyInfo.EncryptedKey == "" { return nil, common.NewBusinessError(consts.CodeNotFound, "渠道没有可用的 API Key") } encKey := relay.GetEncryptionKey() apiKey, err := relay.DecryptChannelKey(encKey, keyInfo.EncryptedKey) if err != nil { return nil, common.NewBadRequestError("解密 API Key 失败") } // 解析渠道设置中的 use_proxy useProxy := false if ch.Settings != "" { var settings struct { UseProxy bool `json:"use_proxy"` } if json.Unmarshal([]byte(ch.Settings), &settings) == nil { useProxy = settings.UseProxy } } // 发送最小测试请求 startTime := time.Now() result := sendTestRequest(ctx, ch.Type, ch.BaseURL, apiKey, upstreamModel, useProxy) latencyMs := time.Since(startTime).Milliseconds() // 更新健康度(喂给调度引擎的健康体系;探测失败只喂熔断窗口不衰减健康分)。 // 必须上报平台模型名 testModel:调度健康键与目录聚合均按 chn_abilities.model_name // 组织,上报上游名会写进调度器永远不读的孤立 key,探测结果对健康度完全无效。 // 仅 active 渠道上报:禁用/测试中渠道不在调度目录,喂健康分只会无意义拉低展示值; // 渠道启用时 MarkChannelRecovered 会复位熔断,无需在禁用期间预热。 if ch.Status == "active" { dispatchadapter.ReportProbeOutcome(ctx, channelID, testModel, result.Success, float64(latencyMs)) // 请求立即重算渠道健康快照:维护循环 5 分钟一轮,不触发的话管理员会看到 // "测试成功了但渠道健康度纹丝不动"(模型级能力列表直读 Redis 会立刻变, // 渠道健康分读的是 chn_health_scores 落盘值) dispatchadapter.RequestHealthSnapshot(channelID) // 探测目标不在调度目录:EWMA 照常写入 Redis,但调度不读、维护快照不落盘, // 管理后台健康分不会变化——探测看起来"生效了但没效果"的常见根因,必须告警 if !dispatchadapter.CatalogHasModel(channelID, testModel) { g.Log().Warningf(ctx, "[ChannelProbe] 渠道 %s (%d) 探测结果已投递健康上报,但模型 %s 不在调度目录(渠道无启用的模型能力行或该模型未配置能力)| 健康分不会落盘展示,渠道/模型不参与调度,请检查渠道的模型能力配置", ch.Name, channelID, testModel) } } // 仅失败需要日志留痕(成功结果已通过 API 响应返回) if !result.Success { g.Log().Warningf(ctx, "[ChannelProbe] 渠道 %s (%d) 探测失败 | 模型: %s (上游: %s) | 延迟: %dms | 代理: %v | 错误: %s", ch.Name, channelID, testModel, upstreamModel, latencyMs, useProxy, result.Error) } return &v1.ChannelTestRes{ Success: result.Success, Latency: latencyMs, ModelName: upstreamModel, Error: result.Error, Request: result.Request, Response: result.Response, }, nil } // testResult 测试结果 type testResult struct { Success bool Error string Response string Request *v1.ChannelTestReqDetail }