From 4b5e2eaf351cbe709cdf8738e3bf79ca9b431695 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 11 Mar 2026 12:05:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=A1=E8=B4=B9=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/server/api/v1/gaia/forward_proxy.go | 118 +-- admin/server/api/v1/gaia/model_provider.go | 94 +- admin/server/api/v1/gaia/system.go | 86 +- .../gaia/model_provider_config_extend.go | 43 + admin/server/model/gaia/request/system.go | 107 +- admin/server/model/gaia/response/system.go | 26 + admin/server/router/gaia/system.go | 14 +- admin/server/service/gaia/email_api_test.go | 273 +++++ admin/server/service/gaia/gaia_login.go | 93 +- admin/server/service/gaia/login_options.go | 16 + admin/server/service/gaia/model_provider.go | 254 ++++- admin/server/service/gaia/system.go | 755 ++++++++++---- admin/server/source/system/api.go | 3 + admin/server/source/system/casbin.go | 3 + admin/web/src/api/gaia/system.js | 29 + admin/web/src/view/login/callback.vue | 14 + .../view/systemIntegrated/dingTalk/index.vue | 943 +++++++++++------- docs/1.11.4升级到1.12.2需要执行的权限SQL.sql | 24 + 18 files changed, 2145 insertions(+), 750 deletions(-) create mode 100644 admin/server/model/gaia/response/system.go create mode 100644 admin/server/service/gaia/email_api_test.go 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 @@ + + +
+
+ 配置 URL 查询参数,系统将自动判断使用 ? 或 & 拼接到请求地址后 +
+
+ + + + + + + + +
+ + 添加参数 + +
+
+
- form-data - x-www-form-urlencoded - raw (JSON) + + form-data + + + x-www-form-urlencoded + + + raw (JSON) +
@@ -185,23 +239,32 @@ v-model="item.key" placeholder="字段名" class="flex-1" - :disabled="item.isSystemField" - /> - + + + + + + + + + + - 系统字段 添加字段 @@ -215,23 +278,32 @@ v-model="item.key" placeholder="字段名" class="flex-1" - :disabled="item.isSystemField" - /> - + + + + + + + + + + - 系统字段 添加字段 @@ -240,12 +312,28 @@
+
+ + 插入钉钉 ID + +
+
+ 点击「插入钉钉 ID」将在光标位置插入 $<{[ding_id]}> 占位符,发送请求时自动替换为实际钉钉 ID +
@@ -255,9 +343,15 @@
- None - Bearer Token - Basic Auth + + None + + + Bearer Token + + + Basic Auth +
@@ -293,30 +387,24 @@
-
Headers:
+
+ Headers: +
{{ key }}: {{ value }}
-
Authorization:
+
+ Authorization: +
{{ emailApiConfig.authorization.type === 'bearer' ? 'Bearer Token' : 'Basic Auth' }}
- +
- 邮箱请求字段: - - {{ emailApiConfig.request_param_field || 'userId' }} -
-
邮箱信息提取: {{ emailApiConfig.response_email_field || 'data[0].userName' }}
-
+
+ + 测试配置 + 保存 @@ -341,53 +432,6 @@
- - -
-
- - 温馨提示 -
-
-

- 1. 扫码登录应用创建入口: - - https://open-dev.dingtalk.com/fe/app - -

-

- 2. AppId和AppSecret是扫码登录应用的唯一标识,创建完成后可见 -

-

- 查看路径: 钉钉开放平台>应用开发>移动接入应用>扫码登录应用授权应用的列表。 -

-
-
-
- - -
-
- 转发集成配置 - 已启用 - 未启用 -
-

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

- - - - -
-
- 启用转发: - -
-
- -
@@ -428,46 +472,26 @@
- -
-
- 第三方钉钉 ID 匹配用户 API +
+ + 温馨提示
-

当本地表中找不到钉钉 ID 对应用户时,调用此 API 通过 ding_id 获取用户名。开启或修改后请点击下方「保存」按钮。

-
-
- 启用: - -
-
- API URL: - -
-
- HTTP 方法: - - - - - - -
-
- 请求参数字段: - -
-
- 响应用户名路径: - -
-
- - 保存「转发集成」与「钉钉 ID 匹配 API」配置 - -
+
+

+ 1. 扫码登录应用创建入口: + + https://open-dev.dingtalk.com/fe/app + +

+

+ 2. AppId和AppSecret是扫码登录应用的唯一标识,创建完成后可见 +

+

+ 查看路径: 钉钉开放平台>应用开发>移动接入应用>扫码登录应用授权应用的列表。 +

@@ -476,30 +500,153 @@
-

点击「生成并保存」将随机生成 Token,保存后会自动复制到系统剪贴板,请粘贴到安全位置保管。Token 仅展示一次。

+

+ 点击「生成并保存」将随机生成 Token,保存后会自动复制到系统剪贴板,请粘贴到安全位置保管。Token 仅展示一次。 +

-

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

+

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

+
+ + + +
+ +
+
+ 测试用钉钉 ID + (可选,填入真实钉钉 ID 可验证完整流程;留空则用占位符代替) +
+ +
+ + +
+ + 正在发送测试请求... +
+ + +
+ +
+ + HTTP {{ testResult.status_code }} + + 配置有效 + {{ testResult.error_message }} +
+ + +
+
邮箱字段解析
+ {{ testResult.email_field_preview }} +
+ + +
+
+
+ 响应 Body +
+ + {{ testBodyExpanded ? '折叠' : '展开选择解析' }} + +
+
+
+
+
+ {{ path }} + {{ typeof item === 'string' ? item : JSON.stringify(item) }} + + 选为邮箱字段 + +
+
+
{{ JSON.stringify(testResult.body, null, 2) }}
+
+
{{ testResult.body }}
+
+
+
+ + +
+ +
+
+
@@ -507,11 +654,11 @@