feat: 平台管理 - 模型提供商连接测试功能

- 前端:添加"测试连接"按钮,支持实时测试 provider 可用性
- 后端:新增 TestProvider 接口,发送测试请求验证配置
- 路由:注册 POST /providers/{id}/test 端点
- 初始化:LLM Manager 优先从数据库加载 providers,失败时回退到环境变量
- .gitignore:忽略构建产物 server/bin/、server-* 和 test-* 目录
This commit is contained in:
selfrelease
2026-06-22 18:05:19 +08:00
parent fe2c6fb2e4
commit 0c1a8544e8
4 changed files with 175 additions and 40 deletions
+119 -31
View File
@@ -8,11 +8,13 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/enterprise-ai-platform/server/internal/response"
"github.com/enterprise-ai-platform/server/pkg/llm"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -96,9 +98,9 @@ func (h *PlatformHandler) OrgRanking(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var (
id, name, short string
users, apps int
conversations, toks int64
id, name, short string
users, apps int
conversations, toks int64
)
if err := rows.Scan(&id, &name, &short, &users, &apps, &conversations, &toks); err != nil {
continue
@@ -295,12 +297,12 @@ func (h *PlatformHandler) ListAllUsers(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var (
id, name, email, role, status string
avatarURL, employeeID *string
lastLoginAt *time.Time
loginCount int
createdAt time.Time
orgID, orgName, orgShort string
id, name, email, role, status string
avatarURL, employeeID *string
lastLoginAt *time.Time
loginCount int
createdAt time.Time
orgID, orgName, orgShort string
)
if err := rows.Scan(&id, &name, &email, &avatarURL, &role, &status, &employeeID, &lastLoginAt, &loginCount, &createdAt, &orgID, &orgName, &orgShort); err != nil {
continue
@@ -439,14 +441,14 @@ func (h *PlatformHandler) ListAllApps(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var (
id, name, slug, status, visibility string
desc, iconURL *string
appType *string
usageCount int64
isFeatured bool
createdAt time.Time
catName, creatorName string
orgID, orgName, orgShort string
id, name, slug, status, visibility string
desc, iconURL *string
appType *string
usageCount int64
isFeatured bool
createdAt time.Time
catName, creatorName string
orgID, orgName, orgShort string
)
if err := rows.Scan(&id, &name, &slug, &desc, &iconURL, &appType, &status, &visibility,
&usageCount, &isFeatured, &createdAt, &catName, &creatorName, &orgID, &orgName, &orgShort); err != nil {
@@ -646,12 +648,12 @@ func (h *PlatformHandler) CreateProvider(w http.ResponseWriter, r *http.Request)
func (h *PlatformHandler) UpdateProvider(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req struct {
Name *string `json:"name"`
BaseURL *string `json:"base_url"`
APIKey *string `json:"api_key"`
Models json.RawMessage `json:"models"`
IsActive *bool `json:"is_active"`
Priority *int `json:"priority"`
Name *string `json:"name"`
BaseURL *string `json:"base_url"`
APIKey *string `json:"api_key"`
Models json.RawMessage `json:"models"`
IsActive *bool `json:"is_active"`
Priority *int `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.BadRequest(w, "无效的请求格式")
@@ -696,6 +698,92 @@ func (h *PlatformHandler) DeleteProvider(w http.ResponseWriter, r *http.Request)
response.JSON(w, http.StatusOK, map[string]string{"message": "已删除"})
}
func (h *PlatformHandler) TestProvider(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// 查询 provider 配置
var baseURL, apiKey string
var modelsJSON, configJSON []byte
err := h.pool.QueryRow(r.Context(), `
SELECT base_url, api_key_encrypted, models, config
FROM model_providers
WHERE id = $1
`, id).Scan(&baseURL, &apiKey, &modelsJSON, &configJSON)
if err != nil {
response.BadRequest(w, "provider 不存在")
return
}
var config map[string]any
if err := json.Unmarshal(configJSON, &config); err != nil {
response.InternalError(w, "配置解析失败")
return
}
providerType, _ := config["provider"].(string)
if providerType == "" {
providerType = "openai"
}
// 选择测试模型:优先 config.default_model,其次取 models 列的第一个模型
// 兼容两种模型项结构:seed 数据用 "id" 字段,前端表单用 "name" 字段作为模型标识
var testModel string
if dm, ok := config["default_model"].(string); ok && dm != "" {
testModel = dm
}
if testModel == "" {
var modelList []map[string]any
if err := json.Unmarshal(modelsJSON, &modelList); err == nil && len(modelList) > 0 {
if v, ok := modelList[0]["id"].(string); ok && v != "" {
testModel = v
} else if v, ok := modelList[0]["name"].(string); ok && v != "" {
testModel = v
}
}
}
if testModel == "" {
testModel = "gpt-3.5-turbo" // fallback
}
// 创建临时 provider 进行测试(注意:构造函数签名为 apiKey, baseURL, model,顺序不可颠倒)
var provider llm.Provider
switch providerType {
case "openai":
provider = llm.NewOpenAIProvider(apiKey, baseURL, "")
case "anthropic":
provider = llm.NewAnthropicProvider(apiKey, baseURL, "")
default:
response.BadRequest(w, "不支持的 provider 类型")
return
}
// 发送测试请求
testReq := &llm.ChatRequest{
Model: testModel,
Messages: []llm.Message{
{Role: "user", Content: "请回复:测试成功"},
},
MaxTokens: 20,
Temperature: 0.1,
Stream: false,
}
result, err := provider.ChatCompletion(r.Context(), testReq)
if err != nil {
response.JSON(w, http.StatusOK, map[string]any{
"success": false,
"message": fmt.Sprintf("连接失败: %v", err),
})
return
}
response.JSON(w, http.StatusOK, map[string]any{
"success": true,
"message": "连接成功",
"response": result.Content,
})
}
// ==================== 全局配额管理 ====================
func (h *PlatformHandler) ListQuotas(w http.ResponseWriter, r *http.Request) {
@@ -722,14 +810,14 @@ func (h *PlatformHandler) ListQuotas(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var (
id, targetType string
targetID *string
modelName *string
dailyTokens, monthlyTokens *int64
dailyReqs *int
isActive bool
createdAt time.Time
targetName string
id, targetType string
targetID *string
modelName *string
dailyTokens, monthlyTokens *int64
dailyReqs *int
isActive bool
createdAt time.Time
targetName string
)
if err := rows.Scan(&id, &targetType, &targetID, &modelName, &dailyTokens, &monthlyTokens,
&dailyReqs, &isActive, &createdAt, &targetName); err != nil {