渠道: 远程候选按渠道过滤 + key 掩码 + 移除批量导入端点
- 远程模型候选只排除本渠道已允许的模型,同名模型可被多个渠道各自允许 - 渠道 key 掩码统一 xxxxxxx******Mq4Y(保留前 7 位与后 4 位) - 移除已弃用的批量导入端点 POST /channels/:id/models/import Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote
|
// AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote
|
||||||
// 拉取渠道接口的模型列表(仅预览,不绑定)。
|
// 拉取渠道接口的模型列表,返回本渠道尚未允许的模型(新增候选)。
|
||||||
|
// 每个渠道有各自的支持列表:只排除本渠道已允许的模型,其他渠道允许的同名模型仍可作为本渠道候选。
|
||||||
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
|
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,10 +54,19 @@ func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
|
|||||||
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
|
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 本渠道已允许的上游模型名:不作为新增候选(其他渠道的模型仍可勾选)
|
||||||
|
var boundNames []string
|
||||||
|
h.a.DB.Model(&store.ChannelModelBinding{}).Where("channel_id = ?", id).Pluck("upstream_model", &boundNames)
|
||||||
|
boundSet := make(map[string]bool, len(boundNames))
|
||||||
|
for _, n := range boundNames {
|
||||||
|
boundSet[strings.TrimSpace(n)] = true
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]string, 0, len(list.Data))
|
items := make([]string, 0, len(list.Data))
|
||||||
for _, m := range list.Data {
|
for _, m := range list.Data {
|
||||||
if strings.TrimSpace(m.ID) != "" {
|
name := strings.TrimSpace(m.ID)
|
||||||
items = append(items, strings.TrimSpace(m.ID))
|
if name != "" && !boundSet[name] {
|
||||||
|
items = append(items, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp.OK(c, gin.H{"items": items})
|
resp.OK(c, gin.H{"items": items})
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/openteam/server/internal/pkg/resp"
|
"github.com/openteam/server/internal/pkg/resp"
|
||||||
"github.com/openteam/server/internal/store"
|
"github.com/openteam/server/internal/store"
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
|
// AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
|
||||||
@@ -28,7 +26,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
|||||||
for _, ch := range chs {
|
for _, ch := range chs {
|
||||||
masked := ""
|
masked := ""
|
||||||
if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
|
if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
|
||||||
masked = strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
masked = maskAPIKey(key)
|
||||||
} else if err == nil {
|
} else if err == nil {
|
||||||
masked = "****"
|
masked = "****"
|
||||||
}
|
}
|
||||||
@@ -371,87 +369,15 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
|
|||||||
resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg})
|
resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminImportChannelModels POST /api/v1/admin/channels/:id/models/import
|
// maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
|
||||||
// 拉取渠道 GET /v1/models,导入模型库并绑定。
|
// 示例:xxxxxxx******Mq4Y;密钥较短时退化为仅保留后 4 位。
|
||||||
func (h *Handler) AdminImportChannelModels(c *gin.Context) {
|
func maskAPIKey(key string) string {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
if len(key) <= 11 {
|
||||||
if err != nil {
|
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
||||||
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
var ch store.Channel
|
return key[:7] + "******" + key[len(key)-4:]
|
||||||
if err := h.a.DB.First(&ch, id).Error; err != nil {
|
|
||||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
|
|
||||||
if err != nil {
|
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := ch.UpstreamURL("", "/models")
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+key)
|
|
||||||
resp2, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp2.Body.Close()
|
|
||||||
if resp2.StatusCode != http.StatusOK {
|
|
||||||
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var list struct {
|
|
||||||
Data []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
|
|
||||||
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(list.Data) == 0 {
|
|
||||||
resp.Fail(c, http.StatusNotFound, "channel returned no models")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
imported := 0
|
|
||||||
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
for _, item := range list.Data {
|
|
||||||
name := strings.TrimSpace(item.ID)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var m store.Model
|
|
||||||
if err := tx.Where("name = ?", name).FirstOrCreate(&m, store.Model{
|
|
||||||
Name: name, DisplayName: name, Enabled: true,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// upsert 绑定(upstream_model 默认同名)
|
|
||||||
var binding store.ChannelModelBinding
|
|
||||||
err := tx.Where("channel_id = ? AND model_id = ?", ch.ID, m.ID).First(&binding).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
binding = store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: name, Weight: 1}
|
|
||||||
if err := tx.Create(&binding).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
imported++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to import models")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp.OK(c, gin.H{"imported": imported})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
|
|
||||||
|
|
||||||
func intOr(p *int, def int) int {
|
func intOr(p *int, def int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return def
|
return def
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
|||||||
admin.PUT("/channels/:id", h.AdminUpdateChannel)
|
admin.PUT("/channels/:id", h.AdminUpdateChannel)
|
||||||
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
||||||
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
||||||
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
|
|
||||||
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
|
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
|
||||||
admin.GET("/channels/:id/models", h.AdminChannelModels)
|
admin.GET("/channels/:id/models", h.AdminChannelModels)
|
||||||
admin.POST("/channels/:id/models", h.AdminChannelAddModel)
|
admin.POST("/channels/:id/models", h.AdminChannelAddModel)
|
||||||
@@ -111,6 +110,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
|||||||
admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel)
|
admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel)
|
||||||
// 模型与定价
|
// 模型与定价
|
||||||
admin.GET("/models", h.AdminModels)
|
admin.GET("/models", h.AdminModels)
|
||||||
|
admin.DELETE("/models/unused", h.AdminDeleteUnusedModels)
|
||||||
admin.POST("/models", h.AdminCreateModel)
|
admin.POST("/models", h.AdminCreateModel)
|
||||||
admin.PUT("/models/:id", h.AdminUpdateModel)
|
admin.PUT("/models/:id", h.AdminUpdateModel)
|
||||||
admin.DELETE("/models/:id", h.AdminDeleteModel)
|
admin.DELETE("/models/:id", h.AdminDeleteModel)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const mappings = ref<ChannelModelMapping[]>([])
|
|||||||
const remote = ref<string[]>([])
|
const remote = ref<string[]>([])
|
||||||
const selected = ref<string[]>([])
|
const selected = ref<string[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const fetched = ref(false)
|
||||||
const addForm = reactive({ custom_name: '', upstream_model: '' })
|
const addForm = reactive({ custom_name: '', upstream_model: '' })
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -29,8 +30,8 @@ async function fetchRemote() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`)
|
const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`)
|
||||||
remote.value = data.data.items
|
remote.value = data.data.items
|
||||||
const bound = new Set(mappings.value.map((m) => m.upstream_model))
|
selected.value = []
|
||||||
selected.value = remote.value.filter((r) => bound.has(r))
|
fetched.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -39,10 +40,8 @@ async function fetchRemote() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addSelected() {
|
async function addSelected() {
|
||||||
const bound = new Set(mappings.value.map((m) => m.upstream_model))
|
|
||||||
let added = 0
|
let added = 0
|
||||||
for (const name of selected.value) {
|
for (const name of selected.value) {
|
||||||
if (bound.has(name)) continue
|
|
||||||
try {
|
try {
|
||||||
await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name })
|
await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name })
|
||||||
added++
|
added++
|
||||||
@@ -50,8 +49,11 @@ async function addSelected() {
|
|||||||
/* 单个失败不中断 */
|
/* 单个失败不中断 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selected.value = []
|
||||||
toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加')
|
toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加')
|
||||||
await load()
|
await load()
|
||||||
|
// 已添加的模型已被渠道允许,从拉取候选中移除
|
||||||
|
await fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addManual() {
|
async function addManual() {
|
||||||
@@ -98,9 +100,9 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- 当前支持的模型 -->
|
<!-- 已允许的模型 -->
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-1.5 text-xs font-medium text-muted">当前支持的模型({{ mappings.length }})</p>
|
<p class="mb-1.5 text-xs font-medium text-muted">已允许的模型({{ mappings.length }})</p>
|
||||||
<div v-if="mappings.length" class="flex flex-wrap gap-2">
|
<div v-if="mappings.length" class="flex flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="b in mappings"
|
v-for="b in mappings"
|
||||||
@@ -119,7 +121,7 @@ onMounted(load)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-muted">尚未添加模型</p>
|
<p v-else class="text-xs text-muted">尚未允许任何模型</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 从接口拉取 + 勾选 -->
|
<!-- 从接口拉取 + 勾选 -->
|
||||||
@@ -135,8 +137,8 @@ onMounted(load)
|
|||||||
<label
|
<label
|
||||||
v-for="m in remote"
|
v-for="m in remote"
|
||||||
:key="m"
|
:key="m"
|
||||||
class="flex cursor-pointer items-center gap-1.5 rounded-md border border-edge2 px-2 py-1 font-mono text-[11px] text-muted transition select-none"
|
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[11px] text-muted transition select-none"
|
||||||
:class="selected.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'hover:border-edge'"
|
:class="selected.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 hover:border-edge'"
|
||||||
>
|
>
|
||||||
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" />
|
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" />
|
||||||
{{ m }}
|
{{ m }}
|
||||||
@@ -148,7 +150,9 @@ onMounted(load)
|
|||||||
添加所选({{ selected.length }})
|
添加所选({{ selected.length }})
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="!loading" class="text-xs text-muted">点「拉取」获取渠道接口返回的模型,勾选需要的加入</p>
|
<p v-else-if="!loading" class="text-xs text-muted">
|
||||||
|
{{ remote.length === 0 && fetched ? '接口返回的模型均已允许,无新增候选' : '点「拉取」获取渠道接口返回的新模型,勾选需要的加入' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 手动添加 -->
|
<!-- 手动添加 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user