Files
team-api/internal/logic/admin/channel.go
T
Qian 1f2046d0fd 重构协议转换,实现更完整的转换功能 (#26)
* fix(relay):转换层路径缺失

* refactor(relay):移除coze和dify支持

* fix(relay): 流式错误传播三态返回与安全拦截分类

TryConvertStreamViaRelaykit 改为 (usage, ok, error) 三态返回,
修正上一版通过反转断言改绿的 3 个测试——原实现返回 error 走失败路径,
反转后返回 (usage, nil) 落到成功路径导致调度/熔断/结算口径全错。

- stream.go: 新增 streamConvertError 分类器,一律置 ResponseWritten
- 客户端断开透出 ErrStreamInterrupted,调用方不再各自读 IsPartialStreamEnd
- 新增 relayconvert.ErrContentBlocked 哨兵,6 处 Gemini 出站安全拦截 wrap
- response.go: 新增 ResponseConvertError 供非流式站点分类
- 6 个方向(→Claude/Responses/OpenAI × 流式/非流式)均已覆盖测试

修前:安全拦截映射 502 → 正常渠道因用户违规提示词被扣健康分/熔断
修后:识别哨兵映射 4xx 请求类错误


* fix(relay): 修复 Anthropic 兼容端点渠道的 Claude 入站断链

ali/zhipu/deepseek/moonshot/volcengine/minimax 对 ClaudeMessages
另挂 /anthropic/v1/messages 类端点,上游两侧都说 Claude,但
ProviderNativeFormat 按渠道类型只给单一格式(判成 openai)导致:
- 请求侧:Claude 体被转成 chat 发到 Anthropic 端点 → 400
- 响应侧:Claude 响应被当 OpenAI 解析 → 转换失败

新增 constant.HasNativeClaudeEndpoint 谓词,在
EffectiveUpstreamFormat 与 inboundMatchesChannelNative 两处叠加判定,
Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式不转换。

登记判据:GetRequestURL 对 ClaudeMessages 返回与 chat 不同的地址。
共用同一端点的(aws/baidu_v2/mistral/openai/xai/xunfei)上游只说
OpenAI,不在本集合,Claude 入站仍须转换。


* fix(relay): relaykit 路径接回 5 家私有请求后处理

relaykit 矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest
整个不再被调用。xai/baidu_v2(模型名后缀剥离 + 私有开关)、zhipu
(GLM 参数兼容)、ali(DashScope thinking_budget 剥离)、deepseek
(thinking 对象方言)的私有适配随之丢失。

最实际的是模型名:未配映射时 UpstreamModel = row.ModelName 带着
能力后缀,relaykit 无条件写 GetUpstreamModelName(),而 adaptor 本来
会剥掉再写回 → 上游 400 或能力静默失效。

新增可选能力接口 RequestPostProcessor,handler.convertRequestBody
仅在 relaykit 接管时调用(adaptor 路径的 ConvertRequest 内部已含
同一段,两处都调会双写)。5 家各把尾段抽成 postProcessRequest 与钩子
共用。新增接口断言与功能测试。


* docs(relaykit): 更新工作状态与回归记录

- §5.1/§5.2 已完成,新增 §3.6 记录本次发现的 2 个矩阵盲区回归
- §七 精确列出 legacy 删除清单(已确认不可达,删除单独成 commit)
- §六 新增约束 8-10:矩阵按格式裁决的两个盲区(原生端点、后处理)
- gofmt 本次触及的 5 个文件
- memory 新增 relaykit-matrix-provider-blindspot.md 根因记录


* refactor(relay): 删除被 relaykit 取代的 legacy 协议转换代码

净删约 6400 行。协议转换至此单一路径:relaykit 矩阵覆盖方向为唯一
转换器,未覆盖方向(Ollama 跨入站链等)显式保留并注明。

- 删 claude/gemini converter.go 整文件(o2c/o2g 请求侧双实现)
- 删 openai/converter.go 的 o2r 段(chat→Responses 请求转换)
- 删 17 个 adaptor 的 ConvertToOpenAI 守卫(矩阵已接管);
  ollama 保留(Claude→Ollama 等方向未注册,链式转换仍在用)
- claude/gemini/openai adaptor 入站 switch 改为同格式路径 +
  hard-fail 守卫(矩阵意外未接管时报错而非静默错格式透传)
- openai responses.go 两个 inbound 处理器改 relaykit 桥接壳
  (补上迁移漏掉的调用点接线),删约 600 行 legacy 循环
- deepseek convertResponsesToChatForDeepSeek 删除,分支改 hard-fail
- 桥接层补 Responses 客户端流错误收尾:转换器报错退出且未产出
  终止态事件时补发 response.failed(防 codex 等客户端挂起);
  已自行收尾的(Gemini SAFETY completed 拒答)不追加第二终止事件

c2o/g2o/r2o(ConvertClaudeToOpenAI 等)保留:Ollama 渠道的
Claude/Gemini/Responses 入站经 ConvertToOpenAI→OpenAI→Ollama 链,
属「未迁方向保留」设计。

测试:新增删除项的替代断言(hard-fail 守卫、PostProcess 钩子、
response.failed 收尾);删测已删函数的用例。全仓 build/vet 通过,
relaykit 全绿,relay 侧仅剩 helper 既有失败。

* refactor(relay): 移除模型名能力后缀机制(虚拟模型语法)

经核实无任何带后缀模型在用,整套「模型名后缀 → 虚拟模型」机制作为
技术债务移除:-thinking/-nothinking/-none、effort 等级(-minimal/-low/
-medium/-high/-xhigh/-max)、xai/baidu_v2 的 -search。思考/搜索能力
此后一律由客户端请求参数表达(thinking 对象、reasoning_effort、
reasoning.effort),网关只做协议转换不做能力注入。

宿主侧:
- 删 helper/thinking.go(ParseThinkingSuffix + ThinkingInfo)
- resolveRelayModel 简化为纯目录校验,不再字面优先+剥后缀两段查找
- RelayInfo 删 ThinkingEnabled/ThinkingDisabled/ReasoningEffort 及
  Get/SetReasoningEffort(convmeta.Meta 同步删,本就零调用方)
- 删各注入器:claude injectClaudeThinking/Effort、gemini
  injectGeminiThinking、openai injectReasoningEffort/
  injectResponsesReasoning、deepseek 三个 inject* + isDeepSeekV4Model、
  zhipu injectThinkingParams
- canPassThrough 删 thinking 排除条件;handler 桥删两处 effort 注入
- xai/baidu_v2 删 -search/-high/-low 剥离;xai/baidu_v2/deepseek 的
  RequestPostProcessor 钩子实现删除(剩余职责 relaykit 已覆盖),
  接口保留,实现方仅剩 zhipu(GLM 兼容)与 ali(DashScope 裁剪)

relaykit 侧:
- 删 internal/shared/thinking_adapter.go 与 reasoning/ 包
- convmeta.Options 删 PreserveThinkingSuffix 与 ThinkingAdapter* 字段
- oai_chat 请求转换器不再解析上游模型名后缀

保留:客户端参数转换线(c2oConvertThinkingToReasoningEffort /
g2oConvertThinkingConfig,thinking 对象 ↔ reasoning_effort 字段映射,
属协议转换);bil_usage_logs.reasoning_effort 列(此后恒空);
BaseModelName 字段(responses 路由记录在用)。

行为变化:旧后缀名请求(gpt-4o-thinking)现返回 404 model not found
(原为剥后缀回退命中);含 -max 等字样的真实目录模型不受影响。

全仓 build/vet 通过,relaykit 全绿,relay 侧仅剩 helper 既有失败。

* fix(dispatch): 探测失败不再衰减健康 EWMA,修复 4 处测试失效

processOutcome 之前忽略 Outcome.Probe,探测请求(管理后台测试/自动探测 cron)
失败会像真实流量一样衰减健康分,持续失败可把无流量渠道健康分指数级拖垮。
现探测失败跳过健康 EWMA 写入(探测成功仍照常记录),熔断窗口计数不受影响。

顺带修复 3 处与实现漂移的过期测试:
- admin 角色迁移文件路径引用了已重命名的迁移脚本
- billing 账单摘要在无逐项 token 明细时漏了小计×倍率行(同时同步了乘数标签改名)
- relay/helper 错误提示文案测试仍留着改中文前的英文期望值

* refactor(relay):补充gemini和claude协议的转换

* fix(relay):修复gemini转换问题

* fix(relay):优化工具转换

* feat(test):添加测试用例

* feat(test):添加测试用例

* feat(relay):补充转换矩阵

* feat(relay):补充协议转换测试

* fix(relay):修复vertex缺陷

* feat(relay):补充协议间的工具转换

* feat(relay):添加语料采集功能

* fix(relay):修复claude输出协议转换问题

* fix(relay):修复codex请求claude模型无法使用搜索工具问题

* fix(relay):修复codex请求gemini模型无法使用搜索工具问题

* refactor(relay):修改真实语料处理逻辑,将真实响应数据移出仓库

* fix(relay):补充手写测试数据集
2026-09-12 16:08:05 +08:00

1133 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"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/common"
"github.com/qianfree/team-api/internal/logic/relay"
uc "github.com/qianfree/team-api/internal/utility/crypto"
"github.com/qianfree/team-api/internal/utility/export"
)
// providerTypeNames 供应商类型名称映射
var providerTypeNames = map[int]string{
1: "OpenAI",
2: "Claude",
3: "Gemini",
4: "Ali",
5: "Baidu",
6: "Tencent",
7: "Zhipu",
8: "DeepSeek",
9: "Moonshot",
10: "Volcengine",
11: "AWS Bedrock",
12: "Azure OpenAI",
13: "Vertex AI",
14: "Cohere",
15: "Mistral",
16: "xAI",
41: "New API",
42: "Sub2API",
}
// channelRuntimeStates 返回目录快照的渠道熔断聚合(管理后台列表/详情展示用)。
// 目录未初始化(首次 relay 请求前)时返回 nil,调用方按「正常」处理。
func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState {
cat := dispatchadapter.CatalogInstance()
if cat == nil {
return nil
}
return cat.ChannelRuntimeStates()
}
// ListChannels 获取渠道列表
func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) {
query := dao.ChnChannels.Ctx(ctx).
LeftJoin("chn_health_scores h ON chn_channels.id = h.channel_id")
if req.Type > 0 {
query = query.Where("chn_channels.type", req.Type)
}
if req.Status != "" {
query = query.Where("chn_channels.status", req.Status)
}
if req.Search != "" {
query = query.Where("chn_channels.name LIKE ? OR chn_channels.remark LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
if req.ID > 0 {
query = query.Where("chn_channels.id", req.ID)
}
if req.BaseURL != "" {
query = query.Where("chn_channels.base_url LIKE ?", "%"+req.BaseURL+"%")
}
if req.Model != "" {
// 按支持的模型名筛选:渠道需具备匹配的模型能力(chn_abilities)。
// 用 EXISTS 相关子查询而非 JOIN,避免一个渠道命中多个模型时产生重复行、破坏分页与总数。
query = query.Where("EXISTS (SELECT 1 FROM chn_abilities WHERE chn_abilities.channel_id = chn_channels.id AND (chn_abilities.model_name LIKE ? OR chn_abilities.upstream_model LIKE ?))", "%"+req.Model+"%", "%"+req.Model+"%")
}
var total int
var channels []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
BaseURL string `json:"base_url"`
Status string `json:"status"`
Priority int `json:"priority"`
Weight int `json:"weight"`
MaxConcurrency int `json:"max_concurrency"`
Tier string `json:"tier"`
StrictCapacity bool `json:"strict_capacity"`
TestModel string `json:"test_model"`
Remark string `json:"remark"`
IsVIP bool `json:"is_vip"`
SharingThreshold *float64 `json:"sharing_threshold"`
PreemptionThreshold *float64 `json:"preemption_threshold"`
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"`
CreatedAt *gtime.Time `json:"created_at"`
HealthScore *float64 `json:"health_score"`
Settings string `json:"settings"`
}
err := query.Fields("chn_channels.id, chn_channels.name, chn_channels.type, chn_channels.base_url, chn_channels.status, chn_channels.priority, chn_channels.weight, chn_channels.max_concurrency, chn_channels.tier, chn_channels.strict_capacity, chn_channels.test_model, chn_channels.remark, chn_channels.is_vip, chn_channels.sharing_threshold, chn_channels.preemption_threshold, chn_channels.borrowing_cooldown_seconds, chn_channels.created_at, h.health_score, chn_channels.settings").
// 列表默认按 id 倒序:新建渠道排在最前,便于运营查看最新配置。
// 注意分页场景下排序必须在 DB 层完成,前端只在当前页排序会导致跨页错乱。
OrderDesc("chn_channels.id").
Page(req.Page, req.PageSize).
ScanAndCount(&channels, &total, false)
if err != nil {
return nil, err
}
list := make([]v1.ChannelItem, 0, len(channels))
runtimeStates := channelRuntimeStates()
for _, ch := range channels {
typeName := providerTypeNames[ch.Type]
if typeName == "" {
typeName = fmt.Sprintf("Unknown(%d)", ch.Type)
}
settings := relay.ParseChannelSettings(ch.Settings)
item := v1.ChannelItem{
ID: ch.ID,
Name: ch.Name,
Type: ch.Type,
TypeName: typeName,
BaseURL: ch.BaseURL,
Status: ch.Status,
Priority: ch.Priority,
Weight: ch.Weight,
MaxConcurrency: ch.MaxConcurrency,
Tier: ch.Tier,
StrictCapacity: ch.StrictCapacity,
TestModel: ch.TestModel,
Remark: ch.Remark,
IsVIP: ch.IsVIP,
UseProxy: settings.UseProxy,
SharingThreshold: ch.SharingThreshold,
PreemptionThreshold: ch.PreemptionThreshold,
BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds,
CreatedAt: ch.CreatedAt.String(),
HealthScore: ch.HealthScore,
}
if rs, ok := runtimeStates[ch.ID]; ok {
item.BreakerState = int(rs.Breaker)
item.BreakerModels = rs.BreakerModels
item.WorstModel = rs.WorstModel
item.WorstModelScore = rs.WorstModelScore
}
list = append(list, item)
}
return &v1.ChannelListRes{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
// CloneChannel 克隆渠道
func (s *sAdmin) CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1.ChannelCloneRes, error) {
// 查询源渠道
var src *struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
BaseURL string `json:"base_url"`
Priority int `json:"priority"`
Weight int `json:"weight"`
MaxConcurrency int `json:"max_concurrency"`
TestModel string `json:"test_model"`
Remark string `json:"remark"`
Settings string `json:"settings"`
IsVIP bool `json:"is_vip"`
SharingThreshold float64 `json:"sharing_threshold"`
PreemptionThreshold float64 `json:"preemption_threshold"`
BorrowingCooldownSeconds int `json:"borrowing_cooldown_seconds"`
}
err := dao.ChnChannels.Ctx(ctx).
Fields("id, name, type, base_url, priority, weight, max_concurrency, test_model, remark, settings, is_vip, sharing_threshold, preemption_threshold, borrowing_cooldown_seconds").
Where("id", req.ID).
Scan(&src)
if err != nil {
return nil, err
}
if src == nil {
return nil, common.NewBusinessError(404, "源渠道不存在")
}
// 确定名称
name := req.Name
if name == "" {
name = src.Name + " (副本)"
}
// 加密 Key(放在事务外,加密失败无需回滚任何写入)
encKey := relay.GetEncryptionKey()
encrypted, err := uc.EncryptString(encKey, req.ApiKey)
if err != nil {
return nil, gerror.Wrapf(err, "encrypt api key failed")
}
// 渠道 + 密钥在同一事务内创建:密钥插入失败则回滚渠道,避免留下没有任何 Key 的孤儿渠道。
var newID int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var e error
newID, e = dao.ChnChannels.Ctx(ctx).InsertAndGetId(do.ChnChannels{
Name: name,
Type: src.Type,
BaseUrl: src.BaseURL,
Status: "active",
Priority: src.Priority,
Weight: src.Weight,
MaxConcurrency: src.MaxConcurrency,
TestModel: src.TestModel,
Remark: src.Remark,
Settings: src.Settings,
IsVip: src.IsVIP,
SharingThreshold: src.SharingThreshold,
PreemptionThreshold: src.PreemptionThreshold,
BorrowingCooldownSeconds: src.BorrowingCooldownSeconds,
})
if e != nil {
return e
}
_, e = dao.ChnChannelKeys.Ctx(ctx).Insert(do.ChnChannelKeys{
ChannelId: newID,
Name: "default",
EncryptedKey: encrypted,
Status: "active",
})
return e
})
if err != nil {
return nil, err
}
// 克隆 abilities
var abilities []struct {
ModelName string `json:"model_name"`
UpstreamModel string `json:"upstream_model"`
Enabled bool `json:"enabled"`
CostRatio float64 `json:"cost_ratio"`
SupportsResponses bool `json:"supports_responses"`
ChatViaResponses bool `json:"chat_via_responses"`
}
err = dao.ChnAbilities.Ctx(ctx).
Fields("model_name, upstream_model, enabled, cost_ratio, supports_responses, chat_via_responses").
Where("channel_id", req.ID).
Scan(&abilities)
if err != nil {
return nil, err
}
for _, ab := range abilities {
_, err := dao.ChnAbilities.Ctx(ctx).Insert(do.ChnAbilities{
ChannelId: newID,
ModelName: ab.ModelName,
UpstreamModel: ab.UpstreamModel,
Enabled: ab.Enabled,
CostRatio: ab.CostRatio,
SupportsResponses: ab.SupportsResponses,
ChatViaResponses: ab.ChatViaResponses,
})
if err != nil {
g.Log().Warningf(ctx, "clone ability %s for channel %d failed: %v", ab.ModelName, newID, err)
}
}
// 初始化健康分
if err := relay.InitHealthScore(ctx, newID); err != nil {
g.Log().Warningf(ctx, "init health score for channel %d failed: %v", newID, err)
}
return &v1.ChannelCloneRes{ID: newID}, nil
}
// CreateChannel 创建渠道
func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (*v1.ChannelCreateRes, error) {
// 自动填充默认 Base URL
baseURL := req.BaseURL
if baseURL == "" {
baseURL = defaultProviderURL(req.Type)
}
// Build settings JSON(只写显式开启的布尔项,其余字段用 Parse 时的默认值)
settingsJSON := "{}"
if req.UseProxy || req.WebSearchToGoogleSearch {
boolFlags := map[string]bool{}
if req.UseProxy {
boolFlags["use_proxy"] = true
}
if req.WebSearchToGoogleSearch {
boolFlags["web_search_to_google_search"] = true
}
if b, err := json.Marshal(boolFlags); err == nil {
settingsJSON = string(b)
}
}
tier := req.Tier
if tier == "" {
tier = "primary"
}
id, err := dao.ChnChannels.Ctx(ctx).InsertAndGetId(do.ChnChannels{
Name: req.Name,
Type: req.Type,
BaseUrl: baseURL,
Status: "active",
Priority: req.Priority,
Weight: req.Weight,
MaxConcurrency: req.MaxConcurrency,
Tier: tier,
StrictCapacity: req.StrictCapacity,
TestModel: req.TestModel,
Remark: req.Remark,
Settings: settingsJSON,
IsVip: req.IsVIP,
SharingThreshold: req.SharingThreshold,
PreemptionThreshold: req.PreemptionThreshold,
BorrowingCooldownSeconds: req.BorrowingCooldownSeconds,
})
if err != nil {
return nil, err
}
// 加密并创建 Key
encKey := relay.GetEncryptionKey()
encrypted, err := uc.EncryptString(encKey, req.ApiKey)
if err != nil {
return nil, gerror.Wrapf(err, "encrypt api key failed")
}
_, err = dao.ChnChannelKeys.Ctx(ctx).Insert(do.ChnChannelKeys{
ChannelId: id,
Name: "default",
EncryptedKey: encrypted,
Status: "active",
})
if err != nil {
return nil, err
}
// 初始化健康度记录
if err := relay.InitHealthScore(ctx, id); err != nil {
g.Log().Warningf(ctx, "init health score for channel %d failed: %v", id, err)
}
return &v1.ChannelCreateRes{ID: id}, nil
}
// UpdateChannel 更新渠道
func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*v1.ChannelUpdateRes, error) {
data := do.ChnChannels{}
if req.Name != "" {
data.Name = req.Name
}
if req.Type != nil {
data.Type = *req.Type
}
if req.BaseURL != "" {
data.BaseUrl = req.BaseURL
} else if req.Type != nil {
// 切换供应商类型且未显式传 base_url:旧地址为空或等于旧类型默认值(用户未自定义过)时,
// 自动跟随为新类型默认地址,避免残留旧供应商端点导致上游请求 404;自定义地址(如中转站)保留不动
var old struct {
Type int `json:"type"`
BaseUrl string `json:"base_url"`
}
if err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Fields("type, base_url").Scan(&old); err == nil {
if newDefault := defaultProviderURL(*req.Type); newDefault != "" &&
(old.BaseUrl == "" || old.BaseUrl == defaultProviderURL(old.Type)) {
data.BaseUrl = newDefault
}
}
}
// priority/weight 为指针字段,仅在显式传入时更新,避免不含这两个字段的
// 局部更新(如仅切换状态、仅换 Key)把已有值洗成 0。
if req.Priority != nil {
data.Priority = *req.Priority
}
if req.Weight != nil {
data.Weight = *req.Weight
}
if req.MaxConcurrency != nil {
data.MaxConcurrency = *req.MaxConcurrency
}
if req.TestModel != "" {
data.TestModel = req.TestModel
}
if req.Remark != "" {
data.Remark = req.Remark
}
if req.Status != "" {
data.Status = req.Status
}
if req.Tier != "" {
data.Tier = req.Tier
}
if req.StrictCapacity != nil {
data.StrictCapacity = *req.StrictCapacity
}
if req.IsVIP != nil {
data.IsVip = *req.IsVIP
}
if req.SharingThreshold != nil {
data.SharingThreshold = *req.SharingThreshold
}
if req.PreemptionThreshold != nil {
data.PreemptionThreshold = *req.PreemptionThreshold
}
if req.BorrowingCooldownSeconds != nil {
data.BorrowingCooldownSeconds = *req.BorrowingCooldownSeconds
}
// Update use_proxy / debug_log_* in settings JSONB。
// 必须单块合并处理(读一次旧值 → 同时应用全部字段 → 回写一次):
// 若写成多个同构块,各自从 DB 读旧值再各自 marshal,同请求多字段时会互相覆盖。
if req.UseProxy != nil || req.WebSearchToGoogleSearch != nil || req.DebugLogEnabled != nil ||
req.DebugLogTenantID != nil || req.DebugLogUserID != nil || req.DebugLogApiKeyID != nil {
// 注意:读单列字符串必须用 Value()——gdb 的 Model.Scan 只接受 struct 系列,
// 传入 *string 会直接报错;此前用 _ = Scan(&string) 吞错导致永远读到空串,
// 每次局部更新都会把未提交的 settings 字段静默重置为默认值
var currentSettings string
if v, err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Fields("settings").Value(); err == nil {
currentSettings = v.String()
}
settings := relay.ParseChannelSettings(currentSettings)
if req.UseProxy != nil {
settings.UseProxy = *req.UseProxy
}
if req.WebSearchToGoogleSearch != nil {
settings.WebSearchToGoogleSearch = *req.WebSearchToGoogleSearch
}
if req.DebugLogEnabled != nil {
settings.DebugLogEnabled = *req.DebugLogEnabled
}
if req.DebugLogTenantID != nil {
settings.DebugLogTenantID = *req.DebugLogTenantID
}
if req.DebugLogUserID != nil {
settings.DebugLogUserID = *req.DebugLogUserID
}
if req.DebugLogApiKeyID != nil {
settings.DebugLogApiKeyID = *req.DebugLogApiKeyID
}
if settingsJSON, err := json.Marshal(settings); err == nil {
data.Settings = string(settingsJSON)
}
}
_, err := dao.ChnChannels.Ctx(ctx).
Where("id", req.ID).
Data(data).
Update()
if err != nil {
return nil, err
}
// 更新 Key(如果提供了新的 API Key)
if req.ApiKey != nil && *req.ApiKey != "" {
encKey := relay.GetEncryptionKey()
encrypted, err := uc.EncryptString(encKey, *req.ApiKey)
if err != nil {
return nil, gerror.Wrapf(err, "encrypt api key failed")
}
// 用 g.Map 而非 do 结构体:需要把 token_expires_at 写回 NULL(do 的零值会被 gf 忽略)。
// 由 OAuth 改回静态 Key 时旧的 access_token 过期时间必须清掉,否则详情页会显示
// 一个与当前 Key 无关的过期时间;last_error 同理属于旧 Key 的历史。
_, err = dao.ChnChannelKeys.Ctx(ctx).
Where("channel_id", req.ID).
Data(g.Map{
"encrypted_key": encrypted,
"key_type": "apikey",
"token_expires_at": nil,
"last_error": "",
}).
Update()
if err != nil {
return nil, err
}
// 换 Key 必须解除凭证冷却:冷却按 keyID 打标而这里是原地改同一行(keyID 不变),
// 不解除的话新 Key 会被调度器整段跳过直到 TTL 到期,期间连试都不试。
if n := dispatchadapter.ClearChannelCredentialCooldown(ctx, req.ID); n > 0 {
g.Log().Infof(ctx, "[Channel] 渠道 %d 更换 Key,已解除 %d 个 Key 的凭证冷却", req.ID, n)
}
}
dispatchadapter.InvalidateChannel(ctx, req.ID)
if req.Status == "active" {
// 手动启用/恢复:复位熔断并开启爬坡窗口,恢复初期小流量验证(rampFactor)
dispatchadapter.MarkChannelRecovered(ctx, req.ID)
}
return nil, nil
}
// DeleteChannel 删除渠道
func (s *sAdmin) DeleteChannel(ctx context.Context, req *v1.ChannelDeleteReq) (*v1.ChannelDeleteRes, error) {
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := dao.ChnAbilities.Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
return err
}
if _, err := dao.ChnChannelKeys.Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
return err
}
if _, err := dao.ChnHealthScores.Ctx(ctx).Where("channel_id", req.ID).Delete(); err != nil {
return err
}
if _, err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Delete(); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
dispatchadapter.InvalidateChannel(ctx, req.ID)
return nil, nil
}
// GetChannelDetail 获取渠道详情
func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) (*v1.ChannelDetailRes, error) {
var ch *struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
BaseURL string `json:"base_url"`
Status string `json:"status"`
Priority int `json:"priority"`
Weight int `json:"weight"`
MaxConcurrency int `json:"max_concurrency"`
Tier string `json:"tier"`
StrictCapacity bool `json:"strict_capacity"`
TestModel string `json:"test_model"`
Remark string `json:"remark"`
IsVIP bool `json:"is_vip"`
Settings string `json:"settings"`
SharingThreshold *float64 `json:"sharing_threshold"`
PreemptionThreshold *float64 `json:"preemption_threshold"`
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
HealthScore *float64 `json:"health_score"`
}
err := dao.ChnChannels.Ctx(ctx).
LeftJoin("chn_health_scores h ON chn_channels.id = h.channel_id").
Fields("chn_channels.id, chn_channels.name, chn_channels.type, chn_channels.base_url, chn_channels.status, chn_channels.priority, chn_channels.weight, chn_channels.max_concurrency, chn_channels.tier, chn_channels.strict_capacity, chn_channels.test_model, chn_channels.remark, chn_channels.is_vip, chn_channels.settings, chn_channels.sharing_threshold, chn_channels.preemption_threshold, chn_channels.borrowing_cooldown_seconds, chn_channels.created_at, chn_channels.updated_at, h.health_score").
Where("chn_channels.id", req.ID).
Scan(&ch)
if err != nil {
return nil, err
}
if ch == nil {
return nil, common.NewBusinessError(404, "渠道不存在")
}
typeName := providerTypeNames[ch.Type]
if typeName == "" {
typeName = fmt.Sprintf("Unknown(%d)", ch.Type)
}
// 查询 Key 信息
var keyInfo *struct {
ID int64 `json:"id"`
KeyType string `json:"key_type"`
Status string `json:"status"`
Name string `json:"name"`
TokenExpiresAt *gtime.Time `json:"token_expires_at"`
}
err = dao.ChnChannelKeys.Ctx(ctx).
Where("channel_id", req.ID).
Fields("id, key_type, status, name, token_expires_at").
Scan(&keyInfo)
if err != nil {
return nil, err
}
if keyInfo == nil {
return nil, common.NewNotFoundError("密钥")
}
settings := relay.ParseChannelSettings(ch.Settings)
res := &v1.ChannelDetailRes{
ID: ch.ID,
Name: ch.Name,
Type: ch.Type,
TypeName: typeName,
BaseURL: ch.BaseURL,
Status: ch.Status,
Priority: ch.Priority,
Weight: ch.Weight,
MaxConcurrency: ch.MaxConcurrency,
Tier: ch.Tier,
StrictCapacity: ch.StrictCapacity,
TestModel: ch.TestModel,
Remark: ch.Remark,
IsVIP: ch.IsVIP,
UseProxy: settings.UseProxy,
WebSearchToGoogleSearch: settings.WebSearchToGoogleSearch,
DebugLogEnabled: settings.DebugLogEnabled,
DebugLogTenantID: settings.DebugLogTenantID,
DebugLogUserID: settings.DebugLogUserID,
DebugLogApiKeyID: settings.DebugLogApiKeyID,
SharingThreshold: ch.SharingThreshold,
PreemptionThreshold: ch.PreemptionThreshold,
BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds,
CreatedAt: ch.CreatedAt.String(),
UpdatedAt: ch.UpdatedAt.String(),
HealthScore: ch.HealthScore,
KeyType: keyInfo.KeyType,
KeyStatus: keyInfo.Status,
KeyName: keyInfo.Name,
KeyCooldownRemaining: dispatchadapter.CredentialCooldownRemaining(ctx, keyInfo.ID),
}
if rs, ok := channelRuntimeStates()[req.ID]; ok {
res.BreakerState = int(rs.Breaker)
res.BreakerModels = rs.BreakerModels
res.WorstModel = rs.WorstModel
res.WorstModelScore = rs.WorstModelScore
}
if res.KeyType == "" {
res.KeyType = "apikey"
}
if keyInfo.TokenExpiresAt != nil {
res.TokenExpiresAt = keyInfo.TokenExpiresAt.String()
}
return res, nil
}
// AddChannelKey 添加渠道 API Key(已废弃:每渠道仅支持一个 Key)
func (s *sAdmin) AddChannelKey(ctx context.Context, req *v1.ChannelKeyCreateReq) (*v1.ChannelKeyCreateRes, error) {
return nil, common.NewBusinessError(400, "每渠道仅支持一个 Key,请通过编辑渠道更新 Key")
}
// DeleteChannelKey 删除渠道 API Key
func (s *sAdmin) DeleteChannelKey(ctx context.Context, req *v1.ChannelKeyDeleteReq) (*v1.ChannelKeyDeleteRes, error) {
_, err := dao.ChnChannelKeys.Ctx(ctx).
Where("id", req.KeyID).
Where("channel_id", req.ChannelID).
Delete()
if err != nil {
return nil, err
}
dispatchadapter.InvalidateChannel(ctx, req.ChannelID)
return nil, nil
}
// SetChannelAbilities 设置渠道模型能力
func (s *sAdmin) SetChannelAbilities(ctx context.Context, req *v1.ChannelAbilityBatchReq) (*v1.ChannelAbilityBatchRes, error) {
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := dao.ChnAbilities.Ctx(ctx).Where("channel_id", req.ChannelID).Delete(); err != nil {
return err
}
for _, ab := range req.Abilities {
costRatio := ab.CostRatio
if costRatio <= 0 {
costRatio = 1.0
}
if _, err := dao.ChnAbilities.Ctx(ctx).Insert(do.ChnAbilities{
ChannelId: req.ChannelID,
ModelName: ab.ModelName,
UpstreamModel: ab.UpstreamModel,
Enabled: ab.Enabled,
CostRatio: costRatio,
SupportsResponses: ab.SupportsResponses,
ChatViaResponses: ab.ChatViaResponses,
}); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
dispatchadapter.InvalidateChannel(ctx, req.ChannelID)
return nil, nil
}
// GetChannelKeys 获取渠道 Key 列表
func (s *sAdmin) GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq) (*v1.ChannelKeyListRes, error) {
var keys []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
KeyType string `json:"key_type"`
TokenExpiresAt *gtime.Time `json:"token_expires_at"`
LastUsedAt *gtime.Time `json:"last_used_at"`
LastError string `json:"last_error"`
CreatedAt *gtime.Time `json:"created_at"`
}
err := dao.ChnChannelKeys.Ctx(ctx).
Where("channel_id", req.ChannelID).
Fields("id, name, status, key_type, token_expires_at, last_used_at, last_error, created_at").
OrderDesc("id").
Scan(&keys)
if err != nil {
return nil, err
}
list := make([]v1.ChannelKeyItem, len(keys))
for i, k := range keys {
item := v1.ChannelKeyItem{
ID: k.ID,
Name: k.Name,
Status: k.Status,
KeyType: k.KeyType,
CreatedAt: k.CreatedAt.String(),
CooldownRemaining: dispatchadapter.CredentialCooldownRemaining(ctx, k.ID),
}
if item.KeyType == "" {
item.KeyType = "apikey"
}
if k.TokenExpiresAt != nil {
item.TokenExpiresAt = k.TokenExpiresAt.String()
}
list[i] = item
}
return &v1.ChannelKeyListRes{List: list}, nil
}
// GetChannelAbilities 获取渠道模型能力列表
func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbilitiesGetReq) (*v1.ChannelAbilitiesGetRes, error) {
var abilities []struct {
ID int64 `json:"id" orm:"id"`
ModelName string `json:"model_name" orm:"model_name"`
UpstreamModel string `json:"upstream_model" orm:"upstream_model"`
Enabled bool `json:"enabled" orm:"enabled"`
CostRatio float64 `json:"cost_ratio" orm:"cost_ratio"`
SupportsResponses bool `json:"supports_responses" orm:"supports_responses"`
ChatViaResponses bool `json:"chat_via_responses" orm:"chat_via_responses"`
}
err := dao.ChnAbilities.Ctx(ctx).
Where("channel_id", req.ChannelID).
OrderAsc("model_name").
Scan(&abilities)
if err != nil {
return nil, err
}
// 转换为具名类型以传递给 batchGetModelHealth
abilityRecords := make([]abilityRecord, len(abilities))
for i, a := range abilities {
abilityRecords[i] = abilityRecord{
ID: a.ID,
ModelName: a.ModelName,
UpstreamModel: a.UpstreamModel,
Enabled: a.Enabled,
CostRatio: a.CostRatio,
SupportsResponses: a.SupportsResponses,
ChatViaResponses: a.ChatViaResponses,
}
}
// 批量读取模型级健康状态(从 Redis)
healthMap := s.batchGetModelHealth(ctx, req.ChannelID, abilityRecords)
list := make([]v1.AbilityItem, len(abilities))
for i, a := range abilities {
item := v1.AbilityItem{
ID: a.ID,
ModelName: a.ModelName,
UpstreamModel: a.UpstreamModel,
Enabled: a.Enabled,
CostRatio: a.CostRatio,
SupportsResponses: a.SupportsResponses,
ChatViaResponses: a.ChatViaResponses,
}
// 填充健康数据
if h, ok := healthMap[a.ModelName]; ok {
item.HealthScore = h.HealthScore
item.SuccEWMA = h.SuccEWMA
item.LatencyEWMA = h.LatencyEWMA
item.BreakerState = h.BreakerState
}
list[i] = item
}
return &v1.ChannelAbilitiesGetRes{List: list}, nil
}
// ModelHealthData 模型健康数据
type ModelHealthData struct {
HealthScore *float64
SuccEWMA *float64
LatencyEWMA *float64
BreakerState *int
}
// abilityRecord 能力记录(用于类型匹配)
type abilityRecord struct {
ID int64
ModelName string
UpstreamModel string
Enabled bool
CostRatio float64
SupportsResponses bool
ChatViaResponses bool
}
// batchGetModelHealth 批量读取模型级健康状态(从 Redis)
// Redis key 模式:
// - dispatch:v1:health:<channelID>:<model> (HASH) → succ_ewma, lat_ewma
// - dispatch:v1:breaker:<channelID>:<model> (HASH) → state, half_open_at, first_opened_ms
func (s *sAdmin) batchGetModelHealth(ctx context.Context, channelID int64, abilities []abilityRecord) map[string]*ModelHealthData {
healthMap := make(map[string]*ModelHealthData)
if len(abilities) == 0 {
return healthMap
}
redis := g.Redis()
// 健康分折算口径必须与调度 healthFactor / 维护快照落盘一致(succ^α×100),
// α 取当前生效的路由策略而非硬编码——策略支持热更新。
alpha := dispatchadapter.HealthAlpha()
for _, ab := range abilities {
healthKey := fmt.Sprintf("dispatch:v1:health:%d:%s", channelID, ab.ModelName)
breakerKey := fmt.Sprintf("dispatch:v1:breaker:%d:%s", channelID, ab.ModelName)
data := &ModelHealthData{}
// 读取健康分数(HGETALL health key)
healthHash, err := redis.HGetAll(ctx, healthKey)
if err == nil && !healthHash.IsEmpty() {
hashMap := healthHash.Map()
// succ_ewma: 成功率 EWMA (0-1)
if succEWMAVal, ok := hashMap["succ_ewma"]; ok && succEWMAVal != nil {
if succStr, ok := succEWMAVal.(string); ok {
var succEWMA float64
if _, err := fmt.Sscanf(succStr, "%f", &succEWMA); err == nil && succEWMA >= 0 {
data.SuccEWMA = &succEWMA
// 健康分 = succ_ewma^α × 100(与调度 healthFactor 同源)
healthScore := math.Pow(succEWMA, alpha) * 100
data.HealthScore = &healthScore
}
}
}
// lat_ewma: 延迟 EWMA (ms)
if latEWMAVal, ok := hashMap["lat_ewma"]; ok && latEWMAVal != nil {
if latStr, ok := latEWMAVal.(string); ok {
var latEWMA float64
if _, err := fmt.Sscanf(latStr, "%f", &latEWMA); err == nil && latEWMA >= 0 {
data.LatencyEWMA = &latEWMA
}
}
}
}
// 读取熔断状态(HGET breaker key state)
stateVal, err := redis.HGet(ctx, breakerKey, "state")
if err == nil && !stateVal.IsNil() {
if stateStr := stateVal.String(); stateStr != "" {
var state int
if _, err := fmt.Sscanf(stateStr, "%d", &state); err == nil {
// 0=CLOSED(正常), 1=OPEN(熔断), 2=HALF_OPEN(半开)
data.BreakerState = &state
}
}
}
// 只有至少有一个健康指标时才加入 map
if data.HealthScore != nil || data.SuccEWMA != nil || data.LatencyEWMA != nil || data.BreakerState != nil {
healthMap[ab.ModelName] = data
}
}
return healthMap
}
// GetProviderDefaultURLs 获取供应商默认 API 地址
func (s *sAdmin) GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error) {
return &v1.ProviderDefaultURLRes{URLs: defaultProviderURLs}, nil
}
// defaultProviderURLs 供应商默认 API 地址
var defaultProviderURLs = map[int]string{
1: "https://api.openai.com",
2: "https://api.anthropic.com",
3: "https://generativelanguage.googleapis.com",
// 阿里云百炼填裸域名:adaptor 会按 relay 模式自行拼 /compatible-mode/v1/...(OpenAI 兼容)、
// /apps/anthropic/v1/messages(Claude)、/api/v1/services/aigc/...(图像),
// 这里若带上 /compatible-mode 会导致路径重复而 404。
4: "https://dashscope.aliyuncs.com",
5: "https://aip.baidubce.com",
6: "https://hunyuan.tencentcloudapi.com",
7: "https://open.bigmodel.cn",
8: "https://api.deepseek.com",
9: "https://api.moonshot.cn",
10: "https://ark.cn-beijing.volces.com/api",
11: "https://bedrock-runtime.us-east-1.amazonaws.com",
12: "", // Azure 需要自行填写,格式 https://{resource}.openai.azure.com
13: "https://us-central1-aiplatform.googleapis.com",
14: "https://api.cohere.com",
15: "https://api.mistral.ai",
16: "https://api.x.ai",
// 零一万物 OpenAI 兼容端点,adaptor 自拼 /v1/chat/completions
18: "https://api.lingyiwanwu.com",
// 百度千帆 v2:qianfan.baidubce.com/v2/chat/completions
19: "https://qianfan.baidubce.com",
// Cloudflare 填裸域名即可:adaptor 自拼 /client/v4/accounts/{id}/ai/v1,
// account_id 从 ApiKey(token|accountid 格式)解析,无需写在地址里
20: "https://api.cloudflare.com",
// Ollama 本地服务,adaptor 自拼 /api/chat
22: "http://localhost:11434",
// 硅基流动(国内站;国际站为 api.siliconflow.com)
25: "https://api.siliconflow.cn",
// 讯飞 OpenAI 兼容端点是 spark-api-open(注意 -open 后缀),
// 旧版 spark-api.xf-yun.com 是 WebSocket 协议主机,走这里会 404
26: "https://spark-api-open.xf-yun.com",
// OpenRouter:openrouter.ai/api/v1/chat/completions
27: "https://openrouter.ai/api",
// XInference 默认本地端口
28: "http://localhost:9997",
// MiniMax 国内站(海外站为 api.minimaxi.chat),adaptor 自拼 /v1/text/chatcompletion_v2
29: "https://api.minimax.chat",
// 32 = Coze、33 = Dify:渠道已移除,编号永久保留不复用
// 即梦:适配器拼 /v2/images/generations,无官方对齐端点可验证,需自行填写
34: "",
// Codex 直连 chatgpt.com/backend-api/codex/responses(对齐 Codex CLI)
35: "https://chatgpt.com",
41: "", // New API 自引用渠道,需自行填写实例地址
42: "", // Sub2API 渠道,需自行填写实例地址
}
// defaultProviderURL 返回供应商类型的默认 API 地址
func defaultProviderURL(t int) string {
return defaultProviderURLs[t]
}
// GetChannelHealthTrend 获取渠道健康趋势数据(健康度、延迟在 SQL 层四舍五入取整,避免展示小数)
// 不再返回 stability_score / consecutive_failures:健康体系重写后这两列已停止写入,
// 返回恒定值只会让前端画出两条毫无意义的常量线。
func (s *sAdmin) GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (*v1.ChannelHealthTrendRes, error) {
var points []v1.HealthTrendPoint
err := dao.ChnHealthSnapshots.Ctx(ctx).
Fields("snapshot_at, ROUND(health_score)::int AS health_score, success_rate, ROUND(latency_ms)::int AS latency_ms").
Where("channel_id", req.ID).
Where("snapshot_at >= ?", gtime.Now().Add(-time.Duration(req.Hours)*time.Hour)).
OrderAsc("snapshot_at").
Scan(&points)
if err != nil {
return nil, err
}
if points == nil {
points = []v1.HealthTrendPoint{}
}
return &v1.ChannelHealthTrendRes{Points: points}, nil
}
// ResetChannelHealth 重置渠道健康度(渠道可能已修复、重新可用):落库健康分 80(展示层),
// 并复位熔断 + 成功率 EWMA(调度层),使渠道立即恢复被调度选择的能力。
func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (*v1.ChannelResetHealthRes, error) {
// 渠道必须存在
count, err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Count()
if err != nil {
return nil, err
}
if count == 0 {
return nil, common.NewBusinessError(404, "渠道不存在")
}
// 如果指定了模型名,只重置该模型的健康度
if req.ModelName != "" {
// 验证模型能力是否存在
abilityCount, err := dao.ChnAbilities.Ctx(ctx).
Where("channel_id", req.ID).
Where("model_name", req.ModelName).
Count()
if err != nil {
return nil, err
}
if abilityCount == 0 {
return nil, common.NewBusinessError(404, fmt.Sprintf("模型 %s 不存在于该渠道", req.ModelName))
}
// 调度层:只复位指定模型的熔断 + 成功率恢复
dispatchadapter.ResetModelHealth(ctx, req.ID, req.ModelName)
// 单模型重置不直接写 chn_health_scores(渠道分是全模型聚合值,不能按单模型拍值),
// 改为请求重算——否则展示分永远停在旧值,管理员会以为重置没生效
dispatchadapter.RequestHealthSnapshot(req.ID)
return nil, nil
}
// 未指定模型名,重置整个渠道(含所有模型)
// 展示层:健康分落库为 80(调度不读此表,仅供仪表盘/审计展示;随后的按需重算/
// 维护快照会按 Redis 实际读值重算,此处只是让界面立即有反馈)
// stability_score / consecutive_failures 已废弃,不再写入
affected, err := dao.ChnHealthScores.Ctx(ctx).
Where("channel_id", req.ID).
Data(do.ChnHealthScores{
SuccessRate: 90.00,
LatencyMs: 0,
HealthScore: 80.00,
CalculatedAt: gtime.Now(),
}).
UpdateAndGetAffected()
if err != nil {
return nil, err
}
// 健康分记录缺失时兜底插入(正常情况下创建渠道时已由 InitHealthScore 初始化)
if affected == 0 {
_, err = dao.ChnHealthScores.Ctx(ctx).Insert(do.ChnHealthScores{
ChannelId: req.ID,
SuccessRate: 90.00,
LatencyMs: 0,
HealthScore: 80.00,
CalculatedAt: gtime.Now(),
})
if err != nil {
return nil, err
}
}
// 调度层:复位渠道级/模型级熔断 + 成功率恢复,渠道立即恢复被选择能力
dispatchadapter.ResetChannelHealth(ctx, req.ID)
// 凭证冷却同属"渠道不可用"状态,且只靠 TTL 自愈、无其它人工出口:
// 管理员点重置的语义就是"这个渠道我修好了",只复位熔断不解除冷却等于重置了一半
// ——渠道会继续以「凭证全部冷却」被整体跳过,界面上却显示健康已恢复。
if n := dispatchadapter.ClearChannelCredentialCooldown(ctx, req.ID); n > 0 {
g.Log().Infof(ctx, "[Channel] 渠道 %d 重置健康度,已解除 %d 个 Key 的凭证冷却", req.ID, n)
}
// 请求按 Redis 实际读值重算落盘:上面写死的 80 只是调度引擎未组装时的兜底,
// 重算后会收敛到与调度同源的 0.9^α×100
dispatchadapter.RequestHealthSnapshot(req.ID)
return nil, nil
}
// ExportChannels exports channel list to CSV or Excel.
func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error) {
channelFields := "chn_channels.id, chn_channels.name, chn_channels.type, chn_channels.status, chn_channels.priority, chn_channels.weight, chn_channels.created_at, h.health_score"
config := export.Config{
Format: req.Format,
Filename: "渠道_" + gtime.Now().Format("Ymd_His"),
Columns: []export.Column{
{Field: "id", Header: "ID"},
{Field: "name", Header: "名称"},
{Field: "type_name", Header: "供应商类型"},
{Field: "status", Header: "状态"},
{Field: "priority", Header: "优先级"},
{Field: "weight", Header: "权重"},
{Field: "health_score", Header: "健康分数"},
{Field: "created_at", Header: "创建时间"},
},
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
for {
query := dao.ChnChannels.Ctx(ctx).
LeftJoin("chn_health_scores h ON chn_channels.id = h.channel_id")
if req.Type > 0 {
query = query.Where("chn_channels.type", req.Type)
}
if req.Status != "" {
query = query.Where("chn_channels.status", req.Status)
}
if req.Search != "" {
query = query.Where("chn_channels.name LIKE ? OR chn_channels.remark LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
if req.ID > 0 {
query = query.Where("chn_channels.id", req.ID)
}
if req.BaseURL != "" {
query = query.Where("chn_channels.base_url LIKE ?", "%"+req.BaseURL+"%")
}
if req.Model != "" {
// 与 ListChannels 保持一致:EXISTS 相关子查询避免 JOIN 重复行
query = query.Where("EXISTS (SELECT 1 FROM chn_abilities WHERE chn_abilities.channel_id = chn_channels.id AND (chn_abilities.model_name LIKE ? OR chn_abilities.upstream_model LIKE ?))", "%"+req.Model+"%", "%"+req.Model+"%")
}
var batch []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Status string `json:"status"`
Priority int `json:"priority"`
Weight int `json:"weight"`
CreatedAt *gtime.Time `json:"created_at"`
HealthScore *float64 `json:"health_score"`
}
if err := query.Fields(channelFields).OrderDesc("chn_channels.priority").Limit(1000).Offset(offset).Scan(&batch); err != nil {
g.Log().Errorf(ctx, "ExportChannels: query batch at offset %d failed: %v", offset, err)
return
}
for _, ch := range batch {
typeName := providerTypeNames[ch.Type]
if typeName == "" {
typeName = fmt.Sprintf("Unknown(%d)", ch.Type)
}
healthScore := ""
if ch.HealthScore != nil {
healthScore = fmt.Sprintf("%.2f", *ch.HealthScore)
}
if !yield(map[string]any{
"id": ch.ID,
"name": ch.Name,
"type_name": typeName,
"status": ch.Status,
"priority": ch.Priority,
"weight": ch.Weight,
"health_score": healthScore,
"created_at": ch.CreatedAt.String(),
}) {
return
}
}
if len(batch) < 1000 {
break
}
offset += 1000
}
})
}