From db5d11442897e117acdf95fd10b97996bfde9be8 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 22 May 2026 15:29:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0=E6=A8=A1=E5=9E=8B=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了管理后台模型渠道的增删改查功能 - 将前端直接调用API改为通过后端接口代理获取模型列表 - 添加了渠道测试连接和模型可用性检测功能 - 实现了API Key安全存储机制,前端不再明文显示 - 更新了管理后台设置页面的渠道配置界面 - 添加了表格选中行背景色主题配置支持 - 重构了错误处理机制,统一返回安全错误信息 - 整理了后端模型代理路径为OpenAI风格的/api/v1/* --- AGENTS.md | 2 +- CHANGELOG.md | 10 +- docs/pending-test.md | 26 +-- handler/response.go | 4 + handler/settings.go | 28 +++ router/router.go | 2 + service/settings.go | 216 +++++++++++++++++++- web/src/app/(admin)/admin/settings/page.tsx | 66 ++++-- web/src/lib/app-theme.ts | 8 + web/src/services/api/admin.ts | 42 +--- 10 files changed, 321 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index abc2f76..5cb9f06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ - 详细功能介绍写到 `docs/features.md`。 - 后续待办写到 `docs/todo.md`。 - 已实现但还需要用户测试确认的事项写到 `docs/pending-test.md`。 -- 面向用户的新增、调整、修复等版本变更写到根目录 `CHANGELOG.md` 的 `Unreleased` 中。 +- `docs/pending-test.md` 用来记录这个版本实际做了哪些可测试变更;`CHANGELOG.md` 的 `Unreleased` 只保留对这些变更的版本级归纳,避免逐条照搬实现细节。 - 每次 todo 事项完成后,先从 `docs/todo.md` 移到 `docs/pending-test.md`,不要直接写进正式功能说明;用户确认测试通过后再更新 `docs/features.md`。 - 每次任务完成前,都要根据实际变更检查并更新 `docs/todo.md` 和 `docs/pending-test.md`;如果功能或待办没有变化,也要确认无需修改。 - 接口响应规则写到 `docs/api-response.md`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7c50c..86d11c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,9 @@ ## Unreleased -+ [修复] 后端业务接口失败时统一返回通用错误文案,避免把数据库或内部错误细节暴露给前端。 -+ [修复] `/api/*` 后端请求改用统一 Next Route Handler 代理,避免长时间生图响应被 Next rewrites 断开。 -+ [修复] 图片节点生成会用扁平 metadata 字段记录提示词、生成类型、模型、尺寸、质量、数量和参考图引用,重试优先使用这些字段。 -+ [修复] 参考图丢失时会直接提示无法继续重试,避免误发空的 `images/edits` 请求。 -+ [修复] AI 代理失败统一返回通用错误,前端按业务 `code` 弹 `message.error`。 -+ [调整] 后端模型代理路径改为 OpenAI 风格的 `/api/v1/*`。 -+ [修复] 空图片节点生成和批量重试逻辑已整理,避免空框残留和重试走错接口。 ++ [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。 ++ [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。 ++ [优化] 后端模型代理路径调整为 OpenAI 风格。 ## v0.0.5 - 2026-05-20 diff --git a/docs/pending-test.md b/docs/pending-test.md index c09f71f..8e27a2d 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,27 +1,3 @@ # 待测试 -- 后端业务接口遇到数据库或内部错误时,前端应只收到统一的“操作失败”文案,后端日志保留真实错误。 -- 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。 -- 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。 -- Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。 -- 后端应自动维护 `settings` 表,并支持公开读取 public 配置、管理员读取和保存 public/private 两行 JSON 配置。 -- 公开系统配置中的模型相关字段应保存到 `modelChannel` 配置组下。 -- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。 -- 管理后台系统设置页面的公开配置应把私有配置中已启用渠道的模型列表作为系统可用模型的下拉选项,但系统可用模型仍由管理员手动选择。 -- 管理后台私有配置页应提示:当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API,不建议公网部署,避免私有渠道额度被他人消耗。 -- 用户侧配置弹窗应读取公共配置;未允许自定义渠道时固定使用云端渠道,允许时可切换云端渠道和本地直连。 -- 用户侧云端模式应通过 `/api/v1/*` 代理路径请求后端模型接口,路径风格应与 OpenAI 保持一致。 -- 用户侧云端模式长时间生图时,统一 `/api/*` 代理应等待后端模型响应,不应再出现 Next rewrites 的 socket hang up。 -- 根布局初始化组件应在页面刷新后统一加载当前用户信息和公共系统配置。 -- 用户侧生图、画布节点生成和画布助手请求应按渠道模式分别走后端代理或浏览器本地直连。 -- AI 代理请求上游失败时,后端日志应记录上游地址、状态或错误,前端只展示通用失败文案,不应暴露 EOF、socket hang up 等原始错误。 -- 后端未显式配置 `JWT_SECRET` 或仍使用默认值时,应使用运行时随机密钥签发登录 token,管理员接口不能被默认密钥伪造 token 访问。 -- 空图片节点生成图片时,数量为 1 应直接回填当前节点,数量大于 1 应将当前节点作为主图并继续生成子图,不应再留下额外空框。 -- 空图片节点批量生成后,某个子图重试时应保持原来的生图方式,不应因为挂在批量主图下就误走编辑接口。 -- 图片节点生成成功或失败后,节点 metadata 应以扁平字段保留当次提示词、生成类型、模型、尺寸、质量、数量和参考图引用;重试应优先使用这些字段。 -- 图生图重试时如果参考图片已丢失,应提示无法继续重试,不应继续请求 `images/edits`。 -- 图片节点内容应统一按圆角裁切,主图边框圆角和图片内容不应再出现方角错位。 -- 图片节点生成后的主图、子图和批量叠层都应保持圆角一致,不应出现方形图片顶出圆角边框的情况。 -- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。 -- 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。 -- 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。 +- 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。 diff --git a/handler/response.go b/handler/response.go index e1b6e10..44909bd 100644 --- a/handler/response.go +++ b/handler/response.go @@ -25,6 +25,10 @@ func Fail(w http.ResponseWriter, msg string) { func FailError(w http.ResponseWriter, err error) { log.Printf("request failed: %v", err) + if safe, ok := err.(interface{ SafeMessage() string }); ok { + Fail(w, safe.SafeMessage()) + return + } Fail(w, "操作失败") } diff --git a/handler/settings.go b/handler/settings.go index ecdbc53..01b7a1a 100644 --- a/handler/settings.go +++ b/handler/settings.go @@ -8,6 +8,12 @@ import ( "github.com/basketikun/infinite-canvas/service" ) +type adminChannelActionRequest struct { + Index *int `json:"index"` + Channel model.ModelChannel `json:"channel"` + Model string `json:"model"` +} + func Settings(w http.ResponseWriter, r *http.Request) { settings, err := service.PublicSettings() if err != nil { @@ -36,3 +42,25 @@ func AdminSaveSettings(w http.ResponseWriter, r *http.Request) { } OK(w, result) } + +func AdminChannelModels(w http.ResponseWriter, r *http.Request) { + var request adminChannelActionRequest + _ = json.NewDecoder(r.Body).Decode(&request) + models, err := service.AdminChannelModels(request.Index, request.Channel) + if err != nil { + FailError(w, err) + return + } + OK(w, models) +} + +func AdminTestChannelModel(w http.ResponseWriter, r *http.Request) { + var request adminChannelActionRequest + _ = json.NewDecoder(r.Body).Decode(&request) + result, err := service.AdminTestChannelModel(request.Index, request.Channel, request.Model) + if err != nil { + FailError(w, err) + return + } + OK(w, result) +} diff --git a/router/router.go b/router/router.go index 70abd19..f00f714 100644 --- a/router/router.go +++ b/router/router.go @@ -35,6 +35,8 @@ func New() *gin.Engine { }) admin.GET("/settings", gin.WrapF(handler.AdminSettings)) admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings)) + admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels)) + admin.POST("/settings/channel-test", gin.WrapF(handler.AdminTestChannelModel)) admin.GET("/prompt-categories", gin.WrapF(handler.AdminPromptCategories)) admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories)) admin.GET("/prompts", gin.WrapF(handler.AdminPrompts)) diff --git a/service/settings.go b/service/settings.go index d9b68be..5bca04a 100644 --- a/service/settings.go +++ b/service/settings.go @@ -1,8 +1,13 @@ package service import ( + "encoding/json" "errors" + "fmt" + "io" "math/rand" + "net/http" + "sort" "strings" "github.com/basketikun/infinite-canvas/model" @@ -16,11 +21,34 @@ func PublicSettings() (model.PublicSetting, error) { func AdminSettings() (model.Settings, error) { settings, err := repository.GetSettings() - return normalizeSettings(settings), err + return hidePrivateAPIKeys(normalizeSettings(settings)), err } func SaveSettings(settings model.Settings) (model.Settings, error) { - return repository.SaveSettings(normalizeSettings(settings), now()) + saved, err := repository.GetSettings() + if err != nil { + return model.Settings{}, err + } + settings = normalizeSettings(settings) + keepPrivateAPIKeys(&settings, normalizeSettings(saved)) + result, err := repository.SaveSettings(settings, now()) + return hidePrivateAPIKeys(result), err +} + +func AdminChannelModels(index *int, channel model.ModelChannel) ([]string, error) { + resolved, err := resolveAdminChannel(index, channel) + if err != nil { + return nil, err + } + return fetchAdminChannelModels(resolved) +} + +func AdminTestChannelModel(index *int, channel model.ModelChannel, modelName string) (string, error) { + resolved, err := resolveAdminChannel(index, channel) + if err != nil { + return "", err + } + return testAdminChannelModel(resolved, modelName) } func normalizeSettings(settings model.Settings) model.Settings { @@ -58,6 +86,36 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting return setting } +func hidePrivateAPIKeys(settings model.Settings) model.Settings { + for i := range settings.Private.Channels { + settings.Private.Channels[i].APIKey = "" + } + return settings +} + +func keepPrivateAPIKeys(settings *model.Settings, saved model.Settings) { + for i := range settings.Private.Channels { + if strings.TrimSpace(settings.Private.Channels[i].APIKey) != "" { + continue + } + if channel, ok := findSavedChannel(settings.Private.Channels[i], saved.Private.Channels, i); ok { + settings.Private.Channels[i].APIKey = channel.APIKey + } + } +} + +func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) { + for _, item := range saved { + if item.Name == channel.Name && item.BaseURL == channel.BaseURL { + return item, true + } + } + if index < len(saved) { + return saved[index], true + } + return model.ModelChannel{}, false +} + func SelectModelChannel(modelName string) (model.ModelChannel, error) { settings, err := repository.GetSettings() if err != nil { @@ -89,6 +147,160 @@ func BuildModelChannelURL(channel model.ModelChannel, path string) string { return baseURL + path } +func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel { + if channel.Protocol == "" { + channel.Protocol = "openai" + } + if channel.Models == nil { + channel.Models = []string{} + } + if channel.Weight <= 0 { + channel.Weight = 1 + } + return channel +} + +func resolveAdminChannel(index *int, channel model.ModelChannel) (model.ModelChannel, error) { + resolved := normalizeModelChannel(channel) + if strings.TrimSpace(resolved.APIKey) == "" { + settings, err := repository.GetSettings() + if err != nil { + return model.ModelChannel{}, err + } + saved := normalizePrivateSetting(settings.Private).Channels + if index != nil && *index >= 0 && *index < len(saved) { + if resolved.APIKey == "" { + resolved.APIKey = saved[*index].APIKey + } + if resolved.BaseURL == "" { + resolved.BaseURL = saved[*index].BaseURL + } + if resolved.Name == "" { + resolved.Name = saved[*index].Name + } + } + if resolved.APIKey == "" { + if savedChannel, ok := findSavedChannel(resolved, saved, -1); ok { + resolved.APIKey = savedChannel.APIKey + } + } + } + if strings.TrimSpace(resolved.BaseURL) == "" { + return model.ModelChannel{}, safeMessageError{message: "缺少接口地址"} + } + if strings.TrimSpace(resolved.APIKey) == "" { + return model.ModelChannel{}, safeMessageError{message: "缺少 API Key"} + } + return resolved, nil +} + +func fetchAdminChannelModels(channel model.ModelChannel) ([]string, error) { + request, err := http.NewRequest(http.MethodGet, BuildModelChannelURL(channel, "/models"), nil) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+channel.APIKey) + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode >= http.StatusBadRequest { + return nil, readAdminChannelError(body, response.StatusCode, "读取模型失败") + } + var payload struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(body, &payload) + result := make([]string, 0, len(payload.Data)) + for _, item := range payload.Data { + if strings.TrimSpace(item.ID) != "" { + result = append(result, item.ID) + } + } + sort.Strings(result) + return result, nil +} + +func testAdminChannelModel(channel model.ModelChannel, modelName string) (string, error) { + if strings.TrimSpace(modelName) == "" { + return "", errors.New("缺少模型名称") + } + body, _ := json.Marshal(map[string]any{ + "model": modelName, + "messages": []map[string]string{{ + "role": "user", + "content": "hi", + }}, + }) + request, err := http.NewRequest(http.MethodPost, BuildModelChannelURL(channel, "/chat/completions"), strings.NewReader(string(body))) + if err != nil { + return "", err + } + request.Header.Set("Authorization", "Bearer "+channel.APIKey) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + responseBody, _ := io.ReadAll(response.Body) + if response.StatusCode >= http.StatusBadRequest { + return "", readAdminChannelError(responseBody, response.StatusCode, "测试失败") + } + var payload struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + _ = json.Unmarshal(responseBody, &payload) + if len(payload.Choices) > 0 && strings.TrimSpace(payload.Choices[0].Message.Content) != "" { + return payload.Choices[0].Message.Content, nil + } + return "ok", nil +} + +func readAdminChannelError(body []byte, statusCode int, fallback string) error { + var payload struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + Msg string `json:"msg"` + } + if len(body) > 0 && json.Unmarshal(body, &payload) == nil { + if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" { + return safeMessageError{message: payload.Error.Message} + } + if strings.TrimSpace(payload.Msg) != "" { + return safeMessageError{message: payload.Msg} + } + } + if statusCode == http.StatusUnauthorized { + return safeMessageError{message: "上游接口认证失败(401),请检查 API Key"} + } + if statusCode > 0 { + return safeMessageError{message: fmt.Sprintf("%s:%d", fallback, statusCode)} + } + return safeMessageError{message: fallback} +} + +type safeMessageError struct { + message string +} + +func (err safeMessageError) Error() string { + return err.message +} + +func (err safeMessageError) SafeMessage() string { + return err.message +} + func modelChannelsForModel(channels []model.ModelChannel, modelName string) []model.ModelChannel { result := []model.ModelChannel{} for _, channel := range channels { diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index b06d2d4..342424c 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons"; +import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons"; import { json } from "@codemirror/lang-json"; import { Alert, App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd"; import dynamic from "next/dynamic"; @@ -102,11 +102,12 @@ export default function AdminSettingsPage() { setIsSaving(true); try { const saved = normalizeSettings(await saveAdminSettings(token, values)); - form.setFieldsValue(saved); - setChannels(saved.private.channels); + const merged = mergeChannelApiKeys(values.private.channels, saved); + form.setFieldsValue(merged); + setChannels(merged.private.channels); setJsonText({ - public: JSON.stringify(saved.public, null, 2), - private: JSON.stringify(saved.private, null, 2), + public: JSON.stringify(merged.public, null, 2), + private: JSON.stringify(merged.private, null, 2), }); message.success("已保存"); } catch (error) { @@ -164,19 +165,23 @@ export default function AdminSettingsPage() { const nextChannels = [...channels]; if (editingChannelIndex === null) nextChannels.push(channel); else nextChannels[editingChannelIndex] = channel; - setChannels(nextChannels); - form.setFieldValue(["private", "channels"], nextChannels); + await persistChannels(nextChannels); closeChannelDrawer(); }; const fetchChannelModelList = async () => { + if (!token) return; const channel = channelForm.getFieldsValue(); - if (!channel?.baseUrl || !channel?.apiKey) { - message.warning("请先填写接口地址和 API Key"); + if (!channel?.baseUrl) { + message.warning("请先填写接口地址"); + return; + } + if (editingChannelIndex === null && !channel?.apiKey) { + message.warning("请先填写 API Key"); return; } try { - const channelModels = await fetchChannelModels(channel); + const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) }); channelForm.setFieldValue("models", channelModels); message.success(`已获取 ${channelModels.length} 个模型`); } catch (error) { @@ -186,8 +191,8 @@ export default function AdminSettingsPage() { const openTestDialog = (index: number) => { const channel = normalizeChannel(channels[index]); - if (!channel.baseUrl || !channel.apiKey || channel.models.length === 0) { - message.warning("请先填写接口地址、API Key 和至少一个模型"); + if (!channel.baseUrl || channel.models.length === 0) { + message.warning("请先填写接口地址和至少一个模型"); return; } setTestChannelIndex(index); @@ -207,11 +212,12 @@ export default function AdminSettingsPage() { const testModelOnline = async (model: string) => { if (testChannelIndex === null) return; + if (!token) return; const channel = normalizeChannel(channels[testChannelIndex]); setTestingModels((current) => [...current, model]); try { const startedAt = performance.now(); - const result = await testChannelModel(channel, model); + const result = await testChannelModel(token, { index: testChannelIndex, channel, model }); setTestResults((current) => ({ ...current, [model]: { status: "success", duration: `${((performance.now() - startedAt) / 1000).toFixed(2)}s`, message: result } })); } catch (error) { setTestResults((current) => ({ ...current, [model]: { status: "error", message: error instanceof Error ? error.message : "测试失败" } })); @@ -229,6 +235,24 @@ export default function AdminSettingsPage() { const testChannel = testChannelIndex === null ? null : normalizeChannel(channels[testChannelIndex]); const testModels = (testChannel?.models || []).filter((model) => model.toLowerCase().includes(testKeyword.trim().toLowerCase())); + async function persistChannels(nextChannels: AdminModelChannel[]) { + if (!token) return; + const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings); + const nextSettings = normalizeSettings({ + ...values, + private: { ...values.private, channels: nextChannels }, + }); + const saved = normalizeSettings(await saveAdminSettings(token, nextSettings)); + const merged = mergeChannelApiKeys(nextChannels, saved); + setChannels(merged.private.channels); + form.setFieldsValue(merged); + setJsonText({ + public: JSON.stringify(merged.public, null, 2), + private: JSON.stringify(merged.private, null, 2), + }); + message.success("已保存"); + } + return (
@@ -322,8 +346,7 @@ export default function AdminSettingsPage() {