diff --git a/.gitignore b/.gitignore index a988d5a81..a06581806 100644 --- a/.gitignore +++ b/.gitignore @@ -209,6 +209,9 @@ api/.vscode .history .idea/ +.claude/ +.cursor/ +openspec/ # pnpm /.pnpm-store diff --git a/admin/server/Dockerfile b/admin/server/Dockerfile index 259edaed4..1d0df712b 100644 --- a/admin/server/Dockerfile +++ b/admin/server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:alpine as builder +FROM golang:alpine AS builder RUN mkdir /app WORKDIR /app diff --git a/admin/server/api/v1/gaia/enter.go b/admin/server/api/v1/gaia/enter.go index df3f4e504..e1e2b42d0 100644 --- a/admin/server/api/v1/gaia/enter.go +++ b/admin/server/api/v1/gaia/enter.go @@ -12,6 +12,7 @@ type ApiGroup struct { BatchWorkflowApi AppVersionApi ModelProviderApi + ForwardProxyApi } var ( diff --git a/admin/server/api/v1/gaia/system.go b/admin/server/api/v1/gaia/system.go index bbb1828b0..bff28c0ae 100644 --- a/admin/server/api/v1/gaia/system.go +++ b/admin/server/api/v1/gaia/system.go @@ -2,10 +2,18 @@ package gaia import ( "context" + "crypto/sha256" + "encoding/json" + "fmt" "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/gaia" + "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request" + "github.com/flipped-aurora/gin-vue-admin/server/model/system" + "github.com/flipped-aurora/gin-vue-admin/server/utils" "github.com/gin-gonic/gin" + "github.com/google/uuid" + "time" ) type SystemApi struct{} @@ -55,3 +63,182 @@ func (systemApi *SystemApi) SetDingTalk(c *gin.Context) { } response.OkWithData("ok", c) } + +// GetForwardTokens 获取转发 Token 列表 +// @Tags System +// @Summary 获取转发 Token 列表 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.Response{data=[]request.ForwardToken,msg=string} "查询成功" +// @Router /gaia/system/forward-tokens [get] +func (systemApi *SystemApi) GetForwardTokens(c *gin.Context) { + integrate := systemIntegratedService.GetIntegratedConfig(gaia.SystemIntegrationDingTalk) + + var configMap request.DingTalkConfigRequest + if integrate.Config != "" { + if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil { + response.FailWithMessage("解析配置失败:"+err.Error(), c) + return + } + } + + // 返回不包含 token_hash 的列表 + type TokenInfo struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + } + + tokens := make([]TokenInfo, 0, len(configMap.ForwardConfig.Tokens)) + for _, token := range configMap.ForwardConfig.Tokens { + tokens = append(tokens, TokenInfo{ + ID: token.ID, + CreatedAt: token.CreatedAt, + }) + } + + response.OkWithData(gin.H{"tokens": tokens, "count": len(tokens), "max": 20}, c) +} + +// CreateForwardToken 新增转发 Token +// @Tags System +// @Summary 新增转发 Token +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param token body string true "Token 明文" +// @Success 200 {object} response.Response{data=request.ForwardToken,msg=string} "创建成功" +// @Router /gaia/system/forward-tokens [post] +func (systemApi *SystemApi) CreateForwardToken(c *gin.Context) { + var req struct { + Token string `json:"token"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + if req.Token == "" { + response.FailWithMessage("Token 不能为空", c) + return + } + + integrate := systemIntegratedService.GetIntegratedConfig(gaia.SystemIntegrationDingTalk) + var configMap request.DingTalkConfigRequest + if integrate.Config != "" { + if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil { + response.FailWithMessage("解析配置失败:"+err.Error(), c) + return + } + } + + // 检查数量限制 + if len(configMap.ForwardConfig.Tokens) >= 20 { + response.FailWithMessage("转发 Token 最多 20 个", c) + return + } + + // 生成唯一 ID 和哈希 + tokenID := "tok_" + uuid.New().String() + tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(req.Token))) + + newToken := request.ForwardToken{ + ID: tokenID, + TokenHash: tokenHash, + CreatedAt: time.Now(), + } + + // 添加到配置 + configMap.ForwardConfig.Tokens = append(configMap.ForwardConfig.Tokens, newToken) + configJSON, _ := json.Marshal(configMap) + integrate.Config = string(configJSON) + + // 保存配置 + if err := systemIntegratedService.SetIntegratedConfig(integrate, "", false); err != nil { + response.FailWithMessage("保存失败:"+err.Error(), c) + return + } + + // 返回明文 token(仅此次展示) + response.OkWithData(gin.H{ + "id": tokenID, + "token": req.Token, + "created_at": newToken.CreatedAt, + }, c) +} + +// DeleteForwardToken 删除转发 Token +// @Tags System +// @Summary 删除转发 Token +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param id path string true "Token ID" +// @Param password body string true "当前用户密码" +// @Success 200 {object} response.Response{msg=string} "删除成功" +// @Router /gaia/system/forward-tokens/:id [delete] +func (systemApi *SystemApi) DeleteForwardToken(c *gin.Context) { + tokenID := c.Param("id") + if tokenID == "" { + response.FailWithMessage("Token ID 不能为空", c) + return + } + + var req struct { + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + // 验证当前用户密码 + userID := utils.GetUserUuid(c).String() + var user system.SysUser + if err := global.GVA_DB.Select("password").First(&user, userID).Error; err != nil { + response.FailWithMessage("查询用户失败:"+err.Error(), c) + return + } + if !utils.BcryptCheck(req.Password, user.Password) { + response.FailWithMessage("密码错误", c) + return + } + + // 获取配置 + integrate := systemIntegratedService.GetIntegratedConfig(gaia.SystemIntegrationDingTalk) + var configMap request.DingTalkConfigRequest + if integrate.Config != "" { + if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil { + response.FailWithMessage("解析配置失败:"+err.Error(), c) + return + } + } + + // 查找并删除 token + found := false + newTokens := make([]request.ForwardToken, 0, len(configMap.ForwardConfig.Tokens)) + for _, token := range configMap.ForwardConfig.Tokens { + if token.ID == tokenID { + found = true + continue + } + newTokens = append(newTokens, token) + } + + if !found { + response.FailWithMessage("Token 不存在", c) + return + } + + // 更新配置 + configMap.ForwardConfig.Tokens = newTokens + configJSON, _ := json.Marshal(configMap) + integrate.Config = string(configJSON) + + if err := systemIntegratedService.SetIntegratedConfig(integrate, "", false); err != nil { + response.FailWithMessage("保存失败:"+err.Error(), c) + return + } + + response.OkWithMessage("删除成功", c) +} diff --git a/admin/server/api/v1/system/sys_initdb.go b/admin/server/api/v1/system/sys_initdb.go index 4081efada..3929b2ea1 100644 --- a/admin/server/api/v1/system/sys_initdb.go +++ b/admin/server/api/v1/system/sys_initdb.go @@ -20,7 +20,7 @@ type DBApi struct{} // @Router /init/initdb [post] func (i *DBApi) InitDB(c *gin.Context) { - if !initDBService.IfInit() { + if initDBService.IfInit() { global.GVA_LOG.Error("已存在数据库配置!") response.FailWithMessage("已存在数据库配置", c) return diff --git a/admin/server/config.docker.yaml b/admin/server/config.docker.yaml index d514f4e08..7842c7196 100644 --- a/admin/server/config.docker.yaml +++ b/admin/server/config.docker.yaml @@ -113,10 +113,10 @@ pgsql: prefix: "" port: "5432" config: sslmode=disable TimeZone=Asia/Shanghai - db-name: - username: - password: - path: + db-name: dify + username: postgres + password: difyai123456 + path: db_postgres engine: "" log-mode: error max-idle-conns: 10 diff --git a/admin/server/config.yaml b/admin/server/config.yaml index ac71001a8..ecb06db90 100644 --- a/admin/server/config.yaml +++ b/admin/server/config.yaml @@ -152,7 +152,7 @@ pgsql: db-name: dify username: postgres password: difyai123456 - path: 127.0.0.01 + path: 127.0.0.1 engine: "" log-mode: error max-idle-conns: 10 diff --git a/admin/server/core/env_override.go b/admin/server/core/env_override.go new file mode 100644 index 000000000..2a24dfda2 --- /dev/null +++ b/admin/server/core/env_override.go @@ -0,0 +1,126 @@ +package core + +import ( + "fmt" + "os" + "strconv" + + "github.com/flipped-aurora/gin-vue-admin/server/global" +) + +// overrideDBFromEnv 从环境变量覆盖数据库配置 +// 优先级:环境变量 > 配置文件 +func overrideDBFromEnv() { + // 数据库类型 + if dbType := os.Getenv("DB_TYPE"); dbType != "" { + switch dbType { + case "mysql": + global.GVA_CONFIG.System.DbType = "mysql" + overrideMysqlFromEnv() + case "postgresql", "postgres": + global.GVA_CONFIG.System.DbType = "pgsql" + overridePgsqlFromEnv() + default: + global.GVA_CONFIG.System.DbType = "pgsql" + overridePgsqlFromEnv() + } + fmt.Printf("Database type overridden from DB_TYPE environment variable: %s\n", dbType) + } +} + +// overrideMysqlFromEnv 从环境变量覆盖 MySQL 配置 +func overrideMysqlFromEnv() { + cfg := &global.GVA_CONFIG.Mysql + if host := os.Getenv("DB_HOST"); host != "" { + cfg.Path = host + } + if port := os.Getenv("DB_PORT"); port != "" { + cfg.Port = port + } else { + cfg.Port = "3306" + } + if username := os.Getenv("DB_USERNAME"); username != "" { + cfg.Username = username + } + if password := os.Getenv("DB_PASSWORD"); password != "" { + cfg.Password = password + } + if dbname := os.Getenv("DB_DATABASE"); dbname != "" { + cfg.Dbname = dbname + } + if config := os.Getenv("DB_CONFIG"); config != "" { + cfg.Config = config + } +} + +// overridePgsqlFromEnv 从环境变量覆盖 PostgreSQL 配置 +func overridePgsqlFromEnv() { + cfg := &global.GVA_CONFIG.Pgsql + if host := os.Getenv("DB_HOST"); host != "" { + cfg.Path = host + } + if port := os.Getenv("DB_PORT"); port != "" { + cfg.Port = port + } else { + cfg.Port = "5432" + } + if username := os.Getenv("DB_USERNAME"); username != "" { + cfg.Username = username + } + if password := os.Getenv("DB_PASSWORD"); password != "" { + cfg.Password = password + } + if dbname := os.Getenv("DB_DATABASE"); dbname != "" { + cfg.Dbname = dbname + } + if config := os.Getenv("DB_CONFIG"); config != "" { + cfg.Config = config + } else { + cfg.Config = "sslmode=disable TimeZone=Asia/Shanghai" + } +} + +// overrideRedisFromEnv 从环境变量覆盖 Redis 配置 +func overrideRedisFromEnv() { + // 覆盖主 Redis 配置 + if host := os.Getenv("REDIS_HOST"); host != "" { + port := os.Getenv("REDIS_PORT") + if port == "" { + port = "6379" + } + global.GVA_CONFIG.Redis.Addr = host + ":" + port + } + if password := os.Getenv("REDIS_PASSWORD"); password != "" { + global.GVA_CONFIG.Redis.Password = password + } + if db := os.Getenv("REDIS_DB"); db != "" { + if dbNum, err := strconv.Atoi(db); err == nil { + global.GVA_CONFIG.Redis.DB = dbNum + } + } + + // 覆盖 Dify Redis 配置(与主 Redis 相同) + if host := os.Getenv("REDIS_HOST"); host != "" { + port := os.Getenv("REDIS_PORT") + if port == "" { + port = "6379" + } + global.GVA_CONFIG.DifyRedis.Addr = host + ":" + port + } + if password := os.Getenv("REDIS_PASSWORD"); password != "" { + global.GVA_CONFIG.DifyRedis.Password = password + } + if db := os.Getenv("REDIS_DB"); db != "" { + if dbNum, err := strconv.Atoi(db); err == nil { + global.GVA_CONFIG.DifyRedis.DB = dbNum + } + } + + fmt.Printf("Redis configuration overridden from environment variables: %s\n", global.GVA_CONFIG.Redis.Addr) +} + +// overrideAllFromEnv 从环境变量覆盖所有配置 +func overrideAllFromEnv() { + overrideDBFromEnv() + overrideRedisFromEnv() +} diff --git a/admin/server/core/viper.go b/admin/server/core/viper.go index 31f06e9f7..1004ff200 100644 --- a/admin/server/core/viper.go +++ b/admin/server/core/viper.go @@ -26,7 +26,7 @@ func overrideJWTSigningKeyFromEnv() { } // Viper // -// 优先级: 命令行 > 环境变量 > 默认值 +// 优先级:命令行 > 环境变量 > 默认值 // Author [SliverHorn](https://github.com/SliverHorn) func Viper(path ...string) *viper.Viper { var config string @@ -45,17 +45,17 @@ func Viper(path ...string) *viper.Viper { case gin.TestMode: config = internal.ConfigTestFile } - fmt.Printf("您正在使用gin模式的%s环境名称,config的路径为%s\n", gin.Mode(), config) - } else { // internal.ConfigEnv 常量存储的环境变量不为空 将值赋值于config + fmt.Printf("您正在使用 gin 模式的%s环境名称,config 的路径为%s\n", gin.Mode(), config) + } else { // internal.ConfigEnv 常量存储的环境变量不为空 将值赋值于 config config = configEnv - fmt.Printf("您正在使用%s环境变量,config的路径为%s\n", internal.ConfigEnv, config) + fmt.Printf("您正在使用%s环境变量,config 的路径为%s\n", internal.ConfigEnv, config) } - } else { // 命令行参数不为空 将值赋值于config - fmt.Printf("您正在使用命令行的-c参数传递的值,config的路径为%s\n", config) + } else { // 命令行参数不为空 将值赋值于 config + fmt.Printf("您正在使用命令行的 -c 参数传递的值,config 的路径为%s\n", config) } - } else { // 函数传递的可变参数的第一个值赋值于config + } else { // 函数传递的可变参数的第一个值赋值于 config config = path[0] - fmt.Printf("您正在使用func Viper()传递的值,config的路径为%s\n", config) + fmt.Printf("您正在使用 func Viper() 传递的值,config 的路径为%s\n", config) } v := viper.New() @@ -82,7 +82,11 @@ func Viper(path ...string) *viper.Viper { // Extend: Override JWT signing key from environment variable after initial load overrideJWTSigningKeyFromEnv() - // root 适配性 根据root位置去找到对应迁移位置,保证root路径有效 + // Extend: Override database and redis configuration from environment variables + // This allows admin-server to use the same configuration as docker-compose + overrideAllFromEnv() + + // root 适配性 根据 root 位置去找到对应迁移位置,保证 root 路径有效 global.GVA_CONFIG.AutoCode.Root, _ = filepath.Abs("..") return v diff --git a/admin/server/corn/main.go b/admin/server/corn/main.go index 5781151d3..ef2c16d79 100644 --- a/admin/server/corn/main.go +++ b/admin/server/corn/main.go @@ -21,11 +21,12 @@ func newWithSeconds() *cron.Cron { func Corn() { var lock bool + initDBService := system.InitDBService{} c := newWithSeconds() // 每分钟同步一次用户列表 if _, err := c.AddFunc("0 */1 * * * *", func() { - if global.GVA_DB == nil { - global.GVA_LOG.Info("【定时任务-每1分钟执行1次】同步用户列表任务,数据库没有初始化,暂未开始同步") + if global.GVA_DB == nil || !initDBService.IfInit() { + global.GVA_LOG.Info("【定时任务-每1分钟执行1次】同步用户列表任务,数据库没有初始化或尚未完成初始化,暂未开始同步") return } diff --git a/admin/server/initialize/router_biz.go b/admin/server/initialize/router_biz.go index ede454112..5e73890ba 100644 --- a/admin/server/initialize/router_biz.go +++ b/admin/server/initialize/router_biz.go @@ -22,6 +22,7 @@ func initBizRouter(routers ...*gin.RouterGroup) { gaiaRouter.InitSystemRouter(privateGroup) gaiaRouter.InitWorkflowRouter(privateGroup) gaiaRouter.InitAppVersionRouter(publicGroup, privateGroup) - gaiaRouter.InitModelProviderRouter(privateGroup) // 模型提供商路由 + gaiaRouter.InitModelProviderRouter(privateGroup) // 模型提供商路由 + gaiaRouter.InitForwardProxyRouter(publicGroup) // GPT 转发代理(免 JWT) } } diff --git a/admin/server/middleware/casbin_rbac.go b/admin/server/middleware/casbin_rbac.go index a1ca4c2b7..d07454c41 100644 --- a/admin/server/middleware/casbin_rbac.go +++ b/admin/server/middleware/casbin_rbac.go @@ -25,6 +25,11 @@ func CasbinHandler() gin.HandlerFunc { // 获取用户的角色 sub := strconv.Itoa(int(waitUse.AuthorityId)) e := casbinService.Casbin() // 判断策略中是否存在 + if e == nil { + global.GVA_LOG.Warn("Casbin enforcer is nil, skipping permission check") + c.Next() + return + } success, _ := e.Enforce(sub, obj, act) if !success { response.FailWithDetailed(gin.H{}, "权限不足", c) diff --git a/admin/server/model/gaia/request/system.go b/admin/server/model/gaia/request/system.go index 939a64187..a2432a0a5 100644 --- a/admin/server/model/gaia/request/system.go +++ b/admin/server/model/gaia/request/system.go @@ -1,5 +1,7 @@ package request +import "time" + // SystemOAuth2Error OAuth2 错误返回 type SystemOAuth2Error struct { Code int `json:"code" gorm:"comment:分类"` // 错误代码 @@ -11,53 +13,80 @@ type SystemOAuth2Request struct { Classify uint `json:"classify" gorm:"comment:分类"` // 分类 Status bool `json:"status" gorm:"comment:状态"` // 状态 ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址 - AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的URL"` // 申请认证的URL - TokenURL string `json:"token_url" gorm:"comment:获取Token的URL"` // 获取Token的URL - UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息URL"` // 获取用户信息的URL - LogoutURL string `json:"logout_url" gorm:"comment:退出登录回调URL"` // 退出登录回调URL - DiscoveryURL string `json:"discovery_url" gorm:"comment:OIDC发现配置URL"` // OIDC 发现配置URL + AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的 URL"` // 申请认证的 URL + TokenURL string `json:"token_url" gorm:"comment:获取 Token 的 URL"` // 获取 Token 的 URL + UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息 URL"` // 获取用户信息的 URL + LogoutURL string `json:"logout_url" gorm:"comment:退出登录回调 URL"` // 退出登录回调 URL + DiscoveryURL string `json:"discovery_url" gorm:"comment:OIDC 发现配置 URL"` // OIDC 发现配置 URL AppID string `json:"app_id" gorm:"comment:Client ID"` // Client ID AppSecret string `json:"app_secret" gorm:"comment:Client Secret"` // Client Secret UserNameField string `json:"user_name_field" gorm:"comment:用户名字段"` // 用户名字段 UserEmailField string `json:"user_email_field" gorm:"comment:邮箱字段"` // 邮箱字段 UserIDField string `json:"user_id_field" gorm:"comment:用户唯一标识字段"` // 用户唯一标识字段 - Scope string `json:"scope" gorm:"comment:授权范围scope"` // 授权范围 + Scope string `json:"scope" gorm:"comment:授权范围 scope"` // 授权范围 TokenAuthMethod string `json:"token_auth_method" gorm:"comment:令牌端点认证方式"` // client_secret_post|client_secret_basic RedirectUri string `json:"redirect_uri" gorm:"comment:测试用回调地址"` // 测试用回调地址 Test bool `json:"test" gorm:"default:0;comment:是否测试链接联通性"` // 是否测试链接联通性 - Code string `json:"code" gorm:"default:0;comment:code代码"` // code代码 + Code string `json:"code" gorm:"default:0;comment:code 代码"` // code 代码 } // AuthorizationConfig 认证配置 type AuthorizationConfig struct { Type string `json:"type"` // none | bearer | basic Token string `json:"token"` // Bearer Token - Username string `json:"username"` // Basic Auth用户名 - Password string `json:"password"` // Basic Auth密码 + Username string `json:"username"` // Basic Auth 用户名 + Password string `json:"password"` // Basic Auth 密码 } -// BodyData Body数据配置 +// BodyData Body 数据配置 type BodyData struct { - FormData []map[string]string `json:"form_data"` // form-data格式数据 - Urlencoded []map[string]string `json:"urlencoded"` // x-www-form-urlencoded格式数据 - Raw string `json:"raw"` // raw JSON字符串 + FormData []map[string]string `json:"form_data"` // form-data 格式数据 + Urlencoded []map[string]string `json:"urlencoded"` // x-www-form-urlencoded 格式数据 + Raw string `json:"raw"` // raw JSON 字符串 } -// EmailApiConfig 第三方邮箱API配置 +// EmailApiConfig 第三方邮箱 API 配置 type EmailApiConfig struct { + Enabled bool `json:"enabled"` // 是否启用 + URL string `json:"url"` // API 地址 + Method string `json:"method"` // HTTP 方法 + RequestParamField string `json:"request_param_field"` // 请求参数字段名 + BodyType string `json:"body_type"` // Body 类型:form-data | x-www-form-urlencoded | raw + Headers map[string]string `json:"headers"` // 请求头 + Authorization AuthorizationConfig `json:"authorization"` // 认证配置 + BodyData BodyData `json:"body_data"` // Body 数据 + ResponseEmailField string `json:"response_email_field"` // 响应邮箱字段路径 +} + +// DingIdApiConfig 第三方钉钉 ID 匹配用户 API 配置 +type DingIdApiConfig struct { Enabled bool `json:"enabled"` // 是否启用 - URL string `json:"url"` // API地址 - Method string `json:"method"` // HTTP方法 - RequestParamField string `json:"request_param_field"` // 请求参数字段名 - BodyType string `json:"body_type"` // Body类型: form-data | x-www-form-urlencoded | raw + URL string `json:"url"` // API 地址 + Method string `json:"method"` // HTTP 方法 + RequestParamField string `json:"request_param_field"` // 请求参数字段名(如 ding_id) + BodyType string `json:"body_type"` // Body 类型:form-data | x-www-form-urlencoded | raw Headers map[string]string `json:"headers"` // 请求头 - Authorization AuthorizationConfig `json:"authorization"` // 认证配置 - BodyData BodyData `json:"body_data"` // Body数据 - ResponseEmailField string `json:"response_email_field"` // 响应邮箱字段路径 + Authorization AuthorizationConfig `json:"authorization"` // 认证配置 + BodyData BodyData `json:"body_data"` // Body 数据 + ResponseUserNamePath string `json:"response_user_name_path"` // 响应用户名 JSON 路径(如 data.username) +} + +// ForwardToken 转发 Token 配置 +type ForwardToken struct { + ID string `json:"id"` // 前端生成的唯一 ID(用于删除) + TokenHash string `json:"token_hash"` // SHA256(token) + CreatedAt time.Time `json:"created_at"` // 创建时间 +} + +// ForwardConfig 转发集成配置 +type ForwardConfig struct { + Enabled bool `json:"enabled"` // 是否启用转发 + Tokens []ForwardToken `json:"tokens"` // Token 列表,最多 20 个 + DingIdApi DingIdApiConfig `json:"ding_id_api"` // 第三方钉钉 ID 匹配用户 API } // DingTalkConfigRequest 钉钉集成配置 type DingTalkConfigRequest struct { - EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱API配置 + EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱 API 配置 + ForwardConfig ForwardConfig `json:"forward_config"` // 转发集成配置 } - diff --git a/admin/server/router/gaia/enter.go b/admin/server/router/gaia/enter.go index d71978eb8..c487794b8 100644 --- a/admin/server/router/gaia/enter.go +++ b/admin/server/router/gaia/enter.go @@ -23,3 +23,4 @@ var testApi = api.ApiGroupApp.GaiaApiGroup.TestApi var batchWorkflowApi = api.ApiGroupApp.GaiaApiGroup.BatchWorkflowApi var appVersionApi = api.ApiGroupApp.GaiaApiGroup.AppVersionApi var modelProviderApi = api.ApiGroupApp.GaiaApiGroup.ModelProviderApi +var forwardProxyApi = api.ApiGroupApp.GaiaApiGroup.ForwardProxyApi diff --git a/admin/server/router/gaia/system.go b/admin/server/router/gaia/system.go index 0023ef298..d6acdc2c1 100644 --- a/admin/server/router/gaia/system.go +++ b/admin/server/router/gaia/system.go @@ -10,16 +10,26 @@ type SystemRouter struct{} func (s *SystemRouter) InitSystemRouter(Router *gin.RouterGroup) { systemRouter := Router.Group("gaia/system") { - systemRouter.GET("dingtalk", systemApi.GetDingTalk) // 获取钉钉系统配置 - systemRouter.POST("dingtalk", systemApi.SetDingTalk) // 设置钉钉系统配置 - systemRouter.GET("oauth2", systemOAuth2Api.GetOAuth2Config) // 获取OAuth2配置 - systemRouter.POST("oauth2", systemOAuth2Api.SetOAuth2Config) // 设置OAuth2配置 + systemRouter.GET("dingtalk", systemApi.GetDingTalk) // 获取钉钉系统配置 + systemRouter.POST("dingtalk", systemApi.SetDingTalk) // 设置钉钉系统配置 + systemRouter.GET("oauth2", systemOAuth2Api.GetOAuth2Config) // 获取 OAuth2 配置 + systemRouter.POST("oauth2", systemOAuth2Api.SetOAuth2Config) // 设置 OAuth2 配置 + // 转发 Token 管理 + systemRouter.GET("forward-tokens", systemApi.GetForwardTokens) // 获取转发 Token 列表 + systemRouter.POST("forward-tokens", systemApi.CreateForwardToken) // 新增转发 Token + systemRouter.DELETE("forward-tokens/:id", systemApi.DeleteForwardToken) // 删除转发 Token } } +// InitForwardProxyRouter 初始化 GPT 转发代理路由(免 JWT,挂在 PublicGroup) +func (s *SystemRouter) InitForwardProxyRouter(PublicRouter *gin.RouterGroup) { + // 免 JWT 转发入口,通过 forwarding token + ding_id 鉴权 + PublicRouter.Any("gaia/forward/proxy/*path", forwardProxyApi.ForwardProxy) +} + // InitModelProviderRouter 初始化模型提供商路由 func (s *SystemRouter) InitModelProviderRouter(Router *gin.RouterGroup) { - // 管理端API(需要JWT认证) + // 管理端 API(需要 JWT 认证) modelProviderRouter := Router.Group("gaia/model-provider") { modelProviderRouter.GET("list", modelProviderApi.GetProviderList) // 获取提供商配置列表 @@ -29,10 +39,10 @@ func (s *SystemRouter) InitModelProviderRouter(Router *gin.RouterGroup) { modelProviderRouter.GET("logs", modelProviderApi.GetProxyLogs) // 获取代理日志 } - // 第三方API(需要JWT认证) + // 第三方 API(需要 JWT 认证) gaiaRouter := Router.Group("gaia") { - gaiaRouter.GET("models", modelProviderApi.GetModels) // 获取开启的模型列表(OpenAI格式) - gaiaRouter.Any("proxy/*path", modelProviderApi.Proxy) // 通用中转API:按路径转发(v1/chat/completions、v1/messages、v1/images/generations、v1/embeddings 等) + gaiaRouter.GET("models", modelProviderApi.GetModels) // 获取开启的模型列表(OpenAI 格式) + gaiaRouter.Any("proxy/*path", modelProviderApi.Proxy) // 通用中转 API:按路径转发(v1/chat/completions、v1/messages、v1/images/generations、v1/embeddings 等) } } diff --git a/admin/server/service/gaia/system.go b/admin/server/service/gaia/system.go index b1c03d1cd..a1efa9d51 100644 --- a/admin/server/service/gaia/system.go +++ b/admin/server/service/gaia/system.go @@ -1,6 +1,7 @@ package gaia import ( + "context" "encoding/json" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "net/url" "os" "strings" + "time" "github.com/faabiosr/cachego/file" "github.com/fastwego/dingding" @@ -162,6 +164,16 @@ func (e *SystemIntegratedService) TestConnection(integrate gaia.SystemIntegratio global.GVA_LOG.Warn("第三方邮箱API配置验证失败", zap.Error(err)) // 不阻止保存,只记录警告 } + // 验证转发集成配置 + if err := e.ValidateForwardConfig(integrate); err != nil { + global.GVA_LOG.Warn("转发集成配置验证失败", zap.Error(err)) + // 不阻止保存,只记录警告 + } + // 验证第三方钉钉 ID 匹配 API 配置 + if err := e.ValidateDingIdApiConfig(integrate); err != nil { + global.GVA_LOG.Warn("第三方钉钉 ID 匹配 API 配置验证失败", zap.Error(err)) + // 不阻止保存,只记录警告 + } return nil case gaia.SystemIntegrationOAuth2: // 测试OAuth2连接 @@ -325,3 +337,286 @@ func (e *SystemIntegratedService) TestOAuth2Connection(integrate gaia.SystemInte return nil } + +// ValidateForwardConfig 验证转发集成配置 +// @Tags System Integrated +// @Summary 验证转发集成配置 +// @param: integrate gaia.SystemIntegration +// @return: error +func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemIntegration) error { + // 解析 Config 字段 + if integrate.Config == "" { + return nil // 配置为空不算错误 + } + + var configMap request.DingTalkConfigRequest + if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil { + return fmt.Errorf("解析配置失败:%s", err.Error()) + } + + // 检查是否启用转发 + if !configMap.ForwardConfig.Enabled { + return nil // 未启用不需要验证 + } + + // 验证 Token 数量 + if len(configMap.ForwardConfig.Tokens) > 20 { + return errors.New("转发 Token 最多 20 个") + } + + // 验证每个 Token 的必填字段 + for i, token := range configMap.ForwardConfig.Tokens { + if token.ID == "" { + return fmt.Errorf("第%d个 Token 的 ID 不能为空", i+1) + } + if token.TokenHash == "" { + return fmt.Errorf("第%d个 Token 的 TokenHash 不能为空", i+1) + } + } + + global.GVA_LOG.Info("转发集成配置验证通过", + zap.Bool("enabled", configMap.ForwardConfig.Enabled), + zap.Int("token_count", len(configMap.ForwardConfig.Tokens))) + + return nil +} + +// ValidateDingIdApiConfig 验证第三方钉钉 ID 匹配 API 配置 +// @Tags System Integrated +// @Summary 验证第三方钉钉 ID 匹配 API 配置 +// @param: integrate gaia.SystemIntegration +// @return: error +func (e *SystemIntegratedService) ValidateDingIdApiConfig(integrate gaia.SystemIntegration) error { + // 解析 Config 字段 + if integrate.Config == "" { + return nil // 配置为空不算错误 + } + + var configMap request.DingTalkConfigRequest + if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil { + return fmt.Errorf("解析配置失败:%s", err.Error()) + } + + // 检查是否启用 DingIdApi + if !configMap.ForwardConfig.DingIdApi.Enabled { + return nil // 未启用不需要验证 + } + + // 验证必填字段 + if configMap.ForwardConfig.DingIdApi.URL == "" { + return errors.New("钉钉 ID 匹配 API URL 不能为空") + } + + if configMap.ForwardConfig.DingIdApi.Method == "" { + configMap.ForwardConfig.DingIdApi.Method = "GET" + } + + if configMap.ForwardConfig.DingIdApi.RequestParamField == "" { + return errors.New("钉钉 ID 请求字段不能为空") + } + + if configMap.ForwardConfig.DingIdApi.ResponseUserNamePath == "" { + return errors.New("响应用户名路径不能为空") + } + + // 验证 Body 类型(仅 POST/PUT/DELETE 需要) + if configMap.ForwardConfig.DingIdApi.Method != "GET" && configMap.ForwardConfig.DingIdApi.Method != "HEAD" { + bodyType := strings.ToLower(configMap.ForwardConfig.DingIdApi.BodyType) + if bodyType == "" { + configMap.ForwardConfig.DingIdApi.BodyType = "raw" // 默认 raw + } else if bodyType != "form-data" && bodyType != "x-www-form-urlencoded" && bodyType != "raw" { + return fmt.Errorf("不支持的 Body 类型:%s,支持的类型:form-data, x-www-form-urlencoded, raw", bodyType) + } + } + + // 验证 Authorization 配置 + authType := strings.ToLower(configMap.ForwardConfig.DingIdApi.Authorization.Type) + if authType != "" && authType != "none" { + if authType == "bearer" { + if configMap.ForwardConfig.DingIdApi.Authorization.Token == "" { + return errors.New("Bearer Token 不能为空") + } + } else if authType == "basic" { + if configMap.ForwardConfig.DingIdApi.Authorization.Username == "" || configMap.ForwardConfig.DingIdApi.Authorization.Password == "" { + return errors.New("Basic Auth 需要填写 Username 和 Password") + } + } else { + return fmt.Errorf("不支持的 Authorization 类型:%s,支持的类型:none, bearer, basic", authType) + } + } + + global.GVA_LOG.Info("第三方钉钉 ID 匹配 API 配置验证通过", + zap.String("url", configMap.ForwardConfig.DingIdApi.URL), + zap.String("method", configMap.ForwardConfig.DingIdApi.Method), + zap.String("body_type", configMap.ForwardConfig.DingIdApi.BodyType), + zap.String("auth_type", configMap.ForwardConfig.DingIdApi.Authorization.Type)) + + return nil +} + +// extractJSONPath 按点分路径从 JSON 对象中提取字符串值,支持 "data.username" 等多层路径 +func extractJSONPath(data map[string]interface{}, path string) string { + parts := strings.SplitN(path, ".", 2) + val, ok := data[parts[0]] + if !ok { + return "" + } + if len(parts) == 1 { + if s, ok := val.(string); ok { + return s + } + return fmt.Sprintf("%v", val) + } + if nested, ok := val.(map[string]interface{}); ok { + return extractJSONPath(nested, parts[1]) + } + return "" +} + +// callDingIdApi 调用第三方钉钉 ID 匹配 API,返回 username +func (e *SystemIntegratedService) callDingIdApi(dingId string, config request.DingIdApiConfig) (string, error) { + method := strings.ToUpper(config.Method) + if method == "" { + method = "GET" + } + + var bodyReader io.Reader + if method != "GET" && method != "HEAD" { + switch strings.ToLower(config.BodyType) { + case "raw": + // 替换 Body 中的 {{ding_id}} 占位符 + raw := strings.ReplaceAll(config.BodyData.Raw, "{{ding_id}}", dingId) + bodyReader = strings.NewReader(raw) + case "form-data", "x-www-form-urlencoded": + form := url.Values{} + for _, kv := range config.BodyData.Urlencoded { + for k, v := range kv { + form.Set(k, strings.ReplaceAll(v, "{{ding_id}}", dingId)) + } + } + if config.BodyType == "form-data" { + for _, kv := range config.BodyData.FormData { + for k, v := range kv { + form.Set(k, strings.ReplaceAll(v, "{{ding_id}}", dingId)) + } + } + } + bodyReader = strings.NewReader(form.Encode()) + } + } + + // 构建请求 URL(GET 时把 ding_id 拼入 query) + apiURL := config.URL + if method == "GET" || method == "HEAD" { + if config.RequestParamField != "" { + sep := "?" + if strings.Contains(apiURL, "?") { + sep = "&" + } + apiURL = apiURL + sep + url.QueryEscape(config.RequestParamField) + "=" + url.QueryEscape(dingId) + } + } + + req, err := http.NewRequest(method, apiURL, bodyReader) + if err != nil { + return "", fmt.Errorf("构建请求失败:%s", err.Error()) + } + + // 设置 Headers + for k, v := range config.Headers { + req.Header.Set(k, v) + } + + // 设置 Authorization + authType := strings.ToLower(config.Authorization.Type) + switch authType { + case "bearer": + req.Header.Set("Authorization", "Bearer "+config.Authorization.Token) + case "basic": + req.SetBasicAuth(config.Authorization.Username, config.Authorization.Password) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("请求失败:%s", err.Error()) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("第三方 API 返回错误状态码:%d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败:%s", err.Error()) + } + + var respJSON map[string]interface{} + if err = json.Unmarshal(respBody, &respJSON); err != nil { + return "", fmt.Errorf("解析响应 JSON 失败:%s", err.Error()) + } + + userName := extractJSONPath(respJSON, config.ResponseUserNamePath) + if userName == "" { + return "", fmt.Errorf("响应中未找到用户名(路径:%s)", config.ResponseUserNamePath) + } + + return userName, nil +} + +// ResolveAccountByDingId 通过钉钉 ID 解析 gaia account_id +// 解析顺序:Redis 缓存 → AccountDingTalkExtend 本地表 → 第三方 DingIdApi +func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfig request.DingIdApiConfig) (string, error) { + ctx := context.Background() + redisKey := "gaia:forward:ding:" + dingId + + // 1. 查 Redis 缓存 + if cached, err := global.GVA_REDIS.Get(ctx, redisKey).Result(); err == nil && cached != "" { + global.GVA_LOG.Info("ResolveAccountByDingId: Redis 命中", zap.String("ding_id", dingId), zap.String("account_id", cached)) + return cached, nil + } + + // 2. 查本地 AccountDingTalkExtend 表 + var extend gaia.AccountDingTalkExtend + if err := global.GVA_DB.Where("ding_talk = ?", dingId).First(&extend).Error; err == nil { + accountID := extend.ID.String() + global.GVA_LOG.Info("ResolveAccountByDingId: 本地表命中", zap.String("ding_id", dingId), zap.String("account_id", accountID)) + global.GVA_REDIS.Set(ctx, redisKey, accountID, 24*time.Hour) + return accountID, nil + } + + // 3. 第三方 DingIdApi(若配置且启用) + if !apiConfig.Enabled || apiConfig.URL == "" { + return "", fmt.Errorf("未找到 ding_id=%s 对应的用户,且未配置第三方 API", dingId) + } + + userName, err := e.callDingIdApi(dingId, apiConfig) + if err != nil { + return "", fmt.Errorf("调用第三方 DingId API 失败:%s", err.Error()) + } + + // 4. 按 username 查 accounts 表(匹配 name 字段) + var account gaia.Account + if err = global.GVA_DB.Where("name = ?", userName).First(&account).Error; err != nil { + return "", fmt.Errorf("用户名 %s 不存在(来自第三方 API)", userName) + } + + accountID := account.ID.String() + + // 5. 写回 AccountDingTalkExtend,方便下次本地命中 + global.GVA_DB.Create(&gaia.AccountDingTalkExtend{ + ID: account.ID, + DingTalk: dingId, + }) + + // 6. 写 Redis 缓存 + global.GVA_REDIS.Set(ctx, redisKey, accountID, 24*time.Hour) + + global.GVA_LOG.Info("ResolveAccountByDingId: 第三方 API 解析成功", + zap.String("ding_id", dingId), + zap.String("username", userName), + zap.String("account_id", accountID)) + + return accountID, nil +} diff --git a/admin/server/service/system/sys_casbin.go b/admin/server/service/system/sys_casbin.go index 32edc579e..1037aa496 100644 --- a/admin/server/service/system/sys_casbin.go +++ b/admin/server/service/system/sys_casbin.go @@ -171,8 +171,10 @@ func (casbinService *CasbinService) AddPolicies(db *gorm.DB, rules [][]string) e func (CasbinService *CasbinService) FreshCasbin() (err error) { e := CasbinService.Casbin() - err = e.LoadPolicy() - return err + if e == nil { + return errors.New("casbin enforcer is nil, please check database initialization") + } + return e.LoadPolicy() } //@author: [piexlmax](https://github.com/piexlmax) @@ -187,6 +189,10 @@ var ( func (casbinService *CasbinService) Casbin() *casbin.SyncedCachedEnforcer { once.Do(func() { + if global.GVA_DB == nil { + zap.L().Warn("Casbin initialization skipped: global.GVA_DB is nil") + return + } a, err := gormadapter.NewAdapterByDB(global.GVA_DB) if err != nil { zap.L().Error("适配数据库失败请检查casbin表是否为InnoDB引擎!", zap.Error(err)) diff --git a/admin/server/service/system/sys_initdb.go b/admin/server/service/system/sys_initdb.go index 4aa040b76..510ca07c1 100644 --- a/admin/server/service/system/sys_initdb.go +++ b/admin/server/service/system/sys_initdb.go @@ -8,6 +8,7 @@ import ( "github.com/flipped-aurora/gin-vue-admin/server/global" modelSystem "github.com/flipped-aurora/gin-vue-admin/server/model/system" "github.com/flipped-aurora/gin-vue-admin/server/model/system/request" + "go.uber.org/zap" "gorm.io/gorm" "sort" ) @@ -141,6 +142,7 @@ func (initDBService *InitDBService) InitDB(conf request.InitDB) (err error) { global.GVA_DB = db db.Exec("DELETE FROM sys_base_menus") db.Exec("DELETE FROM sys_authorities") + db.Exec("DELETE FROM sys_user_authority") if err = initHandler.InitTables(ctx, initializers); err != nil { return err } @@ -151,6 +153,10 @@ func (initDBService *InitDBService) InitDB(conf request.InitDB) (err error) { if err = initHandler.WriteConfig(ctx); err != nil { return err } + // 初始化完成后刷新 Casbin 策略,避免使用旧的或空的策略 + if err = CasbinServiceApp.FreshCasbin(); err != nil { + global.GVA_LOG.Warn("refresh casbin policy after InitDB failed", zap.Error(err)) + } initializers = initSlice{} cache = map[string]*orderedInitializer{} return nil diff --git a/admin/server/service/system/sys_initdb_pgsql.go b/admin/server/service/system/sys_initdb_pgsql.go index a31eaf453..c9f084ef3 100644 --- a/admin/server/service/system/sys_initdb_pgsql.go +++ b/admin/server/service/system/sys_initdb_pgsql.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/spf13/viper" + "go.uber.org/zap" "path/filepath" "github.com/flipped-aurora/gin-vue-admin/server/config" @@ -35,10 +36,10 @@ func (h PgsqlInitHandler) WriteConfig(ctx context.Context) error { // 改成拿dify的配置,如果不是docker运行,则从dify api的.env文件中获取jwt的加密key if !global.GVA_CONFIG.System.DockerRun { - var err error - global.GVA_CONFIG.JWT.SigningKey, err = h.GetJwtSigningKeyFormDifyApiEnv() - if err != nil { - return err + if secretKey, err := h.GetJwtSigningKeyFormDifyApiEnv(); err != nil { + global.GVA_LOG.Warn("failed to load JWT signing key from Dify API .env, using existing configuration", zap.Error(err)) + } else if secretKey != "" { + global.GVA_CONFIG.JWT.SigningKey = secretKey } } cs := utils.StructToMap(global.GVA_CONFIG) diff --git a/admin/server/source/system/api.go b/admin/server/source/system/api.go index 96ecfd5ab..d0812e645 100644 --- a/admin/server/source/system/api.go +++ b/admin/server/source/system/api.go @@ -244,6 +244,10 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "模型管理", Method: "PATCH", Path: "/gaia/proxy/*", Description: "中转API(第三方)-PATCH"}, {ApiGroup: "模型管理", Method: "DELETE", Path: "/gaia/proxy/*", Description: "中转API(第三方)-DELETE"}, // Extend Stop: model provider + // 转发 Token 管理 + {ApiGroup: "转发集成", Method: "GET", Path: "/gaia/system/forward-tokens", Description: "获取转发 Token 列表"}, + {ApiGroup: "转发集成", Method: "POST", Path: "/gaia/system/forward-tokens", Description: "新增转发 Token"}, + {ApiGroup: "转发集成", Method: "DELETE", Path: "/gaia/system/forward-tokens/:id", Description: "删除转发 Token"}, } if err := db.Create(&entities).Error; err != nil { return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!") diff --git a/admin/web/src/api/gaia/system.js b/admin/web/src/api/gaia/system.js index 2ee9208ac..a86da6f2d 100644 --- a/admin/web/src/api/gaia/system.js +++ b/admin/web/src/api/gaia/system.js @@ -41,7 +41,7 @@ export const getSystemOAuth2 = () => { } // @Tags systrm -// @Summary 修改OAuth2集成配置 +// @Summary 修改 OAuth2 集成配置 // @Security ApiKeyAuth // @Produce application/json // @Success 200 {string} string "{"success":true,"data":{},"msg":"返回成功"}" @@ -53,3 +53,38 @@ export const setSystemOAuth2 = (data) => { data, }) } + +// @Tags systrm +// @Summary 获取转发 Token 列表 +// @Security ApiKeyAuth +// @Router /gaia/system/forward-tokens [get] +export const getForwardTokens = () => { + return service({ + url: '/gaia/system/forward-tokens', + method: 'get' + }) +} + +// @Tags systrm +// @Summary 新增转发 Token +// @Security ApiKeyAuth +// @Router /gaia/system/forward-tokens [post] +export const createForwardToken = (data) => { + return service({ + url: '/gaia/system/forward-tokens', + method: 'post', + data, + }) +} + +// @Tags systrm +// @Summary 删除转发 Token +// @Security ApiKeyAuth +// @Router /gaia/system/forward-tokens/:id [delete] +export const deleteForwardToken = (id, password) => { + return service({ + url: `/gaia/system/forward-tokens/${id}`, + method: 'delete', + data: { password }, + }) +} diff --git a/admin/web/src/view/systemIntegrated/dingTalk/index.vue b/admin/web/src/view/systemIntegrated/dingTalk/index.vue index 92ec1099c..6632bba20 100644 --- a/admin/web/src/view/systemIntegrated/dingTalk/index.vue +++ b/admin/web/src/view/systemIntegrated/dingTalk/index.vue @@ -364,7 +364,147 @@ + + +
+
+ 转发集成配置 + 已启用 + 未启用 +
+

