feat(admin): 模型管理 - 支持从上游渠道拉取模型目录 (#36)

以 main 为基线仅补齐拉取模型能力:管理员可一键导入缺失模型(默认停用待定价),并提供登录态后端代理目录接口避免 CORS。

Co-authored-by: ddcat666 <2695092045@qq.com>
This commit is contained in:
yepandddcat666 authored and GitHub committed 2026-07-23 22:04:54 +08:00
1 parent 61d12ec3ff
commit 243c43f6c2
11 files changed
+293 -7

No files matched your search

+2
View File
@@ -71,6 +71,8 @@ func main() {
handler.RegisterAdminAnalyticsRoutes(api, svc)
handler.RegisterAnnouncementRoutes(api, svc)
handler.RegisterFinanceRoutes(api, svc)
// 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。
handler.RegisterChannelModelRoutes(api, svc)
handler.RegisterSystemProxyRoutes(api, svc)
handler.RegisterCustomRelayRoutes(api, svc)
handler.RegisterTaskRoutes(api, svc)
@@ -0,0 +1,36 @@
package handler
import (
"errors"
"net/http"
"time"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
)
func RegisterChannelModelRoutes(r *gin.RouterGroup, svc *service.Service) {
r.POST("/ai/models", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "channel-models:"+user.ID, 30, time.Minute) {
return
}
var input service.ChannelModelsRequest
if err := c.ShouldBindJSON(&input); err != nil {
fail(c, http.StatusBadRequest, errors.New("模型渠道参数格式错误"))
return
}
// handler 不接触上游响应细节,密钥校验和模型目录解析统一收敛到 service。
models, err := svc.FetchChannelModels(c.Request.Context(), user, input)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"models": models})
})
}
+17
View File
@@ -205,6 +205,23 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"models": items})
})
r.POST("/admin/channels/:id/models/fetch", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "admin-channel-models-fetch:"+user.ID+":"+c.Param("id"), 10, time.Minute) {
return
}
// 上游密钥只在 service 内使用,handler 仅返回去重后的模型标识和新增数量。
result, err := svc.FetchAdminChannelModels(c.Request.Context(), user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.POST("/admin/channels/:id/models", func(c *gin.Context) {
saveChannelModel(c, svc, "")
})
+9
View File
@@ -93,6 +93,15 @@ func (r *Repository) SaveChannelModel(item *model.ChannelModel) error {
return r.db.Save(item).Error
}
func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) {
if len(items) == 0 {
return 0, nil
}
// 拉取目录可能与其他管理员操作并发,唯一键冲突时保留已有定价配置。
result := r.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "channel_id"}, {Name: "model_key"}}, DoNothing: true}).Create(&items)
return result.RowsAffected, result.Error
}
func (r *Repository) CreditAccount(userID string) (*model.CreditAccount, error) {
account := model.CreditAccount{UserID: userID}
if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil {
@@ -0,0 +1,128 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"infinite-canvas/backend/internal/model"
)
type ChannelModelsRequest struct {
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
}
type channelModelsPayload struct {
Data []channelModelItem `json:"data"`
Models []channelModelItem `json:"models"`
Error *providerError `json:"error"`
Code *int `json:"code"`
Msg string `json:"msg"`
}
type channelModelItem struct {
ID string `json:"id"`
Name string `json:"name"`
}
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
if actor == nil || strings.TrimSpace(actor.ID) == "" {
return nil, Unauthorized("请先登录")
}
baseURL := strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
apiKey := strings.TrimSpace(input.APIKey)
if baseURL == "" {
return nil, BadAuthRequest("请填写 Base URL")
}
if apiKey == "" {
return nil, BadAuthRequest("请填写 API Key")
}
apiFormat := strings.ToLower(strings.TrimSpace(input.APIFormat))
if apiFormat == "" {
apiFormat = "openai"
}
if apiFormat != "openai" && apiFormat != "gemini" {
return nil, BadAuthRequest("接口协议不支持拉取模型")
}
target := apiURL(baseURL, "/models")
if apiFormat == "gemini" {
if !strings.HasSuffix(strings.ToLower(baseURL), "/v1beta") {
baseURL += "/v1beta"
}
target = baseURL + "/models"
}
if _, err := ValidateOutboundURL(target); err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, BadAuthRequest("模型服务地址无效")
}
if apiFormat == "gemini" {
request.Header.Set("x-goog-api-key", apiKey)
} else {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
// 只代理固定的模型目录 GET;用户密钥仅用于本次请求,不写入数据库或日志。
data, _, err := doBinary(request)
if err != nil {
return nil, channelModelsUpstreamError(err)
}
var payload channelModelsPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, &AuthError{Status: http.StatusBadGateway, Message: "模型服务返回的不是有效 JSON"}
}
if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" {
return nil, &AuthError{Status: http.StatusBadGateway, Message: payload.Error.Message}
}
if payload.Code != nil && *payload.Code != 0 {
return nil, &AuthError{Status: http.StatusBadGateway, Message: firstNonEmpty(strings.TrimSpace(payload.Msg), "模型服务返回失败")}
}
items := payload.Data
if apiFormat == "gemini" {
items = payload.Models
}
seen := make(map[string]bool, len(items))
models := make([]string, 0, len(items))
for _, item := range items {
name := strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(item.ID, item.Name)), "models/")
if name == "" || seen[name] {
continue
}
seen[name] = true
models = append(models, name)
}
sort.Strings(models)
return models, nil
}
func channelModelsUpstreamError(err error) error {
var authErr *AuthError
if errors.As(err, &authErr) {
return authErr
}
var httpErr providerHTTPError
if !errors.As(err, &httpErr) {
return &AuthError{Status: http.StatusBadGateway, Message: "连接模型服务失败:" + err.Error()}
}
switch httpErr.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务鉴权失败,请检查 API Key"}
case http.StatusNotFound:
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务未提供 /models 接口"}
case http.StatusTooManyRequests:
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务请求过于频繁或额度不足"}
default:
return &AuthError{Status: http.StatusBadGateway, Message: fmt.Sprintf("模型服务请求失败:HTTP %d", httpErr.StatusCode)}
}
}
@@ -1,6 +1,7 @@
package service
import (
"context"
"encoding/json"
"strings"
@@ -17,6 +18,12 @@ type ChannelModelRequest struct {
Enabled *bool `json:"enabled"`
}
// AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。
type AdminChannelModelFetchResult struct {
Models []string `json:"models"`
Added int64 `json:"added"`
}
func (s *Service) EnsureSystemChannelModels() error {
channels, err := s.repo.SystemChannels(true)
if err != nil {
@@ -46,6 +53,42 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
return s.ensureChannelModels(channelID, true)
}
func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
channel, err := s.repo.AdminSystemChannel(channelID)
if err != nil {
return nil, err
}
// 使用服务端保存的渠道密钥请求上游,避免密钥为了拉目录再次经过浏览器。
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat})
if err != nil {
return nil, err
}
existing, err := s.repo.ChannelModels(channelID, true)
if err != nil {
return nil, err
}
known := make(map[string]struct{}, len(existing))
for _, item := range existing {
known[item.ModelKey] = struct{}{}
}
missing := make([]model.ChannelModel, 0, len(models))
for _, name := range models {
if _, ok := known[name]; ok {
continue
}
// 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。
missing = append(missing, model.ChannelModel{ID: newID(), ChannelID: channelID, ModelKey: name, DisplayName: name, Capability: capabilityForChannel(*channel), BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
}
added, err := s.repo.CreateMissingChannelModels(missing)
if err != nil {
return nil, err
}
return &AdminChannelModelFetchResult{Models: models, Added: added}, nil
}
func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err