diff --git a/admin/server/api/v1/gaia/forward_proxy.go b/admin/server/api/v1/gaia/forward_proxy.go
index c16441485..c362d3086 100644
--- a/admin/server/api/v1/gaia/forward_proxy.go
+++ b/admin/server/api/v1/gaia/forward_proxy.go
@@ -2,17 +2,13 @@ package gaia
import (
"crypto/sha256"
- "encoding/json"
"fmt"
- "io"
- "net/http"
- "strings"
-
"github.com/flipped-aurora/gin-vue-admin/server/global"
gaiaModel "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
+ "net/http"
)
type ForwardProxyApi struct{}
@@ -29,90 +25,56 @@ type ForwardProxyApi struct{}
func (f *ForwardProxyApi) ForwardProxy(c *gin.Context) {
// 1. 读取转发配置
integrate := systemIntegratedService.GetIntegratedConfig(gaiaModel.SystemIntegrationDingTalk)
- var configMap request.DingTalkConfigRequest
- if integrate.Config != "" {
- if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": "配置解析失败"}})
+ configMap, err := systemIntegratedService.ParseDingTalkConfig(integrate.Config)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": "配置解析失败"}})
+ return
+ }
+
+ // 2. 获取并校验 forwarding token(存在有效 Token 即视为开启转发能力)
+ dingId := c.GetHeader("X-Ding-Id")
+ bearer := c.GetHeader("Authorization")
+ token := c.GetHeader("X-Forward-Token")
+ if len(bearer) > 7 && len(dingId) == 0 {
+ // 从 Token 中验签并提取 ding_id
+ dingId, err = systemIntegratedService.ParseForwardToken(bearer[7:], configMap.ForwardConfig.Tokens)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "Token 验证失败: " + err.Error()}})
+ return
+ }
+ } else {
+ if token == "" {
+ token = c.Query("forward_token")
+ }
+ if token == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "缺少转发 Token"}})
+ return
+ }
+ if !validateForwardToken(token, configMap.ForwardConfig.Tokens) {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "无效的转发 Token"}})
+ return
+ }
+
+ // 4. 获取 ding_id
+ if dingId == "" {
+ dingId = c.Query("ding_id")
+ }
+ if dingId == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "缺少 ding_id"}})
return
}
}
- // 2. 检查转发开关
- if !configMap.ForwardConfig.Enabled {
- c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "转发功能未开启"}})
- return
- }
-
- // 3. 获取并校验 forwarding token
- token := c.GetHeader("X-Forward-Token")
- if token == "" {
- token = c.Query("forward_token")
- }
- if token == "" {
- c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "缺少转发 Token"}})
- return
- }
- if !validateForwardToken(token, configMap.ForwardConfig.Tokens) {
- c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "无效的转发 Token"}})
- return
- }
-
- // 4. 获取 ding_id
- dingId := c.GetHeader("X-Ding-Id")
- if dingId == "" {
- dingId = c.Query("ding_id")
- }
- if dingId == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "缺少 ding_id"}})
- return
- }
-
// 5. 解析 account_id
- accountId, err := systemIntegratedService.ResolveAccountByDingId(dingId, configMap.ForwardConfig.DingIdApi)
+ accountId, err := systemIntegratedService.ResolveAccountByDingId(dingId, configMap.EmailApi)
if err != nil {
global.GVA_LOG.Warn("ForwardProxy 用户解析失败", zap.String("ding_id", dingId), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "无法解析用户:" + err.Error()}})
return
}
- // 6. 转发请求
- path := c.Param("path")
- if path == "" || path == "/" {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "代理路径不能为空"}})
- return
- }
-
- // 将 query provider 转为请求头,供 service 解析
- reqHeader := c.Request.Header.Clone()
- if q := strings.TrimSpace(c.Query("provider")); q != "" {
- reqHeader.Set("X-Gaia-Provider", q)
- }
-
- body, err := io.ReadAll(c.Request.Body)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "读取请求体失败"}})
- return
- }
-
- global.GVA_LOG.Info("ForwardProxy 请求",
- zap.String("ding_id", dingId),
- zap.String("account_id", accountId),
- zap.String("path", path),
- zap.String("method", c.Request.Method),
- zap.Int("body_len", len(body)),
- )
-
- if err = modelProviderService.ProxyRequest(
- accountId, path, c.Request.Method, reqHeader, body, c.Writer); err != nil {
- global.GVA_LOG.Error("ForwardProxy 转发失败",
- zap.String("account_id", accountId),
- zap.String("path", path),
- zap.Error(err),
- )
- if !c.Writer.Written() {
- c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": err.Error()}})
- }
- }
+ // 6. 复用与 Proxy 相同的转发逻辑(path/body/ProxyRequest)
+ proxyWithAccountId(c, accountId)
}
// validateForwardToken 校验 token 是否在转发 Token 列表中(SHA256 比对)
diff --git a/admin/server/api/v1/gaia/model_provider.go b/admin/server/api/v1/gaia/model_provider.go
index b518a1e04..f51247c0c 100644
--- a/admin/server/api/v1/gaia/model_provider.go
+++ b/admin/server/api/v1/gaia/model_provider.go
@@ -90,62 +90,56 @@ func (m *ModelProviderApi) GetModels(c *gin.Context) {
c.JSON(http.StatusOK, models)
}
-// Proxy 通用中转 API:将 /gaia/proxy/* 的请求按路径转发到上游(如 /v1/chat/completions、/v1/messages、/v1/images/generations、/v1/embeddings 等)。
-// 上游 base 优先使用 provider_credentials 的 openai_api_base(如 "https://yunwu.ai"),便于计费区分。
+// proxyWithAccountId 通用代理逻辑:按路径转发到上游并计费。
+func proxyWithAccountId(c *gin.Context, accountId string) {
+ path := c.Param("path")
+ if path == "" || path == "/" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "代理路径不能为空"}})
+ return
+ }
+ reqHeader := c.Request.Header.Clone()
+ if q := strings.TrimSpace(c.Query("provider")); q != "" {
+ reqHeader.Set("X-Gaia-Provider", q)
+ }
+ body, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "读取请求体失败"}})
+ return
+ }
+ var bodyModel string
+ if len(body) > 0 {
+ var parseObj map[string]interface{}
+ if jsonErr := json.Unmarshal(body, &parseObj); jsonErr == nil {
+ if mv, ok := parseObj["model"].(string); ok {
+ bodyModel = mv
+ }
+ }
+ }
+ global.GVA_LOG.Info("Gaia代理请求入参",
+ zap.String("account_id", accountId),
+ zap.String("path", path),
+ zap.String("method", c.Request.Method),
+ zap.Int("body_len", len(body)),
+ zap.String("body_model", bodyModel),
+ )
+ if err = modelProviderService.ProxyRequest(
+ accountId, path, c.Request.Method, reqHeader, body, c.Writer); err != nil {
+ global.GVA_LOG.Error("代理请求失败", zap.String("account_id", accountId), zap.String("path", path), zap.Error(err))
+ if !c.Writer.Written() {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": err.Error()}})
+ }
+ }
+}
+
+// Proxy 通用中转 API:将 /gaia/proxy/* 的请求按路径转发到上游(需 JWT,account 来自当前登录用户)。
// @Tags ModelProvider
// @Summary 通用中转API(按路径转发)
// @Security ApiKeyAuth
// @Param path path string true "上游路径,如 v1/chat/completions、v1/messages"
// @Router /gaia/proxy/*path [get,post,put,patch,delete]
func (m *ModelProviderApi) Proxy(c *gin.Context) {
- // init
- var err error
- var body []byte
- path := c.Param("path")
- userID := utils.GetUserUuid(c).String()
- if path == "" || path == "/" {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "代理路径不能为空"}})
- return
- }
- // 将 query provider 转为请求头,供 service 解析
- reqHeader := c.Request.Header.Clone()
- if q := strings.TrimSpace(c.Query("provider")); q != "" {
- reqHeader.Set("X-Gaia-Provider", q)
- }
-
- if body, err = io.ReadAll(c.Request.Body); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "读取请求体失败"}})
- return
- }
-
- // 打印传入参数便于排查
- queryProvider := strings.TrimSpace(c.Query("provider"))
- var bodyModel string
- if len(body) > 0 {
- var parseObj map[string]interface{}
- if jsonErr := json.Unmarshal(body, &parseObj); jsonErr == nil {
- if m, ok := parseObj["model"].(string); ok {
- bodyModel = m
- }
- }
- }
- global.GVA_LOG.Info("Gaia代理请求入参",
- zap.String("path", path),
- zap.String("method", c.Request.Method),
- zap.String("query_provider", queryProvider),
- zap.Int("body_len", len(body)),
- zap.String("body_model", bodyModel),
- zap.String("body", string(body)),
- )
-
- if err = modelProviderService.ProxyRequest(
- userID, path, c.Request.Method, reqHeader, body, c.Writer); err != nil {
- global.GVA_LOG.Error("代理请求失败", zap.String("user_id", userID), zap.String(
- "path", path), zap.Error(err))
- if !c.Writer.Written() {
- c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": err.Error()}})
- }
- }
+ accountId := utils.GetUserUuid(c).String()
+ proxyWithAccountId(c, accountId)
}
// GetAvailableModels 获取提供商的可用模型
diff --git a/admin/server/api/v1/gaia/system.go b/admin/server/api/v1/gaia/system.go
index bff28c0ae..4957bba45 100644
--- a/admin/server/api/v1/gaia/system.go
+++ b/admin/server/api/v1/gaia/system.go
@@ -5,10 +5,14 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
+ "net/url"
+ "strings"
+
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
+ gaiaResp "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/gin-gonic/gin"
@@ -64,13 +68,79 @@ func (systemApi *SystemApi) SetDingTalk(c *gin.Context) {
response.OkWithData("ok", c)
}
+// TestEmailApiConfig 测试第三方邮箱 API 配置
+// @Tags System
+// @Summary 测试第三方邮箱 API 配置
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Param data body request.TestEmailApiConfigRequest true "测试配置请求"
+// @Success 200 {object} response.Response{data=gaiaResp.TestEmailApiConfigResponse,msg=string} "测试结果"
+// @Router /gaia/system/dingtalk/test-email-config [post]
+func (systemApi *SystemApi) TestEmailApiConfig(c *gin.Context) {
+ var req request.TestEmailApiConfigRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.FailWithMessage(err.Error(), c)
+ return
+ }
+
+ result, err := systemIntegratedService.TestEmailApiConfig(req.Config, req.TestDingID)
+ if err != nil {
+ response.FailWithMessage("测试失败:"+err.Error(), c)
+ return
+ }
+
+ response.OkWithData(result, c)
+}
+
+// GetDingTalkTestAuthURL 获取「测试连接」用的钉钉授权 URL,打开后扫码完成即视为连接成功
+// @Router /gaia/system/dingtalk/test-auth-url [get]
+func (systemApi *SystemApi) GetDingTalkTestAuthURL(c *gin.Context) {
+ origin := c.GetHeader("Referer")
+ if origin == "" {
+ origin = c.GetHeader("Origin")
+ }
+ if origin != "" {
+ if u, err := url.Parse(origin); err == nil {
+ origin = u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/")
+ }
+ }
+ if origin == "" {
+ response.FailWithMessage("无法获取前端地址,请从配置页点击「测试连接」", c)
+ return
+ }
+ authURL, err := systemIntegratedService.GetDingTalkTestAuthURL(origin)
+ if err != nil {
+ response.FailWithMessage(err.Error(), c)
+ return
+ }
+ response.OkWithData(gin.H{"auth_url": authURL}, c)
+}
+
+// DingTalkTestCallback 测试连接回调:仅用 code 换 token 验证,不登录
+// @Router /gaia/system/dingtalk/test-callback [post]
+func (systemApi *SystemApi) DingTalkTestCallback(c *gin.Context) {
+ var req struct {
+ Code string `json:"code"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Code) == "" {
+ response.FailWithMessage("缺少授权码 code", c)
+ return
+ }
+ if err := systemIntegratedService.DingTalkTestCallback(req.Code); err != nil {
+ response.FailWithMessage("验证失败: "+err.Error(), c)
+ return
+ }
+ response.OkWithMessage("验证成功", 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} "查询成功"
+// @Success 200 {object} response.Response{data=gaiaResp.ForwardTokensResponse,msg=string} "查询成功"
// @Router /gaia/system/forward-tokens [get]
func (systemApi *SystemApi) GetForwardTokens(c *gin.Context) {
integrate := systemIntegratedService.GetIntegratedConfig(gaia.SystemIntegrationDingTalk)
@@ -83,21 +153,15 @@ func (systemApi *SystemApi) GetForwardTokens(c *gin.Context) {
}
}
- // 返回不包含 token_hash 的列表
- type TokenInfo struct {
- ID string `json:"id"`
- CreatedAt time.Time `json:"created_at"`
- }
-
- tokens := make([]TokenInfo, 0, len(configMap.ForwardConfig.Tokens))
+ tokens := make([]gaiaResp.ForwardTokenInfo, 0, len(configMap.ForwardConfig.Tokens))
for _, token := range configMap.ForwardConfig.Tokens {
- tokens = append(tokens, TokenInfo{
- ID: token.ID,
+ tokens = append(tokens, gaiaResp.ForwardTokenInfo{
+ ID: utils.AddAsteriskToString(token.ID),
CreatedAt: token.CreatedAt,
})
}
- response.OkWithData(gin.H{"tokens": tokens, "count": len(tokens), "max": 20}, c)
+ response.OkWithData(gaiaResp.ForwardTokensResponse{Tokens: tokens, Count: len(tokens), Max: 20}, c)
}
// CreateForwardToken 新增转发 Token
diff --git a/admin/server/model/gaia/model_provider_config_extend.go b/admin/server/model/gaia/model_provider_config_extend.go
index 2ecffed7a..b24e3cf39 100644
--- a/admin/server/model/gaia/model_provider_config_extend.go
+++ b/admin/server/model/gaia/model_provider_config_extend.go
@@ -2,6 +2,49 @@ package gaia
import "time"
+// ModelPricing 从 Dify Console API 拉取的模型定价信息(对应 pricing 字段)
+type ModelPricing struct {
+ Input float64 `json:"input"` // 每 unit 的输入单价
+ Output float64 `json:"output"` // 每 unit 的输出单价(0 表示与 Input 相同或不区分)
+ Unit float64 `json:"unit"` // 计费单位(通常 0.001,即每千 token)
+ Currency string `json:"currency"` // 货币(USD / RMB)
+}
+
+// ModelUsage OpenAI 格式响应中的 usage 字段(非流式及流式末尾行)
+type ModelUsage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+}
+
+// ModelUsageResponse OpenAI 格式响应体(仅用于提取 usage 字段)
+type ModelUsageResponse struct {
+ Usage *ModelUsage `json:"usage"`
+}
+
+// DifyModelPricingRaw Dify Console API 返回的原始定价字段(值为字符串形式的数字)
+type DifyModelPricingRaw struct {
+ Input string `json:"input"`
+ Output string `json:"output"`
+ Unit string `json:"unit"`
+ Currency string `json:"currency"`
+}
+
+// DifyModelItem Dify Console API 返回的单个模型信息
+type DifyModelItem struct {
+ Model string `json:"model"`
+ Pricing *DifyModelPricingRaw `json:"pricing"`
+}
+
+// DifyProviderModels Dify Console API 返回的单个 provider 下的模型列表
+type DifyProviderModels struct {
+ Models []DifyModelItem `json:"models"`
+}
+
+// DifyModelsResponse Dify Console API GET /models/model-types/llm 的响应结构
+type DifyModelsResponse struct {
+ Data []DifyProviderModels `json:"data"`
+}
+
// ModelProviderConfig 模型提供商配置表
type ModelProviderConfig struct {
Id uint `json:"id" form:"id" gorm:"primarykey;column:id;comment:id;"`
diff --git a/admin/server/model/gaia/request/system.go b/admin/server/model/gaia/request/system.go
index a2432a0a5..9f80243cd 100644
--- a/admin/server/model/gaia/request/system.go
+++ b/admin/server/model/gaia/request/system.go
@@ -10,26 +10,37 @@ type SystemOAuth2Error struct {
// SystemOAuth2Request OAuth2 集成配置
type SystemOAuth2Request struct {
- Classify uint `json:"classify" gorm:"comment:分类"` // 分类
- Status bool `json:"status" gorm:"comment:状态"` // 状态
- ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址
+ 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
+ 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:用户唯一标识字段"` // 用户唯一标识字段
+ 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"` // 授权范围
- 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:是否测试链接联通性"` // 是否测试链接联通性
+ 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 代码
}
+// ValueType 参数/字段值类型
+const (
+ ValueTypeString = "string" // 字符串类型
+ ValueTypeInt = "int" // 整数类型
+ ValueTypeBool = "bool" // 布尔类型
+ ValueTypeDingID = "ding_id" // 钉钉 ID 类型(运行时自动替换)
+)
+
+// DingIDMarker Raw 模式下钉钉 ID 占位符
+const DingIDMarker = "$<{[ding_id]}>"
+
// AuthorizationConfig 认证配置
type AuthorizationConfig struct {
Type string `json:"type"` // none | bearer | basic
@@ -38,55 +49,63 @@ type AuthorizationConfig struct {
Password string `json:"password"` // Basic Auth 密码
}
+// RequestParam URL 查询参数配置
+type RequestParam struct {
+ Key string `json:"key"` // 参数名
+ ValueType string `json:"value_type"` // string | int | bool | ding_id
+ Value string `json:"value"` // 参数值(ding_id 类型时运行时自动替换)
+}
+
+// BodyField Body 字段配置(支持类型化)
+type BodyField struct {
+ Key string `json:"key"` // 字段名
+ ValueType string `json:"value_type"` // string | int | bool | ding_id
+ Value string `json:"value"` // 字段值
+}
+
// 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 []BodyField `json:"form_data"` // form-data 格式数据(新格式)
+ Urlencoded []BodyField `json:"urlencoded"` // x-www-form-urlencoded 格式数据(新格式)
+ Raw string `json:"raw"` // raw JSON 字符串
}
// 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"` // 响应邮箱字段路径
+ Enabled bool `json:"enabled"` // 是否启用
+ URL string `json:"url"` // API 地址
+ Method string `json:"method"` // HTTP 方法
+ RequestParamField string `json:"request_param_field"` // 请求参数字段名(旧格式兼容)
+ Params []RequestParam `json:"params"` // URL 查询参数列表(新格式)
+ 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"` // 请求参数字段名(如 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 数据
- ResponseUserNamePath string `json:"response_user_name_path"` // 响应用户名 JSON 路径(如 data.username)
+// TestEmailApiConfigRequest 测试邮箱 API 配置请求
+type TestEmailApiConfigRequest struct {
+ Config EmailApiConfig `json:"config"` // 完整的邮箱配置
+ TestDingID string `json:"test_ding_id"` // 测试用的钉钉 ID(可选)
}
// ForwardToken 转发 Token 配置
type ForwardToken struct {
- ID string `json:"id"` // 前端生成的唯一 ID(用于删除)
- TokenHash string `json:"token_hash"` // SHA256(token)
- CreatedAt time.Time `json:"created_at"` // 创建时间
+ ID string `json:"id"` // 前端生成的唯一 ID(用于删除)
+ TokenHash string `json:"token_hash"` // SHA256(token)
+ CreatedAt time.Time `json:"created_at"` // 创建时间
+ TokenSecret string `json:"token_secret"` // HMAC 签名密钥(随机生成,服务端保存)
}
// ForwardConfig 转发集成配置
type ForwardConfig struct {
- Enabled bool `json:"enabled"` // 是否启用转发
- Tokens []ForwardToken `json:"tokens"` // Token 列表,最多 20 个
- DingIdApi DingIdApiConfig `json:"ding_id_api"` // 第三方钉钉 ID 匹配用户 API
+ Enabled bool `json:"enabled"` // 是否启用转发
+ Tokens []ForwardToken `json:"tokens"` // Token 列表,最多 20 个
}
// DingTalkConfigRequest 钉钉集成配置
type DingTalkConfigRequest struct {
- EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱 API 配置
- ForwardConfig ForwardConfig `json:"forward_config"` // 转发集成配置
+ EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱 API 配置
+ ForwardConfig ForwardConfig `json:"forward_config"` // 转发集成配置
}
diff --git a/admin/server/model/gaia/response/system.go b/admin/server/model/gaia/response/system.go
new file mode 100644
index 000000000..83d572de8
--- /dev/null
+++ b/admin/server/model/gaia/response/system.go
@@ -0,0 +1,26 @@
+package response
+
+import "time"
+
+// ForwardTokenInfo 转发 Token 列表项(脱敏后的 Token ID)
+type ForwardTokenInfo struct {
+ ID string `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ForwardTokensResponse 获取转发 Token 列表响应
+type ForwardTokensResponse struct {
+ Tokens []ForwardTokenInfo `json:"tokens"`
+ Count int `json:"count"`
+ Max int `json:"max"`
+}
+
+// TestEmailApiConfigResponse 测试邮箱 API 配置响应
+type TestEmailApiConfigResponse struct {
+ StatusCode int `json:"status_code"` // HTTP 状态码
+ Body interface{} `json:"body"` // 响应 Body(JSON 时为对象,否则为字符串)
+ EmailFieldPreview string `json:"email_field_preview"` // 邮箱字段解析预览(如 data[0].userName = test@example.com)
+ IsValid bool `json:"is_valid"` // 配置是否有效(能正确提取邮箱)
+ ErrorMessage string `json:"error_message,omitempty"` // 错误信息(可选)
+}
+
diff --git a/admin/server/router/gaia/system.go b/admin/server/router/gaia/system.go
index d6acdc2c1..bf3ecd5e2 100644
--- a/admin/server/router/gaia/system.go
+++ b/admin/server/router/gaia/system.go
@@ -10,10 +10,14 @@ 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("dingtalk/test-auth-url", systemApi.GetDingTalkTestAuthURL) // 测试连接:获取钉钉授权 URL
+ systemRouter.POST("dingtalk/test-callback", systemApi.DingTalkTestCallback) // 测试连接:回调验证 code
+ systemRouter.GET("oauth2", systemOAuth2Api.GetOAuth2Config) // 获取 OAuth2 配置
+ systemRouter.POST("oauth2", systemOAuth2Api.SetOAuth2Config) // 设置 OAuth2 配置
+ // 邮箱 API 配置测试
+ systemRouter.POST("dingtalk/test-email-config", systemApi.TestEmailApiConfig) // 测试第三方邮箱 API 配置
// 转发 Token 管理
systemRouter.GET("forward-tokens", systemApi.GetForwardTokens) // 获取转发 Token 列表
systemRouter.POST("forward-tokens", systemApi.CreateForwardToken) // 新增转发 Token
@@ -21,7 +25,7 @@ func (s *SystemRouter) InitSystemRouter(Router *gin.RouterGroup) {
}
}
-// InitForwardProxyRouter 初始化 GPT 转发代理路由(免 JWT,挂在 PublicGroup)
+// InitForwardProxyRouter 初始化 GPT 转发代理路由
func (s *SystemRouter) InitForwardProxyRouter(PublicRouter *gin.RouterGroup) {
// 免 JWT 转发入口,通过 forwarding token + ding_id 鉴权
PublicRouter.Any("gaia/forward/proxy/*path", forwardProxyApi.ForwardProxy)
diff --git a/admin/server/service/gaia/email_api_test.go b/admin/server/service/gaia/email_api_test.go
new file mode 100644
index 000000000..5049c2bb7
--- /dev/null
+++ b/admin/server/service/gaia/email_api_test.go
@@ -0,0 +1,273 @@
+package gaia
+
+import (
+ "testing"
+
+ "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
+)
+
+// TestBuildURL_NewFormat 测试新格式 Params 自动拼接 URL
+func TestBuildURL_NewFormat(t *testing.T) {
+ config := request.EmailApiConfig{
+ URL: "https://api.example.com/user",
+ Params: []request.RequestParam{},
+ }
+
+ // 无参数
+ got := buildURL("https://api.example.com/user", config, "USER123")
+ if got != "https://api.example.com/user" {
+ t.Errorf("无参数时 URL 应保持不变,got: %s", got)
+ }
+
+ // 单个 string 类型参数
+ config.Params = []request.RequestParam{
+ {Key: "appKey", ValueType: "string", Value: "mykey"},
+ }
+ got = buildURL("https://api.example.com/user", config, "USER123")
+ if got != "https://api.example.com/user?appKey=mykey" {
+ t.Errorf("单参数拼接错误,got: %s", got)
+ }
+
+ // URL 已有 ? 时使用 &
+ got = buildURL("https://api.example.com/user?type=admin", config, "USER123")
+ if got != "https://api.example.com/user?type=admin&appKey=mykey" {
+ t.Errorf("已有参数时应使用 & 拼接,got: %s", got)
+ }
+}
+
+// TestBuildURL_DingIDParam 测试钉钉 ID 类型参数自动替换
+func TestBuildURL_DingIDParam(t *testing.T) {
+ config := request.EmailApiConfig{
+ URL: "https://api.example.com/user",
+ Params: []request.RequestParam{
+ {Key: "userId", ValueType: "ding_id"},
+ },
+ }
+
+ got := buildURL("https://api.example.com/user", config, "USER123")
+ if got != "https://api.example.com/user?userId=USER123" {
+ t.Errorf("钉钉 ID 类型参数应自动替换,got: %s", got)
+ }
+}
+
+// TestBuildURL_OldFormat 测试旧格式 RequestParamField 兼容
+func TestBuildURL_OldFormat(t *testing.T) {
+ config := request.EmailApiConfig{
+ URL: "https://api.example.com/user",
+ RequestParamField: "userId",
+ Params: nil, // 旧格式:Params 为 nil
+ }
+
+ got := buildURL("https://api.example.com/user", config, "USER123")
+ if got != "https://api.example.com/user?userId=USER123" {
+ t.Errorf("旧格式应使用 RequestParamField,got: %s", got)
+ }
+}
+
+// TestResolveParamValue 测试参数值解析
+func TestResolveParamValue(t *testing.T) {
+ tests := []struct {
+ vt string
+ value string
+ dingId string
+ want string
+ }{
+ {"ding_id", "", "USER123", "USER123"},
+ {"string", "myvalue", "USER123", "myvalue"},
+ {"string", "prefix_{{ding_id}}_suffix", "USER123", "prefix_USER123_suffix"},
+ {"string", "prefix_$<{[ding_id]}>_suffix", "USER123", "prefix_USER123_suffix"},
+ {"int", "42", "USER123", "42"},
+ {"bool", "true", "USER123", "true"},
+ }
+
+ for _, tt := range tests {
+ got := resolveParamValue(tt.vt, tt.value, tt.dingId)
+ if got != tt.want {
+ t.Errorf("resolveParamValue(%q, %q, %q) = %q, want %q", tt.vt, tt.value, tt.dingId, got, tt.want)
+ }
+ }
+}
+
+// TestExtractJSONPathAdvanced 测试 JSON 路径提取
+func TestExtractJSONPathAdvanced(t *testing.T) {
+ data := map[string]interface{}{
+ "code": float64(0),
+ "data": []interface{}{
+ map[string]interface{}{
+ "userName": "test@example.com",
+ "userId": "USER123",
+ },
+ },
+ "nested": map[string]interface{}{
+ "email": "nested@example.com",
+ },
+ }
+
+ tests := []struct {
+ path string
+ want string
+ }{
+ {"code", "0"},
+ {"data[0].userName", "test@example.com"},
+ {"data[0].userId", "USER123"},
+ {"nested.email", "nested@example.com"},
+ {"notexist", ""},
+ {"data[1].userName", ""},
+ }
+
+ for _, tt := range tests {
+ got := extractJSONPathAdvanced(data, tt.path)
+ if got != tt.want {
+ t.Errorf("extractJSONPathAdvanced(data, %q) = %q, want %q", tt.path, got, tt.want)
+ }
+ }
+}
+
+// TestParseEmailApiConfigFromJSON_NewFormat 测试新格式解析
+func TestParseEmailApiConfigFromJSON_NewFormat(t *testing.T) {
+ jsonStr := `{
+ "enabled": true,
+ "url": "https://api.example.com",
+ "method": "GET",
+ "params": [
+ {"key": "userId", "value_type": "ding_id", "value": ""},
+ {"key": "appKey", "value_type": "string", "value": "mykey"}
+ ],
+ "response_email_field": "data[0].email"
+ }`
+
+ cfg, err := parseEmailApiConfigFromJSON([]byte(jsonStr))
+ if err != nil {
+ t.Fatalf("解析新格式配置失败: %v", err)
+ }
+ if !isNewEmailApiConfig(cfg) {
+ t.Error("应检测为新格式")
+ }
+ if len(cfg.Params) != 2 {
+ t.Errorf("Params 数量错误,got: %d", len(cfg.Params))
+ }
+}
+
+// TestParseEmailApiConfigFromJSON_OldFormat 测试旧格式兼容解析
+func TestParseEmailApiConfigFromJSON_OldFormat(t *testing.T) {
+ jsonStr := `{
+ "enabled": true,
+ "url": "https://api.example.com",
+ "method": "GET",
+ "request_param_field": "userId",
+ "response_email_field": "data[0].email",
+ "body_data": {
+ "form_data": [{"userId": ""}],
+ "urlencoded": []
+ }
+ }`
+
+ cfg, err := parseEmailApiConfigFromJSON([]byte(jsonStr))
+ if err != nil {
+ t.Fatalf("解析旧格式配置失败: %v", err)
+ }
+ if isNewEmailApiConfig(cfg) {
+ t.Error("旧格式配置不应检测为新格式")
+ }
+ if cfg.RequestParamField != "userId" {
+ t.Errorf("RequestParamField 应为 userId,got: %s", cfg.RequestParamField)
+ }
+}
+
+// TestValidateEmailApiConfigFields_NewFormat 测试新格式配置验证
+func TestValidateEmailApiConfigFields_NewFormat(t *testing.T) {
+ cfg := request.EmailApiConfig{
+ Enabled: true,
+ URL: "https://api.example.com",
+ Method: "GET",
+ Params: []request.RequestParam{},
+ ResponseEmailField: "data[0].email",
+ }
+
+ if err := validateEmailApiConfigFields(cfg); err != nil {
+ t.Errorf("有效的新格式配置不应报错: %v", err)
+ }
+}
+
+// TestValidateEmailApiConfigFields_InvalidParamType 测试 Params 不支持 int 类型
+func TestValidateEmailApiConfigFields_InvalidParamType(t *testing.T) {
+ cfg := request.EmailApiConfig{
+ Enabled: true,
+ URL: "https://api.example.com",
+ Method: "GET",
+ Params: []request.RequestParam{
+ {Key: "count", ValueType: "int", Value: "10"}, // Params 不支持 int
+ },
+ ResponseEmailField: "data[0].email",
+ }
+
+ if err := validateEmailApiConfigFields(cfg); err == nil {
+ t.Error("Params 不应支持 int 类型,应报错")
+ }
+}
+
+// TestValidateEmailApiConfigFields_InvalidBodyType 测试 Body 不支持未知类型
+func TestValidateEmailApiConfigFields_InvalidBodyType(t *testing.T) {
+ cfg := request.EmailApiConfig{
+ Enabled: true,
+ URL: "https://api.example.com",
+ Method: "POST",
+ Params: []request.RequestParam{},
+ BodyData: request.BodyData{
+ FormData: []request.BodyField{
+ {Key: "field1", ValueType: "invalid_type", Value: "val"},
+ },
+ },
+ ResponseEmailField: "data[0].email",
+ }
+
+ if err := validateEmailApiConfigFields(cfg); err == nil {
+ t.Error("Body 不应支持未知类型,应报错")
+ }
+}
+
+// TestBuildBodyFields 测试 Body 字段类型转换
+func TestBuildBodyFields(t *testing.T) {
+ fields := []request.BodyField{
+ {Key: "userId", ValueType: "ding_id", Value: ""},
+ {Key: "appKey", ValueType: "string", Value: "mykey"},
+ {Key: "count", ValueType: "int", Value: "10"},
+ {Key: "enabled", ValueType: "bool", Value: "true"},
+ }
+
+ form := buildBodyFields(fields, "USER123")
+
+ if form.Get("userId") != "USER123" {
+ t.Errorf("ding_id 类型应替换为实际钉钉 ID,got: %s", form.Get("userId"))
+ }
+ if form.Get("appKey") != "mykey" {
+ t.Errorf("string 类型应直接使用,got: %s", form.Get("appKey"))
+ }
+ if form.Get("count") != "10" {
+ t.Errorf("int 类型值不正确,got: %s", form.Get("count"))
+ }
+}
+
+// TestDingIDMarkerReplacement 测试 Raw 模式钉钉 ID 标记替换
+func TestDingIDMarkerReplacement(t *testing.T) {
+ raw := `{"userId": "$<{[ding_id]}>", "other": "value"}`
+ dingId := "USER789"
+
+ // 使用 resolveParamValue 测试标记替换
+ replaced := resolveParamValue("string", raw, dingId)
+ expected := `{"userId": "USER789", "other": "value"}`
+
+ if replaced != expected {
+ t.Errorf("钉钉 ID 标记替换错误\ngot: %s\nwant: %s", replaced, expected)
+ }
+}
+
+// TestDingIDMarkerOldFormat 测试旧格式 {{ding_id}} 占位符替换
+func TestDingIDMarkerOldFormat(t *testing.T) {
+ raw := `{"userId": "{{ding_id}}", "other": "value"}`
+ replaced := resolveParamValue("string", raw, "USER789")
+ expected := `{"userId": "USER789", "other": "value"}`
+ if replaced != expected {
+ t.Errorf("旧格式占位符替换错误\ngot: %s\nwant: %s", replaced, expected)
+ }
+}
diff --git a/admin/server/service/gaia/gaia_login.go b/admin/server/service/gaia/gaia_login.go
index 5be9a159c..7f5e12ef0 100644
--- a/admin/server/service/gaia/gaia_login.go
+++ b/admin/server/service/gaia/gaia_login.go
@@ -148,6 +148,45 @@ func (e *SystemIntegratedService) OAuth2CodeLogin(
return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil
}
+// DingTalkTestCallback 仅用 code 换 token,用于「测试连接」回调,不登录、不写 session
+func (e *SystemIntegratedService) DingTalkTestCallback(code string) error {
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return fmt.Errorf("授权码为空")
+ }
+ integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
+ if integrate.AppKey == "" || integrate.AppSecret == "" {
+ return fmt.Errorf("钉钉配置不完整")
+ }
+ bodyJSON, _ := json.Marshal(map[string]string{
+ "clientId": integrate.AppKey,
+ "clientSecret": integrate.AppSecret,
+ "code": code,
+ "grantType": "authorization_code",
+ })
+ httpReq, err := http.NewRequest("POST", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", bytes.NewReader(bodyJSON))
+ if err != nil {
+ return err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ client := &http.Client{}
+ resp, err := client.Do(httpReq)
+ if err != nil {
+ return fmt.Errorf("钉钉 token 请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ global.GVA_LOG.Error("钉钉 token 非 200", zap.Int("status", resp.StatusCode), zap.String("body", string(respBody)))
+ return fmt.Errorf("钉钉返回错误: %d", resp.StatusCode)
+ }
+ var tokenResp map[string]interface{}
+ if err = json.Unmarshal(respBody, &tokenResp); err != nil || tokenResp["accessToken"] == "" {
+ return fmt.Errorf("解析钉钉 token 失败")
+ }
+ return nil
+}
+
// DingTalkCodeLogin 钉钉 code 换用户并登录(扫码/OAuth2 回调带 code)
func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLoginReq) (*response.GaiaLoginResult, error) {
integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
@@ -205,8 +244,56 @@ func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLogi
if err = json.Unmarshal(userBody, &dingUser); err != nil {
return nil, fmt.Errorf("解析钉钉用户信息失败")
}
- email := dingUser["email"].(string)
- username := dingUser["nick"].(string)
+
+ // 提取钉钉 ID(user_id 字段)
+ dingId := ""
+ if v, ok := dingUser["unionId"]; ok && v != nil {
+ dingId, _ = v.(string)
+ }
+ if dingId == "" {
+ if v, ok := dingUser["userId"]; ok && v != nil {
+ dingId, _ = v.(string)
+ }
+ }
+
+ // 解析邮箱配置
+ var configMap request.DingTalkConfigRequest
+ var emailConfig request.EmailApiConfig
+ if integrate.Config != "" {
+ if jsonErr := json.Unmarshal([]byte(integrate.Config), &configMap); jsonErr == nil {
+ var rawMsg json.RawMessage
+ if rawBytes, marshalErr := json.Marshal(configMap.EmailApi); marshalErr == nil {
+ rawMsg = rawBytes
+ if cfg, parseErr := parseEmailApiConfigFromJSON(rawMsg); parseErr == nil {
+ emailConfig = cfg
+ }
+ }
+ }
+ }
+
+ // 优先通过邮箱 API 获取邮箱(新格式)
+ if emailConfig.Enabled && dingId != "" {
+ email, apiErr := e.callEmailApi(dingId, emailConfig)
+ if apiErr == nil && email != "" {
+ global.GVA_LOG.Info("DingTalkCodeLogin: 通过第三方邮箱 API 获取邮箱",
+ zap.String("ding_id", dingId), zap.String("email", email))
+ sysUser, findErr := e.findUserByEmail(email)
+ if findErr != nil {
+ return nil, findErr
+ }
+ token, _, tokenErr := utils.LoginToken(sysUser)
+ if tokenErr != nil {
+ return nil, fmt.Errorf("签发 token 失败")
+ }
+ return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil
+ }
+ global.GVA_LOG.Warn("DingTalkCodeLogin: 第三方邮箱 API 获取失败,尝试钉钉直接返回邮箱",
+ zap.String("ding_id", dingId), zap.Error(apiErr))
+ }
+
+ // 回退:直接从钉钉用户信息获取邮箱
+ email, _ := dingUser["email"].(string)
+ username, _ := dingUser["nick"].(string)
if username == "" {
username = email
}
@@ -311,7 +398,7 @@ func (e *SystemIntegratedService) findUserByEmail(email string) (*system.SysUser
if err := global.GVA_DB.Where("email IN (?)", mailList).Preload(
"Authorities").Preload("Authority").First(&u).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, fmt.Errorf(fmt.Sprintf("邮箱%s尚未开通账号,请联系管理员", email))
+ return nil, fmt.Errorf("邮箱%s尚未开通账号,请联系管理员", email)
}
return nil, err
}
diff --git a/admin/server/service/gaia/login_options.go b/admin/server/service/gaia/login_options.go
index 0166d3b32..b0ce594c7 100644
--- a/admin/server/service/gaia/login_options.go
+++ b/admin/server/service/gaia/login_options.go
@@ -72,6 +72,22 @@ func (e *SystemIntegratedService) GetLoginOptions(frontendOrigin string) (res re
return res
}
+// GetDingTalkTestAuthURL 返回用于「测试连接」的钉钉授权 URL(state=dingtalk_test,回调后仅验证 code 换 token,不登录)
+func (e *SystemIntegratedService) GetDingTalkTestAuthURL(frontendOrigin string) (string, error) {
+ integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
+ if integrate.AppKey == "" || integrate.AppSecret == "" {
+ return "", fmt.Errorf("请先配置 AppKey 与 AppSecret")
+ }
+ frontendOrigin = strings.TrimSuffix(frontendOrigin, "/")
+ if !strings.Contains(frontendOrigin, "localhost") {
+ frontendOrigin = frontendOrigin + "/admin"
+ }
+ callbackURI := frontendOrigin + "/#/loginCallback?provider=dingtalk"
+ authURL := fmt.Sprintf("https://login.dingtalk.com/oauth2/auth?client_id=%s&response_type=code&scope=openid&redirect_uri=%s&state=dingtalk_test",
+ integrate.AppKey, url.QueryEscape(callbackURI))
+ return authURL, nil
+}
+
// getIntegratedConfigRaw 获取集成配置(不脱敏,仅内部使用)
func (e *SystemIntegratedService) getIntegratedConfigRaw(classID uint) (integrate gaia.SystemIntegration) {
if err := global.GVA_DB.Where("classify = ?", classID).First(&integrate).Error; err != nil {
diff --git a/admin/server/service/gaia/model_provider.go b/admin/server/service/gaia/model_provider.go
index 6640b29f0..5514cfd0a 100644
--- a/admin/server/service/gaia/model_provider.go
+++ b/admin/server/service/gaia/model_provider.go
@@ -18,6 +18,8 @@ import (
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
gaiaRequest "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
gaiaResponse "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
+ "github.com/flipped-aurora/gin-vue-admin/server/model/system"
+ "github.com/flipped-aurora/gin-vue-admin/server/utils"
"go.gnd.pw/crypto/eax"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -29,6 +31,150 @@ import (
"time"
)
+// fetchAdminToken 查询一个管理员用户,生成 Dify Console API 兼容的 JWT。
+// 结果缓存到 Redis,TTL 50 分钟(JWT 有效期内复用,避免频繁生成)。
+func (s *ModelProviderService) fetchAdminToken() (token string, err error) {
+ const cacheKey = "gaia:admin_console_token"
+ ctx := context.Background()
+
+ // 优先从 Redis 读取缓存
+ if cached, e := global.GVA_REDIS.Get(ctx, cacheKey).Result(); e == nil && cached != "" {
+ return cached, nil
+ }
+
+ // 查询一个活跃管理员
+ var adminUser system.SysUser
+ if err = global.GVA_DB.Where("authority_id = ? AND enable = ?",
+ system.AdminAuthorityId, system.UserActive).First(&adminUser).Error; err != nil {
+ return "", fmt.Errorf("找不到可用的管理员账号:%w", err)
+ }
+
+ token, _, _, err = utils.LoginTokenWithCSRF(&adminUser)
+ if err != nil {
+ return "", fmt.Errorf("生成管理员 token 失败:%w", err)
+ }
+
+ // 缓存 50 分钟(JWT 缓冲时间内有效)
+ global.GVA_REDIS.Set(ctx, cacheKey, token, 50*time.Minute)
+ return token, nil
+}
+
+// fetchModelPricingFromDify 通过 Dify Console API 拉取 LLM 模型定价,结果按 model 名缓存到 Redis(TTL 1 小时)。
+// Dify Console API:GET /console/api/workspaces/current/models/model-types/llm
+// 响应结构:{"data": [{"models": [{"model": "gpt-4o", "fetch_from": "...", "pricing": {"input":"0.005","output":"0.015","unit":"0.001","currency":"USD"}}]}]}
+func (s *ModelProviderService) fetchModelPricingFromDify(modelName string) (*gaia.ModelPricing, error) {
+ const redisTTL = time.Hour
+ cacheKey := "gaia:model_pricing:" + modelName
+ ctx := context.Background()
+
+ // 先查 Redis
+ if cached, err := global.GVA_REDIS.Get(ctx, cacheKey).Result(); err == nil && cached != "" {
+ var p gaia.ModelPricing
+ if json.Unmarshal([]byte(cached), &p) == nil {
+ return &p, nil
+ }
+ }
+
+ // 获取管理员 token
+ token, err := s.fetchAdminToken()
+ if err != nil {
+ return nil, err
+ }
+
+ // 调用 Dify Console API
+ apiURL := strings.TrimSuffix(global.GVA_CONFIG.Gaia.Url, "/") +
+ "/console/api/workspaces/current/models/model-types/llm"
+ req, err := http.NewRequest(http.MethodGet, apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("构建定价请求失败:%w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("请求 Dify 定价接口失败:%w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("读取定价响应失败:%w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Dify 定价接口返回 %d:%s", resp.StatusCode, string(respBody))
+ }
+
+ // 解析响应,批量缓存所有模型的定价
+ var apiResp gaia.DifyModelsResponse
+ if err = json.Unmarshal(respBody, &apiResp); err != nil {
+ return nil, fmt.Errorf("解析定价响应失败:%w", err)
+ }
+
+ var targetPricing *gaia.ModelPricing
+ for _, providerData := range apiResp.Data {
+ for _, m := range providerData.Models {
+ if m.Pricing == nil {
+ continue
+ }
+ p := gaia.ModelPricing{Currency: m.Pricing.Currency}
+ fmt.Sscanf(m.Pricing.Input, "%f", &p.Input)
+ fmt.Sscanf(m.Pricing.Output, "%f", &p.Output)
+ fmt.Sscanf(m.Pricing.Unit, "%f", &p.Unit)
+ if p.Unit == 0 {
+ p.Unit = 0.001 // 默认按千 token 计费
+ }
+
+ // 缓存每个模型的定价
+ if b, e := json.Marshal(p); e == nil {
+ global.GVA_REDIS.Set(ctx, "gaia:model_pricing:"+m.Model, string(b), redisTTL)
+ }
+ if m.Model == modelName {
+ cp := p
+ targetPricing = &cp
+ }
+ }
+ }
+
+ if targetPricing != nil {
+ return targetPricing, nil
+ }
+ // 未找到该模型的定价,写入空标记避免反复请求(TTL 10 分钟)
+ global.GVA_REDIS.Set(ctx, cacheKey, "{}", 10*time.Minute)
+ return nil, nil
+}
+
+// calcQuotaDelta 根据定价和 token 用量计算本次消耗的配额金额。
+// 若未找到定价则回退到默认单价 0.001(每 token)。
+func calcQuotaDelta(pricing *gaia.ModelPricing, promptTokens, completionTokens int) float64 {
+ if pricing == nil || pricing.Unit == 0 {
+ // 回退:按 0.001/token 统一计费
+ return float64(promptTokens+completionTokens) * 0.001
+ }
+ inputCost := float64(promptTokens) * pricing.Input * pricing.Unit
+ outputPrice := pricing.Output
+ if outputPrice == 0 {
+ outputPrice = pricing.Input
+ }
+ outputCost := float64(completionTokens) * outputPrice * pricing.Unit
+ return inputCost + outputCost
+}
+
+// deductAccountQuota 将消耗配额计入 account_money_extend.used_quota(原子累加)。
+func deductAccountQuota(userID string, delta float64) {
+ if delta <= 0 {
+ return
+ }
+ if err := global.GVA_DB.Exec(
+ `UPDATE account_money_extend SET used_quota = used_quota + ?, updated_at = NOW() WHERE account_id = ?::uuid`,
+ delta, userID,
+ ).Error; err != nil {
+ global.GVA_LOG.Warn("deductAccountQuota 失败",
+ zap.String("user_id", userID), zap.Float64("delta", delta), zap.Error(err))
+ }
+}
+
// ModelProviderService 模型提供商服务,负责提供商配置、凭证获取、可用模型拉取及聊天请求代理。
type ModelProviderService struct{}
@@ -900,7 +1046,8 @@ func (s *ModelProviderService) ProxyRequest(
// 解析 provider:头 > query 已在 handler 传入;此处从 body 取 model 仅当 body 为 JSON 且含 model 时用于推断
xGaiaProvider := reqHeader.Get("X-Gaia-Provider")
- global.GVA_LOG.Info("ProxyRequest 解析 provider", zap.String("path", path), zap.String("X-Gaia-Provider", xGaiaProvider), zap.Int("body_len", len(body)))
+ global.GVA_LOG.Info("ProxyRequest 解析 provider", zap.String("path", path), zap.String(
+ "X-Gaia-Provider", xGaiaProvider), zap.Int("body_len", len(body)))
if p := xGaiaProvider; p != "" {
providerName = strings.TrimSpace(strings.ToLower(p))
}
@@ -912,7 +1059,8 @@ func (s *ModelProviderService) ProxyRequest(
// 按“已选模型”解析实际渠道(如 gpt-5-chat 若只在 Azure 下勾选则走 azure)
providerName, err = s.resolveProviderByModel(m)
if err != nil {
- global.GVA_LOG.Error("ProxyRequest resolveProviderByModel 失败", zap.String("model", m), zap.Error(err))
+ global.GVA_LOG.Error("ProxyRequest resolveProviderByModel 失败", zap.String(
+ "model", m), zap.Error(err))
return err
}
global.GVA_LOG.Info("ProxyRequest 解析得到 provider", zap.String("provider", providerName))
@@ -939,7 +1087,20 @@ func (s *ModelProviderService) ProxyRequest(
return fmt.Errorf("提供商 %s 无可用上游地址", providerName)
}
+ // 若 body 是 JSON 且含 stream: true,注入 stream_options.include_usage = true
+ // 这样上游会在 SSE 末尾的 data 行返回 usage,供后续计费解析使用。
if len(body) > 0 {
+ var bodyObj map[string]interface{}
+ if json.Unmarshal(body, &bodyObj) == nil {
+ if streamVal, ok := bodyObj["stream"].(bool); ok && streamVal {
+ if _, hasOpt := bodyObj["stream_options"]; !hasOpt {
+ bodyObj["stream_options"] = map[string]interface{}{"include_usage": true}
+ if injected, e := json.Marshal(bodyObj); e == nil {
+ body = injected
+ }
+ }
+ }
+ }
bodyReader = bytes.NewReader(body)
}
@@ -1017,18 +1178,43 @@ func (s *ModelProviderService) ProxyRequest(
}
}
var logStatus, logError string
+ var promptTokens, completionTokens int
defer func() {
if logStatus == "" {
logStatus = "success"
}
global.GVA_DB.Create(&gaia.ModelProxyLog{
- UserId: userID,
- ProviderName: providerName,
- ModelName: modelOrPath,
- Status: logStatus,
- ErrorMessage: logError,
- CreatedAt: startTime,
+ UserId: userID,
+ ProviderName: providerName,
+ ModelName: modelOrPath,
+ RequestTokens: promptTokens,
+ ResponseTokens: completionTokens,
+ Status: logStatus,
+ ErrorMessage: logError,
+ CreatedAt: startTime,
})
+
+ // 计费:仅成功时扣费
+ if logStatus == "success" {
+ if promptTokens > 0 || completionTokens > 0 {
+ // LLM 类型:按 token 计费
+ pricing, _ := s.fetchModelPricingFromDify(modelOrPath)
+ delta := calcQuotaDelta(pricing, promptTokens, completionTokens)
+ deductAccountQuota(userID, delta)
+ } else if isImageOrPerRequestPath(path) {
+ // 图片生成等无 usage 的接口:按请求次数计费(固定 0.04 USD/次,约 1 张图片单价)
+ // 实际价格因模型而异(DALL-E 3 1024x1024 = $0.04),此处为保守默认值,可通过定价表覆盖
+ pricing, _ := s.fetchModelPricingFromDify(modelOrPath)
+ var delta float64
+ if pricing != nil && pricing.Input > 0 {
+ // 若定价表有配置,input 字段用作每次请求单价
+ delta = pricing.Input
+ } else {
+ delta = 0.04
+ }
+ deductAccountQuota(userID, delta)
+ }
+ }
}()
// 写回状态码与响应头(流式由上游 Content-Type 决定)
@@ -1044,17 +1230,36 @@ func (s *ModelProviderService) ProxyRequest(
_, _ = io.Copy(writer, resp.Body)
return nil
}
- // 流式响应时按行刷新,避免缓冲
+
+ // extractUsage 从 OpenAI 格式的 JSON 对象中提取 usage 字段
+ extractUsage := func(data []byte) {
+ var obj gaia.ModelUsageResponse
+ if json.Unmarshal(data, &obj) == nil && obj.Usage != nil {
+ if obj.Usage.PromptTokens > 0 {
+ promptTokens = obj.Usage.PromptTokens
+ }
+ if obj.Usage.CompletionTokens > 0 {
+ completionTokens = obj.Usage.CompletionTokens
+ }
+ }
+ }
+
+ // 流式响应:按行扫描,顺带从最后一条含 usage 的 data 行中提取 token 数
if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") {
if flusher, ok := writer.(http.Flusher); ok {
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
- fmt.Println("sss", scanner.Text())
- if _, err = writer.Write([]byte(scanner.Text() + "\n")); err != nil {
+ line := scanner.Text()
+ if _, err = writer.Write([]byte(line + "\n")); err != nil {
logStatus, logError = "error", err.Error()
return err
}
flusher.Flush()
+ // 解析 SSE data 行中的 usage(stream_options.include_usage=true 时上游会附带)
+ if strings.HasPrefix(line, "data:") && strings.Contains(line, `"usage"`) {
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ extractUsage([]byte(payload))
+ }
}
if err = scanner.Err(); err != nil {
logStatus, logError = "error", err.Error()
@@ -1063,9 +1268,15 @@ func (s *ModelProviderService) ProxyRequest(
return nil
}
}
- _, err = io.Copy(writer, resp.Body)
+
+ // 非流式响应:TeeReader 同时转发给客户端并读取 body 用于解析 usage
+ var buf bytes.Buffer
+ tee := io.TeeReader(resp.Body, &buf)
+ _, err = io.Copy(writer, tee)
if err != nil {
logStatus, logError = "error", err.Error()
+ } else {
+ extractUsage(buf.Bytes())
}
return err
}
@@ -1078,3 +1289,22 @@ func (s *ModelProviderService) isProviderEnabled(providerName string) bool {
}
return true
}
+
+// isImageOrPerRequestPath 判断请求路径是否为按次计费的接口(图片生成、语音合成等无 usage 字段的接口)。
+func isImageOrPerRequestPath(path string) bool {
+ perRequestPaths := []string{
+ "images/generations",
+ "images/edits",
+ "images/variations",
+ "audio/speech",
+ "audio/transcriptions",
+ "audio/translations",
+ }
+ lpath := strings.ToLower(path)
+ for _, p := range perRequestPaths {
+ if strings.Contains(lpath, p) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/admin/server/service/gaia/system.go b/admin/server/service/gaia/system.go
index a1efa9d51..ff8a1829f 100644
--- a/admin/server/service/gaia/system.go
+++ b/admin/server/service/gaia/system.go
@@ -2,6 +2,10 @@ package gaia
import (
"context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -9,6 +13,7 @@ import (
"net/http"
"net/url"
"os"
+ "strconv"
"strings"
"time"
@@ -17,6 +22,7 @@ import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
+ gaiaResp "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -126,22 +132,52 @@ func (e *SystemIntegratedService) SetIntegratedConfig(
// @param: req gaia.SystemIntegration
// @return: *dingding.Client, error
func (e *SystemIntegratedService) DingTalkConfigAvailable(req gaia.SystemIntegration) (*dingding.Client, error) {
- var err error
+ // 1. 先直接调用钉钉 gettoken 接口,校验 AppKey/AppSecret 是否正确
+ if strings.TrimSpace(req.AppKey) == "" || strings.TrimSpace(req.AppSecret) == "" {
+ return nil, errors.New("AppKey 或 AppSecret 不能为空")
+ }
+
+ params := url.Values{}
+ params.Add("appkey", req.AppKey)
+ params.Add("appsecret", req.AppSecret)
+
+ resp, err := http.Get("https://oapi.dingtalk.com/gettoken?" + params.Encode())
+ if err != nil {
+ return nil, fmt.Errorf("请求钉钉 gettoken 失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("钉钉 gettoken HTTP 状态异常: %d, body=%s", resp.StatusCode, string(body))
+ }
+
+ var tokenResp struct {
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err = json.Unmarshal(body, &tokenResp); err != nil {
+ return nil, fmt.Errorf("解析钉钉 gettoken 响应失败: %w", err)
+ }
+ if tokenResp.ErrCode != 0 {
+ return nil, fmt.Errorf("钉钉 gettoken 返回错误: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
+ }
+
+ // 2. 校验通过后再构造 client(保持原有行为,供后续使用)
var reqs *http.Request
dingding.ServerUrl = "https://api.dingtalk.com"
- // 特殊需要,检查可用性就不设置缓存了
- return dingding.NewClient(&dingding.DefaultAccessTokenManager{
+ client := dingding.NewClient(&dingding.DefaultAccessTokenManager{
Id: uuid.New().String(),
Cache: file.New(os.TempDir()),
Name: "x-acs-dingtalk-access-token",
GetRefreshRequestFunc: func() *http.Request {
- params := url.Values{}
- params.Add("appkey", req.AppKey)
- params.Add("appsecret", req.AppSecret)
- reqs, err = http.NewRequest(http.MethodGet, "https://oapi.dingtalk.com/gettoken?"+params.Encode(), nil)
+ // 这里沿用原来的 token 刷新逻辑
+ reqs, _ = http.NewRequest(http.MethodGet, "https://oapi.dingtalk.com/gettoken?"+params.Encode(), nil)
return reqs
},
- }), err
+ })
+
+ return client, nil
}
// TestConnection 测试连接
@@ -169,11 +205,6 @@ func (e *SystemIntegratedService) TestConnection(integrate gaia.SystemIntegratio
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连接
@@ -183,79 +214,307 @@ func (e *SystemIntegratedService) TestConnection(integrate gaia.SystemIntegratio
}
}
-// ValidateEmailApiConfig 验证第三方邮箱API配置
-// @Tags System Integrated
-// @Summary 验证第三方邮箱API配置
-// @param: integrate gaia.SystemIntegration
-// @return: error
-func (e *SystemIntegratedService) ValidateEmailApiConfig(integrate gaia.SystemIntegration) error {
- // 解析Config字段
- if integrate.Config == "" {
- return nil // 配置为空不算错误
- }
-
+// ParseDingTalkConfig 解析钉钉集成配置,自动处理新旧格式兼容
+func (e *SystemIntegratedService) ParseDingTalkConfig(configJSON string) (request.DingTalkConfigRequest, error) {
var configMap request.DingTalkConfigRequest
- if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil {
- return fmt.Errorf("解析配置失败: %s", err.Error())
+ if configJSON == "" {
+ return configMap, nil
}
- // 检查是否启用邮箱API
- if !configMap.EmailApi.Enabled {
- return nil // 未启用不需要验证
+ // 先解析顶层结构
+ var raw struct {
+ EmailApi json.RawMessage `json:"email_api"`
+ ForwardConfig request.ForwardConfig `json:"forward_config"`
+ }
+ if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
+ return configMap, fmt.Errorf("解析钉钉配置失败: %s", err.Error())
}
- // 验证必填字段
- if configMap.EmailApi.URL == "" {
+ configMap.ForwardConfig = raw.ForwardConfig
+
+ if raw.EmailApi != nil {
+ cfg, err := parseEmailApiConfigFromJSON(raw.EmailApi)
+ if err != nil {
+ return configMap, err
+ }
+ configMap.EmailApi = cfg
+ }
+
+ return configMap, nil
+}
+
+// oldBodyDataCompat 旧格式 BodyData(兼容解析用)
+type oldBodyDataCompat struct {
+ FormData []map[string]string `json:"form_data"`
+ Urlencoded []map[string]string `json:"urlencoded"`
+ Raw string `json:"raw"`
+}
+
+// isNewEmailApiConfig 检测配置是否使用新格式(通过 params 字段判断)
+func isNewEmailApiConfig(config request.EmailApiConfig) bool {
+ return config.Params != nil
+}
+
+// convertOldBodyDataToNew 将旧格式 BodyData([]map[string]string)转换为新格式([]BodyField)
+func convertOldBodyDataToNew(old oldBodyDataCompat) request.BodyData {
+ newData := request.BodyData{Raw: old.Raw}
+ for _, kv := range old.FormData {
+ for k, v := range kv {
+ newData.FormData = append(newData.FormData, request.BodyField{
+ Key: k,
+ ValueType: request.ValueTypeString,
+ Value: v,
+ })
+ }
+ }
+ for _, kv := range old.Urlencoded {
+ for k, v := range kv {
+ newData.Urlencoded = append(newData.Urlencoded, request.BodyField{
+ Key: k,
+ ValueType: request.ValueTypeString,
+ Value: v,
+ })
+ }
+ }
+ return newData
+}
+
+// parseEmailApiConfigFromJSON 解析 EmailApiConfig,自动兼容新旧格式
+// 新格式:包含 params 字段
+// 旧格式:包含 request_param_field 字段,body_data 中使用 []map[string]string
+func parseEmailApiConfigFromJSON(raw json.RawMessage) (request.EmailApiConfig, error) {
+ // 先尝试解析为新格式
+ var newConfig request.EmailApiConfig
+ if err := json.Unmarshal(raw, &newConfig); err != nil {
+ return request.EmailApiConfig{}, fmt.Errorf("解析邮箱配置失败: %s", err.Error())
+ }
+
+ // 如果有 params 字段,说明是新格式
+ if isNewEmailApiConfig(newConfig) {
+ return newConfig, nil
+ }
+
+ // 旧格式:尝试解析 body_data 中的 []map[string]string
+ var oldCompat struct {
+ request.EmailApiConfig
+ BodyData oldBodyDataCompat `json:"body_data"`
+ }
+ if err := json.Unmarshal(raw, &oldCompat); err == nil {
+ newConfig.BodyData = convertOldBodyDataToNew(oldCompat.BodyData)
+ global.GVA_LOG.Info("邮箱配置:检测到旧格式,已在内存中转换为新格式",
+ zap.String("request_param_field", newConfig.RequestParamField))
+ }
+
+ return newConfig, nil
+}
+
+// validateEmailApiConfigFields 验证 EmailApiConfig 字段
+func validateEmailApiConfigFields(cfg request.EmailApiConfig) error {
+ if !cfg.Enabled {
+ return nil
+ }
+ if cfg.URL == "" {
return errors.New("邮箱API URL不能为空")
}
-
- if configMap.EmailApi.Method == "" {
- configMap.EmailApi.Method = "GET"
+ if cfg.Method == "" {
+ cfg.Method = "GET"
}
- if configMap.EmailApi.RequestParamField == "" {
- return errors.New("邮箱请求字段不能为空")
- }
-
- if configMap.EmailApi.ResponseEmailField == "" {
- return errors.New("邮箱信息提取字段不能为空")
- }
-
- // 验证Body类型(仅POST/PUT/DELETE需要)
- if configMap.EmailApi.Method != "GET" {
- bodyType := strings.ToLower(configMap.EmailApi.BodyType)
- if bodyType == "" {
- configMap.EmailApi.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)
+ // 新格式不强制要求 request_param_field(通过 params 配置 URL 查询参数)
+ if isNewEmailApiConfig(cfg) {
+ // Params 只支持 string 和 ding_id 两种类型
+ for i, p := range cfg.Params {
+ if err := validateParamValueType(p.ValueType); err != nil {
+ return fmt.Errorf("第%d个 URL 参数类型无效:%s", i+1, err.Error())
+ }
+ }
+ // Body fields 支持 string、int、bool、ding_id
+ for i, f := range cfg.BodyData.FormData {
+ if err := validateValueType(f.ValueType); err != nil {
+ return fmt.Errorf("form-data 第%d个字段类型无效:%s", i+1, err.Error())
+ }
+ if f.ValueType == request.ValueTypeInt && f.Value != "" {
+ if _, err := strconv.ParseInt(f.Value, 10, 64); err != nil {
+ return fmt.Errorf("form-data 第%d个字段(%s)的值不是有效整数:%s", i+1, f.Key, f.Value)
+ }
+ }
+ if f.ValueType == request.ValueTypeBool && f.Value != "" {
+ if _, err := strconv.ParseBool(f.Value); err != nil {
+ return fmt.Errorf("form-data 第%d个字段(%s)的值不是有效布尔值:%s", i+1, f.Key, f.Value)
+ }
+ }
+ }
+ for i, f := range cfg.BodyData.Urlencoded {
+ if err := validateValueType(f.ValueType); err != nil {
+ return fmt.Errorf("urlencoded 第%d个字段类型无效:%s", i+1, err.Error())
+ }
+ }
+ } else {
+ // 旧格式兼容:request_param_field 不能为空
+ if cfg.RequestParamField == "" {
+ return errors.New("邮箱请求字段不能为空")
}
}
- // 验证Authorization配置
- authType := strings.ToLower(configMap.EmailApi.Authorization.Type)
+ if cfg.ResponseEmailField == "" {
+ return errors.New("邮箱信息提取字段不能为空")
+ }
+
+ if cfg.Method != "GET" {
+ bodyType := strings.ToLower(cfg.BodyType)
+ if bodyType != "" && bodyType != "form-data" && bodyType != "x-www-form-urlencoded" && bodyType != "raw" {
+ return fmt.Errorf("不支持的Body类型: %s,支持的类型: form-data, x-www-form-urlencoded, raw", cfg.BodyType)
+ }
+ }
+
+ authType := strings.ToLower(cfg.Authorization.Type)
if authType != "" && authType != "none" {
if authType == "bearer" {
- if configMap.EmailApi.Authorization.Token == "" {
+ if cfg.Authorization.Token == "" {
return errors.New("Bearer Token不能为空")
}
} else if authType == "basic" {
- if configMap.EmailApi.Authorization.Username == "" || configMap.EmailApi.Authorization.Password == "" {
+ if cfg.Authorization.Username == "" || cfg.Authorization.Password == "" {
return errors.New("Basic Auth需要填写Username和Password")
}
} else {
return fmt.Errorf("不支持的Authorization类型: %s,支持的类型: none, bearer, basic", authType)
}
}
+ return nil
+}
+
+// validateValueType 验证 Body 字段的 ValueType 是否合法(支持全部四种)
+func validateValueType(vt string) error {
+ switch vt {
+ case "", request.ValueTypeString, request.ValueTypeInt, request.ValueTypeBool, request.ValueTypeDingID:
+ return nil
+ default:
+ return fmt.Errorf("不支持的值类型: %s,支持的类型: string, int, bool, ding_id", vt)
+ }
+}
+
+// validateParamValueType 验证 URL Params 的 ValueType 是否合法(只支持 string 和 ding_id)
+func validateParamValueType(vt string) error {
+ switch vt {
+ case "", request.ValueTypeString, request.ValueTypeDingID:
+ return nil
+ default:
+ return fmt.Errorf("URL 参数不支持的值类型: %s,支持的类型: string, ding_id", vt)
+ }
+}
+
+// ValidateEmailApiConfig 验证第三方邮箱API配置
+// @Tags System Integrated
+// @Summary 验证第三方邮箱API配置
+// @param: integrate gaia.SystemIntegration
+// @return: error
+func (e *SystemIntegratedService) ValidateEmailApiConfig(integrate gaia.SystemIntegration) error {
+ if integrate.Config == "" {
+ return nil
+ }
+
+ var configMap struct {
+ EmailApi json.RawMessage `json:"email_api"`
+ }
+ if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil {
+ return fmt.Errorf("解析配置失败: %s", err.Error())
+ }
+ if configMap.EmailApi == nil {
+ return nil
+ }
+
+ cfg, err := parseEmailApiConfigFromJSON(configMap.EmailApi)
+ if err != nil {
+ return err
+ }
+
+ if err = validateEmailApiConfigFields(cfg); err != nil {
+ return err
+ }
global.GVA_LOG.Info("第三方邮箱API配置验证通过",
- zap.String("url", configMap.EmailApi.URL),
- zap.String("method", configMap.EmailApi.Method),
- zap.String("body_type", configMap.EmailApi.BodyType),
- zap.String("auth_type", configMap.EmailApi.Authorization.Type))
+ zap.String("url", cfg.URL),
+ zap.String("method", cfg.Method),
+ zap.String("body_type", cfg.BodyType),
+ zap.String("auth_type", cfg.Authorization.Type),
+ zap.Bool("new_format", isNewEmailApiConfig(cfg)))
return nil
}
+// TestEmailApiConfig 测试邮箱 API 配置,返回详细的响应结果用于调试
+func (e *SystemIntegratedService) TestEmailApiConfig(cfg request.EmailApiConfig, testDingID string) (*gaiaResp.TestEmailApiConfigResponse, error) {
+ if err := validateEmailApiConfigFields(cfg); err != nil {
+ return &gaiaResp.TestEmailApiConfigResponse{
+ IsValid: false,
+ ErrorMessage: "配置验证失败:" + err.Error(),
+ }, nil
+ }
+
+ dingId := strings.TrimSpace(testDingID)
+ if dingId == "" {
+ return &gaiaResp.TestEmailApiConfigResponse{
+ IsValid: false,
+ ErrorMessage: "测试钉钉 ID 不能为空,请先在弹窗中填写一个真实的 ding_id",
+ }, nil
+ }
+
+ respBody, statusCode, reqErr := e.doEmailApiRequest(dingId, cfg)
+
+ result := &gaiaResp.TestEmailApiConfigResponse{
+ StatusCode: statusCode,
+ }
+
+ // 尝试解析响应 Body 为 JSON
+ var bodyJSON interface{}
+ if json.Unmarshal(respBody, &bodyJSON) == nil {
+ result.Body = bodyJSON
+ } else {
+ result.Body = string(respBody)
+ }
+
+ if reqErr != nil {
+ result.IsValid = false
+ result.ErrorMessage = reqErr.Error()
+ return result, nil
+ }
+
+ // 尝试提取邮箱字段
+ if cfg.ResponseEmailField != "" {
+ if bodyMap, ok := bodyJSON.(map[string]interface{}); ok {
+ email := extractJSONPathAdvanced(bodyMap, cfg.ResponseEmailField)
+ if email != "" {
+ result.EmailFieldPreview = cfg.ResponseEmailField + " = " + email
+ result.IsValid = true
+ } else {
+ result.IsValid = false
+ result.ErrorMessage = "未找到邮箱字段:" + cfg.ResponseEmailField
+ }
+ } else if bodySlice, ok := bodyJSON.([]interface{}); ok {
+ email := extractJSONPathAdvanced(bodySlice, cfg.ResponseEmailField)
+ if email != "" {
+ result.EmailFieldPreview = cfg.ResponseEmailField + " = " + email
+ result.IsValid = true
+ } else {
+ result.IsValid = false
+ result.ErrorMessage = "未找到邮箱字段:" + cfg.ResponseEmailField
+ }
+ } else {
+ result.IsValid = statusCode >= 200 && statusCode < 300
+ }
+ } else {
+ result.IsValid = statusCode >= 200 && statusCode < 300
+ }
+
+ global.GVA_LOG.Info("测试邮箱 API 配置",
+ zap.Int("status_code", statusCode),
+ zap.String("email_preview", result.EmailFieldPreview),
+ zap.Bool("is_valid", result.IsValid))
+
+ return result, nil
+}
+
// TestOAuth2Connection 测试OAuth2连接
// @Tags System Integrated
// @Summary 测试OAuth2连接
@@ -354,9 +613,14 @@ func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemInt
return fmt.Errorf("解析配置失败:%s", err.Error())
}
- // 检查是否启用转发
- if !configMap.ForwardConfig.Enabled {
- return nil // 未启用不需要验证
+ // 若未配置转发 Token,则认为未使用转发能力,不强制校验
+ if len(configMap.ForwardConfig.Tokens) == 0 {
+ return nil
+ }
+
+ // 使用转发能力的前置条件:至少 1 个 Token + 启用并配置「第三方邮箱配置」
+ if !configMap.EmailApi.Enabled || strings.TrimSpace(configMap.EmailApi.URL) == "" {
+ return errors.New("使用转发能力前请先启用并配置「第三方邮箱配置」")
}
// 验证 Token 数量
@@ -375,7 +639,6 @@ func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemInt
}
global.GVA_LOG.Info("转发集成配置验证通过",
- zap.Bool("enabled", configMap.ForwardConfig.Enabled),
zap.Int("token_count", len(configMap.ForwardConfig.Tokens)))
return nil
@@ -386,74 +649,6 @@ func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemInt
// @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)
@@ -473,56 +668,131 @@ func extractJSONPath(data map[string]interface{}, path string) string {
return ""
}
-// callDingIdApi 调用第三方钉钉 ID 匹配 API,返回 username
-func (e *SystemIntegratedService) callDingIdApi(dingId string, config request.DingIdApiConfig) (string, error) {
+// resolveParamValue 根据 ValueType 解析参数值,ding_id 类型替换为实际的钉钉 ID
+func resolveParamValue(vt, value, dingId string) string {
+ switch vt {
+ case request.ValueTypeDingID:
+ return dingId
+ default:
+ // 兼容旧格式的 {{ding_id}} 占位符和新格式的 $<{[ding_id]}> 标记
+ v := strings.ReplaceAll(value, "{{ding_id}}", dingId)
+ v = strings.ReplaceAll(v, request.DingIDMarker, dingId)
+ return v
+ }
+}
+
+// buildBodyFields 将 []BodyField 按类型转换,构建 url.Values(用于 form-data 和 urlencoded)
+func buildBodyFields(fields []request.BodyField, dingId string) url.Values {
+ form := url.Values{}
+ for _, f := range fields {
+ if f.Key == "" {
+ continue
+ }
+ val := resolveParamValue(f.ValueType, f.Value, dingId)
+ form.Set(f.Key, val)
+ }
+ return form
+}
+
+// buildURL 根据新格式 Params 或旧格式 RequestParamField 构建带查询参数的 URL
+func buildURL(baseURL string, config request.EmailApiConfig, dingId string) string {
+ if isNewEmailApiConfig(config) {
+ // 新格式:遍历 Params 列表自动拼接
+ params := url.Values{}
+ for _, p := range config.Params {
+ if p.Key == "" {
+ continue
+ }
+ params.Set(p.Key, resolveParamValue(p.ValueType, p.Value, dingId))
+ }
+ if len(params) == 0 {
+ return baseURL
+ }
+ sep := "?"
+ if strings.Contains(baseURL, "?") {
+ sep = "&"
+ }
+ return baseURL + sep + params.Encode()
+ }
+
+ // 旧格式:RequestParamField 字段名 + dingId 作为值
+ if config.RequestParamField != "" {
+ sep := "?"
+ if strings.Contains(baseURL, "?") {
+ sep = "&"
+ }
+ return baseURL + sep + url.QueryEscape(config.RequestParamField) + "=" + url.QueryEscape(dingId)
+ }
+ return baseURL
+}
+
+// callEmailApi 调用第三方邮箱 API,使用 ding_id(用户名) 获取邮箱
+func (e *SystemIntegratedService) callEmailApi(dingId string, config request.EmailApiConfig) (string, error) {
+ respBody, _, err := e.doEmailApiRequest(dingId, config)
+ if err != nil {
+ return "", err
+ }
+
+ var respJSON map[string]interface{}
+ if err = json.Unmarshal(respBody, &respJSON); err != nil {
+ return "", fmt.Errorf("解析响应 JSON 失败:%s", err.Error())
+ }
+
+ email := extractJSONPathAdvanced(respJSON, config.ResponseEmailField)
+ if email == "" {
+ return "", fmt.Errorf("响应中未找到邮箱(路径:%s)", config.ResponseEmailField)
+ }
+
+ return email, nil
+}
+
+// doEmailApiRequest 构建并执行邮箱 API 请求,返回响应体字节和状态码
+func (e *SystemIntegratedService) doEmailApiRequest(dingId string, config request.EmailApiConfig) ([]byte, int, error) {
method := strings.ToUpper(config.Method)
if method == "" {
method = "GET"
}
var bodyReader io.Reader
+ var contentType string
if method != "GET" && method != "HEAD" {
switch strings.ToLower(config.BodyType) {
case "raw":
- // 替换 Body 中的 {{ding_id}} 占位符
raw := strings.ReplaceAll(config.BodyData.Raw, "{{ding_id}}", dingId)
+ raw = strings.ReplaceAll(raw, request.DingIDMarker, 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))
- }
- }
+ contentType = "application/json"
+ case "form-data":
+ form := buildBodyFields(config.BodyData.FormData, dingId)
+ // 也处理旧格式的 urlencoded 字段(兼容)
+ if len(form) == 0 {
+ form = buildBodyFields(config.BodyData.Urlencoded, dingId)
}
bodyReader = strings.NewReader(form.Encode())
+ contentType = "multipart/form-data"
+ case "x-www-form-urlencoded":
+ form := buildBodyFields(config.BodyData.Urlencoded, dingId)
+ if len(form) == 0 {
+ form = buildBodyFields(config.BodyData.FormData, dingId)
+ }
+ bodyReader = strings.NewReader(form.Encode())
+ contentType = "application/x-www-form-urlencoded"
}
}
- // 构建请求 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)
- }
- }
+ apiURL := buildURL(config.URL, config, dingId)
req, err := http.NewRequest(method, apiURL, bodyReader)
if err != nil {
- return "", fmt.Errorf("构建请求失败:%s", err.Error())
+ return nil, 0, fmt.Errorf("构建请求失败:%s", err.Error())
}
- // 设置 Headers
+ // 设置 Content-Type(如果 Headers 未覆盖)
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+
+ // 设置 Headers(可覆盖 Content-Type)
for k, v := range config.Headers {
req.Header.Set(k, v)
}
@@ -536,38 +806,153 @@ func (e *SystemIntegratedService) callDingIdApi(dingId string, config request.Di
req.SetBasicAuth(config.Authorization.Username, config.Authorization.Password)
}
- client := &http.Client{}
+ client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
- return "", fmt.Errorf("请求失败:%s", err.Error())
+ return nil, 0, 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())
+ return nil, resp.StatusCode, 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())
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return respBody, resp.StatusCode, fmt.Errorf("第三方 API 返回错误状态码:%d", resp.StatusCode)
}
- userName := extractJSONPath(respJSON, config.ResponseUserNamePath)
- if userName == "" {
- return "", fmt.Errorf("响应中未找到用户名(路径:%s)", config.ResponseUserNamePath)
- }
+ return respBody, resp.StatusCode, nil
+}
- return userName, nil
+// extractJSONPathAdvanced 支持点分路径和数组索引(如 data[0].userName)
+func extractJSONPathAdvanced(data interface{}, path string) string {
+ if path == "" {
+ return ""
+ }
+ parts := splitJSONPath(path)
+ current := data
+ for _, part := range parts {
+ switch v := current.(type) {
+ case map[string]interface{}:
+ current = v[part.key]
+ case []interface{}:
+ if part.isIndex && part.index >= 0 && part.index < len(v) {
+ current = v[part.index]
+ } else {
+ return ""
+ }
+ default:
+ return ""
+ }
+ if current == nil {
+ return ""
+ }
+ }
+ switch v := current.(type) {
+ case string:
+ return v
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ case bool:
+ return strconv.FormatBool(v)
+ default:
+ return fmt.Sprintf("%v", v)
+ }
+}
+
+type jsonPathPart struct {
+ key string
+ isIndex bool
+ index int
+}
+
+// splitJSONPath 将路径字符串分割为结构化的部分列表,支持 data[0].userName 格式
+func splitJSONPath(path string) []jsonPathPart {
+ var parts []jsonPathPart
+ // 先按点分割
+ segments := strings.Split(path, ".")
+ for _, seg := range segments {
+ seg = strings.TrimSpace(seg)
+ if seg == "" {
+ continue
+ }
+ // 检查是否包含数组索引 data[0]
+ if idx := strings.Index(seg, "["); idx != -1 {
+ key := seg[:idx]
+ rest := seg[idx:]
+ if key != "" {
+ parts = append(parts, jsonPathPart{key: key})
+ }
+ // 解析所有 [N] 部分
+ for len(rest) > 0 && rest[0] == '[' {
+ end := strings.Index(rest, "]")
+ if end == -1 {
+ break
+ }
+ idxStr := rest[1:end]
+ rest = rest[end+1:]
+ if n, err := strconv.Atoi(idxStr); err == nil {
+ parts = append(parts, jsonPathPart{isIndex: true, index: n})
+ }
+ }
+ } else {
+ parts = append(parts, jsonPathPart{key: seg})
+ }
+ }
+ return parts
+}
+
+// ParseForwardToken 从 Bearer Token 中验签并提取 ding_id
+// 遍历 tokens 列表,找到签名匹配的条目
+func (e *SystemIntegratedService) ParseForwardToken(
+ rawToken string, tokens []request.ForwardToken) (dingId string, err error) {
+ parts := strings.SplitN(rawToken, ".", 2)
+ if len(parts) != 2 {
+ return "", errors.New("token 格式非法")
+ }
+ payloadB64, sigB64 := parts[0], parts[1]
+ sigBytes, err := base64.RawURLEncoding.DecodeString(sigB64)
+ if err != nil {
+ return "", errors.New("签名解码失败")
+ }
+ for _, t := range tokens {
+ if t.TokenSecret == "" {
+ continue
+ }
+ secret, err := hex.DecodeString(t.TokenSecret)
+ if err != nil {
+ continue
+ }
+ // 验证 HMAC 签名
+ mac := hmac.New(sha256.New, secret)
+ mac.Write([]byte(payloadB64))
+ expected := mac.Sum(nil)
+ if !hmac.Equal(expected, sigBytes) {
+ continue // 不匹配,试下一个
+ }
+ // 签名验证通过,解析 payload
+ payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64)
+ if err != nil {
+ return "", errors.New("payload 解码失败")
+ }
+ var payload struct {
+ DingID string `json:"ding_id"`
+ }
+ if err := json.Unmarshal(payloadBytes, &payload); err != nil {
+ return "", errors.New("payload 解析失败")
+ }
+ if payload.DingID == "" {
+ return "", errors.New("token 中缺少 ding_id")
+ }
+ return payload.DingID, nil
+ }
+ return "", errors.New("无效的转发 Token")
}
// ResolveAccountByDingId 通过钉钉 ID 解析 gaia account_id
-// 解析顺序:Redis 缓存 → AccountDingTalkExtend 本地表 → 第三方 DingIdApi
-func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfig request.DingIdApiConfig) (string, error) {
+// 解析顺序:Redis 缓存 → AccountDingTalkExtend 本地表 → 第三方 EmailApi(邮箱 API)
+func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfig request.EmailApiConfig) (string, error) {
ctx := context.Background()
redisKey := "gaia:forward:ding:" + dingId
@@ -586,20 +971,20 @@ func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfi
return accountID, nil
}
- // 3. 第三方 DingIdApi(若配置且启用)
- if !apiConfig.Enabled || apiConfig.URL == "" {
- return "", fmt.Errorf("未找到 ding_id=%s 对应的用户,且未配置第三方 API", dingId)
+ // 3. 第三方邮箱 API(若配置)
+ if !apiConfig.Enabled || strings.TrimSpace(apiConfig.URL) == "" {
+ return "", fmt.Errorf("未找到 ding_id=%s 对应的用户,且未配置第三方邮箱 API", dingId)
}
- userName, err := e.callDingIdApi(dingId, apiConfig)
+ email, err := e.callEmailApi(dingId, apiConfig)
if err != nil {
- return "", fmt.Errorf("调用第三方 DingId API 失败:%s", err.Error())
+ return "", fmt.Errorf("调用第三方邮箱 API 失败:%s", err.Error())
}
- // 4. 按 username 查 accounts 表(匹配 name 字段)
+ // 4. 按邮箱查 accounts 表(匹配 email 字段)
var account gaia.Account
- if err = global.GVA_DB.Where("name = ?", userName).First(&account).Error; err != nil {
- return "", fmt.Errorf("用户名 %s 不存在(来自第三方 API)", userName)
+ if err = global.GVA_DB.Where("email = ?", email).First(&account).Error; err != nil {
+ return "", fmt.Errorf("邮箱 %s 不存在(来自第三方邮箱 API)", email)
}
accountID := account.ID.String()
@@ -613,9 +998,9 @@ func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfi
// 6. 写 Redis 缓存
global.GVA_REDIS.Set(ctx, redisKey, accountID, 24*time.Hour)
- global.GVA_LOG.Info("ResolveAccountByDingId: 第三方 API 解析成功",
+ global.GVA_LOG.Info("ResolveAccountByDingId: 第三方邮箱 API 解析成功",
zap.String("ding_id", dingId),
- zap.String("username", userName),
+ zap.String("email", email),
zap.String("account_id", accountID))
return accountID, nil
diff --git a/admin/server/source/system/api.go b/admin/server/source/system/api.go
index 63b81be93..3dc7f6f18 100644
--- a/admin/server/source/system/api.go
+++ b/admin/server/source/system/api.go
@@ -201,6 +201,9 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
// Extend Start: system integration
{ApiGroup: "应用集成配置", Method: "GET", Path: "/gaia/system/dingtalk", Description: "获取钉钉系统配置"},
{ApiGroup: "应用集成配置", Method: "POST", Path: "/gaia/system/dingtalk", Description: "设置钉钉系统配置"},
+ {ApiGroup: "应用集成配置", Method: "POST", Path: "/gaia/system/dingtalk/test-email-config", Description: "测试钉钉邮箱API配置"},
+ {ApiGroup: "应用集成配置", Method: "GET", Path: "/gaia/system/dingtalk/test-auth-url", Description: "测试连接-获取钉钉授权URL"},
+ {ApiGroup: "应用集成配置", Method: "POST", Path: "/gaia/system/dingtalk/test-callback", Description: "测试连接-钉钉回调验证"},
// Extend Stop: system integration
// Extend Start: oauth2
diff --git a/admin/server/source/system/casbin.go b/admin/server/source/system/casbin.go
index 888c838b8..a16018689 100644
--- a/admin/server/source/system/casbin.go
+++ b/admin/server/source/system/casbin.go
@@ -287,6 +287,9 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
// Extend Start: system integration
{Ptype: "p", V0: "888", V1: "/gaia/system/dingtalk", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/gaia/system/dingtalk", V2: "POST"},
+ {Ptype: "p", V0: "888", V1: "/gaia/system/dingtalk/test-email-config", V2: "POST"},
+ {Ptype: "p", V0: "888", V1: "/gaia/system/dingtalk/test-auth-url", V2: "GET"},
+ {Ptype: "p", V0: "888", V1: "/gaia/system/dingtalk/test-callback", V2: "POST"},
// Extend Stop: system integration
// Extend Start: oauth2
diff --git a/admin/web/src/api/gaia/system.js b/admin/web/src/api/gaia/system.js
index a86da6f2d..33f9bbdcd 100644
--- a/admin/web/src/api/gaia/system.js
+++ b/admin/web/src/api/gaia/system.js
@@ -88,3 +88,32 @@ export const deleteForwardToken = (id, password) => {
data: { password },
})
}
+
+// @Tags systrm
+// @Summary 测试第三方邮箱 API 配置
+// @Security ApiKeyAuth
+// @Router /gaia/system/dingtalk/test-email-config [post]
+export const testEmailApiConfig = (data) => {
+ return service({
+ url: '/gaia/system/dingtalk/test-email-config',
+ method: 'post',
+ data,
+ })
+}
+
+// 测试连接:获取钉钉授权 URL(打开后扫码完成即视为连接成功)
+export const getDingTalkTestAuthUrl = () => {
+ return service({
+ url: '/gaia/system/dingtalk/test-auth-url',
+ method: 'get',
+ })
+}
+
+// 测试连接回调:仅用 code 验证,不登录
+export const dingtalkTestCallback = (data) => {
+ return service({
+ url: '/gaia/system/dingtalk/test-callback',
+ method: 'post',
+ data: { code: data.code },
+ })
+}
diff --git a/admin/web/src/view/login/callback.vue b/admin/web/src/view/login/callback.vue
index 3ccd5ca93..1992e38d1 100644
--- a/admin/web/src/view/login/callback.vue
+++ b/admin/web/src/view/login/callback.vue
@@ -11,6 +11,7 @@ import { useUserStore } from '@/pinia/modules/user'
import { useRouterStore } from '@/pinia/modules/router'
import router from '@/router'
import { gaiaOAuth2Login, dingtalkLogin } from '@/api/user_extend'
+import { dingtalkTestCallback } from '@/api/gaia/system'
defineOptions({
name: 'LoginCallback',
@@ -70,6 +71,19 @@ const callback = async () => {
const redirectUri = sessionStorage.getItem('gaia_login_redirect_uri') || ''
const state = sessionStorage.getItem('gaia_login_state') || getQueryParam('state') || ''
+ // 测试连接回调:仅验证 code 换 token,不登录,结果通过 postMessage 回传并关闭
+ if (provider === 'dingtalk' && state === 'dingtalk_test') {
+ try {
+ const res = await dingtalkTestCallback({ code })
+ const payload = { type: 'dingtalk_test_result', success: res?.code === 0, message: res?.msg }
+ if (window.opener) window.opener.postMessage(payload, '*')
+ } catch (e) {
+ if (window.opener) window.opener.postMessage({ type: 'dingtalk_test_result', success: false, message: e?.message || '验证失败' }, '*')
+ }
+ window.close()
+ return
+ }
+
try {
if (provider === 'dingtalk') {
if (!hasCode) {
diff --git a/admin/web/src/view/systemIntegrated/dingTalk/index.vue b/admin/web/src/view/systemIntegrated/dingTalk/index.vue
index fcd851588..79f97aac5 100644
--- a/admin/web/src/view/systemIntegrated/dingTalk/index.vue
+++ b/admin/web/src/view/systemIntegrated/dingTalk/index.vue
@@ -163,6 +163,54 @@
+
+ $<{[ding_id]}> 占位符,发送请求时自动替换为实际钉钉 ID
+
- 1. 扫码登录应用创建入口:
-
- 2. AppId和AppSecret是扫码登录应用的唯一标识,创建完成后可见 -
-- 查看路径: 钉钉开放平台>应用开发>移动接入应用>扫码登录应用授权应用的列表。 -
-为第三方系统(如钉钉入口)提供免登录转发代理能力,通过 Token 鉴权后根据钉钉 ID 自动计费
- -当本地表中找不到钉钉 ID 对应用户时,调用此 API 通过 ding_id 获取用户名。开启或修改后请点击下方「保存」按钮。
-
+ 1. 扫码登录应用创建入口:
+
+ 2. AppId和AppSecret是扫码登录应用的唯一标识,创建完成后可见 +
++ 查看路径: 钉钉开放平台>应用开发>移动接入应用>扫码登录应用授权应用的列表。 +
点击「生成并保存」将随机生成 Token,保存后会自动复制到系统剪贴板,请粘贴到安全位置保管。Token 仅展示一次。
++ 点击「生成并保存」将随机生成 Token,保存后会自动复制到系统剪贴板,请粘贴到安全位置保管。Token 仅展示一次。 +
删除操作需要验证您的登录密码,请输入后确认。
++ 删除操作需要验证您的登录密码,请输入后确认。 +
{{ JSON.stringify(testResult.body, null, 2) }}
+ {{ testResult.body }}
+