feat(channel): 对齐 vivid 最新渠道/模型管理设计

渠道管理(对齐 9db9971/9f6eeca/b607a8d/268a8f7/7f8a87e):
- 列表新增「运行中」(Redis 并发槽位实时计数,与调度同源)和「失败数」
  (event_logs 按渠道聚合)两列
- 行内「同步上游模型」恢复弹窗:默认全开、右侧开关、彩点+单行别名,
  底部「同步模型」批量导入;编辑表单去掉获取模型列表按钮(保存已自动
  全量同步);去掉勾选即解绑语义与 unbind-model 端点
- 导入加固:别名与上游同名报错、同批别名复用拒绝、本地类型冲突拒绝、
  单次上限 50

模型管理(对齐 5545195):
- 模型表单/CreateModel 补齐 max_reference_videos/audios 与
  max_ref_video/audio_seconds 透传

产物(对齐 2253e58):
- 下载结果仅信任 image/*/video/* 响应头,text/plain 等乱报一律按魔数
  嗅探定 MIME

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-19 19:14:25 +08:00
co-authored by Devin
parent 72d4eec203
commit 4632b1ab04
9 changed files with 129 additions and 159 deletions
+2
View File
@@ -402,6 +402,8 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
MaxReferenceImages: intValue(body["max_reference_images"]),
MaxReferenceVideos: intValue(body["max_reference_videos"]),
MaxReferenceAudios: intValue(body["max_reference_audios"]),
MaxRefVideoSeconds: intValue(body["max_ref_video_seconds"]),
MaxRefAudioSeconds: intValue(body["max_ref_audio_seconds"]),
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
Weight: intValue(body["weight"]),
CreatedAt: time.Now(),
+22 -33
View File
@@ -30,13 +30,15 @@ import (
type ChannelService struct {
channels *repo.ChannelRepository
models *repo.ModelRepository
events *repo.EventRepository
conc *ConcurrencyService
up *upstream.Client
secret []byte
}
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, up *upstream.Client, secret string) *ChannelService {
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, events *repo.EventRepository, conc *ConcurrencyService, up *upstream.Client, secret string) *ChannelService {
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
return &ChannelService{channels: channels, models: models, up: up, secret: sum[:]}
return &ChannelService{channels: channels, models: models, events: events, conc: conc, up: up, secret: sum[:]}
}
// ChannelView is the admin list row: local stats + bound model names + a live
@@ -50,6 +52,10 @@ type ChannelView struct {
Concurrency int `json:"concurrency"`
Weight int `json:"weight"`
Models []string `json:"models"`
// RunningCount 运行中 = Redis 并发槽位实时计数(与调度器同源);
// FailCount 失败数 = event_logs 实时聚合(channel 数少,无需预计算)。
RunningCount int64 `json:"running_count"`
FailCount int64 `json:"fail_count"`
SuccessTotal int64 `json:"success_total"`
UsedCredits float64 `json:"used_credits"`
UpstreamBalance *int64 `json:"upstream_balance"`
@@ -63,6 +69,7 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
if err != nil {
return nil, err
}
fails, _ := s.events.FailedByAccount(ctx)
out := make([]ChannelView, len(items))
var wg sync.WaitGroup
for i, ch := range items {
@@ -73,6 +80,8 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
Enabled: ch.Enabled,
Concurrency: ch.Concurrency,
Weight: ch.Weight,
RunningCount: int64(s.conc.Count(ctx, "conc:c:"+ch.ID)),
FailCount: fails[ch.ID],
SuccessTotal: ch.SuccessTotal,
UsedCredits: ch.UsedCredits,
CreatedAt: ch.CreatedAt.Unix(),
@@ -354,37 +363,6 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
return items, nil
}
// UnbindModel detaches one upstream model from a channel; when the local model
// row ends up bound to no channel at all it is deleted too (same orphan
// semantics as deleting a whole channel).
func (s *ChannelService) UnbindModel(ctx context.Context, channelID, upstreamModelID string) error {
upID := strings.TrimSpace(upstreamModelID)
if upID == "" {
return errors.New("model id required")
}
m, err := s.channels.UpstreamModelMap(ctx, channelID)
if err != nil {
return err
}
var localID string
for mid, up := range m {
if up == upID {
localID = mid
break
}
}
if localID == "" {
return nil // not bound — idempotent
}
if err := s.channels.Unbind(ctx, channelID, localID); err != nil {
return err
}
if rest, _ := s.channels.ChannelIDsByModel(ctx, localID); len(rest) == 0 {
_, _ = s.models.Delete(ctx, localID)
}
return nil
}
// PurgeOrphanModels sweeps model_configs rows left over from the provider era:
// an unbound model can never be routed, so any row that didn't come through a
// channel import (provider != "channel") is deleted at startup. Channel-typed
@@ -445,6 +423,10 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if err != nil {
return err
}
if len(specs) > 50 {
return errors.New("单次最多导入 50 个模型")
}
seenAliases := make(map[string]string, len(specs)) // localID → 上游 id,别名冲突当场拒绝
for _, spec := range specs {
upID := strings.TrimSpace(spec.ID)
if upID == "" {
@@ -458,10 +440,17 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if localID == "" {
localID = upID
}
if prev, dup := seenAliases[localID]; dup && prev != upID {
return fmt.Errorf("别名 %s 被多个上游模型复用", localID)
}
seenAliases[localID] = upID
existing, gerr := s.models.Get(ctx, localID)
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
existing = nil
}
if existing != nil && existing.Type != "" && spec.Kind != "" && existing.Type != spec.Kind {
return fmt.Errorf("模型 %s 已存在且类型不同(%s)", localID, existing.Type)
}
var local *model.ModelConfig
if existing != nil && existing.ID == localID {
local = existing