From 431b9e329460e44fb6e5406ea20e9863efc56ee1 Mon Sep 17 00:00:00 2001 From: yep <46060092+yepwait@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:04:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=A8=A1=E5=9E=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E6=94=AF=E6=8C=81=E4=BB=8E=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E6=8B=89=E5=8F=96=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 main 为基线仅补齐拉取模型能力:管理员可一键导入缺失模型(默认停用待定价),并提供登录态后端代理目录接口避免 CORS。 Co-authored-by: ddcat666 <2695092045@qq.com> --- CHANGELOG.md | 3 +- README.md | 2 +- backend/cmd/server/main.go | 2 + backend/internal/handler/channel_models.go | 36 +++++ backend/internal/handler/finance.go | 17 +++ backend/internal/repository/finance.go | 9 ++ .../internal/service/channel_model_catalog.go | 128 ++++++++++++++++++ backend/internal/service/channel_models.go | 43 ++++++ .../components/channel-model-manager.tsx | 31 ++++- web/src/services/api/image.ts | 24 +++- web/src/services/api/wallet.ts | 5 + 11 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 backend/internal/handler/channel_models.go create mode 100644 backend/internal/service/channel_model_catalog.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a26fa1529..fe8bb0eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ -# CHANGELOG +# CHANGELOG ## Unreleased +- 管理员可从上游渠道一键拉取模型目录;缺失模型自动导入为停用待定价,避免浏览器直连上游时的 CORS 问题。 - 管理后台新增统一资源与请求策略配置,支持默认重置、自用模式、即时生效和用户配额用量查看。 ## v1.0.4 diff --git a/README.md b/README.md index 4635c846c..b4eea2aac 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ docker compose -f docker-compose.local.yml up -d --build ## 数据说明 -- 用户自定义 AI API Key 保存在浏览器本地;创建异步任务时会提交给自部署后端并加密入队,生产环境必须使用 HTTPS。 +- 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。 - 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。 - 媒体资源在启用 OSS 时保存到私有 OSS,否则保存到后端数据目录;删除业务记录不会自动清理 OSS 对象。 - 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f0ecef1fa..9c7055c52 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/handler/channel_models.go b/backend/internal/handler/channel_models.go new file mode 100644 index 000000000..ac71b3722 --- /dev/null +++ b/backend/internal/handler/channel_models.go @@ -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}) + }) +} diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index cb9859ba1..55a363962 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -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, "") }) diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 62dd43e03..be7ecb2b1 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -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 { diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go new file mode 100644 index 000000000..a465e8c4e --- /dev/null +++ b/backend/internal/service/channel_model_catalog.go @@ -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)} + } +} diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 583476444..5ddf40700 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -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 diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 64ca39e65..caa11c084 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Select, Space, Switch, Table, Tag } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react"; +import { ArrowLeft, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { ListToolbar, TableSurface } from "@/components/layout/workspace-page"; -import { createAdminChannelModel, disableAdminChannelModel, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet"; +import { createAdminChannelModel, disableAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; type FormValues = { @@ -20,6 +20,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const [items, setItems] = useState([]); const [editing, setEditing] = useState(null); const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(false); const [saving, setSaving] = useState(false); const [editorOpen, setEditorOpen] = useState(false); const [keyword, setKeyword] = useState(""); @@ -51,6 +52,23 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: setPage(1); }, [channel.id]); + const fetchModels = async () => { + setFetching(true); + try { + // 拉取只导入缺失项;新模型仍需管理员定价并手动启用。 + const result = await fetchAdminChannelModels(channel.id); + await reload(); + await onChanged(); + if (result.models.length === 0) message.warning("上游没有返回可用模型"); + else if (result.added > 0) message.success(`已拉取 ${result.models.length} 个模型,新增 ${result.added} 个待配置模型`); + else message.info(`已拉取 ${result.models.length} 个模型,没有需要新增的模型`); + } catch (error) { + message.error(error instanceof Error ? error.message : "拉取模型失败"); + } finally { + setFetching(false); + } + }; + const startCreate = () => { setEditing(null); form.setFieldsValue({ modelKey: "", displayName: "", capability: capabilityFromInterface(channel?.interfaceType), unitPrice: 0, enabled: true }); @@ -148,7 +166,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:

维护此渠道的模型能力、启用状态和每次积分价格。

- + + + + { setKeyword(""); setCapability("all"); setStatus("all"); setPage(1); }}> } value={keyword} placeholder="搜索模型标识或显示名称" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index c9a9a0d61..605e201b9 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -913,8 +913,28 @@ export async function fetchImageModels(config: Pick( + resolveBackendApiUrl("/api/ai/models"), + { + baseUrl: channel.baseUrl, + apiKey: channel.apiKey, + apiFormat: channel.apiFormat, + }, + { withCredentials: true }, + ); + if (typeof response.data.code === "number" && response.data.code !== 0) { + throw new Error(response.data.msg || "读取模型失败"); + } + return Array.from(new Set((response.data.data?.models || []).map((model) => model.trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b)); + } catch (error) { + throw new Error(readAxiosError(error, "读取模型失败")); + } } const defaultGeminiConfig: Pick = { diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index 46d6558e2..fc1150d2e 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -218,6 +218,11 @@ export function listAdminChannelModels(channelId: string) { return request<{ models: ChannelModel[] }>(api.get(`/admin/channels/${encodeURIComponent(channelId)}/models`)); } +// 管理员从上游拉取模型目录;服务端只导入缺失项,价格和启用仍需人工确认。 +export function fetchAdminChannelModels(channelId: string) { + return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)); +} + export function createAdminChannelModel(channelId: string, input: Omit) { return request<{ model: ChannelModel }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models`, input)); }