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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user