feat: 钉钉机器人转发(未测试)

fix: admin初始化出错
This commit is contained in:
npc0-hue
2026-03-09 22:34:02 +08:00
parent 1b447b7b0b
commit 5962b9b518
23 changed files with 1052 additions and 58 deletions
+3
View File
@@ -209,6 +209,9 @@ api/.vscode
.history .history
.idea/ .idea/
.claude/
.cursor/
openspec/
# pnpm # pnpm
/.pnpm-store /.pnpm-store
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:alpine as builder FROM golang:alpine AS builder
RUN mkdir /app RUN mkdir /app
WORKDIR /app WORKDIR /app
+1
View File
@@ -12,6 +12,7 @@ type ApiGroup struct {
BatchWorkflowApi BatchWorkflowApi
AppVersionApi AppVersionApi
ModelProviderApi ModelProviderApi
ForwardProxyApi
} }
var ( var (
+187
View File
@@ -2,10 +2,18 @@ package gaia
import ( import (
"context" "context"
"crypto/sha256"
"encoding/json"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global" "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/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia" "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/gin-gonic/gin"
"github.com/google/uuid"
"time"
) )
type SystemApi struct{} type SystemApi struct{}
@@ -55,3 +63,182 @@ func (systemApi *SystemApi) SetDingTalk(c *gin.Context) {
} }
response.OkWithData("ok", c) 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)
}
+1 -1
View File
@@ -20,7 +20,7 @@ type DBApi struct{}
// @Router /init/initdb [post] // @Router /init/initdb [post]
func (i *DBApi) InitDB(c *gin.Context) { func (i *DBApi) InitDB(c *gin.Context) {
if !initDBService.IfInit() { if initDBService.IfInit() {
global.GVA_LOG.Error("已存在数据库配置!") global.GVA_LOG.Error("已存在数据库配置!")
response.FailWithMessage("已存在数据库配置", c) response.FailWithMessage("已存在数据库配置", c)
return return
+4 -4
View File
@@ -113,10 +113,10 @@ pgsql:
prefix: "" prefix: ""
port: "5432" port: "5432"
config: sslmode=disable TimeZone=Asia/Shanghai config: sslmode=disable TimeZone=Asia/Shanghai
db-name: db-name: dify
username: username: postgres
password: password: difyai123456
path: path: db_postgres
engine: "" engine: ""
log-mode: error log-mode: error
max-idle-conns: 10 max-idle-conns: 10
+1 -1
View File
@@ -152,7 +152,7 @@ pgsql:
db-name: dify db-name: dify
username: postgres username: postgres
password: difyai123456 password: difyai123456
path: 127.0.0.01 path: 127.0.0.1
engine: "" engine: ""
log-mode: error log-mode: error
max-idle-conns: 10 max-idle-conns: 10
+126
View File
@@ -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()
}
+13 -9
View File
@@ -26,7 +26,7 @@ func overrideJWTSigningKeyFromEnv() {
} }
// Viper // // Viper //
// 优先级: 命令行 > 环境变量 > 默认值 // 优先级命令行 > 环境变量 > 默认值
// Author [SliverHorn](https://github.com/SliverHorn) // Author [SliverHorn](https://github.com/SliverHorn)
func Viper(path ...string) *viper.Viper { func Viper(path ...string) *viper.Viper {
var config string var config string
@@ -45,17 +45,17 @@ func Viper(path ...string) *viper.Viper {
case gin.TestMode: case gin.TestMode:
config = internal.ConfigTestFile config = internal.ConfigTestFile
} }
fmt.Printf("您正在使用gin模式的%s环境名称,config的路径为%s\n", gin.Mode(), config) fmt.Printf("您正在使用 gin 模式的%s环境名称config 的路径为%s\n", gin.Mode(), config)
} else { // internal.ConfigEnv 常量存储的环境变量不为空 将值赋值于config } else { // internal.ConfigEnv 常量存储的环境变量不为空 将值赋值于 config
config = configEnv config = configEnv
fmt.Printf("您正在使用%s环境变量,config的路径为%s\n", internal.ConfigEnv, config) fmt.Printf("您正在使用%s环境变量config 的路径为%s\n", internal.ConfigEnv, config)
} }
} else { // 命令行参数不为空 将值赋值于config } else { // 命令行参数不为空 将值赋值于 config
fmt.Printf("您正在使用命令行的-c参数传递的值,config的路径为%s\n", config) fmt.Printf("您正在使用命令行的 -c 参数传递的值config 的路径为%s\n", config)
} }
} else { // 函数传递的可变参数的第一个值赋值于config } else { // 函数传递的可变参数的第一个值赋值于 config
config = path[0] config = path[0]
fmt.Printf("您正在使用func Viper()传递的值,config的路径为%s\n", config) fmt.Printf("您正在使用 func Viper() 传递的值config 的路径为%s\n", config)
} }
v := viper.New() v := viper.New()
@@ -82,7 +82,11 @@ func Viper(path ...string) *viper.Viper {
// Extend: Override JWT signing key from environment variable after initial load // Extend: Override JWT signing key from environment variable after initial load
overrideJWTSigningKeyFromEnv() 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("..") global.GVA_CONFIG.AutoCode.Root, _ = filepath.Abs("..")
return v return v
+3 -2
View File
@@ -21,11 +21,12 @@ func newWithSeconds() *cron.Cron {
func Corn() { func Corn() {
var lock bool var lock bool
initDBService := system.InitDBService{}
c := newWithSeconds() c := newWithSeconds()
// 每分钟同步一次用户列表 // 每分钟同步一次用户列表
if _, err := c.AddFunc("0 */1 * * * *", func() { if _, err := c.AddFunc("0 */1 * * * *", func() {
if global.GVA_DB == nil { if global.GVA_DB == nil || !initDBService.IfInit() {
global.GVA_LOG.Info("【定时任务-每1分钟执行1次】同步用户列表任务,数据库没有初始化,暂未开始同步") global.GVA_LOG.Info("【定时任务-每1分钟执行1次】同步用户列表任务,数据库没有初始化或尚未完成初始化,暂未开始同步")
return return
} }
+2 -1
View File
@@ -22,6 +22,7 @@ func initBizRouter(routers ...*gin.RouterGroup) {
gaiaRouter.InitSystemRouter(privateGroup) gaiaRouter.InitSystemRouter(privateGroup)
gaiaRouter.InitWorkflowRouter(privateGroup) gaiaRouter.InitWorkflowRouter(privateGroup)
gaiaRouter.InitAppVersionRouter(publicGroup, privateGroup) gaiaRouter.InitAppVersionRouter(publicGroup, privateGroup)
gaiaRouter.InitModelProviderRouter(privateGroup) // 模型提供商路由 gaiaRouter.InitModelProviderRouter(privateGroup) // 模型提供商路由
gaiaRouter.InitForwardProxyRouter(publicGroup) // GPT 转发代理(免 JWT
} }
} }
+5
View File
@@ -25,6 +25,11 @@ func CasbinHandler() gin.HandlerFunc {
// 获取用户的角色 // 获取用户的角色
sub := strconv.Itoa(int(waitUse.AuthorityId)) sub := strconv.Itoa(int(waitUse.AuthorityId))
e := casbinService.Casbin() // 判断策略中是否存在 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) success, _ := e.Enforce(sub, obj, act)
if !success { if !success {
response.FailWithDetailed(gin.H{}, "权限不足", c) response.FailWithDetailed(gin.H{}, "权限不足", c)
+52 -23
View File
@@ -1,5 +1,7 @@
package request package request
import "time"
// SystemOAuth2Error OAuth2 错误返回 // SystemOAuth2Error OAuth2 错误返回
type SystemOAuth2Error struct { type SystemOAuth2Error struct {
Code int `json:"code" gorm:"comment:分类"` // 错误代码 Code int `json:"code" gorm:"comment:分类"` // 错误代码
@@ -11,53 +13,80 @@ type SystemOAuth2Request struct {
Classify uint `json:"classify" gorm:"comment:分类"` // 分类 Classify uint `json:"classify" gorm:"comment:分类"` // 分类
Status bool `json:"status" gorm:"comment:状态"` // 状态 Status bool `json:"status" gorm:"comment:状态"` // 状态
ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址 ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址
AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的URL"` // 申请认证的URL AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的 URL"` // 申请认证的 URL
TokenURL string `json:"token_url" gorm:"comment:获取TokenURL"` // 获取TokenURL TokenURL string `json:"token_url" gorm:"comment:获取 TokenURL"` // 获取 TokenURL
UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息URL"` // 获取用户信息的URL UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息 URL"` // 获取用户信息的 URL
LogoutURL string `json:"logout_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 DiscoveryURL string `json:"discovery_url" gorm:"comment:OIDC 发现配置 URL"` // OIDC 发现配置 URL
AppID string `json:"app_id" gorm:"comment:Client ID"` // Client ID AppID string `json:"app_id" gorm:"comment:Client ID"` // Client ID
AppSecret string `json:"app_secret" gorm:"comment:Client Secret"` // Client Secret AppSecret string `json:"app_secret" gorm:"comment:Client Secret"` // Client Secret
UserNameField string `json:"user_name_field" gorm:"comment:用户名字段"` // 用户名字段 UserNameField string `json:"user_name_field" gorm:"comment:用户名字段"` // 用户名字段
UserEmailField string `json:"user_email_field" gorm:"comment:邮箱字段"` // 邮箱字段 UserEmailField string `json:"user_email_field" gorm:"comment:邮箱字段"` // 邮箱字段
UserIDField string `json:"user_id_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 TokenAuthMethod string `json:"token_auth_method" gorm:"comment:令牌端点认证方式"` // client_secret_post|client_secret_basic
RedirectUri string `json:"redirect_uri" gorm:"comment:测试用回调地址"` // 测试用回调地址 RedirectUri string `json:"redirect_uri" gorm:"comment:测试用回调地址"` // 测试用回调地址
Test bool `json:"test" gorm:"default:0;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 认证配置 // AuthorizationConfig 认证配置
type AuthorizationConfig struct { type AuthorizationConfig struct {
Type string `json:"type"` // none | bearer | basic Type string `json:"type"` // none | bearer | basic
Token string `json:"token"` // Bearer Token Token string `json:"token"` // Bearer Token
Username string `json:"username"` // Basic Auth用户名 Username string `json:"username"` // Basic Auth 用户名
Password string `json:"password"` // Basic Auth密码 Password string `json:"password"` // Basic Auth 密码
} }
// BodyData Body数据配置 // BodyData Body 数据配置
type BodyData struct { type BodyData struct {
FormData []map[string]string `json:"form_data"` // form-data格式数据 FormData []map[string]string `json:"form_data"` // form-data 格式数据
Urlencoded []map[string]string `json:"urlencoded"` // x-www-form-urlencoded格式数据 Urlencoded []map[string]string `json:"urlencoded"` // x-www-form-urlencoded 格式数据
Raw string `json:"raw"` // raw JSON字符串 Raw string `json:"raw"` // raw JSON 字符串
} }
// EmailApiConfig 第三方邮箱API配置 // EmailApiConfig 第三方邮箱 API 配置
type EmailApiConfig struct { 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"` // 是否启用 Enabled bool `json:"enabled"` // 是否启用
URL string `json:"url"` // API地址 URL string `json:"url"` // API 地址
Method string `json:"method"` // HTTP方法 Method string `json:"method"` // HTTP 方法
RequestParamField string `json:"request_param_field"` // 请求参数字段名 RequestParamField string `json:"request_param_field"` // 请求参数字段名(如 ding_id
BodyType string `json:"body_type"` // Body类型: form-data | x-www-form-urlencoded | raw BodyType string `json:"body_type"` // Body 类型form-data | x-www-form-urlencoded | raw
Headers map[string]string `json:"headers"` // 请求头 Headers map[string]string `json:"headers"` // 请求头
Authorization AuthorizationConfig `json:"authorization"` // 认证配置 Authorization AuthorizationConfig `json:"authorization"` // 认证配置
BodyData BodyData `json:"body_data"` // Body数据 BodyData BodyData `json:"body_data"` // Body 数据
ResponseEmailField string `json:"response_email_field"` // 响应邮箱字段路径 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 钉钉集成配置 // DingTalkConfigRequest 钉钉集成配置
type DingTalkConfigRequest struct { type DingTalkConfigRequest struct {
EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱API配置 EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱 API 配置
ForwardConfig ForwardConfig `json:"forward_config"` // 转发集成配置
} }
+1
View File
@@ -23,3 +23,4 @@ var testApi = api.ApiGroupApp.GaiaApiGroup.TestApi
var batchWorkflowApi = api.ApiGroupApp.GaiaApiGroup.BatchWorkflowApi var batchWorkflowApi = api.ApiGroupApp.GaiaApiGroup.BatchWorkflowApi
var appVersionApi = api.ApiGroupApp.GaiaApiGroup.AppVersionApi var appVersionApi = api.ApiGroupApp.GaiaApiGroup.AppVersionApi
var modelProviderApi = api.ApiGroupApp.GaiaApiGroup.ModelProviderApi var modelProviderApi = api.ApiGroupApp.GaiaApiGroup.ModelProviderApi
var forwardProxyApi = api.ApiGroupApp.GaiaApiGroup.ForwardProxyApi
+18 -8
View File
@@ -10,16 +10,26 @@ type SystemRouter struct{}
func (s *SystemRouter) InitSystemRouter(Router *gin.RouterGroup) { func (s *SystemRouter) InitSystemRouter(Router *gin.RouterGroup) {
systemRouter := Router.Group("gaia/system") systemRouter := Router.Group("gaia/system")
{ {
systemRouter.GET("dingtalk", systemApi.GetDingTalk) // 获取钉钉系统配置 systemRouter.GET("dingtalk", systemApi.GetDingTalk) // 获取钉钉系统配置
systemRouter.POST("dingtalk", systemApi.SetDingTalk) // 设置钉钉系统配置 systemRouter.POST("dingtalk", systemApi.SetDingTalk) // 设置钉钉系统配置
systemRouter.GET("oauth2", systemOAuth2Api.GetOAuth2Config) // 获取OAuth2配置 systemRouter.GET("oauth2", systemOAuth2Api.GetOAuth2Config) // 获取 OAuth2 配置
systemRouter.POST("oauth2", systemOAuth2Api.SetOAuth2Config) // 设置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 初始化模型提供商路由 // InitModelProviderRouter 初始化模型提供商路由
func (s *SystemRouter) InitModelProviderRouter(Router *gin.RouterGroup) { func (s *SystemRouter) InitModelProviderRouter(Router *gin.RouterGroup) {
// 管理端API(需要JWT认证) // 管理端 API(需要 JWT 认证)
modelProviderRouter := Router.Group("gaia/model-provider") modelProviderRouter := Router.Group("gaia/model-provider")
{ {
modelProviderRouter.GET("list", modelProviderApi.GetProviderList) // 获取提供商配置列表 modelProviderRouter.GET("list", modelProviderApi.GetProviderList) // 获取提供商配置列表
@@ -29,10 +39,10 @@ func (s *SystemRouter) InitModelProviderRouter(Router *gin.RouterGroup) {
modelProviderRouter.GET("logs", modelProviderApi.GetProxyLogs) // 获取代理日志 modelProviderRouter.GET("logs", modelProviderApi.GetProxyLogs) // 获取代理日志
} }
// 第三方API(需要JWT认证) // 第三方 API(需要 JWT 认证)
gaiaRouter := Router.Group("gaia") gaiaRouter := Router.Group("gaia")
{ {
gaiaRouter.GET("models", modelProviderApi.GetModels) // 获取开启的模型列表(OpenAI格式) gaiaRouter.GET("models", modelProviderApi.GetModels) // 获取开启的模型列表(OpenAI 格式)
gaiaRouter.Any("proxy/*path", modelProviderApi.Proxy) // 通用中转API:按路径转发(v1/chat/completions、v1/messages、v1/images/generations、v1/embeddings 等) gaiaRouter.Any("proxy/*path", modelProviderApi.Proxy) // 通用中转 API:按路径转发(v1/chat/completions、v1/messages、v1/images/generations、v1/embeddings 等)
} }
} }
+295
View File
@@ -1,6 +1,7 @@
package gaia package gaia
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,6 +10,7 @@ import (
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time"
"github.com/faabiosr/cachego/file" "github.com/faabiosr/cachego/file"
"github.com/fastwego/dingding" "github.com/fastwego/dingding"
@@ -162,6 +164,16 @@ func (e *SystemIntegratedService) TestConnection(integrate gaia.SystemIntegratio
global.GVA_LOG.Warn("第三方邮箱API配置验证失败", zap.Error(err)) 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 return nil
case gaia.SystemIntegrationOAuth2: case gaia.SystemIntegrationOAuth2:
// 测试OAuth2连接 // 测试OAuth2连接
@@ -325,3 +337,286 @@ func (e *SystemIntegratedService) TestOAuth2Connection(integrate gaia.SystemInte
return nil 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())
}
}
// 构建请求 URLGET 时把 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
}
+8 -2
View File
@@ -171,8 +171,10 @@ func (casbinService *CasbinService) AddPolicies(db *gorm.DB, rules [][]string) e
func (CasbinService *CasbinService) FreshCasbin() (err error) { func (CasbinService *CasbinService) FreshCasbin() (err error) {
e := CasbinService.Casbin() e := CasbinService.Casbin()
err = e.LoadPolicy() if e == nil {
return err return errors.New("casbin enforcer is nil, please check database initialization")
}
return e.LoadPolicy()
} }
//@author: [piexlmax](https://github.com/piexlmax) //@author: [piexlmax](https://github.com/piexlmax)
@@ -187,6 +189,10 @@ var (
func (casbinService *CasbinService) Casbin() *casbin.SyncedCachedEnforcer { func (casbinService *CasbinService) Casbin() *casbin.SyncedCachedEnforcer {
once.Do(func() { 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) a, err := gormadapter.NewAdapterByDB(global.GVA_DB)
if err != nil { if err != nil {
zap.L().Error("适配数据库失败请检查casbin表是否为InnoDB引擎!", zap.Error(err)) zap.L().Error("适配数据库失败请检查casbin表是否为InnoDB引擎!", zap.Error(err))
@@ -8,6 +8,7 @@ import (
"github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/global"
modelSystem "github.com/flipped-aurora/gin-vue-admin/server/model/system" modelSystem "github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/model/system/request" "github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"sort" "sort"
) )
@@ -141,6 +142,7 @@ func (initDBService *InitDBService) InitDB(conf request.InitDB) (err error) {
global.GVA_DB = db global.GVA_DB = db
db.Exec("DELETE FROM sys_base_menus") db.Exec("DELETE FROM sys_base_menus")
db.Exec("DELETE FROM sys_authorities") db.Exec("DELETE FROM sys_authorities")
db.Exec("DELETE FROM sys_user_authority")
if err = initHandler.InitTables(ctx, initializers); err != nil { if err = initHandler.InitTables(ctx, initializers); err != nil {
return err return err
} }
@@ -151,6 +153,10 @@ func (initDBService *InitDBService) InitDB(conf request.InitDB) (err error) {
if err = initHandler.WriteConfig(ctx); err != nil { if err = initHandler.WriteConfig(ctx); err != nil {
return err return err
} }
// 初始化完成后刷新 Casbin 策略,避免使用旧的或空的策略
if err = CasbinServiceApp.FreshCasbin(); err != nil {
global.GVA_LOG.Warn("refresh casbin policy after InitDB failed", zap.Error(err))
}
initializers = initSlice{} initializers = initSlice{}
cache = map[string]*orderedInitializer{} cache = map[string]*orderedInitializer{}
return nil return nil
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap"
"path/filepath" "path/filepath"
"github.com/flipped-aurora/gin-vue-admin/server/config" "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 // 改成拿dify的配置,如果不是docker运行,则从dify api的.env文件中获取jwt的加密key
if !global.GVA_CONFIG.System.DockerRun { if !global.GVA_CONFIG.System.DockerRun {
var err error if secretKey, err := h.GetJwtSigningKeyFormDifyApiEnv(); err != nil {
global.GVA_CONFIG.JWT.SigningKey, err = h.GetJwtSigningKeyFormDifyApiEnv() global.GVA_LOG.Warn("failed to load JWT signing key from Dify API .env, using existing configuration", zap.Error(err))
if err != nil { } else if secretKey != "" {
return err global.GVA_CONFIG.JWT.SigningKey = secretKey
} }
} }
cs := utils.StructToMap(global.GVA_CONFIG) cs := utils.StructToMap(global.GVA_CONFIG)
+4
View File
@@ -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: "PATCH", Path: "/gaia/proxy/*", Description: "中转API(第三方)-PATCH"},
{ApiGroup: "模型管理", Method: "DELETE", Path: "/gaia/proxy/*", Description: "中转API(第三方)-DELETE"}, {ApiGroup: "模型管理", Method: "DELETE", Path: "/gaia/proxy/*", Description: "中转API(第三方)-DELETE"},
// Extend Stop: model provider // 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 { if err := db.Create(&entities).Error; err != nil {
return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!") return ctx, errors.Wrap(err, sysModel.SysApi{}.TableName()+"表数据初始化失败!")
+36 -1
View File
@@ -41,7 +41,7 @@ export const getSystemOAuth2 = () => {
} }
// @Tags systrm // @Tags systrm
// @Summary 修改OAuth2集成配置 // @Summary 修改 OAuth2 集成配置
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Produce application/json // @Produce application/json
// @Success 200 {string} string "{"success":true,"data":{},"msg":"返回成功"}" // @Success 200 {string} string "{"success":true,"data":{},"msg":"返回成功"}"
@@ -53,3 +53,38 @@ export const setSystemOAuth2 = (data) => {
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 },
})
}
@@ -364,7 +364,147 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 转发集成配置卡片 -->
<div class="card mt-4">
<div class="card-header flex items-center justify-between">
<span class="text-lg font-medium">转发集成配置</span>
<el-tag v-if="forwardConfig.enabled" type="success" size="small">已启用</el-tag>
<el-tag v-else type="info" size="small">未启用</el-tag>
</div>
<p class="text-gray-500 text-sm mb-4">为第三方系统如钉钉入口提供免登录转发代理能力通过 Token 鉴权后根据钉钉 ID 自动计费</p>
<el-divider />
<!-- 开关 -->
<div class="card-section">
<div class="flex items-center mb-4">
<span class="info-label">启用转发</span>
<el-switch
v-if="openEdit"
v-model="forwardConfig.enabled"
/>
<span v-else>{{ forwardConfig.enabled ? '已启用' : '未启用' }}</span>
</div>
</div>
<el-divider />
<!-- Token 列表 -->
<div class="card-section">
<div class="flex items-center justify-between mb-3">
<div class="section-title mb-0">
转发 Token 列表
</div>
<div class="flex items-center gap-2">
<span class="text-gray-500 text-sm">{{ forwardTokenList.length }}/20 </span>
<el-button
v-if="openEdit"
type="primary"
size="small"
:disabled="forwardTokenList.length >= 20"
@click="showCreateTokenDialog = true"
>
+ 新增 Token
</el-button>
</div>
</div>
<el-table :data="forwardTokenList" border size="small" class="w-full">
<el-table-column label="Token ID" prop="id" min-width="240">
<template #default="{ row }">
<span class="font-mono text-xs">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column v-if="openEdit" label="操作" width="80" align="center">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="openDeleteTokenDialog(row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-divider />
<!-- 第三方钉钉 ID 匹配用户 API 配置 -->
<div class="card-section">
<div class="section-title">
第三方钉钉 ID 匹配用户 API
</div>
<p class="text-gray-500 text-sm mb-4">当本地表中找不到钉钉 ID 对应用户时调用此 API 通过 ding_id 获取用户名</p>
<div class="bg-gray-50 dark:bg-slate-800 p-5 border dark:border-slate-700 rounded-lg">
<div class="flex items-center mb-4">
<span class="info-label">启用</span>
<el-switch v-if="openEdit" v-model="dingIdApiConfig.enabled" />
<span v-else>{{ dingIdApiConfig.enabled ? '已启用' : '未启用' }}</span>
</div>
<div class="flex items-center mb-4">
<span class="info-label">API URL</span>
<el-input v-if="openEdit" v-model="dingIdApiConfig.url" class="flex-1" placeholder="https://api.example.com/user/by-dingid" />
<span v-else class="info-value flex-1">{{ dingIdApiConfig.url || '未配置' }}</span>
</div>
<div class="flex items-center mb-4">
<span class="info-label">HTTP 方法</span>
<el-select v-if="openEdit" v-model="dingIdApiConfig.method" class="flex-1">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
<span v-else class="info-value">{{ dingIdApiConfig.method || 'GET' }}</span>
</div>
<div class="flex items-center mb-4">
<span class="info-label">请求参数字段</span>
<el-input v-if="openEdit" v-model="dingIdApiConfig.request_param_field" class="flex-1" placeholder="ding_id" />
<span v-else class="info-value">{{ dingIdApiConfig.request_param_field || 'ding_id' }}</span>
</div>
<div class="flex items-center mb-4">
<span class="info-label">响应用户名路径</span>
<el-input v-if="openEdit" v-model="dingIdApiConfig.response_user_name_path" class="flex-1" placeholder="data.username" />
<span v-else class="info-value">{{ dingIdApiConfig.response_user_name_path || '未配置' }}</span>
</div>
</div>
</div>
</div>
</el-tabs> </el-tabs>
<!-- 新增 Token 弹窗 -->
<el-dialog v-model="showCreateTokenDialog" title="新增转发 Token" width="480px" :close-on-click-modal="false">
<div v-if="!newTokenValue">
<p class="text-gray-600 mb-4">输入 Token 明文系统将存储其 SHA256 哈希Token 仅展示一次请妥善保管</p>
<el-input v-model="newTokenInput" placeholder="请输入 Token 明文(留空则自动生成)" clearable />
</div>
<div v-else>
<el-alert type="success" title="Token 创建成功!请复制保存,此后不再显示明文。" :closable="false" class="mb-4" />
<el-input v-model="newTokenValue" readonly>
<template #append>
<el-button @click="copyToken(newTokenValue)">复制</el-button>
</template>
</el-input>
</div>
<template #footer>
<el-button v-if="!newTokenValue" @click="showCreateTokenDialog = false; newTokenInput = ''">取消</el-button>
<el-button v-if="!newTokenValue" type="primary" :loading="creatingToken" @click="handleCreateToken">确认创建</el-button>
<el-button v-if="newTokenValue" type="primary" @click="showCreateTokenDialog = false; newTokenValue = ''; newTokenInput = ''; loadForwardTokens()">完成</el-button>
</template>
</el-dialog>
<!-- 删除 Token 弹窗需密码 -->
<el-dialog v-model="showDeleteTokenDialog" title="删除转发 Token" width="420px" :close-on-click-modal="false">
<p class="text-gray-600 mb-4">删除操作需要验证您的登录密码请输入后确认</p>
<el-input v-model="deleteTokenPassword" type="password" placeholder="请输入您的登录密码" show-password />
<template #footer>
<el-button @click="showDeleteTokenDialog = false; deleteTokenPassword = ''; deletingTokenId = ''">取消</el-button>
<el-button type="danger" :loading="deletingToken" @click="handleDeleteToken">确认删除</el-button>
</template>
</el-dialog>
</el-form> </el-form>
</div> </div>
</template> </template>
@@ -373,7 +513,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue' 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({ defineOptions({
name: 'IntegratedDingTalk', name: 'IntegratedDingTalk',
@@ -411,6 +551,109 @@ const emailApiConfig = ref({
response_email_field: 'data[0].userName' 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') 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) { } catch (e) {
console.error('解析邮箱API配置失败:', e) console.error('解析邮箱API配置失败:', e)
} }
} }
} }
// 加载 Token 列表
await loadForwardTokens()
} }
initForm() initForm()
@@ -792,6 +1051,11 @@ const update = async() => {
...emailApiConfig.value, ...emailApiConfig.value,
headers, headers,
body_data: bodyData body_data: bodyData
},
forward_config: {
enabled: forwardConfig.value.enabled,
tokens: forwardConfig.value.tokens,
ding_id_api: { ...dingIdApiConfig.value },
} }
} }
config.value.config = JSON.stringify(configData) config.value.config = JSON.stringify(configData)
@@ -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); 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');