Files
opencatd-open/backend/internal/channel/channel.go
T
Sakurasan f81b364436 feat: 三协议互转网关 + 鉴权修复 + 管理端增强
后端
- 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转,
  以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测)
- gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式),
  streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀
- gateway: 新增 SetUsageRecorder 注入异步用量记录器
- auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401;
  修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应
- usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段
- channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数
- api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容)

前端
- 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer
- 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts
- 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
2026-08-31 22:29:09 +08:00

247 lines
5.6 KiB
Go

package channel
import (
"context"
"fmt"
"log"
"math/rand"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/crypto"
"sync"
"time"
)
type Service struct {
channelDAO *dao.ChannelDAO
modelDAO *dao.ModelDAO
// Health tracking
mu sync.RWMutex
healthStatus map[uint64]*channelHealth
// Concurrency control per channel
sems map[uint64]chan struct{}
}
type channelHealth struct {
status string
consecutive int
lastCheck time.Time
cooldown time.Time
}
func NewService(channelDAO *dao.ChannelDAO, modelDAO *dao.ModelDAO) *Service {
return &Service{
channelDAO: channelDAO,
modelDAO: modelDAO,
healthStatus: make(map[uint64]*channelHealth),
sems: make(map[uint64]chan struct{}),
}
}
// SelectChannel selects the best channel for a given model using weighted random selection
func (s *Service) SelectChannel(ctx context.Context, modelName string) (*store.Channel, error) {
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
if err != nil {
return nil, fmt.Errorf("failed to get channels for model %s: %w", modelName, err)
}
if len(channels) == 0 {
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
}
// Filter out unhealthy channels
candidates := s.filterHealthy(channels)
if len(candidates) == 0 {
// If all channels are unhealthy, try the first one anyway
candidates = channels[:1]
}
// Weighted random selection
totalWeight := 0
for _, ch := range candidates {
totalWeight += ch.Weight
}
if totalWeight == 0 {
return candidates[0], nil
}
r := rand.Intn(totalWeight)
for _, ch := range candidates {
r -= ch.Weight
if r < 0 {
return ch, nil
}
}
return candidates[0], nil
}
// GetChannelByKeyID decrypts the API key for a channel
func (s *Service) GetChannelByKeyID(ctx context.Context, channelID uint64) (*store.Channel, error) {
ch, err := s.channelDAO.GetByID(channelID)
if err != nil {
return nil, err
}
return ch, nil
}
// GetAPIKey decrypts the channel's API key
func (s *Service) GetAPIKey(ch *store.Channel) (string, error) {
return crypto.Decrypt(ch.APIKeyEnc)
}
// RecordSuccess records a successful request to a channel
func (s *Service) RecordSuccess(channelID uint64) {
s.mu.Lock()
defer s.mu.Unlock()
h := s.getOrCreateHealth(channelID)
h.consecutive = 0
h.status = store.ChannelHealthHealthy
h.lastCheck = time.Now()
}
// RecordFailure records a failed request to a channel
func (s *Service) RecordFailure(channelID uint64) {
s.mu.Lock()
defer s.mu.Unlock()
h := s.getOrCreateHealth(channelID)
h.consecutive++
h.lastCheck = time.Now()
if h.consecutive >= 3 {
h.status = store.ChannelHealthDegraded
h.cooldown = time.Now().Add(5 * time.Minute)
}
if h.consecutive >= 5 {
h.status = store.ChannelHealthCooldown
h.cooldown = time.Now().Add(15 * time.Minute)
}
}
// RecordTimeout records a timeout to a channel
func (s *Service) RecordTimeout(channelID uint64) {
s.RecordFailure(channelID)
}
func (s *Service) getOrCreateHealth(channelID uint64) *channelHealth {
h, ok := s.healthStatus[channelID]
if !ok {
h = &channelHealth{
status: store.ChannelHealthHealthy,
}
s.healthStatus[channelID] = h
}
return h
}
func (s *Service) filterHealthy(channels []*store.Channel) []*store.Channel {
s.mu.RLock()
defer s.mu.RUnlock()
var healthy []*store.Channel
now := time.Now()
for _, ch := range channels {
h, ok := s.healthStatus[ch.ID]
if !ok {
healthy = append(healthy, ch)
continue
}
// Check if cooldown has expired
if now.After(h.cooldown) && h.cooldown.IsZero() == false {
h.consecutive = 0
h.status = store.ChannelHealthHealthy
healthy = append(healthy, ch)
continue
}
if h.status == store.ChannelHealthHealthy || h.status == store.ChannelHealthDegraded {
healthy = append(healthy, ch)
}
}
return healthy
}
// GetHealthStatus returns the health status of a channel
func (s *Service) GetHealthStatus(channelID uint64) string {
s.mu.RLock()
defer s.mu.RUnlock()
h, ok := s.healthStatus[channelID]
if !ok {
return store.ChannelHealthHealthy
}
return h.status
}
// ChannelCandidate represents a channel with its resolved API key
type ChannelCandidate struct {
Channel *store.Channel
APIKey string
Format string
}
// SelectCandidates returns candidates for a model, sorted by priority
func (s *Service) SelectCandidates(ctx context.Context, modelName string, preferredFormat string) ([]ChannelCandidate, error) {
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
if err != nil {
return nil, err
}
var candidates []ChannelCandidate
for _, ch := range channels {
// Check if channel supports the preferred format
formats := ch.FormatsEffective()
supported := false
for _, f := range formats {
if f == preferredFormat || preferredFormat == "" {
supported = true
break
}
}
if !supported {
continue
}
apiKey, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
log.Printf("Failed to decrypt API key for channel %s: %v", ch.Name, err)
continue
}
candidates = append(candidates, ChannelCandidate{
Channel: ch,
APIKey: apiKey,
Format: preferredFormat,
})
}
return candidates, nil
}
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
// MaxConcurrency<=0 视为不限制。
func (s *Service) TryAcquire(ch *store.Channel) (func(), bool) {
if ch.MaxConcurrency <= 0 {
return func() {}, true
}
s.mu.Lock()
sem, ok := s.sems[ch.ID]
if !ok {
sem = make(chan struct{}, ch.MaxConcurrency)
s.sems[ch.ID] = sem
}
s.mu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
default:
return nil, false
}
}