Files
openteam/server/internal/channel/health.go
T
SakurasanandClaude db83972b6f 渠道: 分协议 Base URL + 模型抽屉/远程拉取/操作图标
- 渠道支持分协议 base_url(chat/responses/messages 各一), 网关按协议选 base 直通
  (如智谱三种格式不同 base, 一个渠道即可), UpstreamURL 按 proto 拼接
- 渠道模型改为下方抽屉: 当前绑定列表(内联改上游/解除)、从接口拉取(remote 预览+勾选添加)、手动添加
- 新增 /channels/:id/models/remote 预览接口; 操作按钮加 Phosphor 图标
- 修复 formats jsonb 更新未序列化问题; 手机端渠道卡片化

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:31:28 +08:00

122 lines
3.0 KiB
Go

package channel
import (
"context"
"log"
"net/http"
"sync"
"time"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
// HealthConfig 健康检查参数。
type HealthConfig struct {
Interval time.Duration // 探测周期
FailThreshold int // 连续失败 N 次进入 cooldown
Timeout time.Duration // 单次探测超时
}
// HealthMonitor 定时探测渠道健康状态。
type HealthMonitor struct {
db *gorm.DB
enc *crypto.Encryptor
cfg HealthConfig
hc *http.Client
mu sync.Mutex
fail map[uint64]int // 渠道连续失败次数
}
func NewHealthMonitor(db *gorm.DB, enc *crypto.Encryptor, cfg HealthConfig) *HealthMonitor {
if cfg.Interval <= 0 {
cfg.Interval = 60 * time.Second
}
if cfg.FailThreshold <= 0 {
cfg.FailThreshold = 2
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
return &HealthMonitor{
db: db, enc: enc, cfg: cfg,
hc: &http.Client{Timeout: cfg.Timeout},
fail: map[uint64]int{},
}
}
// Start 启动后台探测循环(ctx 取消即停止)。
func (h *HealthMonitor) Start(ctx context.Context) {
go func() {
ticker := time.NewTicker(h.cfg.Interval)
defer ticker.Stop()
h.probeAll()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
h.probeAll()
}
}
}()
log.Printf("health: monitor started (interval=%s, threshold=%d)", h.cfg.Interval, h.cfg.FailThreshold)
}
// probeAll 探测所有启用渠道并更新健康状态。
func (h *HealthMonitor) probeAll() {
var chs []store.Channel
if err := h.db.Where("enabled = ?", true).Find(&chs).Error; err != nil {
return
}
for i := range chs {
h.probe(&chs[i])
}
}
// probe 单渠道探测:GET {base}/v1/models。
func (h *HealthMonitor) probe(ch *store.Channel) {
key, err := h.enc.Decrypt(ch.APIKeyEnc)
if err != nil {
h.record(ch, false)
return
}
url := ch.UpstreamURL("", "/models")
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := h.hc.Do(req)
ok := err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300
if resp != nil {
resp.Body.Close()
}
h.record(ch, ok)
}
// record 记录一次探测结果,按阈值切换健康状态。
func (h *HealthMonitor) record(ch *store.Channel, ok bool) {
h.mu.Lock()
defer h.mu.Unlock()
prev := ch.HealthStatus
if ok {
h.fail[ch.ID] = 0
if prev != store.ChannelHealthHealthy {
h.setStatus(ch, store.ChannelHealthHealthy)
log.Printf("health: channel %q recovered -> healthy", ch.Name)
}
return
}
h.fail[ch.ID]++
if h.fail[ch.ID] >= h.cfg.FailThreshold && prev != store.ChannelHealthCooldown {
h.setStatus(ch, store.ChannelHealthCooldown)
log.Printf("health: channel %q -> cooldown (%d consecutive failures)", ch.Name, h.fail[ch.ID])
}
}
func (h *HealthMonitor) setStatus(ch *store.Channel, status string) {
h.db.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status)
ch.HealthStatus = status
}