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 channelSecret = cfg.PostgresDSN
} }
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret) 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) v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, channelSvc, siteRepo, cgroupRepo, concSvc, rustfsClient)
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo) showcaseSvc := service.NewShowcaseService(showcaseRepo)
@@ -106,6 +106,24 @@ func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) 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 // writeUpstreamErr translates upstream-protocol errors into sensible admin
// responses (auth → 400 with the upstream's message, temporary → 502). // responses (auth → 400 with the upstream's message, temporary → 502).
func writeUpstreamErr(c *gin.Context, err error) { 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/delete", handlers.ChannelAdmin.Delete)
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels) authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels) authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
authed.POST("/channels/unbind-model", handlers.ChannelAdmin.UnbindModel)
authed.GET("/providers", handlers.AdminRead.Providers) authed.GET("/providers", handlers.AdminRead.Providers)
authed.GET("/images", handlers.AdminRead.Images) authed.GET("/images", handlers.AdminRead.Images)
authed.DELETE("/images", handlers.AdminRead.DeleteImage) authed.DELETE("/images", handlers.AdminRead.DeleteImage)
+69 -2
View File
@@ -205,8 +205,9 @@ type FetchModelsDto struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
} }
// FetchModels lists the upstream's sellable models and flags which are already // FetchModels lists the upstream's sellable models; when a saved channel id is
// imported locally. // 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) { func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]upstream.RemoteModel, error) {
base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey) base, key, err := s.resolveCreds(ctx, d.ID, d.BaseURL, d.APIKey)
if err != nil { if err != nil {
@@ -216,9 +217,75 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
if err != nil { if err != nil {
return nil, err 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 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 // ChannelModelSpec is one model the admin ticks for import (same shape as the
// upstream /v1/models item plus an optional local alias). // upstream /v1/models item plus an optional local alias).
type ChannelModelSpec struct { type ChannelModelSpec struct {
+3
View File
@@ -95,6 +95,9 @@ type RemoteModel struct {
// Combined reference duration caps in seconds; 0 = unlimited. // Combined reference duration caps in seconds; 0 = unlimited.
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"` RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
RefAudioSeconds int64 `json:"refAudioSeconds,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. // RemoteDuration mirrors the duration spec of a vivid model item.
+45 -61
View File
@@ -15,14 +15,11 @@ const form = ref(null)
const saving = ref(false) const saving = ref(false)
const formErr = ref('') const formErr = ref('')
// fetch-models → pick → import // fetch-models → 勾选即绑定(选中自动导入到模型列表,取消即解绑)
const fetchTarget = ref(null) // channel snapshot being imported into const fetchTarget = ref(null) // channel snapshot being imported into
const fetching = ref(false) const fetching = ref(false)
const fetchErr = ref('') const fetchErr = ref('')
const specs = ref([]) const specs = ref([])
const picked = ref(new Set())
const importing = ref(false)
const managedIds = ref(new Set())
async function load() { async function load() {
loading.value = true loading.value = true
@@ -33,11 +30,6 @@ async function load() {
loading.value = false 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) { function modelSummary(c) {
const names = c.models || [] const names = c.models || []
if (!names.length) return '' if (!names.length) return ''
@@ -100,7 +92,6 @@ function fetchFromForm() {
async function openFetch(c) { async function openFetch(c) {
fetchTarget.value = c fetchTarget.value = c
specs.value = [] specs.value = []
picked.value = new Set()
fetchErr.value = '' fetchErr.value = ''
fetching.value = true fetching.value = true
const r = await api('/channels/fetch-models', jsonBody('POST', { const r = await api('/channels/fetch-models', jsonBody('POST', {
@@ -108,15 +99,40 @@ async function openFetch(c) {
})) }))
fetching.value = false fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return } 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) })) specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', busy: false }))
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))
} }
function togglePick(id) { // 勾选 = 立即导入绑定;取消勾选 = 解绑(模型没有其他渠道接入时本地行一并删除)。
const next = new Set(picked.value) // 新渠道尚未入库时,第一次勾选会先自动保存渠道再绑定。
next.has(id) ? next.delete(id) : next.add(id) async function toggleBind(s) {
picked.value = next 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) { function specText(s) {
@@ -135,38 +151,7 @@ function specText(s) {
return parts.join(' · ') || '-' return parts.join(' · ') || '-'
} }
async function doImport() { onMounted(load)
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() })
</script> </script>
<template> <template>
@@ -297,7 +282,7 @@ onMounted(() => { load(); loadManaged() })
</div> </div>
</div> </div>
<!-- 拉取上游模型 → 勾选导入 --> <!-- 拉取上游模型 → 勾选即导入绑定 -->
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null"> <div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
<div class="card !shadow-2xl my-12 w-full max-w-2xl"> <div class="card !shadow-2xl my-12 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between"> <div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
@@ -306,15 +291,16 @@ onMounted(() => { load(); loadManaged() })
</div> </div>
<div class="p-5"> <div class="p-5">
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div> <div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
<div v-else-if="fetchErr" class="py-10 text-center text-rose-300 text-sm">{{ fetchErr }}</div>
<template v-else> <template v-else>
<p class="text-xs text-white/45 mb-3">勾选要导入的模型:能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名);已导入的模型自动标记(重复导入会刷新参数)。</p> <p class="text-xs text-white/45 mb-3">勾选即导入并绑定到本渠道,取消勾选即解绑(模型没有其他渠道接入时本地行一并删除)。能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名,绑定时生效)。</p>
<p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p>
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div> <div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto"> <div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)" <div v-for="s in specs" :key="s.id" @click="toggleBind(s)"
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors" class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
:class="!picked.has(s.id) && 'opacity-50'"> :class="[!s.bound && 'opacity-50', s.busy && 'pointer-events-none']">
<input type="checkbox" :checked="picked.has(s.id)" @click.stop @change="togglePick(s.id)" class="mt-1 accent-sky-400" /> <span class="mt-1 inline-block h-3.5 w-3.5 shrink-0 rounded-full ring-1 transition-all"
:class="s.bound ? 'bg-sky-400 ring-sky-300' : 'bg-transparent ring-white/25'"></span>
<div class="flex-1 min-w-0 space-y-1.5"> <div class="flex-1 min-w-0 space-y-1.5">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<code class="font-mono text-xs text-white/85">{{ s.id }}</code> <code class="font-mono text-xs text-white/85">{{ s.id }}</code>
@@ -323,13 +309,14 @@ onMounted(() => { load(); loadManaged() })
{{ KIND_LABEL[s.kind] || s.kind }} {{ KIND_LABEL[s.kind] || s.kind }}
</span> </span>
<span class="text-[11px] text-white/40">{{ specText(s) }}</span> <span class="text-[11px] text-white/40">{{ specText(s) }}</span>
<span v-if="s.imported" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已导入</span> <span v-if="s.busy" class="ml-auto text-[10px] text-white/40">处理中…</span>
<span v-else-if="s.bound" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已接入</span>
</div> </div>
<div class="flex items-center gap-2" @click.stop> <div class="flex items-center gap-2" @click.stop>
<span class="text-[11px] text-white/35 shrink-0">别名</span> <span class="text-[11px] text-white/35 shrink-0">别名</span>
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)" <input v-model.trim="s.alias" type="text" maxlength="64" :disabled="s.bound"
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40" class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
placeholder="留空 = 与上游同名" /> :placeholder="s.bound ? '已绑定,到模型管理改别名' : '留空 = 与上游同名'" />
</div> </div>
</div> </div>
</div> </div>
@@ -338,9 +325,6 @@ onMounted(() => { load(); loadManaged() })
</div> </div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2"> <div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="fetchTarget = null" class="btn-ghost">关闭</button> <button @click="fetchTarget = null" class="btn-ghost">关闭</button>
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
{{ importing ? '导入中…' : (fetchTarget?.id ? `导入选中(${picked.size})` : `保存渠道并导入(${picked.size})`) }}
</button>
</div> </div>
</div> </div>
</div> </div>
+3 -5
View File
@@ -124,14 +124,11 @@ onMounted(loadModels)
</button> </button>
</div> </div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名 / Provider…" /> <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名…" />
</div> </div>
<button @click="loadModels" class="btn-soft"> <button @click="loadModels" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新 <Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button> </button>
<router-link to="/admin/channels" class="btn-primary inline-flex items-center gap-1">
<Icon name="plug" class="w-3.5 h-3.5" /> 渠道导入模型
</router-link>
</div> </div>
<!-- Table --> <!-- Table -->
@@ -139,7 +136,8 @@ onMounted(loadModels)
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div> <div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20"> <div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span> <span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
<span class="text-sm">{{ models.length ? '没有匹配的模型' : '还没有模型,先到「渠道管理」拉取上游模型导入' }}</span> <span v-if="models.length" class="text-sm">没有匹配的模型</span>
<router-link v-else to="/admin/channels" class="text-sm text-sky-300 underline underline-offset-2">还没有模型,先到「渠道管理」勾选上游模型自动导入</router-link>
</div> </div>
<table v-else class="w-full text-sm table-fixed"> <table v-else class="w-full text-sm table-fixed">