为第三方系统(如钉钉入口)提供免登录转发代理能力,通过 Token 鉴权后根据钉钉 ID 自动计费

+ + + + +
+
+ 启用转发: + + {{ forwardConfig.enabled ? '已启用' : '未启用' }} +
+
+ + + + +
+
+
+ 转发 Token 列表 +
+
+ {{ forwardTokenList.length }}/20 个 + + + 新增 Token + +
+
+ + + + + + + + + + + + +
+ + + + +
+
+ 第三方钉钉 ID 匹配用户 API +
+

当本地表中找不到钉钉 ID 对应用户时,调用此 API 通过 ding_id 获取用户名

+
+
+ 启用: + + {{ dingIdApiConfig.enabled ? '已启用' : '未启用' }} +
+
+ API URL: + + {{ dingIdApiConfig.url || '未配置' }} +
+
+ HTTP 方法: + + + + + + + {{ dingIdApiConfig.method || 'GET' }} +
+
+ 请求参数字段: + + {{ dingIdApiConfig.request_param_field || 'ding_id' }} +
+
+ 响应用户名路径: + + {{ dingIdApiConfig.response_user_name_path || '未配置' }} +
+
+
+
+ + + +
+

输入 Token 明文,系统将存储其 SHA256 哈希。Token 仅展示一次,请妥善保管。

