feat(admin): 新增管理后台模型渠道配置功能

- 实现了管理后台模型渠道的增删改查功能
- 将前端直接调用API改为通过后端接口代理获取模型列表
- 添加了渠道测试连接和模型可用性检测功能
- 实现了API Key安全存储机制,前端不再明文显示
- 更新了管理后台设置页面的渠道配置界面
- 添加了表格选中行背景色主题配置支持
- 重构了错误处理机制,统一返回安全错误信息
- 整理了后端模型代理路径为OpenAI风格的/api/v1/*
This commit is contained in:
HouYunFei
2026-05-22 15:29:31 +08:00
parent 499439d66b
commit db5d114428
10 changed files with 321 additions and 83 deletions
+214 -2
View File
@@ -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 {