Files
yunzerwebsiteallinone/go/controllers/backend_ai_provider.go
T

434 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
"server/models"
"server/pkg/identity"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendAiProviderController AI接入配置控制器
type BackendAiProviderController struct {
beego.Controller
}
func (c *BackendAiProviderController) aiClaims() (*jwtutil.Claims, error) {
return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
}
func (c *BackendAiProviderController) aiJsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *BackendAiProviderController) aiOk(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
type aiProviderPayload struct {
ProviderType string `json:"provider_type"`
Name string `json:"name"`
ApiBase string `json:"api_base"`
ApiKey string `json:"api_key"`
Models []string `json:"models"`
DefaultModel string `json:"default_model"` // 默认使用的模型
IsDefault int8 `json:"is_default"`
Status int8 `json:"status"`
Remark string `json:"remark"`
// Scope "tenant" 表示租户级配置(user_id=0,全员共用,由「基础设置 → AI 设置」维护);
// 其它值/空表示个人配置,仅创建者可用。
Scope string `json:"scope"`
}
// aiTenantScope 本次请求是否在维护租户级配置
func aiTenantScope(scope string) bool {
return strings.EqualFold(strings.TrimSpace(scope), "tenant")
}
// clearTenantDefault 取消同一归属范围内(租户级 / 同一用户)的其他默认配置
func clearTenantDefault(tenantID string, userID uint64) {
var defaults []models.BackendAiProvider
_, _ = models.Orm.QueryTable(new(models.BackendAiProvider)).
Filter("tenant_id", tenantID).
Filter("user_id", userID).
Filter("is_default", 1).
All(&defaults)
for _, d := range defaults {
d.IsDefault = 0
d.UpdateTime = time.Now()
_, _ = models.Orm.Update(&d, "is_default", "update_time")
}
}
// parseModels 将模型JSON字符串解析为数组
func parseProviderModels(m string) []string {
if m == "" {
return []string{}
}
var list []string
if err := json.Unmarshal([]byte(m), &list); err != nil {
// 兼容旧格式:逗号分隔的单个模型
return []string{m}
}
return list
}
// List GET /backend/ai/provider/list?scope=tenant
// scope=tenant 只返回租户级配置(user_id=0);不传时返回「租户级 + 当前用户」,
// 供 AI 广场选择可用接入。
func (c *BackendAiProviderController) List() {
claims, err := c.aiClaims()
if err != nil {
code := identity.Status(err)
c.aiJsonErr(code, code, err.Error())
return
}
cond := orm.NewCondition().
And("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
And("delete_time__isnull", true)
if aiTenantScope(c.GetString("scope")) {
cond = cond.And("user_id", 0)
} else {
cond = cond.AndCond(orm.NewCondition().
Or("user_id", 0).
Or("user_id", uint64(claims.UserID)))
}
var list []models.BackendAiProvider
_, err = models.Orm.QueryTable(new(models.BackendAiProvider)).
SetCond(cond).
OrderBy("user_id", "-id").
All(&list)
if err != nil {
c.aiJsonErr(500, 500, "查询失败: "+err.Error())
return
}
// 解析模型列表。个人配置不回传脱敏 Key(本人可见);
// 租户级配置(user_id=0)全租户共用,Key 必须脱敏,测试走 test 接口的 id 模式。
for i := range list {
list[i].ModelsList = parseProviderModels(list[i].Models)
if list[i].UserID == 0 {
list[i].ApiKey = maskTenantAiKey(list[i].ApiKey)
}
}
c.aiOk(map[string]interface{}{"list": list})
}
// Create POST /backend/ai/provider
func (c *BackendAiProviderController) Create() {
claims, err := c.aiClaims()
if err != nil {
code := identity.Status(err)
c.aiJsonErr(code, code, err.Error())
return
}
body, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.aiJsonErr(400, 400, "读取请求体失败")
return
}
var p aiProviderPayload
if err := json.Unmarshal(body, &p); err != nil {
c.aiJsonErr(400, 400, "参数格式错误")
return
}
if strings.TrimSpace(p.Name) == "" {
c.aiJsonErr(400, 400, "配置名称不能为空")
return
}
if p.ProviderType != "openai" && p.ProviderType != "anthropic" {
c.aiJsonErr(400, 400, "接入类型必须是 openai 或 anthropic")
return
}
if strings.TrimSpace(p.ApiBase) == "" || strings.TrimSpace(p.ApiKey) == "" {
c.aiJsonErr(400, 400, "接口地址、API Key不能为空")
return
}
if len(p.Models) == 0 {
c.aiJsonErr(400, 400, "至少配置一个模型")
return
}
// 模型数组序列化为JSON
modelsJSON, _ := json.Marshal(p.Models)
// 租户级配置(user_id=0)由 OA 设置统一维护,全员可用
ownerID := uint64(claims.UserID)
if aiTenantScope(p.Scope) {
ownerID = 0
}
// 如果设为默认,先取消同一归属范围内的其他默认
if p.IsDefault == 1 {
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId), ownerID)
}
provider := models.BackendAiProvider{
TenantID: fmt.Sprintf("%d", claims.TenantId),
UserID: ownerID,
ProviderType: p.ProviderType,
Name: strings.TrimSpace(p.Name),
ApiBase: strings.TrimSpace(p.ApiBase),
ApiKey: strings.TrimSpace(p.ApiKey),
Models: string(modelsJSON),
DefaultModel: strings.TrimSpace(p.DefaultModel),
IsDefault: p.IsDefault,
Status: p.Status,
Remark: strings.TrimSpace(p.Remark),
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
id, err := models.Orm.Insert(&provider)
if err != nil {
c.aiJsonErr(500, 500, "创建失败: "+err.Error())
return
}
c.aiOk(map[string]interface{}{"id": id})
}
// Update PUT /backend/ai/provider/:id
func (c *BackendAiProviderController) Update() {
claims, err := c.aiClaims()
if err != nil {
code := identity.Status(err)
c.aiJsonErr(code, code, err.Error())
return
}
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.aiJsonErr(400, 400, "ID格式错误")
return
}
provider := models.BackendAiProvider{ID: id}
if err := models.Orm.Read(&provider); err != nil {
c.aiJsonErr(404, 404, "配置不存在")
return
}
if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) {
c.aiJsonErr(403, 403, "无权操作")
return
}
// 租户级配置(user_id=0)允许本租户管理员在 AI 设置中维护,个人配置仅本人可改
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
c.aiJsonErr(403, 403, "无权操作")
return
}
body, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.aiJsonErr(400, 400, "读取请求体失败")
return
}
var p aiProviderPayload
if err := json.Unmarshal(body, &p); err != nil {
c.aiJsonErr(400, 400, "参数格式错误")
return
}
// 如果设为默认,先取消同一归属范围内的其他默认
if p.IsDefault == 1 && provider.IsDefault != 1 {
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId), provider.UserID)
}
provider.Name = strings.TrimSpace(p.Name)
provider.ProviderType = p.ProviderType
provider.ApiBase = strings.TrimSpace(p.ApiBase)
// 只有传入了非脱敏的key才更新
if p.ApiKey != "" && !strings.Contains(p.ApiKey, "****") {
provider.ApiKey = strings.TrimSpace(p.ApiKey)
}
// 更新模型列表
if len(p.Models) > 0 {
modelsJSON, _ := json.Marshal(p.Models)
provider.Models = string(modelsJSON)
}
provider.DefaultModel = strings.TrimSpace(p.DefaultModel)
provider.IsDefault = p.IsDefault
provider.Status = p.Status
provider.Remark = strings.TrimSpace(p.Remark)
provider.UpdateTime = time.Now()
_, err = models.Orm.Update(&provider)
if err != nil {
c.aiJsonErr(500, 500, "更新失败: "+err.Error())
return
}
c.aiOk(nil)
}
// Delete DELETE /backend/ai/provider/:id
func (c *BackendAiProviderController) Delete() {
claims, err := c.aiClaims()
if err != nil {
code := identity.Status(err)
c.aiJsonErr(code, code, err.Error())
return
}
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.aiJsonErr(400, 400, "ID格式错误")
return
}
provider := models.BackendAiProvider{ID: id}
if err := models.Orm.Read(&provider); err != nil {
c.aiJsonErr(404, 404, "配置不存在")
return
}
if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) {
c.aiJsonErr(403, 403, "无权操作")
return
}
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
c.aiJsonErr(403, 403, "无权操作")
return
}
now := time.Now()
provider.DeleteTime = &now
_, err = models.Orm.Update(&provider, "delete_time")
if err != nil {
c.aiJsonErr(500, 500, "删除失败: "+err.Error())
return
}
c.aiOk(nil)
}
type aiTestPayload struct {
// ID 已保存的配置ID:不传 api_key(或传脱敏值)时用库里的密钥测试
ID uint64 `json:"id"`
ProviderType string `json:"provider_type"`
ApiBase string `json:"api_base"`
ApiKey string `json:"api_key"`
Models []string `json:"models"`
}
type modelTestResult struct {
Model string `json:"model"`
Success bool `json:"success"`
Error string `json:"error"`
}
// maskTenantAiKey 脱敏租户级配置的密钥(保留前后各 4 位)。
// 注意:结果包含 ****,Update 遇到含 **** 的 key 会跳过更新,不会把脱敏值写回库。
func maskTenantAiKey(key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
}
// Test POST /backend/ai/provider/test
// 批量测试模型连通性。支持两种模式:
// 1. 传 api_base + api_key:直接测试(新增/修改未保存的表单);
// 2. 传 id(api_key 留空或为脱敏值):用已保存配置的密钥测试。
func (c *BackendAiProviderController) Test() {
claims, err := c.aiClaims()
if err != nil {
code := identity.Status(err)
c.aiJsonErr(code, code, err.Error())
return
}
body, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.aiJsonErr(400, 400, "读取请求体失败")
return
}
var p aiTestPayload
if err := json.Unmarshal(body, &p); err != nil {
c.aiJsonErr(400, 400, "参数格式错误")
return
}
apiKey := strings.TrimSpace(p.ApiKey)
if p.ID > 0 && (apiKey == "" || strings.Contains(apiKey, "****")) {
stored := models.BackendAiProvider{ID: p.ID}
if err := models.Orm.Read(&stored); err != nil {
c.aiJsonErr(404, 404, "配置不存在")
return
}
if stored.TenantID != fmt.Sprintf("%d", claims.TenantId) {
c.aiJsonErr(403, 403, "无权操作")
return
}
if stored.UserID != 0 && stored.UserID != uint64(claims.UserID) {
c.aiJsonErr(403, 403, "无权操作")
return
}
apiKey = stored.ApiKey
if strings.TrimSpace(p.ApiBase) == "" {
p.ApiBase = stored.ApiBase
}
if strings.TrimSpace(p.ProviderType) == "" {
p.ProviderType = stored.ProviderType
}
}
if strings.TrimSpace(p.ApiBase) == "" || apiKey == "" {
c.aiJsonErr(400, 400, "接口地址和API Key不能为空")
return
}
if len(p.Models) == 0 {
c.aiOk(map[string]interface{}{"results": []modelTestResult{}})
return
}
// 构造临时provider
provider := models.BackendAiProvider{
ProviderType: p.ProviderType,
ApiBase: strings.TrimSpace(p.ApiBase),
ApiKey: apiKey,
}
testMessages := []openaiMessage{{Role: "user", Content: "hi"}}
// 并发测试
results := make([]modelTestResult, len(p.Models))
var wg sync.WaitGroup
for i, model := range p.Models {
wg.Add(1)
go func(idx int, m string) {
defer wg.Done()
_, err := callAI(provider, m, "", testMessages)
if err != nil {
results[idx] = modelTestResult{Model: m, Success: false, Error: err.Error()}
} else {
results[idx] = modelTestResult{Model: m, Success: true, Error: ""}
}
}(i, model)
}
wg.Wait()
c.aiOk(map[string]interface{}{"results": results})
}