feat: 合并近期功能与修复

- GLM/MiniMax 模型支持及 provider_name 修复
- OAuth2 登录跳转与重定向 hash 保留
- Azure 模型支持与转发特殊处理
- 后台登录与钉钉邮箱默认域名
- 转发获取密钥、Jinja 路径、RSA 私钥加载
- 模型管理可用模型输入与新增
- 自动更新权限、健康监测、admin 配置等

Co-authored-by: Cursor <github@npc0.com>
This commit is contained in:
npc0-hue
2026-02-24 16:24:23 +08:00
parent 685ff83dcb
commit 10ec0eb953
29 changed files with 16320 additions and 316 deletions
+6
View File
@@ -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(
+16 -14
View File
@@ -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 != "" {
+6 -7
View File
@@ -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
+16 -3
View File
@@ -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) // 飞书集成
+5 -2
View File
@@ -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
+133 -47
View File
@@ -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_tokenExtend: 兼容 casdoor)、拉用户信息、查找/创建用户、签发 JWT
func (e *SystemIntegratedService) OAuth2CodeLogin(req request.GaiaOAuth2LoginReq) (*response.GaiaLoginResult, error) {
// Extend Start: 兼容 casdoorcode 与 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 用户信息中获取邮箱或用户唯一标识")
}
+10 -19
View File
@@ -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)
+264 -47
View File
@@ -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 API2025年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)
}
+1 -1
View File
@@ -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调用)
+5 -1
View File
@@ -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
})
}
+24
View File
@@ -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)) {
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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
}
+71 -61
View File
@@ -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
View File
@@ -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):
+12 -3
View File
@@ -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 签发 JWTpayload 含 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
View File
@@ -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:
+1
View File
@@ -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>
+8 -8
View File
@@ -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} />}
+15570
View File
File diff suppressed because it is too large Load Diff