157 lines
5.5 KiB
Go
157 lines
5.5 KiB
Go
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
|
|
}
|