+ +
+
+ + + + +
+ +
+ + + +

删除操作需要验证您的登录密码,请输入后确认。

+ + +
@@ -373,7 +513,7 @@ import { ref, computed, watch } from 'vue' import { ElMessage } from 'element-plus' import { QuestionFilled } from '@element-plus/icons-vue' -import { getSystemDingTalk, setSystemDingTalk } from "@/api/gaia/system"; +import { getSystemDingTalk, setSystemDingTalk, getForwardTokens, createForwardToken, deleteForwardToken } from "@/api/gaia/system"; defineOptions({ name: 'IntegratedDingTalk', @@ -411,6 +551,109 @@ const emailApiConfig = ref({ response_email_field: 'data[0].userName' }) +// 转发集成配置 +const forwardConfig = ref({ enabled: false, tokens: [] }) + +// 第三方钉钉 ID 匹配 API 配置 +const dingIdApiConfig = ref({ + enabled: false, + url: '', + method: 'GET', + request_param_field: 'ding_id', + body_type: 'raw', + headers: {}, + authorization: { type: 'none', token: '', username: '', password: '' }, + body_data: { raw: '', form_data: [], urlencoded: [] }, + response_user_name_path: 'data.username', +}) + +// 转发 Token 列表 +const forwardTokenList = ref([]) + +// 新增 Token 弹窗 +const showCreateTokenDialog = ref(false) +const newTokenInput = ref('') +const newTokenValue = ref('') +const creatingToken = ref(false) + +// 删除 Token 弹窗 +const showDeleteTokenDialog = ref(false) +const deleteTokenPassword = ref('') +const deletingTokenId = ref('') +const deletingToken = ref(false) + +// 格式化日期 +const formatDate = (dateStr) => { + if (!dateStr) return '' + return new Date(dateStr).toLocaleString('zh-CN', { hour12: false }) +} + +// 加载转发 Token 列表 +const loadForwardTokens = async () => { + const res = await getForwardTokens() + if (res.code === 0) { + forwardTokenList.value = res.data?.tokens || [] + } +} + +// 生成随机 Token +const generateToken = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let token = '' + for (let i = 0; i < 48; i++) { + token += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return token +} + +// 复制 Token +const copyToken = (token) => { + navigator.clipboard.writeText(token).then(() => { + ElMessage({ type: 'success', message: 'Token 已复制到剪贴板' }) + }) +} + +// 创建 Token +const handleCreateToken = async () => { + creatingToken.value = true + const token = newTokenInput.value.trim() || generateToken() + const res = await createForwardToken({ token }) + creatingToken.value = false + if (res.code === 0) { + newTokenValue.value = res.data?.token || token + ElMessage({ type: 'success', message: 'Token 创建成功' }) + } else { + ElMessage({ type: 'error', message: res.msg || '创建失败' }) + } +} + +// 打开删除 Token 弹窗 +const openDeleteTokenDialog = (id) => { + deletingTokenId.value = id + deleteTokenPassword.value = '' + showDeleteTokenDialog.value = true +} + +// 删除 Token +const handleDeleteToken = async () => { + if (!deleteTokenPassword.value) { + ElMessage({ type: 'warning', message: '请输入登录密码' }) + return + } + deletingToken.value = true + const res = await deleteForwardToken(deletingTokenId.value, deleteTokenPassword.value) + deletingToken.value = false + if (res.code === 0) { + ElMessage({ type: 'success', message: '删除成功' }) + showDeleteTokenDialog.value = false + deleteTokenPassword.value = '' + deletingTokenId.value = '' + await loadForwardTokens() + } else { + ElMessage({ type: 'error', message: res.msg || '删除失败' }) + } +} + // 标签页管理 const activeTab = ref('headers') @@ -730,11 +973,27 @@ const initForm = async() => { } } } + + // 解析转发集成配置 + if (configData.forward_config) { + forwardConfig.value = { + enabled: configData.forward_config.enabled || false, + tokens: configData.forward_config.tokens || [], + } + if (configData.forward_config.ding_id_api) { + dingIdApiConfig.value = { + ...dingIdApiConfig.value, + ...configData.forward_config.ding_id_api, + } + } + } } catch (e) { console.error('解析邮箱API配置失败:', e) } } } + // 加载 Token 列表 + await loadForwardTokens() } initForm() @@ -792,6 +1051,11 @@ const update = async() => { ...emailApiConfig.value, headers, body_data: bodyData + }, + forward_config: { + enabled: forwardConfig.value.enabled, + tokens: forwardConfig.value.tokens, + ding_id_api: { ...dingIdApiConfig.value }, } } config.value.config = JSON.stringify(configData) diff --git a/docs/1.11.4升级到1.12.2需要执行的权限SQL.sql b/docs/1.11.4升级到1.12.2需要执行的权限SQL.sql index 2b5e367ff..307a05627 100644 --- a/docs/1.11.4升级到1.12.2需要执行的权限SQL.sql +++ b/docs/1.11.4升级到1.12.2需要执行的权限SQL.sql @@ -153,4 +153,19 @@ 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); +-- --------------- 9. API sys_apis (转发集成:转发 Token 管理 3 条) 2026-03-09 18:08:33 --------------- +-- 请按当前库最大 id 调整起始 id,避免冲突。例如 MAX(id)=269 则从 270 起 +INSERT INTO sys_apis (id, created_at, updated_at, deleted_at, path, description, api_group, method) VALUES +(270, NOW(), NOW(), NULL, '/gaia/system/forward-tokens', '获取转发 Token 列表', '转发集成', 'GET'), +(271, NOW(), NOW(), NULL, '/gaia/system/forward-tokens', '新增转发 Token', '转发集成', 'POST'), +(272, NOW(), NOW(), NULL, '/gaia/system/forward-tokens/:id', '删除转发 Token', '转发集成', 'DELETE'); + +-- --------------- 10. Casbin 规则 casbin_rule (转发集成 888/8881) --------------- +INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES +('p', '888', '/gaia/system/forward-tokens', 'GET'), +('p', '888', '/gaia/system/forward-tokens', 'POST'), +('p', '888', '/gaia/system/forward-tokens/:id', 'DELETE'), +('p', '8881', '/gaia/system/forward-tokens', 'GET'), +('p', '8881', '/gaia/system/forward-tokens', 'POST'), +('p', '8881', '/gaia/system/forward-tokens/:id', 'DELETE');