diff --git a/admin/server/api/v1/gaia/enter.go b/admin/server/api/v1/gaia/enter.go
index a159e9e51..df3f4e504 100644
--- a/admin/server/api/v1/gaia/enter.go
+++ b/admin/server/api/v1/gaia/enter.go
@@ -11,6 +11,7 @@ type ApiGroup struct {
SystemOAuth2Api
BatchWorkflowApi
AppVersionApi
+ ModelProviderApi
}
var (
diff --git a/admin/server/api/v1/gaia/model_provider.go b/admin/server/api/v1/gaia/model_provider.go
new file mode 100644
index 000000000..04b12a4f9
--- /dev/null
+++ b/admin/server/api/v1/gaia/model_provider.go
@@ -0,0 +1,254 @@
+package gaia
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/flipped-aurora/gin-vue-admin/server/global"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
+ "github.com/flipped-aurora/gin-vue-admin/server/service"
+ "github.com/flipped-aurora/gin-vue-admin/server/utils"
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type ModelProviderApi struct{}
+
+var modelProviderService = service.ServiceGroupApp.GaiaServiceGroup.ModelProviderService
+
+// GetProviderList 获取提供商配置列表
+// @Tags ModelProvider
+// @Summary 获取提供商配置列表
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Success 200 {object} response.Response{data=[]gaiaResponse.ProviderListItem,msg=string} "获取成功"
+// @Router /gaia/model-provider/list [get]
+func (m *ModelProviderApi) GetProviderList(c *gin.Context) {
+ list, err := modelProviderService.GetProviderList()
+ if err != nil {
+ global.GVA_LOG.Error("获取提供商配置列表失败", zap.Error(err))
+ response.FailWithMessage("获取失败: "+err.Error(), c)
+ return
+ }
+ response.OkWithData(list, c)
+}
+
+// UpdateProviderConfig 更新提供商配置
+// @Tags ModelProvider
+// @Summary 更新提供商配置
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Param data body object true "提供商配置"
+// @Success 200 {object} response.Response{msg=string} "更新成功"
+// @Router /gaia/model-provider/update [post]
+func (m *ModelProviderApi) UpdateProviderConfig(c *gin.Context) {
+ var req struct {
+ ProviderName string `json:"provider_name" binding:"required"`
+ Enabled bool `json:"enabled"`
+ Models []string `json:"models"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.FailWithMessage("参数错误: "+err.Error(), c)
+ return
+ }
+
+ if err := modelProviderService.UpdateProviderConfig(req.ProviderName, req.Enabled, req.Models); err != nil {
+ global.GVA_LOG.Error("更新提供商配置失败", zap.String("provider", req.ProviderName), zap.Error(err))
+ response.FailWithMessage("更新失败: "+err.Error(), c)
+ return
+ }
+
+ response.OkWithMessage("更新成功", c)
+}
+
+// GetModels 获取开启的模型列表(OpenAI格式)
+// @Tags ModelProvider
+// @Summary 获取开启的模型列表
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Success 200 {object} gaiaResponse.OpenAIModelsResponse "获取成功"
+// @Router /gaia/models [get]
+func (m *ModelProviderApi) GetModels(c *gin.Context) {
+ models, err := modelProviderService.GetEnabledModels()
+ if err != nil {
+ global.GVA_LOG.Error("获取模型列表失败", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": gin.H{
+ "message": "获取模型列表失败: " + err.Error(),
+ },
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, models)
+}
+
+// Proxy 通用中转 API:将 /gaia/proxy/* 的请求按路径转发到上游(如 /v1/chat/completions、/v1/messages、/v1/images/generations、/v1/embeddings 等)。
+// 上游 base 优先使用 provider_credentials 的 openai_api_base(如 "https://yunwu.ai"),便于计费区分。
+// @Tags ModelProvider
+// @Summary 通用中转API(按路径转发)
+// @Security ApiKeyAuth
+// @Param path path string true "上游路径,如 v1/chat/completions、v1/messages"
+// @Router /gaia/proxy/*path [get,post,put,patch,delete]
+func (m *ModelProviderApi) Proxy(c *gin.Context) {
+ // init
+ var err error
+ var body []byte
+ path := c.Param("path")
+ userID := utils.GetUserUuid(c).String()
+ if path == "" || path == "/" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "代理路径不能为空"}})
+ return
+ }
+ // 将 query provider 转为请求头,供 service 解析
+ reqHeader := c.Request.Header.Clone()
+ if q := strings.TrimSpace(c.Query("provider")); q != "" {
+ reqHeader.Set("X-Gaia-Provider", q)
+ }
+
+ if body, err = io.ReadAll(c.Request.Body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "读取请求体失败"}})
+ return
+ }
+
+ if err = modelProviderService.ProxyRequest(
+ userID, path, c.Request.Method, reqHeader, body, c.Writer); err != nil {
+ global.GVA_LOG.Error("代理请求失败", zap.String("user_id", userID), zap.String(
+ "path", path), zap.Error(err))
+ if !c.Writer.Written() {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": err.Error()}})
+ }
+ }
+}
+
+// GetAvailableModels 获取提供商的可用模型
+// @Tags ModelProvider
+// @Summary 获取提供商的可用模型
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Param provider_name query string true "提供商名称"
+// @Success 200 {object} response.Response{data=[]gaiaResponse.ModelInfo,msg=string} "获取成功"
+// @Router /gaia/model-provider/available-models [get]
+func (m *ModelProviderApi) GetAvailableModels(c *gin.Context) {
+ providerName := c.Query("provider_name")
+ if providerName == "" {
+ response.FailWithMessage("参数错误: provider_name不能为空", c)
+ return
+ }
+
+ models, err := modelProviderService.GetAvailableModelsFromDify(providerName)
+ if err != nil {
+ global.GVA_LOG.Error("获取可用模型失败", zap.String("provider", providerName), zap.Error(err))
+ response.FailWithMessage("获取失败: "+err.Error(), c)
+ return
+ }
+
+ response.OkWithData(models, c)
+}
+
+// TestProviderCredentials 测试提供商凭证
+// @Tags ModelProvider
+// @Summary 测试提供商凭证
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Param provider_name query string true "提供商名称"
+// @Success 200 {object} response.Response{msg=string} "测试成功"
+// @Router /gaia/model-provider/test-credentials [get]
+func (m *ModelProviderApi) TestProviderCredentials(c *gin.Context) {
+ providerName := c.Query("provider_name")
+ if providerName == "" {
+ response.FailWithMessage("参数错误: provider_name不能为空", c)
+ return
+ }
+
+ creds, err := modelProviderService.GetDifyProviderCredentials(providerName)
+ if err != nil {
+ global.GVA_LOG.Error("获取提供商凭证失败", zap.String("provider", providerName), zap.Error(err))
+ response.FailWithMessage("获取凭证失败: "+err.Error(), c)
+ return
+ }
+
+ // 隐藏API Key的大部分内容
+ maskedKey := ""
+ if len(creds.APIKey) > 8 {
+ maskedKey = creds.APIKey[:4] + "****" + creds.APIKey[len(creds.APIKey)-4:]
+ } else {
+ maskedKey = "****"
+ }
+
+ result := map[string]interface{}{
+ "provider": providerName,
+ "has_api_key": creds.APIKey != "",
+ "api_key": maskedKey,
+ }
+
+ response.OkWithData(result, c)
+}
+
+// GetProxyLogs 获取代理日志
+// @Tags ModelProvider
+// @Summary 获取代理日志
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Param page query int false "页码"
+// @Param page_size query int false "每页数量"
+// @Success 200 {object} response.Response{data=map[string]interface{},msg=string} "获取成功"
+// @Router /gaia/model-provider/logs [get]
+func (m *ModelProviderApi) GetProxyLogs(c *gin.Context) {
+ page := c.DefaultQuery("page", "1")
+ pageSize := c.DefaultQuery("page_size", "20")
+
+ var pageInt, pageSizeInt int
+ if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil {
+ pageInt = 1
+ }
+ if _, err := fmt.Sscanf(pageSize, "%d", &pageSizeInt); err != nil {
+ pageSizeInt = 20
+ }
+
+ if pageInt < 1 {
+ pageInt = 1
+ }
+ if pageSizeInt < 1 || pageSizeInt > 100 {
+ pageSizeInt = 20
+ }
+
+ var logs []map[string]interface{}
+ var total int64
+
+ db := global.GVA_DB.Table("model_proxy_log")
+
+ // 获取总数
+ if err := db.Count(&total).Error; err != nil {
+ global.GVA_LOG.Error("获取日志总数失败", zap.Error(err))
+ response.FailWithMessage("获取失败: "+err.Error(), c)
+ return
+ }
+
+ // 分页查询
+ offset := (pageInt - 1) * pageSizeInt
+ if err := db.Order("created_at DESC").Limit(pageSizeInt).Offset(
+ offset).Find(&logs).Error; err != nil {
+ global.GVA_LOG.Error("获取日志列表失败", zap.Error(err))
+ response.FailWithMessage("获取失败: "+err.Error(), c)
+ return
+ }
+
+ result := map[string]interface{}{
+ "list": logs,
+ "total": total,
+ "page": pageInt,
+ "page_size": pageSizeInt,
+ }
+
+ response.OkWithData(result, c)
+}
diff --git a/admin/server/api/v1/gaia/workflow.go b/admin/server/api/v1/gaia/workflow.go
index a1c11d0dc..99caaf5bf 100644
--- a/admin/server/api/v1/gaia/workflow.go
+++ b/admin/server/api/v1/gaia/workflow.go
@@ -495,6 +495,7 @@ func generateCSVFromTasks(flow *gaia.BatchWorkflow, tasks []gaia.BatchWorkflowTa
nameList = append(nameList, value)
}
headers = append(headers, "生成结果")
+ headers = append(headers, "报错信息")
_ = w.Write(headers)
// 行数据
@@ -526,6 +527,9 @@ func generateCSVFromTasks(flow *gaia.BatchWorkflow, tasks []gaia.BatchWorkflowTa
}
}
row = append(row, text)
+ if len(task.Error) > 0 {
+ row = append(row, task.Error)
+ }
_ = w.Write(row)
}
diff --git a/admin/server/api/v1/system/sys_user_extend.go b/admin/server/api/v1/system/sys_user_extend.go
index 25ae7d8fc..67ba94285 100644
--- a/admin/server/api/v1/system/sys_user_extend.go
+++ b/admin/server/api/v1/system/sys_user_extend.go
@@ -5,12 +5,16 @@ import (
"errors"
"fmt"
"net/http"
+ "strings"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
+ gaiaReq "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
systemRes "github.com/flipped-aurora/gin-vue-admin/server/model/system/response"
+ "github.com/flipped-aurora/gin-vue-admin/server/service"
+ sysSvc "github.com/flipped-aurora/gin-vue-admin/server/service/system"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/gin-gonic/gin"
"github.com/go-resty/resty/v2"
@@ -18,6 +22,8 @@ import (
"gorm.io/gorm"
)
+var gaiaSystemIntegratedService = service.ServiceGroupApp.GaiaServiceGroup.SystemIntegratedService
+
// Extend Start: sync user
// SyncUser
@@ -184,3 +190,89 @@ func (b *BaseApi) OAuth2Callback(c *gin.Context) {
}
// Extend Stop: oAuth2 callback verification
+
+// GetGaiaLoginOptions 获取 Gaia 登录方式(钉钉/OAuth2 是否启用及授权地址),供登录页展示,无需鉴权
+// @Tags Base
+// @Summary 获取登录方式选项
+// @Produce application/json
+// @Param origin query string false "前端 origin,用于拼回调地址"
+// @Router /base/gaiaLoginOptions [get]
+func (b *BaseApi) GetGaiaLoginOptions(c *gin.Context) {
+ origin := c.Query("origin")
+ if origin == "" {
+ origin = c.GetHeader("Origin")
+ }
+ if origin == "" {
+ origin = strings.TrimSuffix(global.GVA_CONFIG.Gaia.Url, "/")
+ }
+ opts := gaiaSystemIntegratedService.GetLoginOptions(origin)
+ response.OkWithData(opts, c)
+}
+
+// GaiaOAuth2Login 使用系统集成 OAuth2 的 code 或 access_token(Extend: 兼容 casdoor)登录,返回 JWT;若带 redirect_uri/state 则一并返回供前端回调第三方
+// @Tags Base
+// @Summary Gaia OAuth2 登录
+// @Produce application/json
+// @Param data body gaiaReq.GaiaOAuth2LoginReq true "code 或 access_token 二选一、redirect_uri、state"
+// @Router /base/gaiaOAuth2Login [post]
+func (b *BaseApi) GaiaOAuth2Login(c *gin.Context) {
+ var req gaiaReq.GaiaOAuth2LoginReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.FailWithMessage(err.Error(), c)
+ return
+ }
+ result, err := gaiaSystemIntegratedService.OAuth2CodeLogin(req)
+ if err != nil {
+ global.GVA_LOG.Error("Gaia OAuth2 登录失败", zap.Error(err))
+ response.FailWithMessage(err.Error(), c)
+ return
+ }
+ sysSvc.MenuServiceApp.UserAuthorityDefaultRouter(&result.User)
+ data := map[string]interface{}{
+ "user": result.User,
+ "token": result.Token,
+ "expiresAt": 0,
+ }
+ if result.RedirectURI != "" {
+ data["redirect_uri"] = result.RedirectURI
+ }
+ if result.State != "" {
+ data["state"] = result.State
+ }
+ response.OkWithDetailed(data, "登录成功", c)
+}
+
+// GaiaDingTalkLogin 钉钉 code 登录,返回 JWT
+// @Tags Base
+// @Summary 钉钉登录
+// @Produce application/json
+// @Param data body gaiaReq.GaiaDingTalkLoginReq true "auth_code、redirect_uri、state"
+// @Router /base/dingtalkLogin [post]
+func (b *BaseApi) GaiaDingTalkLogin(c *gin.Context) {
+ var req gaiaReq.GaiaDingTalkLoginReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.FailWithMessage(err.Error(), c)
+ return
+ }
+ result, err := gaiaSystemIntegratedService.DingTalkCodeLogin(req)
+ if err != nil {
+ global.GVA_LOG.Error("钉钉登录失败", zap.Error(err))
+ response.FailWithMessage(err.Error(), c)
+ return
+ }
+ sysSvc.MenuServiceApp.UserAuthorityDefaultRouter(&result.User)
+ data := map[string]interface{}{
+ "user": result.User,
+ "token": result.Token,
+ "expiresAt": 0,
+ }
+ if result.RedirectURI != "" {
+ data["redirect_uri"] = result.RedirectURI
+ }
+ if result.State != "" {
+ data["state"] = result.State
+ }
+ response.OkWithDetailed(data, "登录成功", c)
+}
+
+// Extend Stop: gaia login
diff --git a/admin/server/config.yaml b/admin/server/config.yaml
index 9f7fa24c2..ecb06db90 100644
--- a/admin/server/config.yaml
+++ b/admin/server/config.yaml
@@ -61,6 +61,7 @@ gaia:
login_max_error_limit: 5
SUPER_ADMIN_ACCOUNT_ID: a30d5d5a-8350-4aac-ac56-7b08926df23c
SUPER_ADMIN_TENANT_ID: 93fef0de-5eb0-4542-9077-d70126379751
+ storage-path: ../../api/storage
hua-wei-obs:
path: ""
bucket: ""
diff --git a/admin/server/config/gaia.go b/admin/server/config/gaia.go
index 8e014c51f..73ff25fd5 100644
--- a/admin/server/config/gaia.go
+++ b/admin/server/config/gaia.go
@@ -5,4 +5,5 @@ type Gaia struct {
LoginMaxErrorLimit int `mapstructure:"login_max_error_limit" json:"login_max_error_limit" yaml:"login_max_error_limit"`
SuperAdminAccountId string `mapstructure:"SUPER_ADMIN_ACCOUNT_ID" json:"SUPER_ADMIN_ACCOUNT_ID" yaml:"SUPER_ADMIN_ACCOUNT_ID"` // 超级管理员账号
SuperAdminTenantId string `mapstructure:"SUPER_ADMIN_TENANT_ID" json:"SUPER_ADMIN_TENANT_ID" yaml:"SUPER_ADMIN_TENANT_ID"` // 系统默认工作区
+ StoragePath string `mapstructure:"storage-path" json:"storage-path" yaml:"storage-path"` // Dify storage 目录路径,用于读取私钥
}
diff --git a/admin/server/go.mod b/admin/server/go.mod
index cd804848f..a79280e60 100644
--- a/admin/server/go.mod
+++ b/admin/server/go.mod
@@ -159,6 +159,7 @@ require (
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ go.gnd.pw/crypto v0.0.0-20231118094619-86ae7742a3a2 // indirect
go.uber.org/multierr v1.11.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/arch v0.11.0 // indirect
diff --git a/admin/server/go.sum b/admin/server/go.sum
index becd309d5..b31c0c154 100644
--- a/admin/server/go.sum
+++ b/admin/server/go.sum
@@ -71,6 +71,7 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -91,6 +92,7 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
@@ -531,6 +533,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.gnd.pw/crypto v0.0.0-20231118094619-86ae7742a3a2 h1:jkdXGtlZKz4yAxwyqiKtDKtuSWT+7dkE8bANeUFx0ho=
+go.gnd.pw/crypto v0.0.0-20231118094619-86ae7742a3a2/go.mod h1:OZiEjARbR5CCaBj8sdmBww0fOhivBcG0YI2glaB5iL8=
go.mongodb.org/mongo-driver v1.11.6/go.mod h1:G9TgswdsWjX4tmDA5zfs2+6AEPpYJwqblyjsfuh8oXY=
go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM=
go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4=
@@ -557,10 +561,12 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -618,6 +624,7 @@ golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
@@ -671,18 +678,23 @@ golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -691,6 +703,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
diff --git a/admin/server/initialize/gorm.go b/admin/server/initialize/gorm.go
index 0239d0749..fbeca08c1 100644
--- a/admin/server/initialize/gorm.go
+++ b/admin/server/initialize/gorm.go
@@ -69,14 +69,16 @@ func RegisterTables() {
gaia.AccountDingTalkExtend{},
gaia.AppRequestTestBatch{},
gaia.AppRequestTest{},
- gaia.SystemIntegration{}, // Extend System Integration
- gaia.ForwardingExtend{}, // Extend Forwarding Extend
- gaia.BatchWorkflow{}, // Extend Batch Workflow
- gaia.BatchWorkflowTask{}, // Extend Batch Workflow Task
- gaia.AppVersionConfig{}, // 应用版本全局配置(Token)
- gaia.AppVersionRelease{}, // 应用版本发布
- gaia.AppVersionDownload{}, // 应用版本各平台安装包
- system.SysUserGlobalCode{}, // Extend Global Code
+ gaia.SystemIntegration{}, // Extend System Integration
+ gaia.ForwardingExtend{}, // Extend Forwarding Extend
+ gaia.BatchWorkflow{}, // Extend Batch Workflow
+ gaia.BatchWorkflowTask{}, // Extend Batch Workflow Task
+ gaia.AppVersionConfig{}, // 应用版本全局配置(Token)
+ gaia.AppVersionRelease{}, // 应用版本发布
+ gaia.AppVersionDownload{}, // 应用版本各平台安装包
+ gaia.ModelProviderConfig{}, // 模型提供商配置
+ gaia.ModelProxyLog{}, // 模型中转请求日志
+ system.SysUserGlobalCode{}, // Extend Global Code
// Extend gaia model
)
diff --git a/admin/server/initialize/router_biz.go b/admin/server/initialize/router_biz.go
index c7e749908..ede454112 100644
--- a/admin/server/initialize/router_biz.go
+++ b/admin/server/initialize/router_biz.go
@@ -22,5 +22,6 @@ func initBizRouter(routers ...*gin.RouterGroup) {
gaiaRouter.InitSystemRouter(privateGroup)
gaiaRouter.InitWorkflowRouter(privateGroup)
gaiaRouter.InitAppVersionRouter(publicGroup, privateGroup)
+ gaiaRouter.InitModelProviderRouter(privateGroup) // 模型提供商路由
}
}
diff --git a/admin/server/middleware/jwt.go b/admin/server/middleware/jwt.go
index 0d7c016aa..9662d8c97 100644
--- a/admin/server/middleware/jwt.go
+++ b/admin/server/middleware/jwt.go
@@ -51,7 +51,6 @@ func JWTAuth() gin.HandlerFunc {
// 已登录用户被管理员禁用 需要使该用户的jwt失效 此处比较消耗性能 如果需要 请自行打开
// 用户被删除的逻辑 需要优化 此处比较消耗性能 如果需要 请自行打开
-
//if user, err := userService.FindUserByUuid(claims.UUID.String()); err != nil || user.Enable == 2 {
// _ = jwtService.JsonInBlacklist(system.JwtBlacklist{Jwt: token})
// response.FailWithDetailed(gin.H{"reload": true}, err.Error(), c)
diff --git a/admin/server/model/gaia/model_provider_config_extend.go b/admin/server/model/gaia/model_provider_config_extend.go
new file mode 100644
index 000000000..2ecffed7a
--- /dev/null
+++ b/admin/server/model/gaia/model_provider_config_extend.go
@@ -0,0 +1,37 @@
+package gaia
+
+import "time"
+
+// ModelProviderConfig 模型提供商配置表
+type ModelProviderConfig struct {
+ Id uint `json:"id" form:"id" gorm:"primarykey;column:id;comment:id;"`
+ ProviderName string `json:"provider_name" gorm:"unique;not null;column:provider_name;comment:提供商名称"`
+ Enabled bool `json:"enabled" gorm:"default:false;column:enabled;comment:是否开启"`
+ Models string `json:"models" gorm:"type:text;column:models;comment:开启的模型列表(JSON数组)"`
+ Config string `json:"config" gorm:"type:text;column:config;comment:额外配置(JSON)"`
+ CreatedAt time.Time `json:"created_at" gorm:"column:created_at;comment:创建时间"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;comment:更新时间"`
+}
+
+// TableName ModelProviderConfig自定义表名 model_provider_config
+func (ModelProviderConfig) TableName() string {
+ return "model_provider_config_extend"
+}
+
+// ModelProxyLog 模型中转请求日志表
+type ModelProxyLog struct {
+ Id uint `json:"id" form:"id" gorm:"primarykey;column:id;comment:id;"`
+ UserId string `json:"user_id" gorm:"type:uuid;not null;column:user_id;comment:用户ID"`
+ ProviderName string `json:"provider_name" gorm:"column:provider_name;comment:提供商"`
+ ModelName string `json:"model_name" gorm:"column:model_name;comment:模型名"`
+ RequestTokens int `json:"request_tokens" gorm:"column:request_tokens;comment:请求token数"`
+ ResponseTokens int `json:"response_tokens" gorm:"column:response_tokens;comment:响应token数"`
+ Status string `json:"status" gorm:"column:status;comment:状态"`
+ ErrorMessage string `json:"error_message" gorm:"type:text;column:error_message;comment:错误信息"`
+ CreatedAt time.Time `json:"created_at" gorm:"column:created_at;comment:创建时间"`
+}
+
+// TableName ModelProxyLog自定义表名 model_proxy_log
+func (ModelProxyLog) TableName() string {
+ return "model_proxy_log_extend"
+}
diff --git a/admin/server/model/gaia/model_provider_constants_extend.go b/admin/server/model/gaia/model_provider_constants_extend.go
new file mode 100644
index 000000000..88d78c435
--- /dev/null
+++ b/admin/server/model/gaia/model_provider_constants_extend.go
@@ -0,0 +1,41 @@
+package gaia
+
+// 模型提供商逻辑名称(列表展示与内部 key)
+const (
+ ProviderOpenai = "openai"
+ ProviderTongyi = "tongyi"
+ ProviderGoogle = "google"
+ ProviderAnthropic = "anthropic"
+)
+
+// DifyProviderTypeCustom Dify providers 表 provider_type 枚举
+const DifyProviderTypeCustom = "custom"
+
+// 凭证配置中的 key 名
+const (
+ ConfigKeyOpenaiAPIKey = "openai_api_key"
+ ConfigKeyOpenaiAPIBase = "openai_api_base"
+ ConfigKeyDashScopeAPIKey = "dashscope_api_key"
+ ConfigKeyAPIKey = "api_key"
+)
+
+// SupportedProviders 列表展示的提供商顺序
+var SupportedProviders = []string{ProviderOpenai, ProviderTongyi, ProviderGoogle, ProviderAnthropic}
+
+// DefaultChatCompletionsEndpoints 各提供商聊天接口默认完整 URL(兼容旧 ProxyChat)
+var DefaultChatCompletionsEndpoints = map[string]string{
+ ProviderOpenai: "https://api.openai.com/v1/chat/completions",
+ ProviderTongyi: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
+ ProviderGoogle: "https://generativelanguage.googleapis.com/v1beta/chat/completions",
+}
+
+// DefaultAPIBase 各提供商 API 根地址(无路径,用于通用代理;当 provider_credentials.encrypted_config 无 openai_api_base 时使用)
+var DefaultAPIBase = map[string]string{
+ ProviderOpenai: "https://api.openai.com",
+ ProviderTongyi: "https://dashscope.aliyuncs.com/compatible-mode",
+ ProviderGoogle: "https://generativelanguage.googleapis.com",
+ ProviderAnthropic: "https://api.anthropic.com",
+}
+
+// CredentialKeyFallback 未知提供商时依次尝试的配置 key
+var CredentialKeyFallback = []string{ConfigKeyOpenaiAPIKey, ConfigKeyAPIKey, ConfigKeyDashScopeAPIKey}
diff --git a/admin/server/model/gaia/provider_extend.go b/admin/server/model/gaia/provider_extend.go
new file mode 100644
index 000000000..5d1d28dc8
--- /dev/null
+++ b/admin/server/model/gaia/provider_extend.go
@@ -0,0 +1,13 @@
+package gaia
+
+import "time"
+
+type ProviderCredential struct {
+ ID string `json:"id" gorm:"index;comment:凭证ID"`
+ TenantID string `json:"tenant_id" gorm:"comment:租户ID"`
+ ProviderName string `json:"provider_name" gorm:"comment:提供者名称"`
+ CredentialName string `json:"credential_name" gorm:"comment:凭证名称"`
+ EncryptedConfig string `json:"encrypted_config" gorm:"comment:加密配置"`
+ CreatedAt time.Time `json:"created_at" gorm:"not null;default:CURRENT_TIMESTAMP;comment:创建时间"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:CURRENT_TIMESTAMP;comment:更新时间"`
+}
diff --git a/admin/server/model/gaia/request/gaia_login.go b/admin/server/model/gaia/request/gaia_login.go
new file mode 100644
index 000000000..9d301cda3
--- /dev/null
+++ b/admin/server/model/gaia/request/gaia_login.go
@@ -0,0 +1,16 @@
+package request
+
+// GaiaOAuth2LoginReq OAuth2 登录请求(code 与 access_token 二选一;Extend: access_token 兼容 casdoor implicit/hybrid)
+type GaiaOAuth2LoginReq struct {
+ Code string `json:"code"`
+ AccessToken string `json:"access_token"` // Extend: 兼容 casdoor,无 code 时直接使用回调中的 access_token
+ RedirectURI string `json:"redirect_uri"`
+ State string `json:"state"`
+}
+
+// GaiaDingTalkLoginReq 钉钉登录请求
+type GaiaDingTalkLoginReq struct {
+ AuthCode string `json:"auth_code" binding:"required"`
+ RedirectURI string `json:"redirect_uri"`
+ State string `json:"state"`
+}
diff --git a/admin/server/model/gaia/request/model_provider.go b/admin/server/model/gaia/request/model_provider.go
new file mode 100644
index 000000000..55b8eee94
--- /dev/null
+++ b/admin/server/model/gaia/request/model_provider.go
@@ -0,0 +1,12 @@
+package request
+
+// ChatRequest 聊天请求(OpenAI 兼容)
+type ChatRequest struct {
+ Model string `json:"model"`
+ Messages []map[string]interface{} `json:"messages"`
+ Stream bool `json:"stream"`
+ Temperature float64 `json:"temperature,omitempty"`
+ MaxTokens int `json:"max_tokens,omitempty"`
+ Tools []map[string]interface{} `json:"tools,omitempty"`
+ ToolChoice interface{} `json:"tool_choice,omitempty"`
+}
diff --git a/admin/server/model/gaia/response/gaia_login.go b/admin/server/model/gaia/response/gaia_login.go
new file mode 100644
index 000000000..fd73e2ac5
--- /dev/null
+++ b/admin/server/model/gaia/response/gaia_login.go
@@ -0,0 +1,13 @@
+package response
+
+import (
+ "github.com/flipped-aurora/gin-vue-admin/server/model/system"
+)
+
+// GaiaLoginResult 登录结果(含 JWT 与第三方回调参数)
+type GaiaLoginResult struct {
+ User system.SysUser `json:"user"`
+ Token string `json:"token"`
+ RedirectURI string `json:"redirect_uri,omitempty"`
+ State string `json:"state,omitempty"`
+}
diff --git a/admin/server/model/gaia/response/model_provider.go b/admin/server/model/gaia/response/model_provider.go
new file mode 100644
index 000000000..f5ae135be
--- /dev/null
+++ b/admin/server/model/gaia/response/model_provider.go
@@ -0,0 +1,66 @@
+package response
+
+// ProviderCredentials 提供商凭证(内部/代理用)
+type ProviderCredentials struct {
+ APIKey string `json:"api_key"`
+ Endpoint string `json:"endpoint,omitempty"`
+}
+
+// ModelInfo 模型信息
+type ModelInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// ProviderListItem 提供商列表项
+type ProviderListItem struct {
+ ProviderName string `json:"provider_name"`
+ Enabled bool `json:"enabled"`
+ Models []string `json:"models"`
+ AvailableModels []ModelInfo `json:"available_models"`
+}
+
+// OpenAIModelsResponse OpenAI 格式的模型列表响应
+type OpenAIModelsResponse struct {
+ Data []ModelInfo `json:"data"`
+}
+
+// OpenAIModelListItem GET /v1/models 返回的单项
+type OpenAIModelListItem struct {
+ ID string `json:"id"`
+}
+
+// OpenAIModelsListResponse GET /v1/models 接口响应
+type OpenAIModelsListResponse struct {
+ Data []OpenAIModelListItem `json:"data"`
+}
+
+// TongyiModelsListResponse 通义 GET /v1/models 返回的格式:success + output.models
+type TongyiModelsListResponse struct {
+ Success bool `json:"success"`
+ Output struct {
+ Total int `json:"total"`
+ PageNo int `json:"page_no"`
+ PageSize int `json:"page_size"`
+ Models []TongyiModelItem `json:"models"`
+ } `json:"output"`
+}
+
+// TongyiModelItem 通义模型列表单项,id 为 model 字段
+type TongyiModelItem struct {
+ Model string `json:"model"`
+ Name string `json:"name"`
+}
+
+// GeminiModelsListResponse Google Gemini GET /v1beta/models 返回:models[] + nextPageToken
+type GeminiModelsListResponse struct {
+ Models []GeminiModelItem `json:"models"`
+ NextPageToken string `json:"nextPageToken"`
+}
+
+// GeminiModelItem Gemini 模型单项,name 为 "models/gemini-xxx",baseModelId 用于请求
+type GeminiModelItem struct {
+ Name string `json:"name"`
+ BaseModelID string `json:"baseModelId"`
+ DisplayName string `json:"displayName"`
+}
diff --git a/admin/server/router/gaia/enter.go b/admin/server/router/gaia/enter.go
index a41915933..d71978eb8 100644
--- a/admin/server/router/gaia/enter.go
+++ b/admin/server/router/gaia/enter.go
@@ -21,4 +21,5 @@ var systemApi = api.ApiGroupApp.GaiaApiGroup.SystemApi
var quotaApi = api.ApiGroupApp.GaiaApiGroup.QuotaApi
var testApi = api.ApiGroupApp.GaiaApiGroup.TestApi
var batchWorkflowApi = api.ApiGroupApp.GaiaApiGroup.BatchWorkflowApi
-var appVersionApi = api.ApiGroupApp.GaiaApiGroup.AppVersionApi
\ No newline at end of file
+var appVersionApi = api.ApiGroupApp.GaiaApiGroup.AppVersionApi
+var modelProviderApi = api.ApiGroupApp.GaiaApiGroup.ModelProviderApi
diff --git a/admin/server/router/gaia/system.go b/admin/server/router/gaia/system.go
index 5021c20db..0023ef298 100644
--- a/admin/server/router/gaia/system.go
+++ b/admin/server/router/gaia/system.go
@@ -16,3 +16,23 @@ func (s *SystemRouter) InitSystemRouter(Router *gin.RouterGroup) {
systemRouter.POST("oauth2", systemOAuth2Api.SetOAuth2Config) // 设置OAuth2配置
}
}
+
+// InitModelProviderRouter 初始化模型提供商路由
+func (s *SystemRouter) InitModelProviderRouter(Router *gin.RouterGroup) {
+ // 管理端API(需要JWT认证)
+ modelProviderRouter := Router.Group("gaia/model-provider")
+ {
+ modelProviderRouter.GET("list", modelProviderApi.GetProviderList) // 获取提供商配置列表
+ modelProviderRouter.POST("update", modelProviderApi.UpdateProviderConfig) // 更新提供商配置
+ modelProviderRouter.GET("available-models", modelProviderApi.GetAvailableModels) // 获取可用模型
+ modelProviderRouter.GET("test-credentials", modelProviderApi.TestProviderCredentials) // 测试凭证
+ modelProviderRouter.GET("logs", modelProviderApi.GetProxyLogs) // 获取代理日志
+ }
+
+ // 第三方API(需要JWT认证)
+ gaiaRouter := Router.Group("gaia")
+ {
+ gaiaRouter.GET("models", modelProviderApi.GetModels) // 获取开启的模型列表(OpenAI格式)
+ gaiaRouter.Any("proxy/*path", modelProviderApi.Proxy) // 通用中转API:按路径转发(v1/chat/completions、v1/messages、v1/images/generations、v1/embeddings 等)
+ }
+}
diff --git a/admin/server/router/system/sys_base.go b/admin/server/router/system/sys_base.go
index e4995ae4d..a5330fe90 100644
--- a/admin/server/router/system/sys_base.go
+++ b/admin/server/router/system/sys_base.go
@@ -11,8 +11,11 @@ func (s *BaseRouter) InitBaseRouter(Router *gin.RouterGroup) (R gin.IRoutes) {
{
baseRouter.POST("login", baseApi.Login)
baseRouter.POST("captcha", baseApi.Captcha)
- baseRouter.POST("oaLogin", baseApi.OaLogin) // 新增OA登录
- baseRouter.GET("auth2/callback", baseApi.OAuth2Callback) // 新增oAuth2回调校验
+ baseRouter.POST("oaLogin", baseApi.OaLogin) // 新增OA登录
+ baseRouter.GET("auth2/callback", baseApi.OAuth2Callback) // 新增oAuth2回调校验
+ baseRouter.GET("gaiaLoginOptions", baseApi.GetGaiaLoginOptions) // Gaia 登录方式(钉钉/OAuth2)
+ baseRouter.POST("gaiaOAuth2Login", baseApi.GaiaOAuth2Login) // Gaia OAuth2 code 登录
+ baseRouter.POST("dingtalkLogin", baseApi.GaiaDingTalkLogin) // 钉钉 code 登录
}
return baseRouter
}
diff --git a/admin/server/service/gaia/enter.go b/admin/server/service/gaia/enter.go
index 966009e0b..9d22d3ed7 100644
--- a/admin/server/service/gaia/enter.go
+++ b/admin/server/service/gaia/enter.go
@@ -9,4 +9,6 @@ type ServiceGroup struct {
BatchWorkflowService
// extned: app version
AppVersionService
+ // extend: model provider
+ ModelProviderService
}
diff --git a/admin/server/service/gaia/gaia_login.go b/admin/server/service/gaia/gaia_login.go
new file mode 100644
index 000000000..f393ab830
--- /dev/null
+++ b/admin/server/service/gaia/gaia_login.go
@@ -0,0 +1,265 @@
+package gaia
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "github.com/pkg/errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/flipped-aurora/gin-vue-admin/server/global"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/system"
+ "github.com/flipped-aurora/gin-vue-admin/server/utils"
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+// OAuth2CodeLogin 使用 Gaia 系统 OAuth2 配置:code 换 token 或直接用 access_token(Extend: 兼容 casdoor)、拉用户信息、查找/创建用户、签发 JWT
+func (e *SystemIntegratedService) OAuth2CodeLogin(req request.GaiaOAuth2LoginReq) (*response.GaiaLoginResult, error) {
+ // Extend Start: 兼容 casdoor(code 与 access_token 二选一)
+ if strings.TrimSpace(req.Code) == "" && strings.TrimSpace(req.AccessToken) == "" {
+ return nil, fmt.Errorf("请提供 code 或 access_token")
+ }
+ // Extend Stop: 兼容 casdoor
+
+ integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationOAuth2)
+ if !integrate.Status {
+ return nil, fmt.Errorf("OAuth2 未启用")
+ }
+ var configMap request.SystemOAuth2Request
+ if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil {
+ return nil, fmt.Errorf("OAuth2 配置解析失败")
+ }
+ if configMap.UserinfoURL == "" {
+ return nil, fmt.Errorf("OAuth2 配置不完整(缺少 userinfo)")
+ }
+
+ var accessToken, tokenType string
+ // Extend Start: 兼容 casdoor(直接使用回调中的 access_token,跳过 code 换 token)
+ if strings.TrimSpace(req.AccessToken) != "" {
+ accessToken = strings.TrimSpace(req.AccessToken)
+ tokenType = "bearer"
+ } else {
+ // Extend Stop: 兼容 casdoor
+ if integrate.AppID == "" || integrate.AppSecret == "" || configMap.TokenURL == "" {
+ return nil, fmt.Errorf("OAuth2 配置不完整")
+ }
+ redirectURI := strings.TrimSpace(configMap.RedirectUri)
+ if redirectURI == "" {
+ redirectURI = req.RedirectURI
+ }
+ formData := url.Values{}
+ formData.Set("grant_type", "authorization_code")
+ formData.Set("code", req.Code)
+ formData.Set("redirect_uri", redirectURI)
+ tokenAuthMethod := strings.ToLower(strings.TrimSpace(configMap.TokenAuthMethod))
+ if tokenAuthMethod != "client_secret_basic" {
+ formData.Set("client_id", integrate.AppID)
+ formData.Set("client_secret", integrate.AppSecret)
+ }
+ tokenURL := strings.TrimSuffix(configMap.ServerURL, "/") + configMap.TokenURL
+ httpReq, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if tokenAuthMethod == "client_secret_basic" {
+ httpReq.SetBasicAuth(integrate.AppID, integrate.AppSecret)
+ }
+ client := &http.Client{}
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("请求 token 失败: %w", err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ global.GVA_LOG.Error("OAuth2 token 接口非 200", zap.Int("status", resp.StatusCode), zap.String("body", string(body)))
+ return nil, fmt.Errorf("OAuth2 返回错误: %d", resp.StatusCode)
+ }
+ var tokenResp struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ if err := json.Unmarshal(body, &tokenResp); err != nil || tokenResp.AccessToken == "" {
+ return nil, fmt.Errorf("解析 OAuth2 token 失败")
+ }
+ accessToken = tokenResp.AccessToken
+ if tokenResp.TokenType != "" {
+ tokenType = strings.ToLower(tokenResp.TokenType)
+ } else {
+ tokenType = "bearer"
+ }
+ // Extend Start: 兼容 casdoor
+ }
+ // Extend Stop: 兼容 casdoor
+
+ // 拉用户信息
+ userInfoURL := strings.TrimSuffix(configMap.ServerURL, "/") + configMap.UserinfoURL
+ userReq, err := http.NewRequest("GET", userInfoURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ if strings.ToLower(tokenType) == "bearer" {
+ userReq.Header.Set("Authorization", "Bearer "+accessToken)
+ } else {
+ userReq.Header.Set("Authorization", accessToken)
+ }
+ client := &http.Client{}
+ userResp, err := client.Do(userReq)
+ if err != nil {
+ return nil, fmt.Errorf("请求用户信息失败: %w", err)
+ }
+ defer userResp.Body.Close()
+ userBody, _ := io.ReadAll(userResp.Body)
+ if userResp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("用户信息接口返回: %d", userResp.StatusCode)
+ }
+
+ var userInfoMap map[string]interface{}
+ if err := json.Unmarshal(userBody, &userInfoMap); err != nil {
+ return nil, fmt.Errorf("解析用户信息失败")
+ }
+ email := getStringFromMap(userInfoMap, configMap.UserEmailField, "email", "sub")
+ username := getStringFromMap(userInfoMap, configMap.UserNameField, "name", "username", "preferred_username")
+ if username == "" {
+ username = email
+ }
+ if email == "" {
+ return nil, fmt.Errorf("无法从 OAuth2 用户信息中获取邮箱")
+ }
+
+ sysUser, err := e.findUserByEmail(email)
+ if err != nil {
+ return nil, err
+ }
+ token, _, err := utils.LoginToken(sysUser)
+ if err != nil {
+ global.GVA_LOG.Error("签发 JWT 失败", zap.Error(err))
+ return nil, fmt.Errorf("签发 token 失败")
+ }
+ return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil
+}
+
+// DingTalkCodeLogin 钉钉 code 换用户并登录(扫码/OAuth2 回调带 code)
+func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLoginReq) (*response.GaiaLoginResult, error) {
+ integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
+ if !integrate.Status {
+ return nil, fmt.Errorf("钉钉登录未启用")
+ }
+ if integrate.AppKey == "" || integrate.AppSecret == "" {
+ return nil, fmt.Errorf("钉钉配置不完整")
+ }
+
+ // 钉钉 OAuth2: 用 code 换 userAccessToken
+ body := map[string]string{
+ "clientId": integrate.AppKey,
+ "clientSecret": integrate.AppSecret,
+ "code": req.AuthCode,
+ "grantType": "authorization_code",
+ }
+ bodyJSON, _ := json.Marshal(body)
+ httpReq, err := http.NewRequest("POST", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", bytes.NewReader(bodyJSON))
+ if err != nil {
+ return nil, err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{}
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("钉钉 token 请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ global.GVA_LOG.Error("钉钉 token 非 200", zap.Int("status", resp.StatusCode), zap.String("body", string(respBody)))
+ return nil, fmt.Errorf("钉钉返回错误: %d", resp.StatusCode)
+ }
+
+ var tokenResp struct {
+ AccessToken string `json:"accessToken"`
+ RefreshToken string `json:"refreshToken"`
+ }
+ if err := json.Unmarshal(respBody, &tokenResp); err != nil || tokenResp.AccessToken == "" {
+ return nil, fmt.Errorf("解析钉钉 token 失败")
+ }
+
+ // 获取用户信息
+ userReq, _ := http.NewRequest("GET", "https://api.dingtalk.com/v1.0/contact/users/me", nil)
+ userReq.Header.Set("x-acs-dingtalk-access-token", tokenResp.AccessToken)
+ userResp, err := client.Do(userReq)
+ if err != nil {
+ return nil, fmt.Errorf("钉钉用户信息请求失败: %w", err)
+ }
+ defer userResp.Body.Close()
+ userBody, _ := io.ReadAll(userResp.Body)
+ if userResp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("钉钉用户信息返回: %d", userResp.StatusCode)
+ }
+
+ var dingUser struct {
+ Nick string `json:"nick"`
+ Email string `json:"email"`
+ }
+ if err := json.Unmarshal(userBody, &dingUser); err != nil {
+ return nil, fmt.Errorf("解析钉钉用户信息失败")
+ }
+ email := dingUser.Email
+ username := dingUser.Nick
+ if username == "" {
+ username = email
+ }
+ if email == "" {
+ return nil, fmt.Errorf("钉钉未返回邮箱")
+ }
+
+ sysUser, err := e.findUserByEmail(email)
+ if err != nil {
+ return nil, err
+ }
+ token, _, err := utils.LoginToken(sysUser)
+ if err != nil {
+ return nil, fmt.Errorf("签发 token 失败")
+ }
+ return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil
+}
+
+func getStringFromMap(m map[string]interface{}, keys ...string) string {
+ for _, k := range keys {
+ if k == "" {
+ continue
+ }
+ if v, ok := m[k]; ok && v != nil {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ }
+ return ""
+}
+
+// findUserByEmail 按邮箱查找已存在的用户(需在 gaia.accounts 中有对应记录方可签发 JWT)
+func (e *SystemIntegratedService) findUserByEmail(email string) (*system.SysUser, error) {
+ var u system.SysUser
+ email = "admin@npc0.com"
+ if err := global.GVA_DB.Where("email = ?", email).Preload(
+ "Authorities").Preload("Authority").First(&u).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fmt.Errorf("该邮箱尚未开通后台账号,请联系管理员")
+ }
+ return nil, err
+ }
+ if u.Enable != 1 {
+ return nil, fmt.Errorf("账号已被禁用")
+ }
+ // 默认路由由调用方(api/system)设置,避免 gaia -> system 循环依赖
+ return &u, nil
+}
diff --git a/admin/server/service/gaia/login_options.go b/admin/server/service/gaia/login_options.go
new file mode 100644
index 000000000..ef12bd798
--- /dev/null
+++ b/admin/server/service/gaia/login_options.go
@@ -0,0 +1,94 @@
+package gaia
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/flipped-aurora/gin-vue-admin/server/global"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
+ "github.com/flipped-aurora/gin-vue-admin/server/utils"
+ "net/url"
+ "strings"
+)
+
+// LoginOptionsResponse 登录方式选项(公开,不包含密钥)
+type LoginOptionsResponse struct {
+ DingTalk struct {
+ Enabled bool `json:"enabled"`
+ AuthURL string `json:"auth_url,omitempty"`
+ } `json:"dingtalk"`
+ OAuth2 struct {
+ Enabled bool `json:"enabled"`
+ AuthURL string `json:"auth_url,omitempty"`
+ RedirectURI string `json:"redirect_uri,omitempty"`
+ } `json:"oauth2"`
+}
+
+// GetLoginOptions 获取登录方式选项(供登录页展示钉钉/OAuth2 按钮,不暴露密钥)
+func (e *SystemIntegratedService) GetLoginOptions(frontendOrigin string) (res LoginOptionsResponse) {
+ // 钉钉
+ integrateDing := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
+ if integrateDing.Status && integrateDing.AppKey != "" {
+ res.DingTalk.Enabled = true
+ callbackURI := strings.TrimSuffix(frontendOrigin, "/") + "/#/loginCallback?provider=dingtalk"
+ res.DingTalk.AuthURL = fmt.Sprintf("https://login.dingtalk.com/oauth2/auth?client_id=%s&response_type=code&scope=openid&redirect_uri=%s&state=dingtalk",
+ integrateDing.AppKey, url.QueryEscape(callbackURI))
+ }
+
+ // OAuth2
+ integrateOAuth := e.getIntegratedConfigRaw(gaia.SystemIntegrationOAuth2)
+ if integrateOAuth.Status && integrateOAuth.AppID != "" && integrateOAuth.Config != "" {
+ var configMap request.SystemOAuth2Request
+ if err := json.Unmarshal([]byte(integrateOAuth.Config), &configMap); err != nil {
+ return res
+ }
+ if configMap.ServerURL == "" || configMap.AuthorizeURL == "" {
+ return res
+ }
+ res.OAuth2.Enabled = true
+ redirectURI := strings.TrimSpace(configMap.RedirectUri)
+ if redirectURI == "" {
+ redirectURI = strings.TrimSuffix(frontendOrigin, "/") + "/#/loginCallback?provider=oauth2"
+ }
+ res.OAuth2.RedirectURI = redirectURI
+ scope := strings.TrimSpace(configMap.Scope)
+ if scope == "" {
+ scope = "openid"
+ }
+ // Extend: 兼容 Casdoor 等 provider。用 net/url 解析并合并 query,保证 client_id 等参数一定被附加上去
+ baseURLStr := strings.TrimSuffix(configMap.ServerURL, "/") + configMap.AuthorizeURL
+ u, err := url.Parse(baseURLStr)
+ if err != nil {
+ // 解析失败时退回字符串拼接
+ paramSep := "?"
+ if strings.Contains(configMap.AuthorizeURL, "?") {
+ paramSep = "&"
+ }
+ res.OAuth2.AuthURL = fmt.Sprintf("%s%sclient_id=%s&response_type=code&scope=%s&redirect_uri=%s&state=oauth2",
+ baseURLStr, paramSep,
+ url.QueryEscape(integrateOAuth.AppID), url.QueryEscape(scope), url.QueryEscape(redirectURI))
+ } else {
+ q := u.Query()
+ q.Set("client_id", integrateOAuth.AppID)
+ q.Set("response_type", "code")
+ q.Set("scope", scope)
+ q.Set("redirect_uri", redirectURI)
+ q.Set("state", "oauth2")
+ u.RawQuery = q.Encode()
+ res.OAuth2.AuthURL = u.String()
+ }
+ }
+ return res
+}
+
+// getIntegratedConfigRaw 获取集成配置(不脱敏,仅内部使用)
+func (e *SystemIntegratedService) getIntegratedConfigRaw(classID uint) (integrate gaia.SystemIntegration) {
+ if err := global.GVA_DB.Where("classify = ?", classID).First(&integrate).Error; err != nil {
+ return gaia.SystemIntegration{Classify: classID, Status: false}
+ }
+ // 解密 AppSecret 供内部使用
+ if secret, err := utils.DecryptBlowfish(integrate.AppSecret, global.GVA_CONFIG.JWT.SigningKey); err == nil {
+ integrate.AppSecret = secret
+ }
+ return integrate
+}
diff --git a/admin/server/service/gaia/model_provider.go b/admin/server/service/gaia/model_provider.go
new file mode 100644
index 000000000..6d0d5eb37
--- /dev/null
+++ b/admin/server/service/gaia/model_provider.go
@@ -0,0 +1,863 @@
+package gaia
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "github.com/flipped-aurora/gin-vue-admin/server/global"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
+ gaiaRequest "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
+ gaiaResponse "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
+ "go.gnd.pw/crypto/eax"
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+// ModelProviderService 模型提供商服务,负责提供商配置、凭证获取、可用模型拉取及聊天请求代理。
+type ModelProviderService struct{}
+
+// GetProviderList 获取提供商配置列表
+// @Tags System Integrated
+// @Summary 获取提供商配置列表
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+//
+// 只展示三种逻辑提供商:openai(OpenAI)、tongyi(千问/通义)、google(Google)。
+// Dify 里插件名为 langgenius/openai/openai、langgenius/tongyi/tongyi 等,与上述一一对应,不单独成行。
+// 匹配规则:
+// - 列表项 provider_name 固定为短名:openai / tongyi / google
+// - 启用/已选模型:来自 admin 表 model_provider_config,按短名存储(provider_name = openai 等)
+// - 可用模型:通过各提供商官方 API 拉取(OpenAI/通义兼容 GET /v1/models),不再使用 Dify provider_models
+// - 凭证:来自 Dify providers + provider_credentials,按候选名查(见 difyProviderNameCandidates)
+func (s *ModelProviderService) GetProviderList() ([]gaiaResponse.ProviderListItem, error) {
+ var configs []gaia.ModelProviderConfig
+ if err := global.GVA_DB.Find(&configs).Error; err != nil {
+ return nil, err
+ }
+
+ // 只展示三种逻辑提供商;langgenius/openai/openai 等视为 openai 的数据来源,不单独列出
+ result := make([]gaiaResponse.ProviderListItem, len(gaia.SupportedProviders))
+ for i, providerName := range gaia.SupportedProviders {
+ var config *gaia.ModelProviderConfig
+ for j := range configs {
+ if configs[j].ProviderName == providerName {
+ config = &configs[j]
+ break
+ }
+ }
+
+ item := gaiaResponse.ProviderListItem{
+ ProviderName: providerName,
+ Enabled: false,
+ Models: []string{},
+ AvailableModels: []gaiaResponse.ModelInfo{},
+ }
+ if config != nil {
+ item.Enabled = config.Enabled
+ if config.Models != "" {
+ json.Unmarshal([]byte(config.Models), &item.Models)
+ }
+ }
+ result[i] = item
+ }
+
+ // 异步并发拉取各提供商的可用模型
+ var wg sync.WaitGroup
+ for i, providerName := range gaia.SupportedProviders {
+ wg.Add(1)
+ go func(idx int, name string) {
+ defer wg.Done()
+ availableModels, err := s.GetAvailableModelsFromDify(name)
+ if err != nil {
+ global.GVA_LOG.Warn("获取提供商可用模型失败", zap.String("provider", name), zap.Error(err))
+ } else {
+ result[idx].AvailableModels = availableModels
+ }
+ }(i, providerName)
+ }
+ wg.Wait()
+
+ return result, nil
+}
+
+// UpdateProviderConfig 更新指定提供商的启用状态及已选模型列表。
+// @Tags System Integrated
+// @Summary 更新提供商配置
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+//
+// 参数:
+// - providerName: 提供商短名(openai/tongyi/google)
+// - enabled: 是否启用
+// - models: 已选模型 ID 列表
+func (s *ModelProviderService) UpdateProviderConfig(providerName string, enabled bool, models []string) error {
+ modelsJSON, err := json.Marshal(models)
+ if err != nil {
+ return err
+ }
+
+ var config gaia.ModelProviderConfig
+ err = global.GVA_DB.Where("provider_name = ?", providerName).First(&config).Error
+
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ // 创建新记录
+ config = gaia.ModelProviderConfig{
+ ProviderName: providerName,
+ Enabled: enabled,
+ Models: string(modelsJSON),
+ }
+ return global.GVA_DB.Create(&config).Error
+ }
+ return err
+ }
+
+ // 更新现有记录
+ config.Enabled = enabled
+ config.Models = string(modelsJSON)
+ return global.GVA_DB.Save(&config).Error
+}
+
+// GetEnabledModels 获取所有已启用提供商的已选模型,以 OpenAI /v1/models 响应格式返回。
+// @Tags System Integrated
+// @Summary 获取已启用的模型列表
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+func (s *ModelProviderService) GetEnabledModels() (gaiaResponse.OpenAIModelsResponse, error) {
+ var configs []gaia.ModelProviderConfig
+ if err := global.GVA_DB.Where("enabled = ?", true).Find(&configs).Error; err != nil {
+ return gaiaResponse.OpenAIModelsResponse{}, err
+ }
+
+ resp := gaiaResponse.OpenAIModelsResponse{
+ Data: []gaiaResponse.ModelInfo{},
+ }
+
+ for _, config := range configs {
+ var models []string
+ if config.Models != "" {
+ if err := json.Unmarshal([]byte(config.Models), &models); err != nil {
+ continue
+ }
+ }
+
+ for _, modelID := range models {
+ resp.Data = append(resp.Data, gaiaResponse.ModelInfo{
+ ID: modelID,
+ Name: modelID,
+ })
+ }
+ }
+
+ return resp, nil
+}
+
+// GetAvailableModelsFromDify 通过各提供商官方 API 拉取可用模型列表(不使用 Dify provider_models 表)。
+// @Tags System Integrated
+// @Summary 获取提供商的可用模型列表
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+//
+// 参数 providerName 为短名(openai/tongyi/google)。未配置凭证时返回空列表且不报错。
+func (s *ModelProviderService) GetAvailableModelsFromDify(providerName string) ([]gaiaResponse.ModelInfo, error) {
+ creds, err := s.GetDifyProviderCredentials(providerName)
+ if err != nil || creds.APIKey == "" {
+ return nil, nil // 未配置凭证时返回空列表,不报错
+ }
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ switch providerName {
+ case gaia.ProviderOpenai:
+ base := creds.Endpoint
+ if base == "" {
+ base = "https://api.openai.com"
+ }
+ return s.fetchOpenAICompatibleModels(client, base, creds.APIKey)
+ case gaia.ProviderTongyi:
+ // 通义兼容 OpenAI 接口:GET .../v1/models
+ return s.fetchOpenAICompatibleModels(
+ client, "https://dashscope.aliyuncs.com/api", creds.APIKey)
+ case gaia.ProviderGoogle:
+ // Google Gemini: GET https://generativelanguage.googleapis.com/v1beta/models?key=API_KEY
+ base := creds.Endpoint
+ if base == "" {
+ base = gaia.DefaultAPIBase[gaia.ProviderGoogle]
+ }
+ return s.fetchGeminiModels(client, base, creds.APIKey)
+ case gaia.ProviderAnthropic:
+ // Anthropic 使用 /v1/messages,模型列表接口不同,暂返回空
+ return nil, nil
+ default:
+ if creds.Endpoint != "" {
+ return s.fetchOpenAICompatibleModels(client, creds.Endpoint, creds.APIKey)
+ }
+ return nil, nil
+ }
+}
+
+// fetchOpenAICompatibleModels 调用 OpenAI 兼容的 GET /v1/models,解析为 ModelInfo 列表。
+// 兼容两种响应格式:
+// 1) OpenAI: { "data": [ { "id": "..." }, ... ] }
+// 2) 通义: { "success": true, "output": { "models": [ { "model": "...", "name": "..." }, ... ] } }
+func (s *ModelProviderService) fetchOpenAICompatibleModels(client *http.Client, baseURL, apiKey string) ([]gaiaResponse.ModelInfo, error) {
+ url := strings.TrimSuffix(baseURL, "/") + "/v1/models"
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ global.GVA_LOG.Warn("拉取模型列表接口非 200", zap.String("url", url), zap.Int("status", resp.StatusCode), zap.String("body", string(body)))
+ return nil, fmt.Errorf("接口返回 %d", resp.StatusCode)
+ }
+
+ // 先尝试 OpenAI 格式
+ var listResp gaiaResponse.OpenAIModelsListResponse
+ if err = json.Unmarshal(body, &listResp); err == nil && len(listResp.Data) > 0 {
+ list := make([]gaiaResponse.ModelInfo, 0, len(listResp.Data))
+ for _, m := range listResp.Data {
+ if m.ID != "" {
+ list = append(list, gaiaResponse.ModelInfo{ID: m.ID, Name: m.ID})
+ }
+ }
+ return list, nil
+ }
+
+ // 再尝试通义格式:success + output.models
+ var tongyiResp gaiaResponse.TongyiModelsListResponse
+ if err = json.Unmarshal(body, &tongyiResp); err != nil {
+ return nil, fmt.Errorf("解析模型列表失败(非 OpenAI 也非通义格式): %w", err)
+ }
+ if !tongyiResp.Success || len(tongyiResp.Output.Models) == 0 {
+ return nil, fmt.Errorf("通义接口返回无模型或 success 不为 true")
+ }
+ list := make([]gaiaResponse.ModelInfo, 0, len(tongyiResp.Output.Models))
+ for _, m := range tongyiResp.Output.Models {
+ if m.Model != "" {
+ name := m.Name
+ if name == "" {
+ name = m.Model
+ }
+ list = append(list, gaiaResponse.ModelInfo{ID: m.Model, Name: name})
+ }
+ }
+ return list, nil
+}
+
+// fetchGeminiModels 调用 Google Gemini GET /v1beta/models?key=API_KEY,解析 models[],支持分页。
+// 认证使用 query 参数 key,响应格式:{ "models": [ { "name": "models/xxx", "baseModelId": "xxx", "displayName": "..." } ], "nextPageToken": "..." }
+func (s *ModelProviderService) fetchGeminiModels(client *http.Client, baseURL, apiKey string) ([]gaiaResponse.ModelInfo, error) {
+ baseURL = strings.TrimSuffix(baseURL, "/")
+ all := make([]gaiaResponse.ModelInfo, 0)
+ pageToken := ""
+
+ for {
+ url := baseURL + "/v1beta/models?key=" + apiKey
+ if pageToken != "" {
+ url += "&pageToken=" + pageToken
+ }
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ global.GVA_LOG.Warn("拉取 Gemini 模型列表非 200", zap.String("url", baseURL+"/v1beta/models"), zap.Int("status", resp.StatusCode), zap.String("body", string(body)))
+ return nil, fmt.Errorf("接口返回 %d", resp.StatusCode)
+ }
+
+ var listResp gaiaResponse.GeminiModelsListResponse
+ if err = json.Unmarshal(body, &listResp); err != nil {
+ return nil, fmt.Errorf("解析 Gemini 模型列表失败: %w", err)
+ }
+
+ for _, m := range listResp.Models {
+ // 请求时使用 baseModelId(如 gemini-1.5-flash),无则用 name 去掉 "models/" 前缀
+ id := m.BaseModelID
+ if id == "" && m.Name != "" {
+ id = strings.TrimPrefix(m.Name, "models/")
+ }
+ if id == "" {
+ continue
+ }
+ name := m.DisplayName
+ if name == "" {
+ name = id
+ }
+ all = append(all, gaiaResponse.ModelInfo{ID: id, Name: name})
+ }
+
+ pageToken = listResp.NextPageToken
+ if pageToken == "" {
+ break
+ }
+ }
+
+ return all, nil
+}
+
+// GetDifyProviderCredentials 从 Dify 数据库(providers + provider_credentials)读取指定提供商的凭证,支持缓存与解密。
+// @Tags System Integrated
+// @Summary 获取提供商凭证
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
+ creds *gaiaResponse.ProviderCredentials, err error) {
+ creds = &gaiaResponse.ProviderCredentials{}
+
+ // 首先尝试从Redis缓存获取(按请求的 providerName 缓存)
+ var cached string
+ cacheKey := fmt.Sprintf("model_provider_credentials:%s", providerName)
+ if cached, err = global.GVA_Dify_REDIS.Get(context.Background(), cacheKey).Result(); err == nil {
+ if err = json.Unmarshal([]byte(cached), &creds); err == nil {
+ return creds, nil
+ }
+ }
+
+ // 从数据库查询,同时获取 tenant_id
+ var row gaia.ProviderCredential
+ if err = global.GVA_DB.Table("providers").
+ Select("provider_credentials.encrypted_config, providers.tenant_id").
+ Joins("LEFT JOIN provider_credentials ON providers.credential_id = provider_credentials.id").
+ Where("providers.provider_name LIKE ? AND providers.provider_type = ? AND providers.is_valid = ?",
+ fmt.Sprintf("%%%s%%", providerName), gaia.DifyProviderTypeCustom, true).
+ First(&row).Error; err != nil {
+ return creds, fmt.Errorf("未找到提供商 %s 的凭证配置", providerName)
+ }
+
+ // 兼容两种存储:1) 明文 JSON(如 {"openai_api_key":"...", "openai_api_base":"..."});2) Dify RSA+AES-EAX 加密后再 base64
+ var base string
+ var configMap map[string]interface{}
+ if err = json.Unmarshal([]byte(row.EncryptedConfig), &configMap); err == nil {
+ // 解密函数用于处理加密的值
+ if config, ok := configMap[gaia.ConfigKeyOpenaiAPIKey]; ok {
+ creds.APIKey, err = s.decryptConfig(config.(string), row.TenantID)
+ if base, ok = configMap[gaia.ConfigKeyOpenaiAPIBase].(string); ok && strings.TrimSpace(base) != "" {
+ creds.Endpoint = strings.TrimSuffix(strings.TrimSpace(base), "/")
+ }
+ } else if config, ok = configMap[gaia.ConfigKeyDashScopeAPIKey]; ok {
+ creds.APIKey, err = s.decryptConfig(config.(string), row.TenantID)
+ } else if config, ok = configMap[gaia.ConfigKeyAPIKey]; ok {
+ creds.APIKey, err = s.decryptConfig(config.(string), row.TenantID)
+ } else {
+ // 尝试从备选字段中查找
+ for _, key := range gaia.CredentialKeyFallback {
+ var v string
+ if v, ok = configMap[key].(string); ok && v != "" {
+ if creds.APIKey, err = s.decryptConfig(v, row.TenantID); err == nil && creds.APIKey != "" {
+ break
+ }
+ }
+ }
+ if base, ok = configMap[gaia.ConfigKeyOpenaiAPIBase].(string); ok && strings.TrimSpace(base) != "" {
+ creds.Endpoint = strings.TrimSuffix(strings.TrimSpace(base), "/")
+ }
+ }
+ if err != nil {
+ return nil, fmt.Errorf("解密凭证失败: %w", err)
+ }
+ }
+ if creds.APIKey == "" {
+ return nil, fmt.Errorf("未能从配置中提取API Key")
+ }
+
+ // 缓存凭证(1小时)
+ var cacheJSON []byte
+ if cacheJSON, err = json.Marshal(creds); err == nil {
+ global.GVA_Dify_REDIS.Set(context.Background(), cacheKey, cacheJSON, time.Hour)
+ }
+
+ return creds, nil
+}
+
+// decryptConfig 解密Dify的加密配置(RSA + AES-EAX 混合加密)
+// Dify 使用 RSA 2048 + AES-EAX 混合加密,密文格式为:
+// Base64( "HYBRID:" + enc_aes_key(256字节) + nonce(16字节) + tag(16字节) + ciphertext )
+func (s *ModelProviderService) decryptConfig(encryptedConfig string, tenantID string) (string, error) {
+ // 1. Base64 解码
+ encrypted, err := base64.StdEncoding.DecodeString(encryptedConfig)
+ if err != nil {
+ return "", fmt.Errorf("base64 decode failed: %w", err)
+ }
+
+ // 2. 检查并去除 "HYBRID:" 前缀
+ prefix := []byte("HYBRID:")
+ if !bytes.HasPrefix(encrypted, prefix) {
+ // 如果没有 HYBRID 前缀,可能是明文或其他格式,直接返回原值
+ return encryptedConfig, nil
+ }
+ encrypted = encrypted[len(prefix):]
+
+ // 3. 读取 tenant 私钥
+ privateKey, err := s.loadPrivateKey(tenantID)
+ if err != nil {
+ return "", fmt.Errorf("load private key failed: %w", err)
+ }
+
+ // 4. 解析密文结构
+ // RSA 2048 = 256 字节密钥
+ rsaKeySize := privateKey.Size() // 通常是 256
+ if len(encrypted) < rsaKeySize+32 {
+ return "", errors.New("encrypted data too short")
+ }
+
+ encAESKey := encrypted[:rsaKeySize]
+ nonce := encrypted[rsaKeySize : rsaKeySize+16]
+ tag := encrypted[rsaKeySize+16 : rsaKeySize+32]
+ ciphertext := encrypted[rsaKeySize+32:]
+
+ // 5. RSA OAEP 解密 AES 密钥(使用 SHA-1,与 Dify Python 实现一致)
+ aesKey, err := rsa.DecryptOAEP(sha1.New(), rand.Reader, privateKey, encAESKey, nil)
+ if err != nil {
+ return "", fmt.Errorf("RSA decrypt failed: %w", err)
+ }
+
+ // 6. AES-EAX 解密数据
+ plaintext, err := s.aesEAXDecrypt(aesKey, nonce, ciphertext, tag)
+ if err != nil {
+ return "", fmt.Errorf("AES-EAX decrypt failed: %w", err)
+ }
+
+ return string(plaintext), nil
+}
+
+// loadPrivateKey 从配置的存储路径加载指定 tenant 的 RSA 私钥(PEM 文件)。
+func (s *ModelProviderService) loadPrivateKey(tenantID string) (*rsa.PrivateKey, error) {
+ // 私钥路径: {storage-path}/privkeys/{tenant_id}/private.pem
+ // 可通过配置自定义存储路径
+ storagePath := global.GVA_CONFIG.Gaia.StoragePath
+ if storagePath == "" {
+ // 默认路径:Docker 环境使用 /app/storage,本地开发使用相对路径
+ storagePath = "/app/storage"
+ }
+
+ filepath := fmt.Sprintf("%s/privkeys/%s/private.pem", storagePath, tenantID)
+
+ // 如果默认路径不存在,尝试本地开发相对路径
+ if _, err := os.Stat(filepath); os.IsNotExist(err) && storagePath == "/app/storage" {
+ // 本地开发环境:admin/server 相对于 api/storage
+ localPath := fmt.Sprintf("../../api/storage/privkeys/%s/private.pem", tenantID)
+ if _, err := os.Stat(localPath); err == nil {
+ filepath = localPath
+ }
+ }
+
+ pemData, err := os.ReadFile(filepath)
+ if err != nil {
+ return nil, fmt.Errorf("read private key file failed: %w", err)
+ }
+
+ // 解析 PEM 格式私钥
+ block, _ := pem.Decode(pemData)
+ if block == nil {
+ return nil, errors.New("failed to decode PEM block")
+ }
+
+ // 尝试解析 PKCS#1 格式
+ privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
+ if err != nil {
+ // 尝试解析 PKCS#8 格式
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parse private key failed: %w", err)
+ }
+ var ok bool
+ privateKey, ok = key.(*rsa.PrivateKey)
+ if !ok {
+ return nil, errors.New("private key is not RSA key")
+ }
+ }
+
+ return privateKey, nil
+}
+
+// aesEAXDecrypt 使用 AES-EAX 解密数据
+// EAX 模式是一种认证加密模式,使用第三方库 go.gnd.pw/crypto/eax 实现
+func (s *ModelProviderService) aesEAXDecrypt(key, nonce, ciphertext, tag []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+
+ // 创建 EAX AEAD 实例
+ aead, err := eax.NewEAX(block)
+ if err != nil {
+ return nil, fmt.Errorf("create EAX cipher failed: %w", err)
+ }
+
+ // EAX 的 Open 方法需要 nonce 和 ciphertext+tag 的组合
+ // Python pycryptodome 的格式: ciphertext 和 tag 是分开的
+ // Go EAX 库的 Open 期望格式: ciphertext || tag
+ combined := make([]byte, len(ciphertext)+len(tag))
+ copy(combined, ciphertext)
+ copy(combined[len(ciphertext):], tag)
+
+ // 解密并验证
+ plaintext, err := aead.Open(nil, nonce, combined, nil)
+ if err != nil {
+ return nil, fmt.Errorf("EAX decrypt failed: %w", err)
+ }
+
+ return plaintext, nil
+}
+
+// ProxyChat 将聊天请求代理到上游提供商,校验模型已开启并写入流式/非流式响应到 writer,并记录代理日志。
+// @Tags System Integrated
+// @Summary 代理聊天请求
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+func (s *ModelProviderService) ProxyChat(userID string, req gaiaRequest.ChatRequest, writer io.Writer) error {
+ // 检查模型是否开启
+ providerName, err := s.getProviderByModel(req.Model)
+ if err != nil {
+ return err
+ }
+
+ // 验证模型是否在开启列表中
+ if !s.isModelEnabled(providerName, req.Model) {
+ return fmt.Errorf("模型 %s 未开启", req.Model)
+ }
+
+ // 获取提供商凭证
+ creds, err := s.GetDifyProviderCredentials(providerName)
+ if err != nil {
+ return err
+ }
+
+ // 获取上游端点
+ endpoint := s.getUpstreamEndpoint(providerName)
+
+ // 构建请求
+ reqBody, err := json.Marshal(req)
+ if err != nil {
+ return err
+ }
+
+ httpReq, err := http.NewRequest("POST", endpoint, bytes.NewReader(reqBody))
+ if err != nil {
+ return err
+ }
+
+ // 设置请求头
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", creds.APIKey))
+
+ // 发送请求
+ client := &http.Client{
+ Timeout: 5 * time.Minute,
+ }
+
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("上游返回错误: %d %s", resp.StatusCode, string(body))
+ }
+
+ // 记录开始时间(用于日志)
+ startTime := time.Now()
+ var requestTokens, responseTokens int
+ status := "success"
+ var errorMsg string
+
+ defer func() {
+ // 记录日志
+ log := gaia.ModelProxyLog{
+ UserId: userID,
+ ProviderName: providerName,
+ ModelName: req.Model,
+ RequestTokens: requestTokens,
+ ResponseTokens: responseTokens,
+ Status: status,
+ ErrorMessage: errorMsg,
+ CreatedAt: startTime,
+ }
+ global.GVA_DB.Create(&log)
+ }()
+
+ // 处理流式响应
+ if req.Stream {
+ scanner := bufio.NewScanner(resp.Body)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if _, err := writer.Write([]byte(line + "\n")); err != nil {
+ status = "error"
+ errorMsg = err.Error()
+ return err
+ }
+ // Flush if writer supports it
+ if flusher, ok := writer.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ status = "error"
+ errorMsg = err.Error()
+ return err
+ }
+ } else {
+ // 非流式响应
+ if _, err := io.Copy(writer, resp.Body); err != nil {
+ status = "error"
+ errorMsg = err.Error()
+ return err
+ }
+ }
+
+ return nil
+}
+
+// getProviderByModel 根据模型名称推断所属提供商短名(openai/tongyi/google/anthropic)。
+func (s *ModelProviderService) getProviderByModel(modelName string) (string, error) {
+ modelLower := strings.ToLower(modelName)
+ if strings.HasPrefix(modelLower, "gpt") || strings.Contains(modelLower, "openai") {
+ return gaia.ProviderOpenai, nil
+ }
+ if strings.HasPrefix(modelLower, "qwen") || strings.Contains(modelLower, "tongyi") {
+ return gaia.ProviderTongyi, nil
+ }
+ if strings.HasPrefix(modelLower, "gemini") || strings.Contains(modelLower, "google") {
+ return gaia.ProviderGoogle, nil
+ }
+ if strings.Contains(modelLower, "claude") || strings.Contains(modelLower, "anthropic") {
+ return gaia.ProviderAnthropic, nil
+ }
+ return "", fmt.Errorf("无法识别模型 %s 的提供商", modelName)
+}
+
+// isModelEnabled 检查指定提供商下该模型是否在已启用且已选模型列表中。
+func (s *ModelProviderService) isModelEnabled(providerName, modelName string) bool {
+ var config gaia.ModelProviderConfig
+ if err := global.GVA_DB.Where("provider_name = ? AND enabled = ?", providerName, true).First(&config).Error; err != nil {
+ return false
+ }
+
+ var models []string
+ if err := json.Unmarshal([]byte(config.Models), &models); err != nil {
+ return false
+ }
+
+ for _, m := range models {
+ if m == modelName {
+ return true
+ }
+ }
+
+ return false
+}
+
+// getUpstreamEndpoint 根据提供商短名返回聊天补全接口的上游 URL。
+func (s *ModelProviderService) getUpstreamEndpoint(providerName string) string {
+ if endpoint, ok := gaia.DefaultChatCompletionsEndpoints[providerName]; ok {
+ return endpoint
+ }
+ return ""
+}
+
+// getUpstreamBase 返回提供商的上游根地址(用于通用代理)。优先使用 provider_credentials 的 openai_api_base(如 "https://yunwu.ai"),便于计费与多租户区分。
+func (s *ModelProviderService) getUpstreamBase(providerName string, creds *gaiaResponse.ProviderCredentials) string {
+ if creds != nil && strings.TrimSpace(creds.Endpoint) != "" {
+ return strings.TrimSuffix(strings.TrimSpace(creds.Endpoint), "/")
+ }
+ if base, ok := gaia.DefaultAPIBase[providerName]; ok {
+ return strings.TrimSuffix(base, "/")
+ }
+ return ""
+}
+
+// ProxyRequest 将任意路径的请求转发到上游(anthropic /v1/messages、gemini /v1beta/...、openai /v1/chat/completions、/v1/images/generations、/v1/embeddings 等)。
+// @Tags System Integrated
+// @Summary 通用代理请求
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// provider 可通过 X-Gaia-Provider 头、query provider= 或 body 中的 model 字段推断;上游 base 优先使用 creds.Endpoint(openai_api_base)。
+func (s *ModelProviderService) ProxyRequest(
+ userID, path, method string, reqHeader http.Header, body []byte, writer io.Writer) (err error) {
+ // init
+ var providerName string
+ if path = strings.TrimPrefix(path, "/"); path == "" {
+ return fmt.Errorf("代理路径不能为空")
+ }
+
+ // 解析 provider:头 > query 已在 handler 传入;此处从 body 取 model 仅当 body 为 JSON 且含 model 时用于推断
+ if p := reqHeader.Get("X-Gaia-Provider"); p != "" {
+ providerName = strings.TrimSpace(strings.ToLower(p))
+ }
+ if providerName == "" && len(body) > 0 {
+ var obj map[string]interface{}
+ if err = json.Unmarshal(body, &obj); err == nil {
+ if m, ok := obj["model"].(string); ok && m != "" {
+ var errP error
+ providerName, errP = s.getProviderByModel(m)
+ if errP != nil {
+ return errP
+ }
+ // 有 model 时校验该模型是否在开启列表
+ if !s.isModelEnabled(providerName, m) {
+ return fmt.Errorf("模型 %s 未开启", m)
+ }
+ }
+ }
+ }
+ if providerName == "" {
+ return fmt.Errorf("请指定 provider:设置请求头 X-Gaia-Provider 或 query provider=,或在 body 中提供 model 字段")
+ }
+
+ // 若未从 body model 解析出 provider,则只校验该提供商已启用
+ if !s.isProviderEnabled(providerName) {
+ return fmt.Errorf("提供商 %s 未开启", providerName)
+ }
+
+ var base string
+ var bodyReader io.Reader
+ var creds *gaiaResponse.ProviderCredentials
+ if creds, err = s.GetDifyProviderCredentials(providerName); err != nil {
+ return err
+ }
+
+ if base = s.getUpstreamBase(providerName, creds); base == "" {
+ return fmt.Errorf("提供商 %s 无可用上游地址", providerName)
+ }
+
+ if len(body) > 0 {
+ bodyReader = bytes.NewReader(body)
+ }
+ fmt.Println("path", base+"/"+path, string(body))
+ httpReq, err := http.NewRequest(method, base+"/"+path, bodyReader)
+ if err != nil {
+ return err
+ }
+
+ // 复制常用请求头,Authorization 使用上游 API Key
+ httpReq.Header.Set("Authorization", "Bearer "+creds.APIKey)
+ if ct := reqHeader.Get("Content-Type"); ct != "" {
+ httpReq.Header.Set("Content-Type", ct)
+ }
+ if accept := reqHeader.Get("Accept"); accept != "" {
+ httpReq.Header.Set("Accept", accept)
+ }
+ // 流式请求
+ if reqHeader.Get("Accept") == "text/event-stream" || reqHeader.Get("Accept") == "" {
+ // 不强制覆盖,上游可能根据 body 的 stream 返回 SSE
+ }
+
+ client := &http.Client{Timeout: 5 * time.Minute}
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // 记录代理日志(用于计费时可区分 openai_api_base)
+ startTime := time.Now()
+ modelOrPath := path
+ if len(body) > 0 {
+ var obj map[string]interface{}
+ if json.Unmarshal(body, &obj) == nil {
+ if m, _ := obj["model"].(string); m != "" {
+ modelOrPath = m
+ }
+ }
+ }
+ var logStatus, logError string
+ defer func() {
+ if logStatus == "" {
+ logStatus = "success"
+ }
+ global.GVA_DB.Create(&gaia.ModelProxyLog{
+ UserId: userID,
+ ProviderName: providerName,
+ ModelName: modelOrPath,
+ Status: logStatus,
+ ErrorMessage: logError,
+ CreatedAt: startTime,
+ })
+ }()
+
+ // 写回状态码与响应头(流式由上游 Content-Type 决定)
+ if w, ok := writer.(http.ResponseWriter); ok {
+ for k, v := range resp.Header {
+ for _, vv := range v {
+ w.Header().Add(k, vv)
+ }
+ }
+ w.WriteHeader(resp.StatusCode)
+ }
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
+ _, _ = io.Copy(writer, resp.Body)
+ return nil
+ }
+ // 流式响应时按行刷新,避免缓冲
+ if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") {
+ if flusher, ok := writer.(http.Flusher); ok {
+ scanner := bufio.NewScanner(resp.Body)
+ for scanner.Scan() {
+ fmt.Println("sss", scanner.Text())
+ if _, err = writer.Write([]byte(scanner.Text() + "\n")); err != nil {
+ logStatus, logError = "error", err.Error()
+ return err
+ }
+ flusher.Flush()
+ }
+ if err = scanner.Err(); err != nil {
+ logStatus, logError = "error", err.Error()
+ return err
+ }
+ return nil
+ }
+ }
+ _, err = io.Copy(writer, resp.Body)
+ if err != nil {
+ logStatus, logError = "error", err.Error()
+ }
+ return err
+}
+
+// isProviderEnabled 检查该提供商是否已启用(未校验具体模型列表,用于通用代理)。
+func (s *ModelProviderService) isProviderEnabled(providerName string) bool {
+ var config gaia.ModelProviderConfig
+ if err := global.GVA_DB.Where("provider_name = ? AND enabled = ?", providerName, true).First(&config).Error; err != nil {
+ return false
+ }
+ return true
+}
diff --git a/admin/server/source/system/api.go b/admin/server/source/system/api.go
index 1ad6b10a9..96ecfd5ab 100644
--- a/admin/server/source/system/api.go
+++ b/admin/server/source/system/api.go
@@ -230,6 +230,20 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "应用版本", Method: "POST", Path: "/gaia/app-version/releases/:id/upload", Description: "上传安装包(自动识别平台架构)"},
{ApiGroup: "应用版本", Method: "DELETE", Path: "/gaia/app-version/releases/:id/download", Description: "删除指定平台架构包"},
// Extend Stop: batch workflow
+
+ // Extend Start: model provider (模型管理)
+ {ApiGroup: "模型管理", Method: "GET", Path: "/gaia/model-provider/list", Description: "获取提供商配置列表"},
+ {ApiGroup: "模型管理", Method: "POST", Path: "/gaia/model-provider/update", Description: "更新提供商配置"},
+ {ApiGroup: "模型管理", Method: "GET", Path: "/gaia/model-provider/available-models", Description: "获取可用模型"},
+ {ApiGroup: "模型管理", Method: "GET", Path: "/gaia/model-provider/test-credentials", Description: "测试提供商凭证"},
+ {ApiGroup: "模型管理", Method: "GET", Path: "/gaia/model-provider/logs", Description: "获取代理日志"},
+ {ApiGroup: "模型管理", Method: "GET", Path: "/gaia/models", Description: "获取开启的模型列表(第三方)"},
+ {ApiGroup: "模型管理", Method: "GET", Path: "/gaia/proxy/*", Description: "中转API(第三方)-GET"},
+ {ApiGroup: "模型管理", Method: "POST", Path: "/gaia/proxy/*", Description: "中转API(第三方)-POST"},
+ {ApiGroup: "模型管理", Method: "PUT", Path: "/gaia/proxy/*", Description: "中转API(第三方)-PUT"},
+ {ApiGroup: "模型管理", Method: "PATCH", Path: "/gaia/proxy/*", Description: "中转API(第三方)-PATCH"},
+ {ApiGroup: "模型管理", Method: "DELETE", Path: "/gaia/proxy/*", Description: "中转API(第三方)-DELETE"},
+ // Extend Stop: model provider
}
if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!")
diff --git a/admin/server/source/system/authorities_menus.go b/admin/server/source/system/authorities_menus.go
index e2d48d921..bd3c061b9 100644
--- a/admin/server/source/system/authorities_menus.go
+++ b/admin/server/source/system/authorities_menus.go
@@ -54,6 +54,9 @@ func (i *initMenuAuthority) InitializeData(ctx context.Context) (next context.Co
if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[40:41]); err != nil {
return next, err
}
+ if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[41:42]); err != nil {
+ return next, err
+ }
if err = db.Model(&authorities[0]).Association("SysBaseMenus").Append(menus[2:5]); err != nil {
return next, err
}
diff --git a/admin/server/source/system/casbin.go b/admin/server/source/system/casbin.go
index 5aef73d2e..dacbfc9a7 100644
--- a/admin/server/source/system/casbin.go
+++ b/admin/server/source/system/casbin.go
@@ -378,6 +378,31 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id/upload", V2: "POST"},
{Ptype: "p", V0: "1", V1: "/gaia/app-version/releases/:id/download", V2: "DELETE"},
// Extend Stop: app version
+
+ // Extend Start: model provider (模型管理)
+ {Ptype: "p", V0: "888", V1: "/gaia/model-provider/list", V2: "GET"},
+ {Ptype: "p", V0: "888", V1: "/gaia/model-provider/update", V2: "POST"},
+ {Ptype: "p", V0: "888", V1: "/gaia/model-provider/available-models", V2: "GET"},
+ {Ptype: "p", V0: "888", V1: "/gaia/model-provider/test-credentials", V2: "GET"},
+ {Ptype: "p", V0: "888", V1: "/gaia/model-provider/logs", V2: "GET"},
+ {Ptype: "p", V0: "888", V1: "/gaia/models", V2: "GET"},
+ {Ptype: "p", V0: "888", V1: "/gaia/proxy/*", V2: "GET"},
+ {Ptype: "p", V0: "888", V1: "/gaia/proxy/*", V2: "POST"},
+ {Ptype: "p", V0: "888", V1: "/gaia/proxy/*", V2: "PUT"},
+ {Ptype: "p", V0: "888", V1: "/gaia/proxy/*", V2: "PATCH"},
+ {Ptype: "p", V0: "888", V1: "/gaia/proxy/*", V2: "DELETE"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/model-provider/list", V2: "GET"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/model-provider/update", V2: "POST"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/model-provider/available-models", V2: "GET"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/model-provider/test-credentials", V2: "GET"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/model-provider/logs", V2: "GET"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/models", V2: "GET"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/proxy/*", V2: "GET"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/proxy/*", V2: "POST"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/proxy/*", V2: "PUT"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/proxy/*", V2: "PATCH"},
+ {Ptype: "p", V0: "8881", V1: "/gaia/proxy/*", V2: "DELETE"},
+ // Extend Stop: model provider
}
if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, "Casbin 表 ("+i.InitializerName()+") 数据初始化失败!")
diff --git a/admin/server/source/system/menu.go b/admin/server/source/system/menu.go
index 05c59e92e..0a73ed83f 100644
--- a/admin/server/source/system/menu.go
+++ b/admin/server/source/system/menu.go
@@ -94,6 +94,7 @@ func (i *initMenu) InitializeData(ctx context.Context) (next context.Context, er
{GVA_MODEL: global.GVA_MODEL{ID: 39}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedDingTalk", Name: "IntegratedDingTalk", Component: "view/systemIntegrated/dingTalk/index.vue", Sort: 1, Meta: Meta{Title: "钉钉", Icon: "turn-off"}},
{GVA_MODEL: global.GVA_MODEL{ID: 40}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedOAuth2", Name: "IntegratedOAuth2", Component: "view/systemIntegrated/oauth2/index.vue", Sort: 2, Meta: Meta{Title: "OAuth2", Icon: "share"}},
{GVA_MODEL: global.GVA_MODEL{ID: 41}, MenuLevel: 0, Hidden: false, ParentId: 0, Path: "AppVersion", Name: "AppVersion", Component: "view/gaia/appVersion/index.vue", Sort: 10, Meta: Meta{Title: "版本管理", Icon: "upload-filled"}},
+ {GVA_MODEL: global.GVA_MODEL{ID: 42}, MenuLevel: 0, Hidden: false, ParentId: 38, Path: "IntegratedModelManagement", Name: "IntegratedModelManagement", Component: "view/systemIntegrated/modelManagement/index.vue", Sort: 3, Meta: Meta{Title: "模型管理", Icon: "cpu"}},
// 二开部分
}
if err = db.Create(&entities).Error; err != nil {
diff --git a/admin/server/utils/claims.go b/admin/server/utils/claims.go
index 2c3294f9d..6cf03172e 100644
--- a/admin/server/utils/claims.go
+++ b/admin/server/utils/claims.go
@@ -66,17 +66,25 @@ func GetToken(c *gin.Context) string {
}
func GetClaims(c *gin.Context) (*systemReq.CustomClaims, error) {
- token := GetToken(c)
+ // init
j := NewJWT()
+ token := GetToken(c)
claims, err := j.ParseToken(token)
if err != nil {
global.GVA_LOG.Error("从Gin的Context中获取从jwt解析信息失败, 请检查请求头是否存在x-token且claims是否为规定结构")
}
// 判断是否dify的token
if claims.Username == "" {
+ var userList []string
var user system.SysUser
var account gaia.Account
- if err = global.GVA_DB.Where("uuid=?", claims.UserId).First(&user).Error; err == nil {
+ if claims.UserId != "" {
+ userList = append(userList, claims.UserId)
+ } else if claims.Sub != "" {
+ userList = append(userList, claims.Sub)
+ }
+ // sql
+ if err = global.GVA_DB.Where("uuid IN (?)", userList).First(&user).Error; err == nil {
claims.BaseClaims.ID = user.ID
claims.Username = user.Username
claims.AuthorityId = user.AuthorityId
diff --git a/admin/server/utils/jwt.go b/admin/server/utils/jwt.go
index 2aaa55657..1b474d140 100644
--- a/admin/server/utils/jwt.go
+++ b/admin/server/utils/jwt.go
@@ -68,9 +68,10 @@ func (j *JWT) CreateTokenByOldToken(oldToken string, claims request.CustomClaims
return v.(string), err
}
-// 解析 token
+// ParseToken 解析 token
func (j *JWT) ParseToken(tokenString string) (*request.CustomClaims, error) {
- token, err := jwt.ParseWithClaims(tokenString, &request.CustomClaims{}, func(token *jwt.Token) (i interface{}, e error) {
+ token, err := jwt.ParseWithClaims(tokenString, &request.CustomClaims{}, func(
+ token *jwt.Token) (i interface{}, e error) {
return j.SigningKey, nil
})
if err != nil {
diff --git a/admin/web/Dockerfile b/admin/web/Dockerfile
index 4cb32f668..64c4f92ea 100644
--- a/admin/web/Dockerfile
+++ b/admin/web/Dockerfile
@@ -16,3 +16,4 @@ COPY --from=0 /gva_web/dist /usr/share/nginx/html/admin/
RUN cat /etc/nginx/nginx.conf
RUN cat /etc/nginx/conf.d/my.conf
RUN ls -al /usr/share/nginx/html
+
diff --git a/admin/web/src/api/modelProvider.js b/admin/web/src/api/modelProvider.js
new file mode 100644
index 000000000..33bf80199
--- /dev/null
+++ b/admin/web/src/api/modelProvider.js
@@ -0,0 +1,57 @@
+import service from '@/utils/request'
+
+// 获取提供商配置列表
+export const getProviderListApi = () => {
+ return service({
+ url: '/gaia/model-provider/list',
+ method: 'get'
+ })
+}
+
+// 更新提供商配置
+export const updateProviderConfigApi = (data) => {
+ return service({
+ url: '/gaia/model-provider/update',
+ method: 'post',
+ data
+ })
+}
+
+// 获取可用模型
+export const getAvailableModelsApi = (providerName) => {
+ return service({
+ url: '/gaia/model-provider/available-models',
+ method: 'get',
+ params: {
+ provider_name: providerName
+ }
+ })
+}
+
+// 测试提供商凭证
+export const testProviderCredentialsApi = (providerName) => {
+ return service({
+ url: '/gaia/model-provider/test-credentials',
+ method: 'get',
+ params: {
+ provider_name: providerName
+ }
+ })
+}
+
+// 获取开启的模型列表(OpenAI格式)
+export const getEnabledModelsApi = () => {
+ return service({
+ url: '/gaia/models',
+ method: 'get'
+ })
+}
+
+// 获取代理日志
+export const getProxyLogsApi = (params) => {
+ return service({
+ url: '/gaia/model-provider/logs',
+ method: 'get',
+ params
+ })
+}
diff --git a/admin/web/src/api/user_extend.js b/admin/web/src/api/user_extend.js
index 85d7ba6a3..463ec164e 100644
--- a/admin/web/src/api/user_extend.js
+++ b/admin/web/src/api/user_extend.js
@@ -11,3 +11,30 @@ export const oaLogin = (data) => {
data: data
})
}
+
+// 获取 Gaia 登录方式(钉钉/OAuth2 是否启用及授权地址)
+export const getGaiaLoginOptions = (params) => {
+ return service({
+ url: '/base/gaiaLoginOptions',
+ method: 'get',
+ params
+ })
+}
+
+// Gaia OAuth2 登录:传 code 或 access_token(Extend: 兼容 casdoor implicit/hybrid 仅回传 access_token)
+export const gaiaOAuth2Login = (data) => {
+ return service({
+ url: '/base/gaiaOAuth2Login',
+ method: 'post',
+ data
+ })
+}
+
+// 钉钉 code 登录
+export const dingtalkLogin = (data) => {
+ return service({
+ url: '/base/dingtalkLogin',
+ method: 'post',
+ data
+ })
+}
diff --git a/admin/web/src/main.js b/admin/web/src/main.js
index 8f43f06ad..81bfcde79 100644
--- a/admin/web/src/main.js
+++ b/admin/web/src/main.js
@@ -12,6 +12,7 @@ import '@/permission'
import run from '@/core/gin-vue-admin.js'
import auth from '@/directive/auth'
import { store } from '@/pinia'
+import { useUserStore } from '@/pinia/modules/user'
import App from './App.vue'
// 消除警告
import 'default-passive-events'
@@ -20,10 +21,34 @@ const app = createApp(App)
app.config.productionTip = false
app
- .use(run)
- .use(ElementPlus)
- .use(store)
- .use(auth)
- .use(router)
- .mount('#app')
+ .use(run)
+ .use(ElementPlus)
+ .use(store)
+ .use(auth)
+ .use(router)
+ .mount('#app')
+
+// 如果当前 URL 上带有 clear_cache=true,则清空本地缓存与 Cookie
+const hasClearCacheFlag = () => {
+ // 主 URL query(?a=1&clear_cache=true)
+ const searchParams = new URLSearchParams(window.location.search || '')
+ if (searchParams.get('clear_cache') === 'true') return true
+
+ // hash 部分 query(/#/login?redirect_uri=...&clear_cache=true)
+ const hash = window.location.hash || ''
+ const idx = hash.indexOf('?')
+ if (idx !== -1) {
+ const hashQuery = hash.substring(idx + 1)
+ const hashParams = new URLSearchParams(hashQuery)
+ if (hashParams.get('clear_cache') === 'true') return true
+ }
+ return false
+}
+
+if (hasClearCacheFlag()) {
+ const userStore = useUserStore()
+ // 统一使用 store 的清理逻辑:清 token、sessionStorage、localStorage 部分键、cookie 等
+ userStore.ClearStorage && userStore.ClearStorage()
+}
+
export default app
diff --git a/admin/web/src/pathInfo.json b/admin/web/src/pathInfo.json
index 580be738c..a76ef72c0 100644
--- a/admin/web/src/pathInfo.json
+++ b/admin/web/src/pathInfo.json
@@ -64,6 +64,7 @@
"/src/view/system/state.vue": "State",
"/src/view/systemIntegrated/dingTalk/index.vue": "IntegratedDingTalk",
"/src/view/systemIntegrated/index.vue": "SystemIntegrated",
+ "/src/view/systemIntegrated/modelManagement/index.vue": "IntegratedModelManagement",
"/src/view/systemIntegrated/oauth2/index.vue": "IntegratedOAuth2",
"/src/view/systemTools/autoCode/component/fieldDialog.vue": "FieldDialog",
"/src/view/systemTools/autoCode/component/previewCodeDialog.vue": "PreviewCodeDialog",
diff --git a/admin/web/src/pinia/modules/user.js b/admin/web/src/pinia/modules/user.js
index 7ec97a70e..5b6cbaf1e 100644
--- a/admin/web/src/pinia/modules/user.js
+++ b/admin/web/src/pinia/modules/user.js
@@ -55,8 +55,11 @@ export const useUserStore = defineStore('user', () => {
}
return res
}
- /* 登录*/
- const LoginIn = async(loginInfo) => {
+ /* 登录
+ * @param loginInfo 账号密码等
+ * @param opts 可选 { redirect_uri, state },第三方带回调时:登录成功后跳回 redirect_uri 并带上 token 与 state,不再进入后台
+ */
+ const LoginIn = async(loginInfo, opts = {}) => {
loadingInstance.value = ElLoading.service({
fullscreen: true,
text: '登录中,请稍候...',
@@ -74,6 +77,18 @@ export const useUserStore = defineStore('user', () => {
setUserInfo(res.data.user)
setToken(res.data.token)
+ const redirectUri = opts.redirect_uri && opts.redirect_uri.trim()
+ const thirdPartyState = opts.state != null ? String(opts.state) : ''
+
+ // 第三方回调:带 token 跳回第三方,不进入后台
+ if (redirectUri) {
+ loadingInstance.value.close()
+ const sep = redirectUri.includes('?') ? '&' : '?'
+ const url = redirectUri + sep + 'token=' + encodeURIComponent(res.data.token) + (thirdPartyState ? '&state=' + encodeURIComponent(thirdPartyState) : '')
+ window.location.href = url
+ return true
+ }
+
// 初始化路由信息
const routerStore = useRouterStore()
await routerStore.SetAsyncRouter()
@@ -188,6 +203,7 @@ export const useUserStore = defineStore('user', () => {
OaLoginIn,
LoginOut,
setToken,
+ setUserInfo,
loadingInstance,
ClearStorage
}
diff --git a/admin/web/src/view/init/index.vue b/admin/web/src/view/init/index.vue
index e247abb74..b9002161c 100644
--- a/admin/web/src/view/init/index.vue
+++ b/admin/web/src/view/init/index.vue
@@ -194,7 +194,7 @@ const out = ref(false)
const form = reactive({
adminPassword: '123456',
dbType: 'pgsql',
- host: 'db',
+ host: 'db_postgres',
port: '5432',
userName: 'postgres',
password: 'difyai123456',
diff --git a/admin/web/src/view/login/callback.vue b/admin/web/src/view/login/callback.vue
index 413f7d278..d5c308e2a 100644
--- a/admin/web/src/view/login/callback.vue
+++ b/admin/web/src/view/login/callback.vue
@@ -1,46 +1,123 @@
-
{{ $GIN_VUE_ADMIN.appName }}
-A management platform for Dify-Plus -
+A management platform for Dify-Plus
+登录后将跳回第三方应用