978 lines
25 KiB
Go
978 lines
25 KiB
Go
package controllers
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/pkg/jwtutil"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
// PlatformAgentApiController 智能体API管理(yz_platform_agent_api,平台端)
|
||
type PlatformAgentApiController struct {
|
||
beego.Controller
|
||
}
|
||
|
||
// agentApiHTTPClient 用于探测上游接口连通性
|
||
var agentApiHTTPClient = &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
Transport: &http.Transport{
|
||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||
},
|
||
}
|
||
|
||
// 支持的请求协议白名单,与前端 constants.js 的 PROTOCOLS 保持一致
|
||
var agentApiProtocols = map[string]bool{
|
||
models.AgentProtocolChatCompletions: true,
|
||
models.AgentProtocolResponses: true,
|
||
models.AgentProtocolAnthropic: true,
|
||
models.AgentProtocolGemini: true,
|
||
models.AgentProtocolCustom: true,
|
||
}
|
||
|
||
// agentApiEndpointPath 各协议在根地址之后要追加的路径
|
||
// custom 与 gemini 不在此表中:custom 不追加任何路径,gemini 需要嵌入模型名
|
||
var agentApiEndpointPath = map[string]string{
|
||
models.AgentProtocolChatCompletions: "/chat/completions",
|
||
models.AgentProtocolResponses: "/responses",
|
||
models.AgentProtocolAnthropic: "/messages",
|
||
}
|
||
|
||
// normalizeProtocol 缺省或非法值统一回落到 Chat Completions
|
||
// 该协议被绝大多数官方接口、国产网关与自建中转支持,作为兜底最安全
|
||
func normalizeProtocol(p string) string {
|
||
p = strings.TrimSpace(strings.ToLower(p))
|
||
if p == "" || !agentApiProtocols[p] {
|
||
return models.AgentProtocolChatCompletions
|
||
}
|
||
return p
|
||
}
|
||
|
||
func (c *PlatformAgentApiController) jsonErr(httpStatus, bizCode int, msg string) {
|
||
c.Ctx.Output.SetStatus(httpStatus)
|
||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
func (c *PlatformAgentApiController) ok(msg string, data interface{}) {
|
||
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||
if data != nil {
|
||
resp["data"] = data
|
||
}
|
||
c.Data["json"] = resp
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
func (c *PlatformAgentApiController) platformClaims() (*jwtutil.Claims, error) {
|
||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||
if auth == "" {
|
||
return nil, fmt.Errorf("未登录")
|
||
}
|
||
parts := strings.SplitN(auth, " ", 2)
|
||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||
return nil, fmt.Errorf("认证信息格式错误")
|
||
}
|
||
claims, err := jwtutil.ParseToken(parts[1])
|
||
if err != nil {
|
||
return nil, fmt.Errorf("无效的token")
|
||
}
|
||
if claims.UserType != "platform" {
|
||
return nil, fmt.Errorf("无权访问")
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
// agentApiPayload 新增/更新请求体
|
||
// ApiKeys 用指针:编辑时不传该字段表示不修改已存密钥列表
|
||
type agentApiPayload struct {
|
||
Provider string `json:"provider"`
|
||
Protocol string `json:"protocol"`
|
||
BaseURL string `json:"base_url"`
|
||
UseCustomURL int8 `json:"use_custom_url"`
|
||
CustomURL string `json:"custom_url"`
|
||
ApiKeys *[]models.AgentApiKey `json:"api_keys"`
|
||
Models []string `json:"models"`
|
||
Status *int8 `json:"status"`
|
||
Remark string `json:"remark"`
|
||
}
|
||
|
||
// apiKeysToJSON 把密钥列表序列化为 JSON 字符串入库
|
||
// 去掉空密钥、按 key 去重,备注允许为空
|
||
func apiKeysToJSON(list []models.AgentApiKey) string {
|
||
cleaned := make([]models.AgentApiKey, 0, len(list))
|
||
seen := make(map[string]bool, len(list))
|
||
for _, item := range list {
|
||
k := strings.TrimSpace(item.Key)
|
||
if k == "" || seen[k] {
|
||
continue
|
||
}
|
||
seen[k] = true
|
||
cleaned = append(cleaned, models.AgentApiKey{
|
||
Key: k,
|
||
Remark: strings.TrimSpace(item.Remark),
|
||
})
|
||
}
|
||
b, err := json.Marshal(cleaned)
|
||
if err != nil {
|
||
return "[]"
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// apiKeysFromJSON 把库里的 JSON 字符串反序列化为密钥列表
|
||
// 兼容三种历史/异常格式:对象数组、纯字符串数组、单个裸密钥字符串
|
||
func apiKeysFromJSON(raw string) []models.AgentApiKey {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return []models.AgentApiKey{}
|
||
}
|
||
|
||
if strings.HasPrefix(raw, "[") {
|
||
// 标准格式:[{"key":"sk-x","remark":"mimo198"}]
|
||
var objs []models.AgentApiKey
|
||
if err := json.Unmarshal([]byte(raw), &objs); err == nil {
|
||
out := make([]models.AgentApiKey, 0, len(objs))
|
||
for _, o := range objs {
|
||
if strings.TrimSpace(o.Key) != "" {
|
||
out = append(out, o)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
// 退化格式:["sk-x","sk-y"]
|
||
var strs []string
|
||
if err := json.Unmarshal([]byte(raw), &strs); err == nil {
|
||
out := make([]models.AgentApiKey, 0, len(strs))
|
||
for _, s := range strs {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
out = append(out, models.AgentApiKey{Key: s})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
return []models.AgentApiKey{}
|
||
}
|
||
|
||
// 迁移遗漏时的兜底:整个字段就是一个裸密钥
|
||
return []models.AgentApiKey{{Key: raw}}
|
||
}
|
||
|
||
// modelsToJSON 把模型数组序列化为 JSON 字符串入库
|
||
func modelsToJSON(list []string) string {
|
||
cleaned := make([]string, 0, len(list))
|
||
seen := make(map[string]bool, len(list))
|
||
for _, m := range list {
|
||
m = strings.TrimSpace(m)
|
||
if m == "" || seen[m] {
|
||
continue
|
||
}
|
||
seen[m] = true
|
||
cleaned = append(cleaned, m)
|
||
}
|
||
b, err := json.Marshal(cleaned)
|
||
if err != nil {
|
||
return "[]"
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// modelsFromJSON 把库里的 JSON 字符串反序列化为模型数组
|
||
// 兼容历史数据可能存的逗号分隔格式
|
||
func modelsFromJSON(raw string) []string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return []string{}
|
||
}
|
||
if strings.HasPrefix(raw, "[") {
|
||
var out []string
|
||
if err := json.Unmarshal([]byte(raw), &out); err == nil {
|
||
return out
|
||
}
|
||
return []string{}
|
||
}
|
||
parts := strings.Split(raw, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p = strings.TrimSpace(p); p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// agentApiToMap 统一输出结构
|
||
// 密钥以明文返回,前端卡片默认脱敏、可切换显示;接口本身已受平台端鉴权保护
|
||
func agentApiToMap(row *models.PlatformAgentApi) map[string]interface{} {
|
||
keys := apiKeysFromJSON(row.ApiKeys)
|
||
protocol := normalizeProtocol(row.Protocol)
|
||
out := map[string]interface{}{
|
||
"id": row.ID,
|
||
"provider": row.Provider,
|
||
"protocol": protocol,
|
||
"api_keys": keys,
|
||
"key_count": len(keys),
|
||
"base_url": row.BaseURL,
|
||
"use_custom_url": row.UseCustomURL,
|
||
"custom_url": row.CustomURL,
|
||
"models": modelsFromJSON(row.Models),
|
||
"status": row.Status,
|
||
"remark": row.Remark,
|
||
"user_id": row.UserID,
|
||
"user_name": row.UserName,
|
||
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
|
||
"update_time": "",
|
||
}
|
||
if row.UpdateTime != nil {
|
||
out["update_time"] = row.UpdateTime.Format("2006-01-02 15:04:05")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// validateAgentApiPayload 校验必填与地址格式
|
||
func validateAgentApiPayload(p *agentApiPayload, isCreate bool) error {
|
||
// 上游接口为用户自由填写的文本,不做枚举校验
|
||
p.Provider = strings.TrimSpace(p.Provider)
|
||
if p.Provider == "" {
|
||
return fmt.Errorf("请输入上游接口名称")
|
||
}
|
||
if len([]rune(p.Provider)) > 100 {
|
||
return fmt.Errorf("上游接口名称过长")
|
||
}
|
||
|
||
// 请求协议必须是白名单内的值,缺省回落到 Chat Completions
|
||
p.Protocol = strings.TrimSpace(strings.ToLower(p.Protocol))
|
||
if p.Protocol == "" {
|
||
p.Protocol = models.AgentProtocolChatCompletions
|
||
}
|
||
if !agentApiProtocols[p.Protocol] {
|
||
return fmt.Errorf("请求协议不正确")
|
||
}
|
||
|
||
p.BaseURL = strings.TrimSpace(p.BaseURL)
|
||
p.CustomURL = strings.TrimSpace(p.CustomURL)
|
||
|
||
if p.UseCustomURL == 1 {
|
||
if p.CustomURL == "" {
|
||
return fmt.Errorf("请输入自定义地址")
|
||
}
|
||
if !isHTTPURL(p.CustomURL) {
|
||
return fmt.Errorf("自定义地址需以 http:// 或 https:// 开头")
|
||
}
|
||
} else {
|
||
p.CustomURL = ""
|
||
if p.BaseURL == "" {
|
||
return fmt.Errorf("请输入接口地址")
|
||
}
|
||
if !isHTTPURL(p.BaseURL) {
|
||
return fmt.Errorf("接口地址需以 http:// 或 https:// 开头")
|
||
}
|
||
}
|
||
|
||
if len(p.Models) == 0 {
|
||
return fmt.Errorf("请至少添加一个模型")
|
||
}
|
||
|
||
// 新增时必须带密钥;编辑时不传表示沿用已存密钥列表
|
||
if p.ApiKeys != nil {
|
||
valid := 0
|
||
for _, k := range *p.ApiKeys {
|
||
if strings.TrimSpace(k.Key) != "" {
|
||
valid++
|
||
}
|
||
if len([]rune(k.Remark)) > 100 {
|
||
return fmt.Errorf("密钥备注过长,单条限 100 字")
|
||
}
|
||
}
|
||
if valid == 0 {
|
||
return fmt.Errorf("请至少添加一个 API Key")
|
||
}
|
||
} else if isCreate {
|
||
return fmt.Errorf("请至少添加一个 API Key")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func isHTTPURL(s string) bool {
|
||
low := strings.ToLower(s)
|
||
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
|
||
}
|
||
|
||
// buildAgentEndpoint 依据协议算出实际请求地址
|
||
// custom 协议直接把填写的地址当完整端点,不做任何追加;
|
||
// 其余协议在根地址后追加各自路径,若地址已以该路径结尾则不重复追加
|
||
func buildAgentEndpoint(protocol, base, model, apiKey string) string {
|
||
base = strings.TrimRight(base, "/")
|
||
|
||
switch protocol {
|
||
case models.AgentProtocolCustom:
|
||
return base
|
||
|
||
case models.AgentProtocolGemini:
|
||
// 模型名嵌在路径中间,无固定后缀可判重
|
||
return fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, model, apiKey)
|
||
|
||
default:
|
||
path := agentApiEndpointPath[protocol]
|
||
if path == "" {
|
||
path = "/chat/completions"
|
||
}
|
||
// 用户可能填根地址,也可能直接填完整端点,后者不再追加
|
||
if strings.HasSuffix(strings.ToLower(base), strings.ToLower(path)) {
|
||
return base
|
||
}
|
||
return base + path
|
||
}
|
||
}
|
||
|
||
// List GET /platform/agentApi/list?page=1&pageSize=24&keyword=&provider=&status=
|
||
func (c *PlatformAgentApiController) List() {
|
||
if _, err := c.platformClaims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
page, _ := c.GetInt("page", 1)
|
||
pageSize, _ := c.GetInt("pageSize", 24)
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 {
|
||
pageSize = 24
|
||
}
|
||
if pageSize > 200 {
|
||
pageSize = 200
|
||
}
|
||
|
||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||
provider := strings.TrimSpace(c.GetString("provider"))
|
||
statusStr := strings.TrimSpace(c.GetString("status"))
|
||
|
||
qs := models.Orm.QueryTable(new(models.PlatformAgentApi)).Filter("is_deleted", 0)
|
||
|
||
cond := orm.NewCondition()
|
||
needCond := false
|
||
|
||
// 上游名称为自由文本,用模糊匹配而非精确相等
|
||
if provider != "" {
|
||
cond = cond.And("provider__icontains", provider)
|
||
needCond = true
|
||
}
|
||
if statusStr != "" {
|
||
if st, err := strconv.Atoi(statusStr); err == nil {
|
||
cond = cond.And("status", st)
|
||
needCond = true
|
||
}
|
||
}
|
||
if keyword != "" {
|
||
kw := orm.NewCondition().
|
||
Or("provider__icontains", keyword).
|
||
Or("base_url__icontains", keyword).
|
||
Or("custom_url__icontains", keyword).
|
||
Or("models__icontains", keyword).
|
||
Or("remark__icontains", keyword)
|
||
cond = cond.AndCond(kw)
|
||
needCond = true
|
||
}
|
||
if needCond {
|
||
qs = qs.SetCond(cond)
|
||
}
|
||
|
||
total, err := qs.Count()
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "查询失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
var rows []models.PlatformAgentApi
|
||
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
|
||
if err != nil && err != orm.ErrNoRows {
|
||
c.jsonErr(500, 500, "查询失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for i := range rows {
|
||
list = append(list, agentApiToMap(&rows[i]))
|
||
}
|
||
|
||
c.ok("success", map[string]interface{}{
|
||
"list": list,
|
||
"total": total,
|
||
})
|
||
}
|
||
|
||
// Detail GET /platform/agentApi/:id
|
||
func (c *PlatformAgentApiController) Detail() {
|
||
if _, err := c.platformClaims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
row, err := c.findByID()
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
c.ok("success", agentApiToMap(row))
|
||
}
|
||
|
||
// findByID 读取路径参数并查询记录,出错时已写入响应
|
||
func (c *PlatformAgentApiController) findByID() (*models.PlatformAgentApi, error) {
|
||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||
if err != nil || id == 0 {
|
||
c.jsonErr(400, 400, "无效ID")
|
||
return nil, fmt.Errorf("无效ID")
|
||
}
|
||
|
||
var row models.PlatformAgentApi
|
||
err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||
Filter("id", id).
|
||
Filter("is_deleted", 0).
|
||
One(&row)
|
||
if err != nil {
|
||
if err == orm.ErrNoRows {
|
||
c.jsonErr(404, 404, "配置不存在")
|
||
} else {
|
||
c.jsonErr(500, 500, "查询失败: "+err.Error())
|
||
}
|
||
return nil, err
|
||
}
|
||
return &row, nil
|
||
}
|
||
|
||
// Create POST /platform/agentApi
|
||
func (c *PlatformAgentApiController) Create() {
|
||
claims, err := c.platformClaims()
|
||
if err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
|
||
var p agentApiPayload
|
||
if err := json.Unmarshal(raw, &p); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
|
||
if err := validateAgentApiPayload(&p, true); err != nil {
|
||
c.jsonErr(400, 400, err.Error())
|
||
return
|
||
}
|
||
|
||
status := int8(1)
|
||
if p.Status != nil {
|
||
status = *p.Status
|
||
}
|
||
|
||
userID := uint64(claims.UserID)
|
||
row := &models.PlatformAgentApi{
|
||
Provider: p.Provider,
|
||
Protocol: p.Protocol,
|
||
BaseURL: p.BaseURL,
|
||
UseCustomURL: p.UseCustomURL,
|
||
CustomURL: p.CustomURL,
|
||
ApiKeys: apiKeysToJSON(*p.ApiKeys),
|
||
Models: modelsToJSON(p.Models),
|
||
Status: status,
|
||
Remark: strings.TrimSpace(p.Remark),
|
||
UserID: &userID,
|
||
UserName: &claims.Username,
|
||
IsDeleted: 0,
|
||
}
|
||
|
||
id, err := models.Orm.Insert(row)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "创建失败: "+err.Error())
|
||
return
|
||
}
|
||
row.ID = uint64(id)
|
||
|
||
c.ok("创建成功", agentApiToMap(row))
|
||
}
|
||
|
||
// Update PUT /platform/agentApi/:id
|
||
func (c *PlatformAgentApiController) Update() {
|
||
if _, err := c.platformClaims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
row, err := c.findByID()
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
|
||
var p agentApiPayload
|
||
if err := json.Unmarshal(raw, &p); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
|
||
if err := validateAgentApiPayload(&p, false); err != nil {
|
||
c.jsonErr(400, 400, err.Error())
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
updates := map[string]interface{}{
|
||
"provider": p.Provider,
|
||
"protocol": p.Protocol,
|
||
"base_url": p.BaseURL,
|
||
"use_custom_url": p.UseCustomURL,
|
||
"custom_url": p.CustomURL,
|
||
"models": modelsToJSON(p.Models),
|
||
"remark": strings.TrimSpace(p.Remark),
|
||
"update_time": now,
|
||
}
|
||
if p.Status != nil {
|
||
updates["status"] = *p.Status
|
||
}
|
||
// 未上送 api_keys 表示不修改已存密钥列表
|
||
if p.ApiKeys != nil {
|
||
updates["api_keys"] = apiKeysToJSON(*p.ApiKeys)
|
||
}
|
||
|
||
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||
Filter("id", row.ID).
|
||
Update(updates)
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "更新失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
c.ok("更新成功", nil)
|
||
}
|
||
|
||
// Delete DELETE /platform/agentApi/:id
|
||
func (c *PlatformAgentApiController) Delete() {
|
||
if _, err := c.platformClaims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
row, err := c.findByID()
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||
Filter("id", row.ID).
|
||
Update(map[string]interface{}{
|
||
"is_deleted": 1,
|
||
"delete_time": now,
|
||
})
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "删除失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
c.ok("删除成功", nil)
|
||
}
|
||
|
||
// BatchDelete POST /platform/agentApi/batchDelete
|
||
func (c *PlatformAgentApiController) BatchDelete() {
|
||
if _, err := c.platformClaims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
|
||
var p struct {
|
||
IDs []uint64 `json:"ids"`
|
||
}
|
||
if err := json.Unmarshal(raw, &p); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
if len(p.IDs) == 0 {
|
||
c.jsonErr(400, 400, "请选择要删除的配置")
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||
Filter("id__in", p.IDs).
|
||
Filter("is_deleted", 0).
|
||
Update(map[string]interface{}{
|
||
"is_deleted": 1,
|
||
"delete_time": now,
|
||
})
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "批量删除失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
c.ok("批量删除成功", nil)
|
||
}
|
||
|
||
// ToggleStatus POST /platform/agentApi/:id/status
|
||
func (c *PlatformAgentApiController) ToggleStatus() {
|
||
if _, err := c.platformClaims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
row, err := c.findByID()
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
|
||
var p struct {
|
||
Status *int8 `json:"status"`
|
||
}
|
||
if err := json.Unmarshal(raw, &p); err != nil || p.Status == nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
if *p.Status != 0 && *p.Status != 1 {
|
||
c.jsonErr(400, 400, "状态值不正确")
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||
Filter("id", row.ID).
|
||
Update(map[string]interface{}{
|
||
"status": *p.Status,
|
||
"update_time": now,
|
||
})
|
||
if err != nil {
|
||
c.jsonErr(500, 500, "状态切换失败: "+err.Error())
|
||
return
|
||
}
|
||
|
||
c.ok("操作成功", nil)
|
||
}
|
||
|
||
// agentApiTestResult 测试结果统一结构,与前端 test.vue 字段对应
|
||
// Endpoint 回显实际请求的完整地址,便于排查路径拼接类问题(如上游根地址少了版本前缀)
|
||
type agentApiTestResult struct {
|
||
Success bool `json:"success"`
|
||
Message string `json:"message"`
|
||
StatusCode int `json:"status_code,omitempty"`
|
||
LatencyMs int64 `json:"latency_ms"`
|
||
Model string `json:"model,omitempty"`
|
||
KeyRemark string `json:"key_remark,omitempty"`
|
||
Endpoint string `json:"endpoint,omitempty"`
|
||
Response string `json:"response,omitempty"`
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
// redactKeyInURL 隐去 URL 查询串里的密钥
|
||
// Gemini 协议把 key 放在 query 中,回显 endpoint 前必须脱敏
|
||
func redactKeyInURL(rawURL, key string) string {
|
||
if key == "" {
|
||
return rawURL
|
||
}
|
||
return strings.ReplaceAll(rawURL, key, "***")
|
||
}
|
||
|
||
// Test POST /platform/agentApi/test
|
||
// 请求体:{ "id": 1, "model": "gpt-4o", "key_index": 0, "prompt": "你好" }
|
||
// key_index 指定用密钥列表中的第几个密钥,缺省用第一个
|
||
func (c *PlatformAgentApiController) Test() {
|
||
if _, err := c.platformClaims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
|
||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
|
||
var p struct {
|
||
ID uint64 `json:"id"`
|
||
Model string `json:"model"`
|
||
KeyIndex *int `json:"key_index"`
|
||
Prompt string `json:"prompt"`
|
||
}
|
||
if err := json.Unmarshal(raw, &p); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
if p.ID == 0 {
|
||
c.jsonErr(400, 400, "缺少配置ID")
|
||
return
|
||
}
|
||
|
||
var row models.PlatformAgentApi
|
||
err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||
Filter("id", p.ID).
|
||
Filter("is_deleted", 0).
|
||
One(&row)
|
||
if err != nil {
|
||
c.jsonErr(404, 404, "配置不存在")
|
||
return
|
||
}
|
||
|
||
model := strings.TrimSpace(p.Model)
|
||
if model == "" {
|
||
list := modelsFromJSON(row.Models)
|
||
if len(list) == 0 {
|
||
c.jsonErr(400, 400, "该配置未添加模型")
|
||
return
|
||
}
|
||
model = list[0]
|
||
}
|
||
|
||
// 挑选待测密钥
|
||
keys := apiKeysFromJSON(row.ApiKeys)
|
||
if len(keys) == 0 {
|
||
c.jsonErr(400, 400, "该配置未添加 API Key")
|
||
return
|
||
}
|
||
idx := 0
|
||
if p.KeyIndex != nil {
|
||
idx = *p.KeyIndex
|
||
}
|
||
if idx < 0 || idx >= len(keys) {
|
||
c.jsonErr(400, 400, "指定的密钥不存在")
|
||
return
|
||
}
|
||
chosen := keys[idx]
|
||
|
||
prompt := strings.TrimSpace(p.Prompt)
|
||
if prompt == "" {
|
||
prompt = "你好"
|
||
}
|
||
|
||
result := probeAgentApi(&row, chosen, model, prompt)
|
||
c.ok("success", result)
|
||
}
|
||
|
||
// probeAgentApi 用指定密钥按配置的请求协议发起一次最小化对话请求
|
||
func probeAgentApi(
|
||
row *models.PlatformAgentApi,
|
||
apiKey models.AgentApiKey,
|
||
model, prompt string,
|
||
) agentApiTestResult {
|
||
base := strings.TrimRight(row.EffectiveURL(), "/")
|
||
if base == "" {
|
||
return agentApiTestResult{
|
||
Success: false, Message: "接口地址为空",
|
||
Model: model, KeyRemark: apiKey.Remark,
|
||
}
|
||
}
|
||
|
||
// 协议由配置显式指定,不再根据上游名称猜测
|
||
protocol := normalizeProtocol(row.Protocol)
|
||
endpoint := buildAgentEndpoint(protocol, base, model, apiKey.Key)
|
||
|
||
var (
|
||
body []byte
|
||
headers = map[string]string{"Content-Type": "application/json"}
|
||
err error
|
||
)
|
||
|
||
switch protocol {
|
||
case models.AgentProtocolAnthropic:
|
||
headers["x-api-key"] = apiKey.Key
|
||
headers["anthropic-version"] = "2023-06-01"
|
||
body, err = json.Marshal(map[string]interface{}{
|
||
"model": model,
|
||
"max_tokens": 64,
|
||
"messages": []map[string]string{
|
||
{"role": "user", "content": prompt},
|
||
},
|
||
})
|
||
|
||
case models.AgentProtocolGemini:
|
||
// 密钥已放在 query 中,无需鉴权头
|
||
body, err = json.Marshal(map[string]interface{}{
|
||
"contents": []map[string]interface{}{
|
||
{"parts": []map[string]string{{"text": prompt}}},
|
||
},
|
||
})
|
||
|
||
case models.AgentProtocolResponses:
|
||
// Responses API 用 input 字段替代 messages
|
||
headers["Authorization"] = "Bearer " + apiKey.Key
|
||
body, err = json.Marshal(map[string]interface{}{
|
||
"model": model,
|
||
"input": prompt,
|
||
"max_output_tokens": 64,
|
||
})
|
||
|
||
default:
|
||
// Chat Completions 与 custom 均按 OpenAI 标准报文发送
|
||
headers["Authorization"] = "Bearer " + apiKey.Key
|
||
body, err = json.Marshal(map[string]interface{}{
|
||
"model": model,
|
||
"max_tokens": 64,
|
||
"messages": []map[string]string{
|
||
{"role": "user", "content": prompt},
|
||
},
|
||
})
|
||
}
|
||
|
||
if err != nil {
|
||
return agentApiTestResult{
|
||
Success: false, Message: "构造请求失败", Detail: err.Error(),
|
||
Model: model, KeyRemark: apiKey.Remark,
|
||
}
|
||
}
|
||
|
||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||
if err != nil {
|
||
return agentApiTestResult{
|
||
Success: false, Message: "构造请求失败", Detail: err.Error(),
|
||
Model: model, KeyRemark: apiKey.Remark,
|
||
Endpoint: redactKeyInURL(endpoint, apiKey.Key),
|
||
}
|
||
}
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
safeEndpoint := redactKeyInURL(endpoint, apiKey.Key)
|
||
|
||
start := time.Now()
|
||
resp, err := agentApiHTTPClient.Do(req)
|
||
latency := time.Since(start).Milliseconds()
|
||
|
||
if err != nil {
|
||
return agentApiTestResult{
|
||
Success: false,
|
||
Message: "请求上游失败(网络不通或地址错误)",
|
||
Detail: err.Error(),
|
||
LatencyMs: latency,
|
||
Model: model,
|
||
KeyRemark: apiKey.Remark,
|
||
Endpoint: safeEndpoint,
|
||
}
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 限制读取长度,避免超长响应占满内存
|
||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||
preview := string(respBody)
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
msg := fmt.Sprintf("上游返回 HTTP %d", resp.StatusCode)
|
||
// 404 多半是接口地址少了版本前缀,给出可操作的提示
|
||
if resp.StatusCode == 404 {
|
||
msg += ":接口地址可能不正确,请检查是否缺少版本路径(如 /v1、/compatible-mode/v2)"
|
||
}
|
||
return agentApiTestResult{
|
||
Success: false,
|
||
Message: msg,
|
||
StatusCode: resp.StatusCode,
|
||
LatencyMs: latency,
|
||
Model: model,
|
||
KeyRemark: apiKey.Remark,
|
||
Endpoint: safeEndpoint,
|
||
Detail: preview,
|
||
}
|
||
}
|
||
|
||
return agentApiTestResult{
|
||
Success: true,
|
||
Message: "连接正常,密钥与模型可用",
|
||
StatusCode: resp.StatusCode,
|
||
LatencyMs: latency,
|
||
Model: model,
|
||
KeyRemark: apiKey.Remark,
|
||
Endpoint: safeEndpoint,
|
||
Response: extractAgentReply(protocol, respBody, preview),
|
||
}
|
||
}
|
||
|
||
// extractAgentReply 从上游响应里提取模型回复文本,解析失败则回退为原始预览
|
||
func extractAgentReply(protocol string, respBody []byte, fallback string) string {
|
||
switch protocol {
|
||
case models.AgentProtocolAnthropic:
|
||
var r struct {
|
||
Content []struct {
|
||
Text string `json:"text"`
|
||
} `json:"content"`
|
||
}
|
||
if json.Unmarshal(respBody, &r) == nil && len(r.Content) > 0 {
|
||
return r.Content[0].Text
|
||
}
|
||
|
||
case models.AgentProtocolGemini:
|
||
var r struct {
|
||
Candidates []struct {
|
||
Content struct {
|
||
Parts []struct {
|
||
Text string `json:"text"`
|
||
} `json:"parts"`
|
||
} `json:"content"`
|
||
} `json:"candidates"`
|
||
}
|
||
if json.Unmarshal(respBody, &r) == nil &&
|
||
len(r.Candidates) > 0 && len(r.Candidates[0].Content.Parts) > 0 {
|
||
return r.Candidates[0].Content.Parts[0].Text
|
||
}
|
||
|
||
case models.AgentProtocolResponses:
|
||
// Responses API 优先取聚合字段 output_text,回退到 output 数组
|
||
var r struct {
|
||
OutputText string `json:"output_text"`
|
||
Output []struct {
|
||
Content []struct {
|
||
Text string `json:"text"`
|
||
} `json:"content"`
|
||
} `json:"output"`
|
||
}
|
||
if json.Unmarshal(respBody, &r) == nil {
|
||
if r.OutputText != "" {
|
||
return r.OutputText
|
||
}
|
||
if len(r.Output) > 0 && len(r.Output[0].Content) > 0 {
|
||
return r.Output[0].Content[0].Text
|
||
}
|
||
}
|
||
|
||
default:
|
||
var r struct {
|
||
Choices []struct {
|
||
Message struct {
|
||
Content string `json:"content"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
}
|
||
if json.Unmarshal(respBody, &r) == nil && len(r.Choices) > 0 {
|
||
return r.Choices[0].Message.Content
|
||
}
|
||
}
|
||
return fallback
|
||
}
|