mirror of
https://github.com/YFGaia/dify-plus.git
synced 2026-06-04 10:14:00 +08:00
feat: 合并近期功能与修复
- GLM/MiniMax 模型支持及 provider_name 修复 - OAuth2 登录跳转与重定向 hash 保留 - Azure 模型支持与转发特殊处理 - 后台登录与钉钉邮箱默认域名 - 转发获取密钥、Jinja 路径、RSA 私钥加载 - 模型管理可用模型输入与新增 - 自动更新权限、健康监测、admin 配置等 Co-authored-by: Cursor <github@npc0.com>
This commit is contained in:
@@ -30,6 +30,12 @@ func (appVersionApi *AppVersionApi) GetLatest(c *gin.Context) {
|
||||
platform := c.Query("platform")
|
||||
arch := c.Query("arch")
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
token = auth[7:]
|
||||
}
|
||||
}
|
||||
if platform == "" {
|
||||
response.FailWithMessage("platform is required", c)
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gaia
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -117,6 +118,26 @@ func (m *ModelProviderApi) Proxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 打印传入参数便于排查
|
||||
queryProvider := strings.TrimSpace(c.Query("provider"))
|
||||
var bodyModel string
|
||||
if len(body) > 0 {
|
||||
var parseObj map[string]interface{}
|
||||
if jsonErr := json.Unmarshal(body, &parseObj); jsonErr == nil {
|
||||
if m, ok := parseObj["model"].(string); ok {
|
||||
bodyModel = m
|
||||
}
|
||||
}
|
||||
}
|
||||
global.GVA_LOG.Info("Gaia代理请求入参",
|
||||
zap.String("path", path),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("query_provider", queryProvider),
|
||||
zap.Int("body_len", len(body)),
|
||||
zap.String("body_model", bodyModel),
|
||||
zap.String("body", string(body)),
|
||||
)
|
||||
|
||||
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(
|
||||
|
||||
@@ -4,13 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
gaiaResponse "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
|
||||
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
|
||||
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"
|
||||
@@ -20,6 +18,8 @@ import (
|
||||
"github.com/go-resty/resty/v2"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var gaiaSystemIntegratedService = service.ServiceGroupApp.GaiaServiceGroup.SystemIntegratedService
|
||||
@@ -216,23 +216,25 @@ func (b *BaseApi) GetGaiaLoginOptions(c *gin.Context) {
|
||||
// @Param data body gaiaReq.GaiaOAuth2LoginReq true "code 或 access_token 二选一、redirect_uri、state"
|
||||
// @Router /base/gaiaOAuth2Login [post]
|
||||
func (b *BaseApi) GaiaOAuth2Login(c *gin.Context) {
|
||||
// init
|
||||
var err error
|
||||
var req gaiaReq.GaiaOAuth2LoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
var data = make(map[string]interface{})
|
||||
var result *gaiaResponse.GaiaLoginResult
|
||||
if err = c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
result, err := gaiaSystemIntegratedService.OAuth2CodeLogin(req)
|
||||
if err != nil {
|
||||
|
||||
if result, err = gaiaSystemIntegratedService.OAuth2CodeLogin(req); err != nil {
|
||||
global.GVA_LOG.Error("Gaia OAuth2 登录失败", zap.Error(err))
|
||||
response.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
data["expiresAt"] = 0
|
||||
data["user"] = result.User
|
||||
data["token"] = result.Token
|
||||
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
|
||||
}
|
||||
@@ -262,8 +264,8 @@ func (b *BaseApi) GaiaDingTalkLogin(c *gin.Context) {
|
||||
}
|
||||
sysSvc.MenuServiceApp.UserAuthorityDefaultRouter(&result.User)
|
||||
data := map[string]interface{}{
|
||||
"user": result.User,
|
||||
"token": result.Token,
|
||||
"user": result.User,
|
||||
"token": result.Token,
|
||||
"expiresAt": 0,
|
||||
}
|
||||
if result.RedirectURI != "" {
|
||||
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/flipped-aurora/gin-vue-admin/server/core/internal"
|
||||
"github.com/flipped-aurora/gin-vue-admin/server/global"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/flipped-aurora/gin-vue-admin/server/global"
|
||||
)
|
||||
|
||||
// Extend: Override JWT signing key from environment variable
|
||||
@@ -36,8 +34,9 @@ func Viper(path ...string) *viper.Viper {
|
||||
if len(path) == 0 {
|
||||
flag.StringVar(&config, "c", "", "choose config file.")
|
||||
flag.Parse()
|
||||
if config == "" { // 判断命令行参数是否为空
|
||||
if configEnv := os.Getenv(internal.ConfigEnv); configEnv == "" { // 判断 internal.ConfigEnv 常量存储的环境变量是否为空
|
||||
if config == "" {
|
||||
// 判断 internal.ConfigEnv 常量存储的环境变量是否为空
|
||||
if configEnv := os.Getenv(internal.ConfigEnv); configEnv == "" {
|
||||
switch gin.Mode() {
|
||||
case gin.DebugMode:
|
||||
config = internal.ConfigDefaultFile
|
||||
|
||||
@@ -2,10 +2,13 @@ package gaia
|
||||
|
||||
// 模型提供商逻辑名称(列表展示与内部 key)
|
||||
const (
|
||||
ProviderOpenai = "openai"
|
||||
ProviderTongyi = "tongyi"
|
||||
ProviderGoogle = "google"
|
||||
ProviderOpenai = "openai"
|
||||
ProviderTongyi = "tongyi"
|
||||
ProviderGoogle = "google"
|
||||
ProviderAnthropic = "anthropic"
|
||||
ProviderAzure = "azure"
|
||||
ProviderZhipuai = "zhipuai"
|
||||
ProviderMinimax = "minimax"
|
||||
)
|
||||
|
||||
// DifyProviderTypeCustom Dify providers 表 provider_type 枚举
|
||||
@@ -13,20 +16,24 @@ const DifyProviderTypeCustom = "custom"
|
||||
|
||||
// 凭证配置中的 key 名
|
||||
const (
|
||||
ConfigKeyOpenaiAPIKey = "openai_api_key"
|
||||
ConfigKeyOpenaiAPIBase = "openai_api_base"
|
||||
ConfigKeyDashScopeAPIKey = "dashscope_api_key"
|
||||
ConfigKeyAPIKey = "api_key"
|
||||
ConfigKeyOpenaiAPIKey = "openai_api_key"
|
||||
ConfigKeyOpenaiAPIBase = "openai_api_base"
|
||||
ConfigKeyOpenaiAPIVersion = "openai_api_version"
|
||||
ConfigKeyDashScopeAPIKey = "dashscope_api_key"
|
||||
ConfigKeyAPIKey = "api_key"
|
||||
)
|
||||
|
||||
// SupportedProviders 列表展示的提供商顺序
|
||||
var SupportedProviders = []string{ProviderOpenai, ProviderTongyi, ProviderGoogle, ProviderAnthropic}
|
||||
var SupportedProviders = []string{ProviderOpenai, ProviderTongyi, ProviderGoogle, ProviderAnthropic, ProviderAzure, ProviderZhipuai, ProviderMinimax}
|
||||
|
||||
// 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",
|
||||
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",
|
||||
ProviderZhipuai: "https://open.bigmodel.cn/api/paas/v4/chat/completions",
|
||||
ProviderMinimax: "https://api.minimax.chat/v1/text/chatcompletion_v2",
|
||||
// Azure 需要动态构建 URL,不使用默认值
|
||||
}
|
||||
|
||||
// DefaultAPIBase 各提供商 API 根地址(无路径,用于通用代理;当 provider_credentials.encrypted_config 无 openai_api_base 时使用)
|
||||
@@ -35,6 +42,9 @@ var DefaultAPIBase = map[string]string{
|
||||
ProviderTongyi: "https://dashscope.aliyuncs.com/compatible-mode",
|
||||
ProviderGoogle: "https://generativelanguage.googleapis.com",
|
||||
ProviderAnthropic: "https://api.anthropic.com",
|
||||
ProviderZhipuai: "https://open.bigmodel.cn",
|
||||
ProviderMinimax: "https://api.minimax.chat",
|
||||
// Azure 的 base URL 来自 openai_api_base 配置,不设置默认值
|
||||
}
|
||||
|
||||
// CredentialKeyFallback 未知提供商时依次尝试的配置 key
|
||||
|
||||
@@ -7,7 +7,20 @@ import (
|
||||
// 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"`
|
||||
Token string `json:"token"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package response
|
||||
|
||||
// ProviderCredentials 提供商凭证(内部/代理用)
|
||||
type ProviderCredentials struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
APIKey string `json:"api_key"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
APIVersion string `json:"api_version,omitempty"` // Azure OpenAI API 版本
|
||||
}
|
||||
|
||||
// ModelInfo 模型信息
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package gaia
|
||||
|
||||
const EmailDomainEnv = "EMAIL_DOMAIN"
|
||||
const SystemIntegrationDingTalk = uint(1) // 钉钉集成
|
||||
const SystemIntegrationWeiXin = uint(2) // 微信集成
|
||||
const SystemIntegrationFeiShu = uint(3) // 飞书集成
|
||||
|
||||
@@ -62,8 +62,11 @@ func (s *AppVersionService) GetLatest(platform, arch, token string) (*response.L
|
||||
if err != nil {
|
||||
return nil, 500
|
||||
}
|
||||
if cfg.LinkToken != nil && *cfg.LinkToken != "" && (token == "" || token != *cfg.LinkToken) {
|
||||
return nil, 401
|
||||
// 仅当后台配置了 link_token 时才校验;未配置则不验证
|
||||
if cfg.LinkToken != nil && *cfg.LinkToken != "" {
|
||||
if token == "" || token != *cfg.LinkToken {
|
||||
return nil, 401
|
||||
}
|
||||
}
|
||||
|
||||
var release gaia.AppVersionRelease
|
||||
|
||||
@@ -4,35 +4,36 @@ 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"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 二选一)
|
||||
func (e *SystemIntegratedService) OAuth2CodeLogin(
|
||||
req request.GaiaOAuth2LoginReq) (*response.GaiaLoginResult, error) {
|
||||
// init
|
||||
var accessToken, tokenType string
|
||||
var configMap request.SystemOAuth2Request
|
||||
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 配置解析失败")
|
||||
}
|
||||
@@ -40,8 +41,6 @@ func (e *SystemIntegratedService) OAuth2CodeLogin(req request.GaiaOAuth2LoginReq
|
||||
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"
|
||||
@@ -55,9 +54,9 @@ func (e *SystemIntegratedService) OAuth2CodeLogin(req request.GaiaOAuth2LoginReq
|
||||
redirectURI = req.RedirectURI
|
||||
}
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("code", req.Code)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
tokenAuthMethod := strings.ToLower(strings.TrimSpace(configMap.TokenAuthMethod))
|
||||
if tokenAuthMethod != "client_secret_basic" {
|
||||
formData.Set("client_id", integrate.AppID)
|
||||
@@ -83,17 +82,13 @@ func (e *SystemIntegratedService) OAuth2CodeLogin(req request.GaiaOAuth2LoginReq
|
||||
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 == "" {
|
||||
var tokenResp map[string]interface{}
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil || tokenResp["access_token"] == "" {
|
||||
return nil, fmt.Errorf("解析 OAuth2 token 失败")
|
||||
}
|
||||
accessToken = tokenResp.AccessToken
|
||||
if tokenResp.TokenType != "" {
|
||||
tokenType = strings.ToLower(tokenResp.TokenType)
|
||||
accessToken = tokenResp["access_token"].(string)
|
||||
if tokenCache, ok := tokenResp["token_type"]; ok {
|
||||
tokenType = strings.ToLower(tokenCache.(string))
|
||||
} else {
|
||||
tokenType = "bearer"
|
||||
}
|
||||
@@ -124,19 +119,24 @@ func (e *SystemIntegratedService) OAuth2CodeLogin(req request.GaiaOAuth2LoginReq
|
||||
}
|
||||
|
||||
var userInfoMap map[string]interface{}
|
||||
if err := json.Unmarshal(userBody, &userInfoMap); err != nil {
|
||||
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")
|
||||
// 用户信息映射:支持 Jinja 风格路径,如 name、email、phone 或 user.name、data.attributes.phone
|
||||
username := getStringByPathOrKeys(userInfoMap, configMap.UserNameField, "name", "username", "preferred_username")
|
||||
email := getStringByPathOrKeys(userInfoMap, configMap.UserEmailField, "email", "sub")
|
||||
userID := getStringByPathOrKeys(userInfoMap, configMap.UserIDField, "phone", "sub", "id")
|
||||
if username == "" {
|
||||
username = email
|
||||
if username == "" {
|
||||
username = userID
|
||||
}
|
||||
}
|
||||
if email == "" {
|
||||
return nil, fmt.Errorf("无法从 OAuth2 用户信息中获取邮箱")
|
||||
if email == "" && userID == "" {
|
||||
return nil, fmt.Errorf("无法从 OAuth2 用户信息中获取邮箱或用户唯一标识")
|
||||
}
|
||||
|
||||
sysUser, err := e.findUserByEmail(email)
|
||||
sysUser, err := e.findUserByEmailOrPhone(email, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -159,13 +159,12 @@ func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLogi
|
||||
}
|
||||
|
||||
// 钉钉 OAuth2: 用 code 换 userAccessToken
|
||||
body := map[string]string{
|
||||
bodyJSON, _ := json.Marshal(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
|
||||
@@ -184,17 +183,14 @@ func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLogi
|
||||
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 == "" {
|
||||
var tokenResp map[string]interface{}
|
||||
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)
|
||||
userReq.Header.Set("x-acs-dingtalk-access-token", tokenResp["accessToken"].(string))
|
||||
userResp, err := client.Do(userReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("钉钉用户信息请求失败: %w", err)
|
||||
@@ -205,15 +201,12 @@ func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLogi
|
||||
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 {
|
||||
var dingUser map[string]interface{}
|
||||
if err = json.Unmarshal(userBody, &dingUser); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉用户信息失败")
|
||||
}
|
||||
email := dingUser.Email
|
||||
username := dingUser.Nick
|
||||
email := dingUser["email"].(string)
|
||||
username := dingUser["nick"].(string)
|
||||
if username == "" {
|
||||
username = email
|
||||
}
|
||||
@@ -246,14 +239,79 @@ func getStringFromMap(m map[string]interface{}, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// getStringByJinjaPath 按 Jinja 风格路径从 map 提取字符串,支持点分路径如 "name"、"user.email"、"data.attributes.phone";
|
||||
// path 可带 {{ }},会先去除再解析。
|
||||
func getStringByJinjaPath(m map[string]interface{}, path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimPrefix(path, "{{")
|
||||
path = strings.TrimSuffix(path, "}}")
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
keys := strings.Split(path, ".")
|
||||
var current interface{} = m
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
current = v[key]
|
||||
case map[string]string:
|
||||
current = v[key]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if current == nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
switch v := current.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", v)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
return fmt.Sprintf("%v", current)
|
||||
}
|
||||
}
|
||||
|
||||
// getStringByPathOrKeys 优先用 Jinja 路径从 m 取值,若为空则按 keys 顺序从顶层取。
|
||||
func getStringByPathOrKeys(m map[string]interface{}, path string, fallbackKeys ...string) string {
|
||||
if path != "" {
|
||||
if s := getStringByJinjaPath(m, path); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return getStringFromMap(m, fallbackKeys...)
|
||||
}
|
||||
|
||||
// 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(
|
||||
var mailList []string
|
||||
mailList = append(mailList, email)
|
||||
parts := strings.Split(email, "@")
|
||||
defaultMail := os.Getenv(gaia.EmailDomainEnv)
|
||||
if len(defaultMail) > 0 && len(parts) == 2 {
|
||||
mailList = append(mailList, parts[0]+"@"+defaultMail)
|
||||
}
|
||||
// 查询关联邮箱
|
||||
if err := global.GVA_DB.Where("email IN (?)", mailList).Preload(
|
||||
"Authorities").Preload("Authority").First(&u).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("该邮箱尚未开通后台账号,请联系管理员")
|
||||
return nil, fmt.Errorf(fmt.Sprintf("邮箱%s尚未开通账号,请联系管理员", email))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -263,3 +321,31 @@ func (e *SystemIntegratedService) findUserByEmail(email string) (*system.SysUser
|
||||
// 默认路由由调用方(api/system)设置,避免 gaia -> system 循环依赖
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// findUserByEmailOrPhone 按邮箱或用户唯一标识(如手机号)查找用户,优先邮箱
|
||||
func (e *SystemIntegratedService) findUserByEmailOrPhone(email, userID string) (*system.SysUser, error) {
|
||||
if email != "" {
|
||||
u, err := e.findUserByEmail(email)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
}
|
||||
// 仅当“未开通”时再尝试按 userID(phone) 查,其他错误直接返回
|
||||
if err != nil && !strings.Contains(err.Error(), "尚未开通") {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if userID != "" {
|
||||
var u system.SysUser
|
||||
if err := global.GVA_DB.Where("phone = ?", userID).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("账号已被禁用")
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
return nil, fmt.Errorf("无法从 OAuth2 用户信息中获取邮箱或用户唯一标识")
|
||||
}
|
||||
|
||||
@@ -6,31 +6,23 @@ import (
|
||||
"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/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) {
|
||||
// 钉钉
|
||||
func (e *SystemIntegratedService) GetLoginOptions(frontendOrigin string) (res response.LoginOptionsResponse) {
|
||||
// 非本地的需要加上admin
|
||||
integrateDing := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
|
||||
frontendOrigin = strings.TrimSuffix(frontendOrigin, "/")
|
||||
if !strings.Contains(frontendOrigin, "localhost") {
|
||||
frontendOrigin = frontendOrigin + "/admin"
|
||||
}
|
||||
if integrateDing.Status && integrateDing.AppKey != "" {
|
||||
res.DingTalk.Enabled = true
|
||||
callbackURI := strings.TrimSuffix(frontendOrigin, "/") + "/#/loginCallback?provider=dingtalk"
|
||||
callbackURI := 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))
|
||||
}
|
||||
@@ -48,7 +40,7 @@ func (e *SystemIntegratedService) GetLoginOptions(frontendOrigin string) (res Lo
|
||||
res.OAuth2.Enabled = true
|
||||
redirectURI := strings.TrimSpace(configMap.RedirectUri)
|
||||
if redirectURI == "" {
|
||||
redirectURI = strings.TrimSuffix(frontendOrigin, "/") + "/#/loginCallback?provider=oauth2"
|
||||
redirectURI = frontendOrigin + "/#/loginCallback?provider=oauth2"
|
||||
}
|
||||
res.OAuth2.RedirectURI = redirectURI
|
||||
scope := strings.TrimSpace(configMap.Scope)
|
||||
@@ -65,8 +57,7 @@ func (e *SystemIntegratedService) GetLoginOptions(frontendOrigin string) (res Lo
|
||||
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))
|
||||
baseURLStr, paramSep, url.QueryEscape(integrateOAuth.AppID), url.QueryEscape(scope), url.QueryEscape(redirectURI))
|
||||
} else {
|
||||
q := u.Query()
|
||||
q.Set("client_id", integrateOAuth.AppID)
|
||||
|
||||
@@ -171,18 +171,46 @@ func (s *ModelProviderService) GetEnabledModels() (gaiaResponse.OpenAIModelsResp
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetAvailableModelsFromDify 通过各提供商官方 API 拉取可用模型列表(不使用 Dify provider_models 表)。
|
||||
// @Tags System Integrated
|
||||
// @Summary 获取提供商的可用模型列表
|
||||
// @Security ApiKeyAuth
|
||||
// @accept application/json
|
||||
// @Produce application/json
|
||||
// getAvailableModelsFromProviderModelCredentials 从 Dify provider_model_credentials 表拉取指定提供商的可用模型列表。
|
||||
// 返回的模型 ID/Name 均为表内 model_name;实际请求 GPT 时由 API 侧根据 encrypted_config 中的 base_model_name 调用。
|
||||
// 用于 admin 第三方模型列表展示(如 azure_openai 展示 model_name,调用时用 base_model_name)。
|
||||
func (s *ModelProviderService) getAvailableModelsFromProviderModelCredentials(providerName string) ([]gaiaResponse.ModelInfo, error) {
|
||||
var firstTenant gaia.Tenants
|
||||
tenantID := firstTenant.GetSuperAdminTenantId()
|
||||
if tenantID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var modelNames []string
|
||||
err := global.GVA_DB.Table("provider_model_credentials").
|
||||
Where("tenant_id = ? AND provider_name LIKE ?", tenantID, fmt.Sprintf("%%%s%%", providerName)).
|
||||
Distinct("model_name").
|
||||
Pluck("model_name", &modelNames).Error
|
||||
if err != nil {
|
||||
global.GVA_LOG.Warn("从 provider_model_credentials 拉取模型列表失败", zap.String("provider", providerName), zap.Error(err))
|
||||
return nil, nil
|
||||
}
|
||||
list := make([]gaiaResponse.ModelInfo, 0, len(modelNames))
|
||||
for _, name := range modelNames {
|
||||
if name != "" {
|
||||
list = append(list, gaiaResponse.ModelInfo{ID: name, Name: name})
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetAvailableModelsFromDify 获取提供商的可用模型列表。
|
||||
// - Azure:仅从 provider_model_credentials 表拉取,列表展示 model_name,实际请求 GPT 时由 API 侧用 encrypted_config 的 base_model_name。
|
||||
// - OpenAI / 通义 / Google:与原先一致,通过各提供商官方 API 拉取可用模型。未配置凭证时返回空列表且不报错。
|
||||
//
|
||||
// 参数 providerName 为短名(openai/tongyi/google)。未配置凭证时返回空列表且不报错。
|
||||
// 参数 providerName 为短名(openai/tongyi/google/azure)。
|
||||
func (s *ModelProviderService) GetAvailableModelsFromDify(providerName string) ([]gaiaResponse.ModelInfo, error) {
|
||||
if providerName == gaia.ProviderAzure {
|
||||
return s.getAvailableModelsFromProviderModelCredentials(providerName)
|
||||
}
|
||||
|
||||
creds, err := s.GetDifyProviderCredentials(providerName)
|
||||
if err != nil || creds.APIKey == "" {
|
||||
return nil, nil // 未配置凭证时返回空列表,不报错
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
@@ -194,18 +222,15 @@ func (s *ModelProviderService) GetAvailableModelsFromDify(providerName string) (
|
||||
}
|
||||
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 != "" {
|
||||
@@ -329,7 +354,55 @@ func (s *ModelProviderService) fetchGeminiModels(client *http.Client, baseURL, a
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// GetDifyProviderCredentials 从 Dify 数据库(providers + provider_credentials)读取指定提供商的凭证,支持缓存与解密。
|
||||
// fetchAzureOpenAIModels 调用 Azure OpenAI GET {endpoint}/openai/models?api-version={version},解析 data[]。
|
||||
// 认证使用 api-key 请求头,响应格式:{ "data": [ { "id": "...", "object": "model" } ] }
|
||||
func (s *ModelProviderService) fetchAzureOpenAIModels(client *http.Client, baseURL, apiKey, apiVersion string) ([]gaiaResponse.ModelInfo, error) {
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
if apiVersion == "" {
|
||||
apiVersion = "2024-08-01-preview" // 默认 API 版本
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/openai/models?api-version=%s", baseURL, apiVersion)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("api-key", apiKey)
|
||||
|
||||
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("拉取 Azure OpenAI 模型列表非 200", zap.String("url", url), zap.Int("status", resp.StatusCode), zap.String("body", string(body)))
|
||||
return nil, fmt.Errorf("接口返回 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Azure OpenAI 返回格式与 OpenAI 类似
|
||||
var listResp gaiaResponse.OpenAIModelsListResponse
|
||||
if err = json.Unmarshal(body, &listResp); err != nil {
|
||||
return nil, fmt.Errorf("解析 Azure OpenAI 模型列表失败: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// GetDifyProviderCredentials 从 Dify 数据库读取指定提供商的凭证,支持缓存与解密。
|
||||
// 查询优先级:
|
||||
// 1. providers + provider_credentials 表(传统方式)
|
||||
// 2. provider_model_credentials 表(多凭证方式,按 updated_at 倒序取最新)
|
||||
//
|
||||
// @Tags System Integrated
|
||||
// @Summary 获取提供商凭证
|
||||
// @Security ApiKeyAuth
|
||||
@@ -341,6 +414,8 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
|
||||
// 首先尝试从Redis缓存获取(按请求的 providerName 缓存)
|
||||
var cached string
|
||||
var firstTenant gaia.Tenants
|
||||
tenantID := firstTenant.GetSuperAdminTenantId()
|
||||
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 {
|
||||
@@ -348,19 +423,35 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库查询,同时获取 tenant_id
|
||||
// 尝试方式1: 从 providers + provider_credentials 表查询
|
||||
var row gaia.ProviderCredential
|
||||
if err = global.GVA_DB.Table("providers").
|
||||
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 {
|
||||
Where("providers.tenant_id = ? AND providers.provider_name LIKE ? AND providers.provider_type = ? AND providers.is_valid = ?",
|
||||
tenantID, fmt.Sprintf("%%%s%%", providerName), gaia.DifyProviderTypeCustom, true).
|
||||
Order("provider_credentials.updated_at DESC").
|
||||
First(&row).Error
|
||||
|
||||
// 如果方式1 未找到记录,尝试方式2: 从 provider_model_credentials 表查询
|
||||
if err != nil || row.EncryptedConfig == "" {
|
||||
var pmcRow gaia.ProviderCredential
|
||||
if pmcErr := global.GVA_DB.Table("provider_model_credentials").
|
||||
Select("encrypted_config, tenant_id, provider_name, updated_at").
|
||||
Where("tenant_id = ? AND provider_name LIKE ?", tenantID, fmt.Sprintf("%%%s%%", providerName)).
|
||||
Order("updated_at DESC"). // 按 updated_at 倒序,取最新的凭证
|
||||
First(&pmcRow).Error; pmcErr == nil && pmcRow.EncryptedConfig != "" {
|
||||
row = pmcRow
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil || row.EncryptedConfig == "" {
|
||||
return creds, fmt.Errorf("未找到提供商 %s 的凭证配置", providerName)
|
||||
}
|
||||
|
||||
// 兼容两种存储:1) 明文 JSON(如 {"openai_api_key":"...", "openai_api_base":"..."});2) Dify RSA+AES-EAX 加密后再 base64
|
||||
var base string
|
||||
var base, apiVersion string
|
||||
var configMap map[string]interface{}
|
||||
if err = json.Unmarshal([]byte(row.EncryptedConfig), &configMap); err == nil {
|
||||
// 解密函数用于处理加密的值
|
||||
@@ -369,6 +460,10 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
if base, ok = configMap[gaia.ConfigKeyOpenaiAPIBase].(string); ok && strings.TrimSpace(base) != "" {
|
||||
creds.Endpoint = strings.TrimSuffix(strings.TrimSpace(base), "/")
|
||||
}
|
||||
// 提取 API 版本(Azure 使用)
|
||||
if apiVersion, ok = configMap[gaia.ConfigKeyOpenaiAPIVersion].(string); ok && strings.TrimSpace(apiVersion) != "" {
|
||||
creds.APIVersion = strings.TrimSpace(apiVersion)
|
||||
}
|
||||
} 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 {
|
||||
@@ -386,6 +481,10 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
if base, ok = configMap[gaia.ConfigKeyOpenaiAPIBase].(string); ok && strings.TrimSpace(base) != "" {
|
||||
creds.Endpoint = strings.TrimSuffix(strings.TrimSpace(base), "/")
|
||||
}
|
||||
// 提取 API 版本(Azure 使用)
|
||||
if apiVersion, ok = configMap[gaia.ConfigKeyOpenaiAPIVersion].(string); ok && strings.TrimSpace(apiVersion) != "" {
|
||||
creds.APIVersion = strings.TrimSpace(apiVersion)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解密凭证失败: %w", err)
|
||||
@@ -456,20 +555,36 @@ func (s *ModelProviderService) decryptConfig(encryptedConfig string, tenantID st
|
||||
}
|
||||
|
||||
// loadPrivateKey 从配置的存储路径加载指定 tenant 的 RSA 私钥(PEM 文件)。
|
||||
// 若该 tenant 的私钥文件不存在,则回退到「第一个创建的空间」(tenants 表 created_at 最早)的私钥路径,与 Dify 默认空间约定一致。
|
||||
func (s *ModelProviderService) loadPrivateKey(tenantID string) (*rsa.PrivateKey, error) {
|
||||
// 私钥路径: {storage-path}/privkeys/{tenant_id}/private.pem
|
||||
// 可通过配置自定义存储路径
|
||||
key, err := s.loadPrivateKeyFromPath(tenantID)
|
||||
if err != nil {
|
||||
// 若错误为文件不存在,尝试使用第一个创建的空间的私钥
|
||||
if errors.Is(err, os.ErrNotExist) || strings.Contains(err.Error(), "no such file or directory") {
|
||||
var firstTenant gaia.Tenants
|
||||
firstID := firstTenant.GetSuperAdminTenantId()
|
||||
if firstID != "" && firstID != tenantID {
|
||||
key, fallbackErr := s.loadPrivateKeyFromPath(firstID)
|
||||
if fallbackErr == nil {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// loadPrivateKeyFromPath 根据 tenantID 解析私钥文件路径并读取、解析 PEM,不做回退。
|
||||
func (s *ModelProviderService) loadPrivateKeyFromPath(tenantID string) (*rsa.PrivateKey, error) {
|
||||
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
|
||||
@@ -481,16 +596,13 @@ func (s *ModelProviderService) loadPrivateKey(tenantID string) (*rsa.PrivateKey,
|
||||
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)
|
||||
@@ -542,17 +654,12 @@ func (s *ModelProviderService) aesEAXDecrypt(key, nonce, ciphertext, tag []byte)
|
||||
// @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)
|
||||
// 按“已选模型”解析实际渠道(如 gpt-5-chat 若只在 Azure 下勾选则走 azure)
|
||||
providerName, err := s.resolveProviderByModel(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 {
|
||||
@@ -646,7 +753,53 @@ func (s *ModelProviderService) ProxyChat(userID string, req gaiaRequest.ChatRequ
|
||||
return nil
|
||||
}
|
||||
|
||||
// getProviderByModel 根据模型名称推断所属提供商短名(openai/tongyi/google/anthropic)。
|
||||
// getProviderCandidatesByModel 返回可能服务该模型的提供商短名列表(用于按“已选模型”解析实际渠道)。
|
||||
// 例如 gpt 系列可能走 openai 或 azure,返回 [azure, openai] 以便优先匹配用户在 admin 里配置的渠道。
|
||||
func (s *ModelProviderService) getProviderCandidatesByModel(modelName string) []string {
|
||||
modelLower := strings.ToLower(modelName)
|
||||
if strings.HasPrefix(modelLower, "gpt") || strings.Contains(modelLower, "openai") {
|
||||
return []string{gaia.ProviderAzure, gaia.ProviderOpenai}
|
||||
}
|
||||
if strings.Contains(modelLower, "azure") {
|
||||
return []string{gaia.ProviderAzure}
|
||||
}
|
||||
if strings.HasPrefix(modelLower, "qwen") || strings.Contains(modelLower, "tongyi") {
|
||||
return []string{gaia.ProviderTongyi}
|
||||
}
|
||||
if strings.HasPrefix(modelLower, "gemini") || strings.Contains(modelLower, "google") {
|
||||
return []string{gaia.ProviderGoogle}
|
||||
}
|
||||
if strings.Contains(modelLower, "claude") || strings.Contains(modelLower, "anthropic") {
|
||||
return []string{gaia.ProviderAnthropic}
|
||||
}
|
||||
// GLM/智谱 可能配置在 tongyi(统一入口)或 zhipuai 下,先试 tongyi
|
||||
if strings.HasPrefix(modelLower, "glm") || strings.Contains(modelLower, "zhipu") || strings.Contains(modelLower, "chatglm") {
|
||||
return []string{gaia.ProviderTongyi, gaia.ProviderZhipuai}
|
||||
}
|
||||
// 支持 "minimax" 或 "MiniMax/MiniMax-M2.5" 等形式;可能配置在 tongyi(统一入口)或 minimax 下,先试 tongyi
|
||||
if strings.HasPrefix(modelLower, "minimax") || strings.Contains(modelLower, "abab") {
|
||||
return []string{gaia.ProviderTongyi, gaia.ProviderMinimax}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveProviderByModel 根据模型名解析实际使用的提供商:在“可能服务该模型的”渠道中,取第一个已启用且已选模型列表包含该模型的渠道。
|
||||
// 这样当模型名是 gpt-5-chat 且用户只在 Azure 渠道下勾选了该模型时,会正确走 azure 而不是 openai。
|
||||
func (s *ModelProviderService) resolveProviderByModel(modelName string) (string, error) {
|
||||
candidates := s.getProviderCandidatesByModel(modelName)
|
||||
if len(candidates) == 0 {
|
||||
global.GVA_LOG.Warn("resolveProviderByModel 无法识别提供商", zap.String("model", modelName), zap.String("model_lower", strings.ToLower(modelName)))
|
||||
return "", fmt.Errorf("无法识别模型 %s 的提供商", modelName)
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if s.isModelEnabled(p, modelName) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("模型 %s 未开启", modelName)
|
||||
}
|
||||
|
||||
// getProviderByModel 仅根据模型名称推断提供商短名(不查配置表)。代理校验“是否开启”请用 resolveProviderByModel。
|
||||
func (s *ModelProviderService) getProviderByModel(modelName string) (string, error) {
|
||||
modelLower := strings.ToLower(modelName)
|
||||
if strings.HasPrefix(modelLower, "gpt") || strings.Contains(modelLower, "openai") {
|
||||
@@ -661,6 +814,17 @@ func (s *ModelProviderService) getProviderByModel(modelName string) (string, err
|
||||
if strings.Contains(modelLower, "claude") || strings.Contains(modelLower, "anthropic") {
|
||||
return gaia.ProviderAnthropic, nil
|
||||
}
|
||||
if strings.Contains(modelLower, "azure") {
|
||||
return gaia.ProviderAzure, nil
|
||||
}
|
||||
// GLM 类模型可能走 tongyi 或 zhipuai,仅推断时默认 tongyi
|
||||
if strings.HasPrefix(modelLower, "glm") || strings.Contains(modelLower, "zhipu") || strings.Contains(modelLower, "chatglm") {
|
||||
return gaia.ProviderTongyi, nil
|
||||
}
|
||||
// MiniMax 类模型可能走 tongyi 或 minimax,仅推断时默认 tongyi(实际以 resolveProviderByModel + 已选模型为准)
|
||||
if strings.HasPrefix(modelLower, "minimax") || strings.Contains(modelLower, "abab") {
|
||||
return gaia.ProviderTongyi, nil
|
||||
}
|
||||
return "", fmt.Errorf("无法识别模型 %s 的提供商", modelName)
|
||||
}
|
||||
|
||||
@@ -676,10 +840,25 @@ func (s *ModelProviderService) isModelEnabled(providerName, modelName string) bo
|
||||
return false
|
||||
}
|
||||
|
||||
suffixOfRequest := modelName
|
||||
if idx := strings.LastIndex(modelName, "/"); idx >= 0 && idx < len(modelName)-1 {
|
||||
suffixOfRequest = modelName[idx+1:]
|
||||
}
|
||||
for _, m := range models {
|
||||
if m == modelName {
|
||||
return true
|
||||
}
|
||||
// 请求 model 为 "MiniMax/MiniMax-M2.5" 时,与配置中的 "MiniMax-M2.5" 视为同一模型
|
||||
if m == suffixOfRequest {
|
||||
return true
|
||||
}
|
||||
suffixOfConfig := m
|
||||
if idx := strings.LastIndex(m, "/"); idx >= 0 && idx < len(m)-1 {
|
||||
suffixOfConfig = m[idx+1:]
|
||||
}
|
||||
if suffixOfRequest == suffixOfConfig {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -720,22 +899,23 @@ func (s *ModelProviderService) ProxyRequest(
|
||||
}
|
||||
|
||||
// 解析 provider:头 > query 已在 handler 传入;此处从 body 取 model 仅当 body 为 JSON 且含 model 时用于推断
|
||||
if p := reqHeader.Get("X-Gaia-Provider"); p != "" {
|
||||
xGaiaProvider := reqHeader.Get("X-Gaia-Provider")
|
||||
global.GVA_LOG.Info("ProxyRequest 解析 provider", zap.String("path", path), zap.String("X-Gaia-Provider", xGaiaProvider), zap.Int("body_len", len(body)))
|
||||
if p := xGaiaProvider; 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)
|
||||
global.GVA_LOG.Info("ProxyRequest 从 body 解析 model", zap.String("model", m))
|
||||
// 按“已选模型”解析实际渠道(如 gpt-5-chat 若只在 Azure 下勾选则走 azure)
|
||||
providerName, err = s.resolveProviderByModel(m)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("ProxyRequest resolveProviderByModel 失败", zap.String("model", m), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
global.GVA_LOG.Info("ProxyRequest 解析得到 provider", zap.String("provider", providerName))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -762,14 +942,51 @@ func (s *ModelProviderService) ProxyRequest(
|
||||
if len(body) > 0 {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
fmt.Println("path", base+"/"+path, string(body))
|
||||
httpReq, err := http.NewRequest(method, base+"/"+path, bodyReader)
|
||||
|
||||
// 构建请求 URL,Azure 需要特殊处理
|
||||
var requestURL string
|
||||
if providerName == gaia.ProviderAzure {
|
||||
// Azure OpenAI 有两种 API 格式:
|
||||
// 1. 新版 v1 API(2025年8月后):/openai/v1/... 不需要 api-version 参数
|
||||
// 2. 传统 API:/openai/deployments/{deployment}/... 需要 api-version 参数
|
||||
// 参考:https://learn.microsoft.com/en-us/azure/ai-foundry/openai/api-version-lifecycle
|
||||
//
|
||||
// 当请求路径以 "v1/" 开头时,使用新版 v1 API,不添加 api-version
|
||||
if strings.HasPrefix(path, "v1/") {
|
||||
// 新版 v1 API:/openai/v1/chat/completions(不需要 api-version)
|
||||
requestURL = base + "/openai/" + path
|
||||
} else if strings.HasPrefix(path, "openai/v1/") {
|
||||
// 已经包含完整的 openai/v1 前缀
|
||||
requestURL = base + "/" + path
|
||||
} else if strings.HasPrefix(path, "openai/") {
|
||||
// 其他 openai 路径(如 openai/deployments/...),可能需要 api-version
|
||||
requestURL = base + "/" + path
|
||||
if creds.APIVersion != "" {
|
||||
requestURL += "?api-version=" + creds.APIVersion
|
||||
}
|
||||
} else {
|
||||
// 其他路径,添加 openai 前缀并使用传统 API
|
||||
requestURL = base + "/openai/" + path
|
||||
if creds.APIVersion != "" {
|
||||
requestURL += "?api-version=" + creds.APIVersion
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestURL = base + "/" + path
|
||||
}
|
||||
|
||||
fmt.Println("path", requestURL, string(body))
|
||||
httpReq, err := http.NewRequest(method, requestURL, bodyReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 复制常用请求头,Authorization 使用上游 API Key
|
||||
httpReq.Header.Set("Authorization", "Bearer "+creds.APIKey)
|
||||
// 复制常用请求头,Azure 使用 api-key 头,其他使用 Authorization Bearer
|
||||
if providerName == gaia.ProviderAzure {
|
||||
httpReq.Header.Set("api-key", creds.APIKey)
|
||||
} else {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+creds.APIKey)
|
||||
}
|
||||
if ct := reqHeader.Get("Content-Type"); ct != "" {
|
||||
httpReq.Header.Set("Content-Type", ct)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func LoginToken(user system.Login) (token string, claims systemReq.CustomClaims,
|
||||
// Extend Start: add gaia token
|
||||
})
|
||||
token, err = j.CreateToken(claims)
|
||||
return
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// LoginTokenWithCSRF 生成登录token和CSRF token (用于批量处理API调用)
|
||||
|
||||
@@ -35,13 +35,17 @@ export const updateAppVersionRelease = (id, data) => {
|
||||
return service({ url: `/gaia/app-version/releases/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
/** 上传安装包到指定版本(自动识别平台/架构),formData: file。大文件需较长超时,此处 30 分钟 */
|
||||
const UPLOAD_TIMEOUT_MS = 30 * 60 * 1000
|
||||
|
||||
/** 上传安装包到指定版本(自动识别平台/架构),formData: file */
|
||||
export const uploadAppVersionPackage = (releaseId, formData) => {
|
||||
return service({
|
||||
url: `/gaia/app-version/releases/${releaseId}/upload`,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: UPLOAD_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,19 @@ Nprogress.configure({ showSpinner: false, ease: 'ease', speed: 500 })
|
||||
|
||||
const whiteList = ['Login', 'Init', 'LoginCallback'] // 新增OA登录:LoginCallback
|
||||
|
||||
// 检测当前 URL 是否带有 OAuth/钉钉回调参数(code 或 authCode + state),用于处理回调时 hash 被丢弃或已有 token 被误跳到 defaultRouter 的情况
|
||||
const hasOAuthCallbackInUrl = () => {
|
||||
const search = window.location.search
|
||||
if (!search) return null
|
||||
const params = new URLSearchParams(search)
|
||||
const code = params.get('code') || params.get('authCode')
|
||||
const state = params.get('state')
|
||||
if (!code || !state) return null
|
||||
if (state === 'dingtalk') return 'dingtalk'
|
||||
if (state === 'oauth2') return 'oauth2'
|
||||
return null
|
||||
}
|
||||
|
||||
const getRouter = async(userStore) => {
|
||||
const routerStore = useRouterStore()
|
||||
await routerStore.SetAsyncRouter()
|
||||
@@ -52,6 +65,13 @@ router.beforeEach(async(to, from) => {
|
||||
to.meta.matched = [...to.matched]
|
||||
handleKeepAlive(to)
|
||||
const token = userStore.token
|
||||
|
||||
// 钉钉/OAuth 回调:若重定向后 hash 被丢弃,URL 仅有 ?code=xx&state=dingtalk,则强制进入 loginCallback 处理
|
||||
const oauthProvider = hasOAuthCallbackInUrl()
|
||||
if (oauthProvider && to.name !== 'LoginCallback') {
|
||||
return { path: '/loginCallback', query: { provider: oauthProvider } }
|
||||
}
|
||||
|
||||
// 在白名单中的判断情况
|
||||
document.title = getPageTitle(to.meta.title, to)
|
||||
if(to.meta.client) {
|
||||
@@ -62,6 +82,10 @@ router.beforeEach(async(to, from) => {
|
||||
if (!routerStore.asyncRouterFlag && whiteList.indexOf(from.name) < 0) {
|
||||
await getRouter(userStore)
|
||||
}
|
||||
// 正在进行 OAuth/钉钉回调(URL 带 code)时,不跳到 defaultRouter,留在 LoginCallback 处理
|
||||
if (oauthProvider) {
|
||||
return true
|
||||
}
|
||||
// token 可以解析但是却是不存在的用户 id 或角色 id 会导致无限调用
|
||||
if (userStore.userInfo?.authority?.defaultRouter != null) {
|
||||
if (router.hasRoute(userStore.userInfo.authority.defaultRouter)) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ElLoading } from 'element-plus'
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_BASE_API,
|
||||
timeout: 99999
|
||||
timeout: 300000 // 默认 5 分钟,大文件上传等接口可在请求里单独设置 timeout
|
||||
})
|
||||
let activeAxios = 0
|
||||
let timer
|
||||
|
||||
@@ -22,10 +22,12 @@ const redirectToThirdParty = (token, redirectUri, state) => {
|
||||
if (!redirectUri) return false
|
||||
sessionStorage.removeItem('gaia_login_redirect_uri')
|
||||
sessionStorage.removeItem('gaia_login_state')
|
||||
sessionStorage.removeItem('console_token')
|
||||
sessionStorage.removeItem('token')
|
||||
const sep = redirectUri.includes('?') ? '&' : '?'
|
||||
const url = redirectUri + sep + 'token=' + encodeURIComponent(token) + (state ? '&state=' + encodeURIComponent(state) : '')
|
||||
window.location.href = url
|
||||
window.location.href = "/"
|
||||
setTimeout(() => { window.location.href = '/' }, 3000)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<div class="z-[999] pt-12 pb-10 md:w-96 w-full rounded-lg flex flex-col justify-between box-border">
|
||||
<div>
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
<img
|
||||
class="w-24"
|
||||
:src="$GIN_VUE_ADMIN.appLogo"
|
||||
@@ -20,9 +19,15 @@
|
||||
>
|
||||
</div>
|
||||
<div class="mb-9">
|
||||
<p class="text-center text-4xl font-bold">{{ $GIN_VUE_ADMIN.appName }}</p>
|
||||
<p class="text-center text-sm font-normal text-gray-500 mt-2.5">A management platform for Dify-Plus</p>
|
||||
<p v-if="redirectUri" class="text-center text-xs text-blue-600 mt-2">登录后将跳回第三方应用</p>
|
||||
<p class="text-center text-4xl font-bold">
|
||||
{{ $GIN_VUE_ADMIN.appName }}
|
||||
</p>
|
||||
<p class="text-center text-sm font-normal text-gray-500 mt-2.5">
|
||||
A management platform for Dify-Plus
|
||||
</p>
|
||||
<p v-if="redirectUri" class="text-center text-xs text-blue-600 mt-2">
|
||||
登录后将跳回第三方应用
|
||||
</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="loginForm"
|
||||
@@ -35,60 +40,64 @@
|
||||
<template
|
||||
v-if="showInit"
|
||||
>
|
||||
<el-form-item
|
||||
prop="username"
|
||||
class="mb-6"
|
||||
>
|
||||
<el-input
|
||||
v-model="loginFormData.username"
|
||||
size="large"
|
||||
placeholder="请输入dify的第一个帐号,即为管理员帐号"
|
||||
suffix-icon="user"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
prop="password"
|
||||
class="mb-6"
|
||||
>
|
||||
<el-input
|
||||
v-model="loginFormData.password"
|
||||
show-password
|
||||
size="large"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="loginFormData.openCaptcha"
|
||||
prop="captcha"
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex w-full justify-between">
|
||||
<template v-if="!redirectUri">
|
||||
<el-form-item
|
||||
prop="username"
|
||||
class="mb-6"
|
||||
>
|
||||
<el-input
|
||||
v-model="loginFormData.captcha"
|
||||
placeholder="请输入验证码"
|
||||
v-model="loginFormData.username"
|
||||
size="large"
|
||||
class="flex-1 mr-5"
|
||||
placeholder="请输入dify的第一个帐号,即为管理员帐号"
|
||||
suffix-icon="user"
|
||||
/>
|
||||
<div class="w-1/3 h-11 bg-[#c3d4f2] rounded">
|
||||
<img
|
||||
v-if="picPath"
|
||||
class="w-full h-full"
|
||||
:src="picPath"
|
||||
alt="请输入验证码"
|
||||
@click="loginVerify()"
|
||||
>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
prop="password"
|
||||
class="mb-6"
|
||||
>
|
||||
<el-input
|
||||
v-model="loginFormData.password"
|
||||
show-password
|
||||
size="large"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="loginFormData.openCaptcha"
|
||||
prop="captcha"
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex w-full justify-between">
|
||||
<el-input
|
||||
v-model="loginFormData.captcha"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
class="flex-1 mr-5"
|
||||
/>
|
||||
<div class="w-1/3 h-11 bg-[#c3d4f2] rounded">
|
||||
<img
|
||||
v-if="picPath"
|
||||
class="w-full h-full"
|
||||
:src="picPath"
|
||||
alt="请输入验证码"
|
||||
@click="loginVerify()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-6">
|
||||
<el-button
|
||||
class="shadow shadow-active h-11 w-full"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="submitForm"
|
||||
>账号密码登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-6">
|
||||
<el-button
|
||||
class="shadow shadow-active h-11 w-full"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="submitForm"
|
||||
>
|
||||
账号密码登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<!-- 钉钉 / OAuth2 登录:仅在有 redirect_uri(第三方回调)时显示 -->
|
||||
<el-form-item
|
||||
v-if="loginOptions.dingtalk.enabled && redirectUri"
|
||||
@@ -125,18 +134,19 @@
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="checkInit"
|
||||
>前往初始化</el-button>
|
||||
|
||||
>
|
||||
前往初始化
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="hidden md:block w-1/2 h-full float-right bg-[#194bfb]"><img-->
|
||||
<!-- class="h-full"-->
|
||||
<!-- src="@/assets/login_right_banner.jpg"-->
|
||||
<!-- alt="banner"-->
|
||||
<!-- ></div>-->
|
||||
<!-- <div class="hidden md:block w-1/2 h-full float-right bg-[#194bfb]"><img-->
|
||||
<!-- class="h-full"-->
|
||||
<!-- src="@/assets/login_right_banner.jpg"-->
|
||||
<!-- alt="banner"-->
|
||||
<!-- ></div>-->
|
||||
</div>
|
||||
|
||||
<BottomInfo class="left-0 right-0 absolute bottom-3 mx-auto w-full z-20">
|
||||
|
||||
@@ -87,35 +87,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="provider.available_models && provider.available_models.length > 0" class="models-select-wrapper">
|
||||
<div class="models-select-wrapper">
|
||||
<el-select
|
||||
v-model="provider.models"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:max-collapse-tags="5"
|
||||
placeholder="请选择模型"
|
||||
placeholder="选择模型或输入模型 ID 回车新增"
|
||||
class="models-select"
|
||||
@change="onModelSelectChange(provider)"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in provider.available_models"
|
||||
v-for="model in getDisplayModels(provider)"
|
||||
:key="model.id"
|
||||
:label="model.name"
|
||||
:value="model.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="selected-count">
|
||||
已选择 {{ provider.models?.length || 0 }} / {{ provider.available_models.length }} 个模型
|
||||
已选择 {{ provider.models?.length || 0 }}{{ (provider.available_models && provider.available_models.length > 0) ? ` / ${provider.available_models.length} 个可用` : '' }} 个模型
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-else
|
||||
description="未找到可用模型,请先在Dify中配置该提供商"
|
||||
:image-size="80"
|
||||
/>
|
||||
<div v-if="!(provider.available_models && provider.available_models.length > 0)" class="models-hint">
|
||||
未从接口拉取到可用模型,可输入模型 ID 后按回车直接新增
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
@@ -153,6 +153,15 @@ const getProviderDisplayName = (providerName) => {
|
||||
return providerDisplayNames[providerName] || providerName
|
||||
}
|
||||
|
||||
// 用于下拉展示的模型列表:接口返回的可用模型 + 已选中的自定义模型(不在接口列表中的)
|
||||
const getDisplayModels = (provider) => {
|
||||
const available = provider.available_models || []
|
||||
const selectedIds = new Set(provider.models || [])
|
||||
const availableIds = new Set(available.map(m => m.id))
|
||||
const custom = [...selectedIds].filter(id => !availableIds.has(id)).map(id => ({ id, name: id }))
|
||||
return [...available, ...custom]
|
||||
}
|
||||
|
||||
// 获取提供商列表
|
||||
const getProviderList = async() => {
|
||||
loading.value = true
|
||||
@@ -360,6 +369,12 @@ onMounted(() => {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.models-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -12,12 +12,12 @@ RUN pip install --no-cache-dir uv==${UV_VERSION}
|
||||
FROM base AS packages
|
||||
|
||||
# if you located in China, you can use aliyun mirror to speed up
|
||||
RUN sed -i 's@deb.debian.org@mirrors.ustc.edu.cn@g' /etc/apt/sources.list.d/debian.sources
|
||||
# RUN sed -i 's@deb.debian.org@mirrors.aliyun.com@g' /etc/apt/sources.list.d/debian.sources
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
# basic environment
|
||||
gcc g++ libc-dev libffi-dev libgmp-dev \
|
||||
g++ \
|
||||
# for building gmpy2
|
||||
libmpfr-dev libmpc-dev
|
||||
|
||||
@@ -41,9 +41,9 @@ EXPOSE 5001
|
||||
# set timezone
|
||||
ENV TZ=UTC
|
||||
|
||||
# Set UTF-8 locale
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
# Set UTF-8 locale (C.UTF-8 is available in slim images; en_US.UTF-8 would require installing locales)
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
ENV PYTHONIOENCODING=utf-8
|
||||
|
||||
WORKDIR /app/api
|
||||
@@ -108,8 +108,9 @@ RUN python -c "import tiktoken; tiktoken.encoding_for_model('gpt2')" \
|
||||
# Copy source code
|
||||
COPY --chown=dify:dify . /app/api/
|
||||
|
||||
# Prepare entrypoint script
|
||||
COPY --chown=dify:dify --chmod=755 docker/entrypoint.sh /entrypoint.sh
|
||||
# Prepare entrypoint script (chmod in RUN for compatibility without BuildKit)
|
||||
COPY --chown=dify:dify docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod 755 /entrypoint.sh
|
||||
|
||||
|
||||
ARG COMMIT_SHA
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import jwt
|
||||
from flask import request
|
||||
from .. import console_ns
|
||||
from models import Account
|
||||
from configs import dify_config
|
||||
from datetime import UTC, datetime
|
||||
from libs.login import login_required
|
||||
from extensions.ext_database import db
|
||||
from models.account import AccountStatus
|
||||
from flask_restx import Resource, reqparse
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.account import AccountStatus
|
||||
from models.account_money_extend import AccountMoneyExtend
|
||||
from services.account_service import AccountService, TenantService
|
||||
from services.account_service_extend import TenantExtendService
|
||||
|
||||
from .. import console_ns
|
||||
|
||||
|
||||
@console_ns.route("/admin_register_user")
|
||||
class AdminRegisterApi(Resource):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
@@ -20,7 +20,7 @@ def _issue_login_config_jwt(ip: str) -> str:
|
||||
"""extend: CVE-2025-63387 签发 JWT,payload 含 ip 与 1h 过期。"""
|
||||
payload = {
|
||||
"ip": ip,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
}
|
||||
return jwt.encode(payload, dify_config.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
@@ -36,6 +36,16 @@ def _verify_login_config_token(token: Optional[str]) -> bool:
|
||||
return payload.get("ip") == extract_remote_ip(request)
|
||||
|
||||
|
||||
# extend: 防止部分健康监测system-features无响应
|
||||
@console_ns.route("/system-features")
|
||||
class LoginConfigBootstrapApi(Resource):
|
||||
"""extend: 防止部分健康监测system-features无响应"""
|
||||
@console_ns.doc("system-features")
|
||||
@console_ns.response(200, "Success")
|
||||
def get(self):
|
||||
return make_response({"ping": True})
|
||||
|
||||
|
||||
# extend: start CVE-2025-63387未授权访问
|
||||
@console_ns.route("/login_config_bootstrap")
|
||||
class LoginConfigBootstrapApi(Resource):
|
||||
@@ -81,7 +91,6 @@ class FeatureApi(Resource):
|
||||
return FeatureService.get_features(current_tenant_id).model_dump()
|
||||
|
||||
|
||||
|
||||
# extend: start CVE-2025-63387未授权访问
|
||||
@console_ns.route("/login_config")
|
||||
class LoginConfigApi(Resource):
|
||||
|
||||
+28
-12
@@ -186,10 +186,20 @@ class OaOAuth(OAuth):
|
||||
"config": json.loads(integration.config)
|
||||
}
|
||||
|
||||
def _normalize_jinja_path(self, path: str) -> str:
|
||||
"""
|
||||
规范化 Jinja 风格路径:去掉 {{ }} 及首尾空格,得到点分路径供 extract_data 使用。
|
||||
例如 "{{ user.name }}" -> "user.name","email" -> "email"。
|
||||
"""
|
||||
if not path or not isinstance(path, str):
|
||||
return ""
|
||||
s = path.strip().replace("{{", "").replace("}}", "").strip()
|
||||
return s
|
||||
|
||||
def extract_data(self, dictionary, path):
|
||||
"""
|
||||
从字典中提取指定路径的数据
|
||||
支持通配符'*'获取列表中所有元素的特定字段
|
||||
支持通配符'*'获取列表中所有元素的特定字段;路径可为 Jinja 风格(调用前用 _normalize_jinja_path 规范化)。
|
||||
|
||||
Args:
|
||||
dictionary (dict): 源字典
|
||||
@@ -198,6 +208,8 @@ class OaOAuth(OAuth):
|
||||
Returns:
|
||||
提取的数据
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
parts = path.split('.')
|
||||
current = dictionary
|
||||
|
||||
@@ -327,24 +339,28 @@ class OaOAuth(OAuth):
|
||||
name="",
|
||||
email="",
|
||||
)
|
||||
# 提取参数(更健壮:支持点分路径、扁平键名和标准 OIDC 兜底)
|
||||
# 提取参数(支持 Jinja 风格路径如 name、user.name、{{ data.attributes.phone }},及标准 OIDC 兜底)
|
||||
config = auto2_conf.get('config')
|
||||
name_field = config.get('user_name_field') if isinstance(config, dict) else None
|
||||
email_field = config.get('user_email_field') if isinstance(config, dict) else None
|
||||
id_field = config.get('user_id_field') if isinstance(config, dict) else None
|
||||
|
||||
# 首选:按配置路径提取
|
||||
name = self.extract_data(raw_info, name_field) if name_field else None
|
||||
email = self.extract_data(raw_info, email_field) if email_field else None
|
||||
username = self.extract_data(raw_info, id_field) if id_field else None
|
||||
# 首选:按配置路径提取(路径会先做 Jinja 规范化:去掉 {{ }} 再按点分路径取)
|
||||
name_path = self._normalize_jinja_path(name_field) if name_field else ""
|
||||
email_path = self._normalize_jinja_path(email_field) if email_field else ""
|
||||
id_path = self._normalize_jinja_path(id_field) if id_field else ""
|
||||
|
||||
name = self.extract_data(raw_info, name_path) if name_path else None
|
||||
email = self.extract_data(raw_info, email_path) if email_path else None
|
||||
username = self.extract_data(raw_info, id_path) if id_path else None
|
||||
|
||||
# 如果配置为 data.name 但返回是扁平结构,尝试最后一级键名
|
||||
if name is None and isinstance(name_field, str) and '.' in name_field:
|
||||
name = raw_info.get(name_field.split('.')[-1])
|
||||
if email is None and isinstance(email_field, str) and '.' in email_field:
|
||||
email = raw_info.get(email_field.split('.')[-1])
|
||||
if username is None and isinstance(id_field, str) and '.' in id_field:
|
||||
username = raw_info.get(id_field.split('.')[-1])
|
||||
if name is None and name_path and "." in name_path:
|
||||
name = raw_info.get(name_path.split(".")[-1])
|
||||
if email is None and email_path and "." in email_path:
|
||||
email = raw_info.get(email_path.split(".")[-1])
|
||||
if username is None and id_path and "." in id_path:
|
||||
username = raw_info.get(id_path.split(".")[-1])
|
||||
|
||||
# OIDC 常见字段兜底
|
||||
if username is None:
|
||||
|
||||
@@ -41,28 +41,28 @@ class DingTalkService:
|
||||
"""
|
||||
从字典中提取指定路径的数据
|
||||
支持点号分隔的路径和数组索引
|
||||
|
||||
|
||||
Args:
|
||||
dictionary (dict): 源字典
|
||||
path (str): 以点分隔的路径,如 "data.email" 或 "data[0].userName"
|
||||
|
||||
|
||||
Returns:
|
||||
提取的数据,如果路径不存在返回None
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# 处理路径中的数组索引,如 data[0].userName -> data.[0].userName
|
||||
path = re.sub(r'\[(\d+)\]', r'.[\1]', path)
|
||||
parts = path.split('.')
|
||||
current = dictionary
|
||||
|
||||
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
|
||||
|
||||
# 处理数组索引
|
||||
array_match = re.match(r'\[(\d+)\]', part)
|
||||
if array_match:
|
||||
@@ -75,18 +75,18 @@ class DingTalkService:
|
||||
current = current[part]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
return current
|
||||
|
||||
@classmethod
|
||||
def get_email_from_third_party_api(cls, userid: str, integration: SystemIntegrationExtend) -> str:
|
||||
"""
|
||||
通过第三方API获取用户邮箱
|
||||
|
||||
|
||||
Args:
|
||||
userid: 钉钉用户ID
|
||||
integration: 集成配置对象
|
||||
|
||||
|
||||
Returns:
|
||||
邮箱地址,获取失败返回空字符串
|
||||
"""
|
||||
@@ -94,14 +94,14 @@ class DingTalkService:
|
||||
# 解析config字段
|
||||
if not integration.config:
|
||||
return ""
|
||||
|
||||
|
||||
config_data = json.loads(integration.config)
|
||||
email_api_config = config_data.get("email_api", {})
|
||||
|
||||
|
||||
# 检查是否启用
|
||||
if not email_api_config.get("enabled", False):
|
||||
return ""
|
||||
|
||||
|
||||
# 获取配置参数
|
||||
api_url = email_api_config.get("url", "")
|
||||
method = email_api_config.get("method", "GET").upper()
|
||||
@@ -111,14 +111,14 @@ class DingTalkService:
|
||||
headers = email_api_config.get("headers", {})
|
||||
authorization = email_api_config.get("authorization", {})
|
||||
body_data = email_api_config.get("body_data", {})
|
||||
|
||||
|
||||
if not api_url:
|
||||
logger.warning("Third-party email API URL is not configured")
|
||||
return ""
|
||||
|
||||
|
||||
# 准备请求头
|
||||
request_headers = dict(headers) if headers else {}
|
||||
|
||||
|
||||
# 处理Authorization
|
||||
auth = None
|
||||
auth_type = authorization.get("type", "none")
|
||||
@@ -132,10 +132,10 @@ class DingTalkService:
|
||||
if username and password:
|
||||
from requests.auth import HTTPBasicAuth
|
||||
auth = HTTPBasicAuth(username, password)
|
||||
|
||||
|
||||
# 构建请求数据
|
||||
request_data = {}
|
||||
|
||||
|
||||
# 处理Body数据(仅POST/PUT/DELETE)
|
||||
if method in ["POST", "PUT", "DELETE"]:
|
||||
if body_type == "form-data":
|
||||
@@ -151,7 +151,7 @@ class DingTalkService:
|
||||
request_data[param_field] = userid
|
||||
# form-data使用data参数
|
||||
response = requests.request(
|
||||
method, api_url, data=request_data,
|
||||
method, api_url, data=request_data,
|
||||
headers=request_headers, auth=auth, timeout=10
|
||||
)
|
||||
elif body_type == "x-www-form-urlencoded":
|
||||
@@ -197,23 +197,23 @@ class DingTalkService:
|
||||
api_url, params=request_data,
|
||||
headers=request_headers, auth=auth, timeout=10
|
||||
)
|
||||
|
||||
|
||||
# 检查响应
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Third-party email API returned status code: {response.status_code}")
|
||||
return ""
|
||||
|
||||
|
||||
# 解析响应
|
||||
response_data = response.json()
|
||||
email = cls.extract_data(response_data, email_field)
|
||||
|
||||
|
||||
if email and isinstance(email, str) and "@" in email:
|
||||
logger.info("Successfully retrieved email from third-party API for userid: %s", userid)
|
||||
return email
|
||||
else:
|
||||
logger.warning("Failed to extract valid email from response using path: %s", email_field)
|
||||
return ""
|
||||
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to parse email API config: %s", e)
|
||||
return ""
|
||||
@@ -288,7 +288,7 @@ class DingTalkService:
|
||||
SystemIntegrationExtend.status == True,
|
||||
SystemIntegrationExtend.classify == SystemIntegrationClassify.SYSTEM_INTEGRATION_DINGTALK).first()
|
||||
)
|
||||
|
||||
|
||||
dingTalkToken, err = cls.get_access_token()
|
||||
responses = requests.post(
|
||||
f'https://oapi.dingtalk.com/topapi/v2/user/get?access_token={dingTalkToken}',
|
||||
@@ -302,21 +302,21 @@ class DingTalkService:
|
||||
return "", "Request for user information failed: " + userid + " " + json.dumps(reqs)
|
||||
# Check if the user exists
|
||||
username = reqs["result"]['name']
|
||||
|
||||
|
||||
# 优先尝试从第三方API获取邮箱
|
||||
email = ""
|
||||
if integration:
|
||||
email = cls.get_email_from_third_party_api(userid, integration)
|
||||
|
||||
|
||||
# 降级处理:使用钉钉返回的邮箱
|
||||
if not email and "email" in reqs["result"] and len(reqs["result"]["email"]):
|
||||
email = reqs["result"]["email"]
|
||||
|
||||
|
||||
# 最终降级:使用拼音生成邮箱
|
||||
if not email:
|
||||
email = f"{''.join(lazy_pinyin(username))}@{dify_config.EMAIL_DOMAIN}"
|
||||
logger.info("Using pinyin-generated email for user %s: %s", userid, email)
|
||||
|
||||
|
||||
account: Account = (
|
||||
db.session.query(Account).filter(Account.email == email).first()
|
||||
)
|
||||
@@ -385,7 +385,7 @@ class DingTalkService:
|
||||
token_pair, err = cls.auto_create_user(unionIdReq["result"]["userid"])
|
||||
if len(err) > 0:
|
||||
return None, "", "Request failed: " + err
|
||||
|
||||
|
||||
redirect_url = f"{dify_config.CONSOLE_WEB_URL}/explore/apps-center-extend"
|
||||
return token_pair, redirect_url, ""
|
||||
|
||||
|
||||
@@ -1685,6 +1685,7 @@ services:
|
||||
# JWT signing key must match API's SECRET_KEY for token compatibility
|
||||
JWT_SIGNING_KEY: ${SECRET_KEY:-sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U}
|
||||
SECRET_KEY: ${SECRET_KEY:-sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U}
|
||||
EMAIL_DOMAIN: ${EMAIL_DOMAIN:-}
|
||||
ports:
|
||||
- '8888:8888'
|
||||
depends_on:
|
||||
|
||||
@@ -40,10 +40,12 @@ INSERT INTO sys_apis (id, created_at, updated_at, deleted_at, path, description,
|
||||
-- ... 共 8 条;
|
||||
|
||||
|
||||
-- --------------- 3. Casbin 规则 casbin_rule (角色 888/8881/9528/1 的接口权限) ---------------
|
||||
-- --------------- 3. Casbin 规则 casbin_rule (角色 888/8881/9528/1 的接口权限,含 token/reveal、token/generate) ---------------
|
||||
INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
|
||||
('p', '888', '/gaia/app-version/token', 'GET'),
|
||||
('p', '888', '/gaia/app-version/token', 'PUT'),
|
||||
('p', '888', '/gaia/app-version/token/reveal', 'POST'),
|
||||
('p', '888', '/gaia/app-version/token/generate', 'GET'),
|
||||
('p', '888', '/gaia/app-version/releases', 'GET'),
|
||||
('p', '888', '/gaia/app-version/releases', 'POST'),
|
||||
('p', '888', '/gaia/app-version/releases/:id', 'GET'),
|
||||
@@ -60,6 +62,8 @@ INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
|
||||
('p', '8881', '/gaia/app-version/releases/:id/download', 'DELETE'),
|
||||
('p', '9528', '/gaia/app-version/token', 'GET'),
|
||||
('p', '9528', '/gaia/app-version/token', 'PUT'),
|
||||
('p', '9528', '/gaia/app-version/token/reveal', 'POST'),
|
||||
('p', '9528', '/gaia/app-version/token/generate', 'GET'),
|
||||
('p', '9528', '/gaia/app-version/releases', 'GET'),
|
||||
('p', '9528', '/gaia/app-version/releases', 'POST'),
|
||||
('p', '9528', '/gaia/app-version/releases/:id', 'GET'),
|
||||
@@ -149,14 +153,4 @@ INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
|
||||
INSERT INTO sys_authority_menus (sys_authority_authority_id, sys_base_menu_id) VALUES (888, 42);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- 已有库修复:若曾用具体路径 /gaia/proxy/v1/chat/completions,改为通配符 /gaia/proxy/*
|
||||
-- ============================================================
|
||||
-- sys_apis:将具体路径统一改为通配符(与 source/system/api.go、Casbin 一致)
|
||||
UPDATE sys_apis SET path = '/gaia/proxy/*', description = '中转API(第三方)-POST', updated_at = NOW()
|
||||
WHERE path = '/gaia/proxy/v1/chat/completions' AND method = 'POST';
|
||||
-- 若有其他具体子路径也一并改为通配符(可选)
|
||||
UPDATE sys_apis SET path = '/gaia/proxy/*', updated_at = NOW()
|
||||
WHERE path LIKE '/gaia/proxy/%' AND path != '/gaia/proxy/*';
|
||||
-- casbin_rule:若存在具体路径策略可删除,保留通配符策略即可(通常初始化已是 /gaia/proxy/*,可不执行)
|
||||
-- DELETE FROM casbin_rule WHERE ptype = 'p' AND v1 LIKE '/gaia/proxy/%' AND v1 != '/gaia/proxy/*';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { fetchUserMoney } from '@/service/common-extend'
|
||||
import type { UserMoney } from '@/models/common-extend'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { fetchUserMoney } from '@/service/common-extend'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
const AccountMoneyExtend = () => {
|
||||
const [userMoney, setUserMoney] = useState<UserMoney>({ used_quota: 0, total_quota: 0 })
|
||||
@@ -12,12 +12,15 @@ const AccountMoneyExtend = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getUserMoney = async () => {
|
||||
const data: any = await fetchUserMoney()
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
const data: never = await fetchUserMoney()
|
||||
setUserMoney(data)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getUserMoney()
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setIsFetched(true)
|
||||
}, [])
|
||||
|
||||
@@ -51,14 +54,14 @@ const AccountMoneyExtend = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center overflow-hidden rounded-md border border-divider-regular text-xs leading-[18px]'
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center overflow-hidden rounded-md border border-divider-regular text-xs leading-[18px]"
|
||||
>
|
||||
<div className='flex items-center bg-background-default-dimmed px-2 py-1 font-medium text-text-secondary'>
|
||||
<div className="flex items-center bg-background-default-dimmed px-2 py-1 font-medium text-text-secondary">
|
||||
{t('user.credit', { ns: 'extend' })}
|
||||
</div>
|
||||
<div className='flex items-center border-l border-divider-regular bg-background-default px-2 py-1.5'>
|
||||
<span className='mr-1 text-text-tertiary'>{t('user.used', { ns: 'extend' })}</span>
|
||||
<div className="flex items-center border-l border-divider-regular bg-background-default px-2 py-1.5">
|
||||
<span className="mr-1 text-text-tertiary">{t('user.used', { ns: 'extend' })}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-bold transition-all duration-300',
|
||||
@@ -66,11 +69,13 @@ const AccountMoneyExtend = () => {
|
||||
'text-sm md:text-base', // 默认字体稍大,响应式设计
|
||||
)}
|
||||
>
|
||||
¥{usedRMB}
|
||||
¥
|
||||
{usedRMB}
|
||||
</span>
|
||||
<span className='mx-1 text-text-quaternary'>/</span>
|
||||
<span className='text-text-tertiary'>
|
||||
¥{totalRMB.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
<span className="mx-1 text-text-quaternary">/</span>
|
||||
<span className="text-text-tertiary">
|
||||
¥
|
||||
{totalRMB.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { WorkspaceProvider } from '@/context/workspace-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { Plan } from '../billing/type'
|
||||
import AccountDropdown from './account-dropdown'
|
||||
import AccountMoneyExtend from './account-money-extend' // 二开部分 - 额度限制
|
||||
import AppNav from './app-nav'
|
||||
import DatasetNav from './dataset-nav'
|
||||
import EnvNav from './env-nav'
|
||||
@@ -20,9 +21,8 @@ import LicenseNav from './license-env'
|
||||
import PlanBadge from './plan-badge'
|
||||
import PluginsNav from './plugins-nav'
|
||||
import ToolsNav from './tools-nav'
|
||||
import AccountMoneyExtend from './account-money-extend' // 二开部分 - 额度限制
|
||||
import DrawNav from './draw-nav-extend' // Extend draw nav
|
||||
import { AmazonMarketingNav } from './nav-extend/index' // Extend draw nav
|
||||
// import DrawNav from './draw-nav-extend' // Extend draw nav
|
||||
// import { AmazonMarketingNav } from './nav-extend/index' // Extend draw nav
|
||||
|
||||
const navClassName = `
|
||||
flex items-center relative px-3 h-8 rounded-xl
|
||||
@@ -48,7 +48,7 @@ const Header = () => {
|
||||
|
||||
const renderLogo = () => (
|
||||
<h1>
|
||||
{/*extend: 跳转修改*/}
|
||||
{/* extend: 跳转修改 */}
|
||||
<Link href="/explore/apps-center-extend" className="flex h-8 shrink-0 items-center justify-center overflow-hidden whitespace-nowrap px-0.5 indent-[-9999px]">
|
||||
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
|
||||
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
@@ -89,11 +89,11 @@ const Header = () => {
|
||||
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
|
||||
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
|
||||
{/* gaia exnend begin */}
|
||||
<DrawNav className={navClassName}/>
|
||||
{<AmazonMarketingNav className={navClassName}/>}
|
||||
{ /* <DrawNav className={navClassName} /> */ }
|
||||
{ /* <AmazonMarketingNav className={navClassName} /> */ }
|
||||
{/* gaia extend end */}
|
||||
{/* 二开部分 - 额度限制 */}
|
||||
{<AccountMoneyExtend />}
|
||||
<AccountMoneyExtend />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -109,7 +109,7 @@ const Header = () => {
|
||||
</WorkspaceProvider>
|
||||
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
|
||||
{/* 二开部分 - 额度限制 */}
|
||||
{<AccountMoneyExtend />}
|
||||
<AccountMoneyExtend />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
|
||||
|
||||
Generated
+15570
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user