diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go index 2b0cd77..85ac10b 100644 --- a/backend/internal/bootstrap/app.go +++ b/backend/internal/bootstrap/app.go @@ -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) diff --git a/backend/internal/http/handler/channel_admin.go b/backend/internal/http/handler/channel_admin.go index 5dc8614..7df5813 100644 --- a/backend/internal/http/handler/channel_admin.go +++ b/backend/internal/http/handler/channel_admin.go @@ -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) { diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index 6b15e24..c9d4dfd 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -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) diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 054723b..50fffbe 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -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 { diff --git a/backend/internal/upstream/client.go b/backend/internal/upstream/client.go index 04d1c47..07f0e61 100644 --- a/backend/internal/upstream/client.go +++ b/backend/internal/upstream/client.go @@ -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. diff --git a/frontend/src/views/ChannelsView.vue b/frontend/src/views/ChannelsView.vue index f9ac6cc..d7bcac8 100644 --- a/frontend/src/views/ChannelsView.vue +++ b/frontend/src/views/ChannelsView.vue @@ -15,14 +15,11 @@ const form = ref(null) const saving = ref(false) const formErr = ref('') -// fetch-models → pick → import +// fetch-models → 勾选即绑定(选中自动导入到模型列表,取消即解绑) const fetchTarget = ref(null) // channel snapshot being imported into const fetching = ref(false) const fetchErr = ref('') const specs = ref([]) -const picked = ref(new Set()) -const importing = ref(false) -const managedIds = ref(new Set()) async function load() { loading.value = true @@ -33,11 +30,6 @@ async function load() { loading.value = false } -async function loadManaged() { - const r = await api('/managed-models') - managedIds.value = new Set((r.data?.data || []).map((m) => m.id)) -} - function modelSummary(c) { const names = c.models || [] if (!names.length) return '' @@ -100,7 +92,6 @@ function fetchFromForm() { async function openFetch(c) { fetchTarget.value = c specs.value = [] - picked.value = new Set() fetchErr.value = '' fetching.value = true const r = await api('/channels/fetch-models', jsonBody('POST', { @@ -108,15 +99,40 @@ async function openFetch(c) { })) fetching.value = false if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return } - specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', imported: managedIds.value.has(s.id) })) - picked.value = new Set(specs.value.filter((s) => !s.imported).map((s) => s.id)) - if (!picked.value.size) picked.value = new Set(specs.value.map((s) => s.id)) + specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', busy: false })) } -function togglePick(id) { - const next = new Set(picked.value) - next.has(id) ? next.delete(id) : next.add(id) - picked.value = next +// 勾选 = 立即导入绑定;取消勾选 = 解绑(模型没有其他渠道接入时本地行一并删除)。 +// 新渠道尚未入库时,第一次勾选会先自动保存渠道再绑定。 +async function toggleBind(s) { + if (s.busy) return + s.busy = true + fetchErr.value = '' + try { + const target = fetchTarget.value + if (!s.bound) { + if (!target.id) { + const sr = await api('/channels', jsonBody('POST', { + id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key, + enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1, + })) + if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return } + target.id = sr.data?.id + form.value = null + } + const { bound, busy, ...rest } = s + const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models: [rest] })) + if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return } + s.bound = true + } else { + const r = await api('/channels/unbind-model', jsonBody('POST', { id: target.id, model: s.id })) + if (!r.ok) { fetchErr.value = r.data?.detail || '解绑失败'; return } + s.bound = false + } + load() + } finally { + s.busy = false + } } function specText(s) { @@ -135,38 +151,7 @@ function specText(s) { return parts.join(' · ') || '-' } -async function doImport() { - if (importing.value || !picked.value.size) return - const models = specs.value - .filter((s) => picked.value.has(s.id)) - .map((s) => { - const { imported, ...rest } = s - return rest - }) - const target = fetchTarget.value - importing.value = true - try { - if (!target.id) { - // 表单预览路径:渠道尚未入库,先按快照保存再绑定;失败中止不建孤儿。 - const sr = await api('/channels', jsonBody('POST', { - id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key, - enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1, - })) - if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return } - target.id = sr.data?.id - form.value = null - } - const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models })) - if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return } - fetchTarget.value = null - load() - loadManaged() - } finally { - importing.value = false - } -} - -onMounted(() => { load(); loadManaged() }) +onMounted(load)