feat(channels): 拉模型改为勾选即绑定,启动清扫遗留模型

- fetch-models 返回 bound 标记,渠道页勾选=立即导入绑定,
  取消=解绑(无其他渠道接入时本地模型行一并删除)
- 新增 POST /channels/unbind-model
- 启动时 PurgeOrphanModels 清掉 provider 时代的无绑定模型行
- 模型管理页移除「渠道导入模型」按钮

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-16 21:35:30 +08:00
co-authored by Devin
parent b4efe6b192
commit abd7b5c23b
7 changed files with 146 additions and 68 deletions
+7
View File
@@ -115,6 +115,13 @@ func NewApp(ctx context.Context) (*App, error) {
channelSecret = cfg.PostgresDSN
}
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
// Sweep provider-era model rows: anything not bound to a channel can never
// be routed under the channel scheduler.
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
log.Printf("purge orphan models: %v", err)
} else if n > 0 {
log.Printf("purged %d unbound model(s) left over from the provider era", n)
}
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo)
@@ -106,6 +106,24 @@ func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// UnbindModel detaches one upstream model from a channel; the local model row
// is deleted too when no other channel still binds it.
func (h *ChannelAdminHandler) UnbindModel(c *gin.Context) {
var body struct {
ID string `json:"id"`
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" || strings.TrimSpace(body.Model) == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "id and model required"})
return
}
if err := h.channels.UnbindModel(c.Request.Context(), body.ID, body.Model); err != nil {
writeUpstreamErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// writeUpstreamErr translates upstream-protocol errors into sensible admin
// responses (auth → 400 with the upstream's message, temporary → 502).
func writeUpstreamErr(c *gin.Context, err error) {
+1
View File
@@ -147,6 +147,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
authed.POST("/channels/unbind-model", handlers.ChannelAdmin.UnbindModel)
authed.GET("/providers", handlers.AdminRead.Providers)
authed.GET("/images", handlers.AdminRead.Images)
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
+69 -2
View File
@@ -205,8 +205,9 @@ type FetchModelsDto struct {
APIKey string `json:"api_key"`
}
// FetchModels lists the upstream's sellable models and flags which are already
// imported locally.
// FetchModels lists the upstream's sellable models; when a saved channel id is
// given, items already bound to that channel are flagged Bound so the admin UI
// can render them as a bound/unbound toggle.
func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
if err != nil {
@@ -216,9 +217,75 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
if err != nil {
return nil, err
}
if strings.TrimSpace(d.ID) != "" {
if bound, berr := s.channels.UpstreamModelMap(ctx, d.ID); berr == nil && len(bound) > 0 {
set := make(map[string]struct{}, len(bound))
for _, upID := range bound {
set[upID] = struct{}{}
}
for i := range items {
if _, ok := set[items[i].ID]; ok {
items[i].Bound = true
}
}
}
}
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
// orphans keep their pricing config for a later rebind.
func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) {
ids, err := s.channels.OrphanModelIDs(ctx)
if err != nil {
return 0, err
}
n := 0
for _, id := range ids {
m, gerr := s.models.Get(ctx, id)
if gerr != nil || m == nil || m.Provider == "channel" {
continue
}
if _, derr := s.models.Delete(ctx, id); derr == nil {
n++
}
}
return n, nil
}
// ChannelModelSpec is one model the admin ticks for import (same shape as the
// upstream /v1/models item plus an optional local alias).
type ChannelModelSpec struct {
+3
View File
@@ -95,6 +95,9 @@ type RemoteModel struct {
// Combined reference duration caps in seconds; 0 = unlimited.
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
// Bound is set by the admin fetch-models endpoint only: true when this
// upstream id is already bound to that channel locally.
Bound bool `json:"bound,omitempty"`
}
// RemoteDuration mirrors the duration spec of a vivid model item.