fix: 计费完善

This commit is contained in:
npc0-hue
2026-03-11 12:05:53 +08:00
parent e283aa4055
commit 4b5e2eaf35
18 changed files with 2145 additions and 750 deletions
+40 -78
View File
@@ -2,17 +2,13 @@ package gaia
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"strings"
"github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/global"
gaiaModel "github.com/flipped-aurora/gin-vue-admin/server/model/gaia" gaiaModel "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request" "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
"net/http"
) )
type ForwardProxyApi struct{} type ForwardProxyApi struct{}
@@ -29,90 +25,56 @@ type ForwardProxyApi struct{}
func (f *ForwardProxyApi) ForwardProxy(c *gin.Context) { func (f *ForwardProxyApi) ForwardProxy(c *gin.Context) {
// 1. 读取转发配置 // 1. 读取转发配置
integrate := systemIntegratedService.GetIntegratedConfig(gaiaModel.SystemIntegrationDingTalk) integrate := systemIntegratedService.GetIntegratedConfig(gaiaModel.SystemIntegrationDingTalk)
var configMap request.DingTalkConfigRequest configMap, err := systemIntegratedService.ParseDingTalkConfig(integrate.Config)
if integrate.Config != "" { if err != nil {
if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": "配置解析失败"}})
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 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 // 5. 解析 account_id
accountId, err := systemIntegratedService.ResolveAccountByDingId(dingId, configMap.ForwardConfig.DingIdApi) accountId, err := systemIntegratedService.ResolveAccountByDingId(dingId, configMap.EmailApi)
if err != nil { if err != nil {
global.GVA_LOG.Warn("ForwardProxy 用户解析失败", zap.String("ding_id", dingId), zap.Error(err)) 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()}}) c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "无法解析用户:" + err.Error()}})
return return
} }
// 6. 转发请求 // 6. 复用与 Proxy 相同的转发逻辑(path/body/ProxyRequest
path := c.Param("path") proxyWithAccountId(c, accountId)
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()}})
}
}
} }
// validateForwardToken 校验 token 是否在转发 Token 列表中(SHA256 比对) // validateForwardToken 校验 token 是否在转发 Token 列表中(SHA256 比对)
+44 -50
View File
@@ -90,62 +90,56 @@ func (m *ModelProviderApi) GetModels(c *gin.Context) {
c.JSON(http.StatusOK, models) c.JSON(http.StatusOK, models)
} }
// Proxy 通用中转 API:将 /gaia/proxy/* 的请求按路径转发到上游(如 /v1/chat/completions、/v1/messages、/v1/images/generations、/v1/embeddings 等) // proxyWithAccountId 通用代理逻辑:按路径转发到上游并计费
// 上游 base 优先使用 provider_credentials 的 openai_api_base(如 "https://yunwu.ai"),便于计费区分。 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 // @Tags ModelProvider
// @Summary 通用中转API(按路径转发) // @Summary 通用中转API(按路径转发)
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Param path path string true "上游路径,如 v1/chat/completions、v1/messages" // @Param path path string true "上游路径,如 v1/chat/completions、v1/messages"
// @Router /gaia/proxy/*path [get,post,put,patch,delete] // @Router /gaia/proxy/*path [get,post,put,patch,delete]
func (m *ModelProviderApi) Proxy(c *gin.Context) { func (m *ModelProviderApi) Proxy(c *gin.Context) {
// init accountId := utils.GetUserUuid(c).String()
var err error proxyWithAccountId(c, accountId)
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()}})
}
}
} }
// GetAvailableModels 获取提供商的可用模型 // GetAvailableModels 获取提供商的可用模型
+75 -11
View File
@@ -5,10 +5,14 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strings"
"github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response" "github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia" "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request" "github.com/flipped-aurora/gin-vue-admin/server/model/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/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/utils" "github.com/flipped-aurora/gin-vue-admin/server/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -64,13 +68,79 @@ func (systemApi *SystemApi) SetDingTalk(c *gin.Context) {
response.OkWithData("ok", c) 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 列表 // GetForwardTokens 获取转发 Token 列表
// @Tags System // @Tags System
// @Summary 获取转发 Token 列表 // @Summary 获取转发 Token 列表
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @accept application/json // @accept application/json
// @Produce 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] // @Router /gaia/system/forward-tokens [get]
func (systemApi *SystemApi) GetForwardTokens(c *gin.Context) { func (systemApi *SystemApi) GetForwardTokens(c *gin.Context) {
integrate := systemIntegratedService.GetIntegratedConfig(gaia.SystemIntegrationDingTalk) integrate := systemIntegratedService.GetIntegratedConfig(gaia.SystemIntegrationDingTalk)
@@ -83,21 +153,15 @@ func (systemApi *SystemApi) GetForwardTokens(c *gin.Context) {
} }
} }
// 返回不包含 token_hash 的列表 tokens := make([]gaiaResp.ForwardTokenInfo, 0, len(configMap.ForwardConfig.Tokens))
type TokenInfo struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
}
tokens := make([]TokenInfo, 0, len(configMap.ForwardConfig.Tokens))
for _, token := range configMap.ForwardConfig.Tokens { for _, token := range configMap.ForwardConfig.Tokens {
tokens = append(tokens, TokenInfo{ tokens = append(tokens, gaiaResp.ForwardTokenInfo{
ID: token.ID, ID: utils.AddAsteriskToString(token.ID),
CreatedAt: token.CreatedAt, 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 // CreateForwardToken 新增转发 Token
@@ -2,6 +2,49 @@ package gaia
import "time" 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 模型提供商配置表 // ModelProviderConfig 模型提供商配置表
type ModelProviderConfig struct { type ModelProviderConfig struct {
Id uint `json:"id" form:"id" gorm:"primarykey;column:id;comment:id;"` Id uint `json:"id" form:"id" gorm:"primarykey;column:id;comment:id;"`
+63 -44
View File
@@ -10,26 +10,37 @@ type SystemOAuth2Error struct {
// SystemOAuth2Request OAuth2 集成配置 // SystemOAuth2Request OAuth2 集成配置
type SystemOAuth2Request struct { type SystemOAuth2Request struct {
Classify uint `json:"classify" gorm:"comment:分类"` // 分类 Classify uint `json:"classify" gorm:"comment:分类"` // 分类
Status bool `json:"status" gorm:"comment:状态"` // 状态 Status bool `json:"status" gorm:"comment:状态"` // 状态
ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址 ServerURL string `json:"server_url" gorm:"comment:服务器地址"` // OAuth2 服务器地址
AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的 URL"` // 申请认证的 URL AuthorizeURL string `json:"authorize_url" gorm:"comment:申请认证的 URL"` // 申请认证的 URL
TokenURL string `json:"token_url" gorm:"comment:获取 Token 的 URL"` // 获取 Token 的 URL TokenURL string `json:"token_url" gorm:"comment:获取 Token 的 URL"` // 获取 Token 的 URL
UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息 URL"` // 获取用户信息的 URL UserinfoURL string `json:"userinfo_url" gorm:"comment:获取用户信息 URL"` // 获取用户信息的 URL
LogoutURL string `json:"logout_url" gorm:"comment:退出登录回调 URL"` // 退出登录回调 URL LogoutURL string `json:"logout_url" gorm:"comment:退出登录回调 URL"` // 退出登录回调 URL
DiscoveryURL string `json:"discovery_url" gorm:"comment:OIDC 发现配置 URL"` // OIDC 发现配置 URL DiscoveryURL string `json:"discovery_url" gorm:"comment:OIDC 发现配置 URL"` // OIDC 发现配置 URL
AppID string `json:"app_id" gorm:"comment:Client ID"` // Client ID AppID string `json:"app_id" gorm:"comment:Client ID"` // Client ID
AppSecret string `json:"app_secret" gorm:"comment:Client Secret"` // Client Secret AppSecret string `json:"app_secret" gorm:"comment:Client Secret"` // Client Secret
UserNameField string `json:"user_name_field" gorm:"comment:用户名字段"` // 用户名字段 UserNameField string `json:"user_name_field" gorm:"comment:用户名字段"` // 用户名字段
UserEmailField string `json:"user_email_field" gorm:"comment:邮箱字段"` // 邮箱字段 UserEmailField string `json:"user_email_field" gorm:"comment:邮箱字段"` // 邮箱字段
UserIDField string `json:"user_id_field" gorm:"comment:用户唯一标识字段"` // 用户唯一标识字段 UserIDField string `json:"user_id_field" gorm:"comment:用户唯一标识字段"` // 用户唯一标识字段
Scope string `json:"scope" gorm:"comment:授权范围 scope"` // 授权范围 Scope string `json:"scope" gorm:"comment:授权范围 scope"` // 授权范围
TokenAuthMethod string `json:"token_auth_method" gorm:"comment:令牌端点认证方式"` // client_secret_post|client_secret_basic TokenAuthMethod string `json:"token_auth_method" gorm:"comment:令牌端点认证方式"` // client_secret_post|client_secret_basic
RedirectUri string `json:"redirect_uri" gorm:"comment:测试用回调地址"` // 测试用回调地址 RedirectUri string `json:"redirect_uri" gorm:"comment:测试用回调地址"` // 测试用回调地址
Test bool `json:"test" gorm:"default:0;comment:是否测试链接联通性"` // 是否测试链接联通性 Test bool `json:"test" gorm:"default:0;comment:是否测试链接联通性"` // 是否测试链接联通性
Code string `json:"code" gorm:"default:0;comment:code 代码"` // code 代码 Code string `json:"code" gorm:"default:0;comment:code 代码"` // code 代码
} }
// ValueType 参数/字段值类型
const (
ValueTypeString = "string" // 字符串类型
ValueTypeInt = "int" // 整数类型
ValueTypeBool = "bool" // 布尔类型
ValueTypeDingID = "ding_id" // 钉钉 ID 类型(运行时自动替换)
)
// DingIDMarker Raw 模式下钉钉 ID 占位符
const DingIDMarker = "$<{[ding_id]}>"
// AuthorizationConfig 认证配置 // AuthorizationConfig 认证配置
type AuthorizationConfig struct { type AuthorizationConfig struct {
Type string `json:"type"` // none | bearer | basic Type string `json:"type"` // none | bearer | basic
@@ -38,55 +49,63 @@ type AuthorizationConfig struct {
Password string `json:"password"` // Basic Auth 密码 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 数据配置 // BodyData Body 数据配置
type BodyData struct { type BodyData struct {
FormData []map[string]string `json:"form_data"` // form-data 格式数据 FormData []BodyField `json:"form_data"` // form-data 格式数据(新格式)
Urlencoded []map[string]string `json:"urlencoded"` // x-www-form-urlencoded 格式数据 Urlencoded []BodyField `json:"urlencoded"` // x-www-form-urlencoded 格式数据(新格式)
Raw string `json:"raw"` // raw JSON 字符串 Raw string `json:"raw"` // raw JSON 字符串
} }
// EmailApiConfig 第三方邮箱 API 配置 // EmailApiConfig 第三方邮箱 API 配置
type EmailApiConfig struct { type EmailApiConfig struct {
Enabled bool `json:"enabled"` // 是否启用 Enabled bool `json:"enabled"` // 是否启用
URL string `json:"url"` // API 地址 URL string `json:"url"` // API 地址
Method string `json:"method"` // HTTP 方法 Method string `json:"method"` // HTTP 方法
RequestParamField string `json:"request_param_field"` // 请求参数字段名 RequestParamField string `json:"request_param_field"` // 请求参数字段名(旧格式兼容)
BodyType string `json:"body_type"` // Body 类型:form-data | x-www-form-urlencoded | raw Params []RequestParam `json:"params"` // URL 查询参数列表(新格式)
Headers map[string]string `json:"headers"` // 请求头 BodyType string `json:"body_type"` // Body 类型:form-data | x-www-form-urlencoded | raw
Authorization AuthorizationConfig `json:"authorization"` // 认证配置 Headers map[string]string `json:"headers"` // 请求头
BodyData BodyData `json:"body_data"` // Body 数据 Authorization AuthorizationConfig `json:"authorization"` // 认证配置
ResponseEmailField string `json:"response_email_field"` // 响应邮箱字段路径 BodyData BodyData `json:"body_data"` // Body 数据
ResponseEmailField string `json:"response_email_field"` // 响应邮箱字段路径
} }
// DingIdApiConfig 第三方钉钉 ID 匹配用户 API 配置 // TestEmailApiConfigRequest 测试邮箱 API 配置请求
type DingIdApiConfig struct { type TestEmailApiConfigRequest struct {
Enabled bool `json:"enabled"` // 是否启用 Config EmailApiConfig `json:"config"` // 完整的邮箱配置
URL string `json:"url"` // API 地址 TestDingID string `json:"test_ding_id"` // 测试用的钉钉 ID(可选)
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
} }
// ForwardToken 转发 Token 配置 // ForwardToken 转发 Token 配置
type ForwardToken struct { type ForwardToken struct {
ID string `json:"id"` // 前端生成的唯一 ID(用于删除) ID string `json:"id"` // 前端生成的唯一 ID(用于删除)
TokenHash string `json:"token_hash"` // SHA256(token) TokenHash string `json:"token_hash"` // SHA256(token)
CreatedAt time.Time `json:"created_at"` // 创建时间 CreatedAt time.Time `json:"created_at"` // 创建时间
TokenSecret string `json:"token_secret"` // HMAC 签名密钥(随机生成,服务端保存)
} }
// ForwardConfig 转发集成配置 // ForwardConfig 转发集成配置
type ForwardConfig struct { type ForwardConfig struct {
Enabled bool `json:"enabled"` // 是否启用转发 Enabled bool `json:"enabled"` // 是否启用转发
Tokens []ForwardToken `json:"tokens"` // Token 列表,最多 20 个 Tokens []ForwardToken `json:"tokens"` // Token 列表,最多 20 个
DingIdApi DingIdApiConfig `json:"ding_id_api"` // 第三方钉钉 ID 匹配用户 API
} }
// DingTalkConfigRequest 钉钉集成配置 // DingTalkConfigRequest 钉钉集成配置
type DingTalkConfigRequest struct { type DingTalkConfigRequest struct {
EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱 API 配置 EmailApi EmailApiConfig `json:"email_api"` // 第三方邮箱 API 配置
ForwardConfig ForwardConfig `json:"forward_config"` // 转发集成配置 ForwardConfig ForwardConfig `json:"forward_config"` // 转发集成配置
} }
@@ -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"` // 错误信息(可选)
}
+9 -5
View File
@@ -10,10 +10,14 @@ type SystemRouter struct{}
func (s *SystemRouter) InitSystemRouter(Router *gin.RouterGroup) { func (s *SystemRouter) InitSystemRouter(Router *gin.RouterGroup) {
systemRouter := Router.Group("gaia/system") systemRouter := Router.Group("gaia/system")
{ {
systemRouter.GET("dingtalk", systemApi.GetDingTalk) // 获取钉钉系统配置 systemRouter.GET("dingtalk", systemApi.GetDingTalk) // 获取钉钉系统配置
systemRouter.POST("dingtalk", systemApi.SetDingTalk) // 设置钉钉系统配置 systemRouter.POST("dingtalk", systemApi.SetDingTalk) // 设置钉钉系统配置
systemRouter.GET("oauth2", systemOAuth2Api.GetOAuth2Config) // 获取 OAuth2 配置 systemRouter.GET("dingtalk/test-auth-url", systemApi.GetDingTalkTestAuthURL) // 测试连接:获取钉钉授权 URL
systemRouter.POST("oauth2", systemOAuth2Api.SetOAuth2Config) // 设置 OAuth2 配置 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 管理 // 转发 Token 管理
systemRouter.GET("forward-tokens", systemApi.GetForwardTokens) // 获取转发 Token 列表 systemRouter.GET("forward-tokens", systemApi.GetForwardTokens) // 获取转发 Token 列表
systemRouter.POST("forward-tokens", systemApi.CreateForwardToken) // 新增转发 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) { func (s *SystemRouter) InitForwardProxyRouter(PublicRouter *gin.RouterGroup) {
// 免 JWT 转发入口,通过 forwarding token + ding_id 鉴权 // 免 JWT 转发入口,通过 forwarding token + ding_id 鉴权
PublicRouter.Any("gaia/forward/proxy/*path", forwardProxyApi.ForwardProxy) PublicRouter.Any("gaia/forward/proxy/*path", forwardProxyApi.ForwardProxy)
+273
View File
@@ -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("旧格式应使用 RequestParamFieldgot: %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 应为 userIdgot: %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 类型应替换为实际钉钉 IDgot: %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)
}
}
+90 -3
View File
@@ -148,6 +148,45 @@ func (e *SystemIntegratedService) OAuth2CodeLogin(
return &response.GaiaLoginResult{User: *sysUser, Token: token, RedirectURI: req.RedirectURI, State: req.State}, nil 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 // DingTalkCodeLogin 钉钉 code 换用户并登录(扫码/OAuth2 回调带 code
func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLoginReq) (*response.GaiaLoginResult, error) { func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLoginReq) (*response.GaiaLoginResult, error) {
integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk) integrate := e.getIntegratedConfigRaw(gaia.SystemIntegrationDingTalk)
@@ -205,8 +244,56 @@ func (e *SystemIntegratedService) DingTalkCodeLogin(req request.GaiaDingTalkLogi
if err = json.Unmarshal(userBody, &dingUser); err != nil { if err = json.Unmarshal(userBody, &dingUser); err != nil {
return nil, fmt.Errorf("解析钉钉用户信息失败") return nil, fmt.Errorf("解析钉钉用户信息失败")
} }
email := dingUser["email"].(string)
username := dingUser["nick"].(string) // 提取钉钉 IDuser_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 == "" { if username == "" {
username = email username = email
} }
@@ -311,7 +398,7 @@ func (e *SystemIntegratedService) findUserByEmail(email string) (*system.SysUser
if err := global.GVA_DB.Where("email IN (?)", mailList).Preload( if err := global.GVA_DB.Where("email IN (?)", mailList).Preload(
"Authorities").Preload("Authority").First(&u).Error; err != nil { "Authorities").Preload("Authority").First(&u).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf(fmt.Sprintf("邮箱%s尚未开通账号,请联系管理员", email)) return nil, fmt.Errorf("邮箱%s尚未开通账号,请联系管理员", email)
} }
return nil, err return nil, err
} }
@@ -72,6 +72,22 @@ func (e *SystemIntegratedService) GetLoginOptions(frontendOrigin string) (res re
return res return res
} }
// GetDingTalkTestAuthURL 返回用于「测试连接」的钉钉授权 URLstate=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 获取集成配置(不脱敏,仅内部使用) // getIntegratedConfigRaw 获取集成配置(不脱敏,仅内部使用)
func (e *SystemIntegratedService) getIntegratedConfigRaw(classID uint) (integrate gaia.SystemIntegration) { func (e *SystemIntegratedService) getIntegratedConfigRaw(classID uint) (integrate gaia.SystemIntegration) {
if err := global.GVA_DB.Where("classify = ?", classID).First(&integrate).Error; err != nil { if err := global.GVA_DB.Where("classify = ?", classID).First(&integrate).Error; err != nil {
+242 -12
View File
@@ -18,6 +18,8 @@ import (
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia" "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
gaiaRequest "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request" gaiaRequest "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request"
gaiaResponse "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response" 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.gnd.pw/crypto/eax"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
@@ -29,6 +31,150 @@ import (
"time" "time"
) )
// fetchAdminToken 查询一个管理员用户,生成 Dify Console API 兼容的 JWT。
// 结果缓存到 RedisTTL 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 名缓存到 RedisTTL 1 小时)。
// Dify Console APIGET /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 模型提供商服务,负责提供商配置、凭证获取、可用模型拉取及聊天请求代理。 // ModelProviderService 模型提供商服务,负责提供商配置、凭证获取、可用模型拉取及聊天请求代理。
type ModelProviderService struct{} type ModelProviderService struct{}
@@ -900,7 +1046,8 @@ func (s *ModelProviderService) ProxyRequest(
// 解析 provider:头 > query 已在 handler 传入;此处从 body 取 model 仅当 body 为 JSON 且含 model 时用于推断 // 解析 provider:头 > query 已在 handler 传入;此处从 body 取 model 仅当 body 为 JSON 且含 model 时用于推断
xGaiaProvider := reqHeader.Get("X-Gaia-Provider") 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 != "" { if p := xGaiaProvider; p != "" {
providerName = strings.TrimSpace(strings.ToLower(p)) providerName = strings.TrimSpace(strings.ToLower(p))
} }
@@ -912,7 +1059,8 @@ func (s *ModelProviderService) ProxyRequest(
// 按“已选模型”解析实际渠道(如 gpt-5-chat 若只在 Azure 下勾选则走 azure // 按“已选模型”解析实际渠道(如 gpt-5-chat 若只在 Azure 下勾选则走 azure
providerName, err = s.resolveProviderByModel(m) providerName, err = s.resolveProviderByModel(m)
if err != nil { 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 return err
} }
global.GVA_LOG.Info("ProxyRequest 解析得到 provider", zap.String("provider", providerName)) global.GVA_LOG.Info("ProxyRequest 解析得到 provider", zap.String("provider", providerName))
@@ -939,7 +1087,20 @@ func (s *ModelProviderService) ProxyRequest(
return fmt.Errorf("提供商 %s 无可用上游地址", providerName) return fmt.Errorf("提供商 %s 无可用上游地址", providerName)
} }
// 若 body 是 JSON 且含 stream: true,注入 stream_options.include_usage = true
// 这样上游会在 SSE 末尾的 data 行返回 usage,供后续计费解析使用。
if len(body) > 0 { 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) bodyReader = bytes.NewReader(body)
} }
@@ -1017,18 +1178,43 @@ func (s *ModelProviderService) ProxyRequest(
} }
} }
var logStatus, logError string var logStatus, logError string
var promptTokens, completionTokens int
defer func() { defer func() {
if logStatus == "" { if logStatus == "" {
logStatus = "success" logStatus = "success"
} }
global.GVA_DB.Create(&gaia.ModelProxyLog{ global.GVA_DB.Create(&gaia.ModelProxyLog{
UserId: userID, UserId: userID,
ProviderName: providerName, ProviderName: providerName,
ModelName: modelOrPath, ModelName: modelOrPath,
Status: logStatus, RequestTokens: promptTokens,
ErrorMessage: logError, ResponseTokens: completionTokens,
CreatedAt: startTime, 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 决定) // 写回状态码与响应头(流式由上游 Content-Type 决定)
@@ -1044,17 +1230,36 @@ func (s *ModelProviderService) ProxyRequest(
_, _ = io.Copy(writer, resp.Body) _, _ = io.Copy(writer, resp.Body)
return nil 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 strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") {
if flusher, ok := writer.(http.Flusher); ok { if flusher, ok := writer.(http.Flusher); ok {
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() { for scanner.Scan() {
fmt.Println("sss", scanner.Text()) line := scanner.Text()
if _, err = writer.Write([]byte(scanner.Text() + "\n")); err != nil { if _, err = writer.Write([]byte(line + "\n")); err != nil {
logStatus, logError = "error", err.Error() logStatus, logError = "error", err.Error()
return err return err
} }
flusher.Flush() flusher.Flush()
// 解析 SSE data 行中的 usagestream_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 { if err = scanner.Err(); err != nil {
logStatus, logError = "error", err.Error() logStatus, logError = "error", err.Error()
@@ -1063,9 +1268,15 @@ func (s *ModelProviderService) ProxyRequest(
return nil 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 { if err != nil {
logStatus, logError = "error", err.Error() logStatus, logError = "error", err.Error()
} else {
extractUsage(buf.Bytes())
} }
return err return err
} }
@@ -1078,3 +1289,22 @@ func (s *ModelProviderService) isProviderEnabled(providerName string) bool {
} }
return true 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
}
+570 -185
View File
@@ -2,6 +2,10 @@ package gaia
import ( import (
"context" "context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -9,6 +13,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -17,6 +22,7 @@ import (
"github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia" "github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia/request" "github.com/flipped-aurora/gin-vue-admin/server/model/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/flipped-aurora/gin-vue-admin/server/utils"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
@@ -126,22 +132,52 @@ func (e *SystemIntegratedService) SetIntegratedConfig(
// @param: req gaia.SystemIntegration // @param: req gaia.SystemIntegration
// @return: *dingding.Client, error // @return: *dingding.Client, error
func (e *SystemIntegratedService) DingTalkConfigAvailable(req gaia.SystemIntegration) (*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 var reqs *http.Request
dingding.ServerUrl = "https://api.dingtalk.com" dingding.ServerUrl = "https://api.dingtalk.com"
// 特殊需要,检查可用性就不设置缓存了 client := dingding.NewClient(&dingding.DefaultAccessTokenManager{
return dingding.NewClient(&dingding.DefaultAccessTokenManager{
Id: uuid.New().String(), Id: uuid.New().String(),
Cache: file.New(os.TempDir()), Cache: file.New(os.TempDir()),
Name: "x-acs-dingtalk-access-token", Name: "x-acs-dingtalk-access-token",
GetRefreshRequestFunc: func() *http.Request { GetRefreshRequestFunc: func() *http.Request {
params := url.Values{} // 这里沿用原来的 token 刷新逻辑
params.Add("appkey", req.AppKey) reqs, _ = http.NewRequest(http.MethodGet, "https://oapi.dingtalk.com/gettoken?"+params.Encode(), nil)
params.Add("appsecret", req.AppSecret)
reqs, err = http.NewRequest(http.MethodGet, "https://oapi.dingtalk.com/gettoken?"+params.Encode(), nil)
return reqs return reqs
}, },
}), err })
return client, nil
} }
// TestConnection 测试连接 // TestConnection 测试连接
@@ -169,11 +205,6 @@ func (e *SystemIntegratedService) TestConnection(integrate gaia.SystemIntegratio
global.GVA_LOG.Warn("转发集成配置验证失败", zap.Error(err)) global.GVA_LOG.Warn("转发集成配置验证失败", zap.Error(err))
// 不阻止保存,只记录警告 // 不阻止保存,只记录警告
} }
// 验证第三方钉钉 ID 匹配 API 配置
if err := e.ValidateDingIdApiConfig(integrate); err != nil {
global.GVA_LOG.Warn("第三方钉钉 ID 匹配 API 配置验证失败", zap.Error(err))
// 不阻止保存,只记录警告
}
return nil return nil
case gaia.SystemIntegrationOAuth2: case gaia.SystemIntegrationOAuth2:
// 测试OAuth2连接 // 测试OAuth2连接
@@ -183,79 +214,307 @@ func (e *SystemIntegratedService) TestConnection(integrate gaia.SystemIntegratio
} }
} }
// ValidateEmailApiConfig 验证第三方邮箱API配置 // ParseDingTalkConfig 解析钉钉集成配置,自动处理新旧格式兼容
// @Tags System Integrated func (e *SystemIntegratedService) ParseDingTalkConfig(configJSON string) (request.DingTalkConfigRequest, error) {
// @Summary 验证第三方邮箱API配置
// @param: integrate gaia.SystemIntegration
// @return: error
func (e *SystemIntegratedService) ValidateEmailApiConfig(integrate gaia.SystemIntegration) error {
// 解析Config字段
if integrate.Config == "" {
return nil // 配置为空不算错误
}
var configMap request.DingTalkConfigRequest var configMap request.DingTalkConfigRequest
if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil { if configJSON == "" {
return fmt.Errorf("解析配置失败: %s", err.Error()) return configMap, nil
} }
// 检查是否启用邮箱API // 先解析顶层结构
if !configMap.EmailApi.Enabled { var raw struct {
return nil // 未启用不需要验证 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())
} }
// 验证必填字段 configMap.ForwardConfig = raw.ForwardConfig
if configMap.EmailApi.URL == "" {
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不能为空") return errors.New("邮箱API URL不能为空")
} }
if cfg.Method == "" {
if configMap.EmailApi.Method == "" { cfg.Method = "GET"
configMap.EmailApi.Method = "GET"
} }
if configMap.EmailApi.RequestParamField == "" { // 新格式不强制要求 request_param_field(通过 params 配置 URL 查询参数)
return errors.New("邮箱请求字段不能为空") if isNewEmailApiConfig(cfg) {
} // Params 只支持 string 和 ding_id 两种类型
for i, p := range cfg.Params {
if configMap.EmailApi.ResponseEmailField == "" { if err := validateParamValueType(p.ValueType); err != nil {
return errors.New("邮箱信息提取字段不能为空") return fmt.Errorf("第%d个 URL 参数类型无效:%s", i+1, err.Error())
} }
}
// 验证Body类型(仅POST/PUT/DELETE需要) // Body fields 支持 string、int、bool、ding_id
if configMap.EmailApi.Method != "GET" { for i, f := range cfg.BodyData.FormData {
bodyType := strings.ToLower(configMap.EmailApi.BodyType) if err := validateValueType(f.ValueType); err != nil {
if bodyType == "" { return fmt.Errorf("form-data 第%d个字段类型无效:%s", i+1, err.Error())
configMap.EmailApi.BodyType = "raw" // 默认raw }
} else if bodyType != "form-data" && bodyType != "x-www-form-urlencoded" && bodyType != "raw" { if f.ValueType == request.ValueTypeInt && f.Value != "" {
return fmt.Errorf("不支持的Body类型: %s,支持的类型: form-data, x-www-form-urlencoded, raw", bodyType) 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配置 if cfg.ResponseEmailField == "" {
authType := strings.ToLower(configMap.EmailApi.Authorization.Type) 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 != "" && authType != "none" {
if authType == "bearer" { if authType == "bearer" {
if configMap.EmailApi.Authorization.Token == "" { if cfg.Authorization.Token == "" {
return errors.New("Bearer Token不能为空") return errors.New("Bearer Token不能为空")
} }
} else if authType == "basic" { } 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") return errors.New("Basic Auth需要填写Username和Password")
} }
} else { } else {
return fmt.Errorf("不支持的Authorization类型: %s,支持的类型: none, bearer, basic", authType) 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配置验证通过", global.GVA_LOG.Info("第三方邮箱API配置验证通过",
zap.String("url", configMap.EmailApi.URL), zap.String("url", cfg.URL),
zap.String("method", configMap.EmailApi.Method), zap.String("method", cfg.Method),
zap.String("body_type", configMap.EmailApi.BodyType), zap.String("body_type", cfg.BodyType),
zap.String("auth_type", configMap.EmailApi.Authorization.Type)) zap.String("auth_type", cfg.Authorization.Type),
zap.Bool("new_format", isNewEmailApiConfig(cfg)))
return nil 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连接 // TestOAuth2Connection 测试OAuth2连接
// @Tags System Integrated // @Tags System Integrated
// @Summary 测试OAuth2连接 // @Summary 测试OAuth2连接
@@ -354,9 +613,14 @@ func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemInt
return fmt.Errorf("解析配置失败:%s", err.Error()) return fmt.Errorf("解析配置失败:%s", err.Error())
} }
// 检查是否启用转发 // 若未配置转发 Token,则认为未使用转发能力,不强制校验
if !configMap.ForwardConfig.Enabled { if len(configMap.ForwardConfig.Tokens) == 0 {
return nil // 未启用不需要验证 return nil
}
// 使用转发能力的前置条件:至少 1 个 Token + 启用并配置「第三方邮箱配置」
if !configMap.EmailApi.Enabled || strings.TrimSpace(configMap.EmailApi.URL) == "" {
return errors.New("使用转发能力前请先启用并配置「第三方邮箱配置」")
} }
// 验证 Token 数量 // 验证 Token 数量
@@ -375,7 +639,6 @@ func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemInt
} }
global.GVA_LOG.Info("转发集成配置验证通过", global.GVA_LOG.Info("转发集成配置验证通过",
zap.Bool("enabled", configMap.ForwardConfig.Enabled),
zap.Int("token_count", len(configMap.ForwardConfig.Tokens))) zap.Int("token_count", len(configMap.ForwardConfig.Tokens)))
return nil return nil
@@ -386,74 +649,6 @@ func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemInt
// @Summary 验证第三方钉钉 ID 匹配 API 配置 // @Summary 验证第三方钉钉 ID 匹配 API 配置
// @param: integrate gaia.SystemIntegration // @param: integrate gaia.SystemIntegration
// @return: error // @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" 等多层路径 // extractJSONPath 按点分路径从 JSON 对象中提取字符串值,支持 "data.username" 等多层路径
func extractJSONPath(data map[string]interface{}, path string) string { func extractJSONPath(data map[string]interface{}, path string) string {
parts := strings.SplitN(path, ".", 2) parts := strings.SplitN(path, ".", 2)
@@ -473,56 +668,131 @@ func extractJSONPath(data map[string]interface{}, path string) string {
return "" return ""
} }
// callDingIdApi 调用第三方钉钉 ID 匹配 API,返回 username // resolveParamValue 根据 ValueType 解析参数值,ding_id 类型替换为实际的钉钉 ID
func (e *SystemIntegratedService) callDingIdApi(dingId string, config request.DingIdApiConfig) (string, error) { 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) method := strings.ToUpper(config.Method)
if method == "" { if method == "" {
method = "GET" method = "GET"
} }
var bodyReader io.Reader var bodyReader io.Reader
var contentType string
if method != "GET" && method != "HEAD" { if method != "GET" && method != "HEAD" {
switch strings.ToLower(config.BodyType) { switch strings.ToLower(config.BodyType) {
case "raw": case "raw":
// 替换 Body 中的 {{ding_id}} 占位符
raw := strings.ReplaceAll(config.BodyData.Raw, "{{ding_id}}", dingId) raw := strings.ReplaceAll(config.BodyData.Raw, "{{ding_id}}", dingId)
raw = strings.ReplaceAll(raw, request.DingIDMarker, dingId)
bodyReader = strings.NewReader(raw) bodyReader = strings.NewReader(raw)
case "form-data", "x-www-form-urlencoded": contentType = "application/json"
form := url.Values{} case "form-data":
for _, kv := range config.BodyData.Urlencoded { form := buildBodyFields(config.BodyData.FormData, dingId)
for k, v := range kv { // 也处理旧格式的 urlencoded 字段(兼容)
form.Set(k, strings.ReplaceAll(v, "{{ding_id}}", dingId)) if len(form) == 0 {
} form = buildBodyFields(config.BodyData.Urlencoded, dingId)
}
if config.BodyType == "form-data" {
for _, kv := range config.BodyData.FormData {
for k, v := range kv {
form.Set(k, strings.ReplaceAll(v, "{{ding_id}}", dingId))
}
}
} }
bodyReader = strings.NewReader(form.Encode()) 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"
} }
} }
// 构建请求 URLGET 时把 ding_id 拼入 query apiURL := buildURL(config.URL, config, dingId)
apiURL := config.URL
if method == "GET" || method == "HEAD" {
if config.RequestParamField != "" {
sep := "?"
if strings.Contains(apiURL, "?") {
sep = "&"
}
apiURL = apiURL + sep + url.QueryEscape(config.RequestParamField) + "=" + url.QueryEscape(dingId)
}
}
req, err := http.NewRequest(method, apiURL, bodyReader) req, err := http.NewRequest(method, apiURL, bodyReader)
if err != nil { 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 { for k, v := range config.Headers {
req.Header.Set(k, v) 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) req.SetBasicAuth(config.Authorization.Username, config.Authorization.Password)
} }
client := &http.Client{} client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("请求失败:%s", err.Error()) return nil, 0, fmt.Errorf("请求失败:%s", err.Error())
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("第三方 API 返回错误状态码:%d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("读取响应失败:%s", err.Error()) return nil, resp.StatusCode, fmt.Errorf("读取响应失败:%s", err.Error())
} }
var respJSON map[string]interface{} if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if err = json.Unmarshal(respBody, &respJSON); err != nil { return respBody, resp.StatusCode, fmt.Errorf("第三方 API 返回错误状态码:%d", resp.StatusCode)
return "", fmt.Errorf("解析响应 JSON 失败:%s", err.Error())
} }
userName := extractJSONPath(respJSON, config.ResponseUserNamePath) return respBody, resp.StatusCode, nil
if userName == "" { }
return "", fmt.Errorf("响应中未找到用户名(路径:%s)", config.ResponseUserNamePath)
}
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 // ResolveAccountByDingId 通过钉钉 ID 解析 gaia account_id
// 解析顺序:Redis 缓存 → AccountDingTalkExtend 本地表 → 第三方 DingIdApi // 解析顺序:Redis 缓存 → AccountDingTalkExtend 本地表 → 第三方 EmailApi(邮箱 API
func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfig request.DingIdApiConfig) (string, error) { func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfig request.EmailApiConfig) (string, error) {
ctx := context.Background() ctx := context.Background()
redisKey := "gaia:forward:ding:" + dingId redisKey := "gaia:forward:ding:" + dingId
@@ -586,20 +971,20 @@ func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfi
return accountID, nil return accountID, nil
} }
// 3. 第三方 DingIdApi(若配置且启用 // 3. 第三方邮箱 API(若配置)
if !apiConfig.Enabled || apiConfig.URL == "" { if !apiConfig.Enabled || strings.TrimSpace(apiConfig.URL) == "" {
return "", fmt.Errorf("未找到 ding_id=%s 对应的用户,且未配置第三方 API", dingId) return "", fmt.Errorf("未找到 ding_id=%s 对应的用户,且未配置第三方邮箱 API", dingId)
} }
userName, err := e.callDingIdApi(dingId, apiConfig) email, err := e.callEmailApi(dingId, apiConfig)
if err != nil { 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 var account gaia.Account
if err = global.GVA_DB.Where("name = ?", userName).First(&account).Error; err != nil { if err = global.GVA_DB.Where("email = ?", email).First(&account).Error; err != nil {
return "", fmt.Errorf("用户名 %s 不存在(来自第三方 API)", userName) return "", fmt.Errorf("邮箱 %s 不存在(来自第三方邮箱 API", email)
} }
accountID := account.ID.String() accountID := account.ID.String()
@@ -613,9 +998,9 @@ func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfi
// 6. 写 Redis 缓存 // 6. 写 Redis 缓存
global.GVA_REDIS.Set(ctx, redisKey, accountID, 24*time.Hour) 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("ding_id", dingId),
zap.String("username", userName), zap.String("email", email),
zap.String("account_id", accountID)) zap.String("account_id", accountID))
return accountID, nil return accountID, nil
+3
View File
@@ -201,6 +201,9 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
// Extend Start: system integration // Extend Start: system integration
{ApiGroup: "应用集成配置", Method: "GET", Path: "/gaia/system/dingtalk", Description: "获取钉钉系统配置"}, {ApiGroup: "应用集成配置", Method: "GET", Path: "/gaia/system/dingtalk", Description: "获取钉钉系统配置"},
{ApiGroup: "应用集成配置", Method: "POST", 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 Stop: system integration
// Extend Start: oauth2 // Extend Start: oauth2
+3
View File
@@ -287,6 +287,9 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
// Extend Start: system integration // Extend Start: system integration
{Ptype: "p", V0: "888", V1: "/gaia/system/dingtalk", V2: "GET"}, {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", 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 Stop: system integration
// Extend Start: oauth2 // Extend Start: oauth2
+29
View File
@@ -88,3 +88,32 @@ export const deleteForwardToken = (id, password) => {
data: { 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 },
})
}
+14
View File
@@ -11,6 +11,7 @@ import { useUserStore } from '@/pinia/modules/user'
import { useRouterStore } from '@/pinia/modules/router' import { useRouterStore } from '@/pinia/modules/router'
import router from '@/router' import router from '@/router'
import { gaiaOAuth2Login, dingtalkLogin } from '@/api/user_extend' import { gaiaOAuth2Login, dingtalkLogin } from '@/api/user_extend'
import { dingtalkTestCallback } from '@/api/gaia/system'
defineOptions({ defineOptions({
name: 'LoginCallback', name: 'LoginCallback',
@@ -70,6 +71,19 @@ const callback = async () => {
const redirectUri = sessionStorage.getItem('gaia_login_redirect_uri') || '' const redirectUri = sessionStorage.getItem('gaia_login_redirect_uri') || ''
const state = sessionStorage.getItem('gaia_login_state') || getQueryParam('state') || '' 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 { try {
if (provider === 'dingtalk') { if (provider === 'dingtalk') {
if (!hasCode) { if (!hasCode) {
File diff suppressed because it is too large Load Diff
@@ -169,3 +169,27 @@ INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
('p', '8881', '/gaia/system/forward-tokens', 'GET'), ('p', '8881', '/gaia/system/forward-tokens', 'GET'),
('p', '8881', '/gaia/system/forward-tokens', 'POST'), ('p', '8881', '/gaia/system/forward-tokens', 'POST'),
('p', '8881', '/gaia/system/forward-tokens/:id', 'DELETE'); ('p', '8881', '/gaia/system/forward-tokens/:id', 'DELETE');
-- --------------- 11. API sys_apis (钉钉邮箱配置测试 1 条) ---------------
-- 请按当前库最大 id 调整起始 id,避免冲突。例如 MAX(id)=272 则从 273 起
INSERT INTO sys_apis (id, created_at, updated_at, deleted_at, path, description, api_group, method) VALUES
(273, NOW(), NOW(), NULL, '/gaia/system/dingtalk/test-email-config', '测试钉钉邮箱API配置', '应用集成配置', 'POST');
-- --------------- 12. Casbin 规则 casbin_rule (钉钉邮箱配置测试 888) ---------------
INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
('p', '888', '/gaia/system/dingtalk/test-email-config', 'POST');
-- --------------- 13. API sys_apis (钉钉测试连接:授权 URL + 回调验证 2 条) ---------------
-- 请按当前库最大 id 调整起始 id,避免冲突。例如 MAX(id)=273 则从 274 起
INSERT INTO sys_apis (id, created_at, updated_at, deleted_at, path, description, api_group, method) VALUES
(274, NOW(), NOW(), NULL, '/gaia/system/dingtalk/test-auth-url', '测试连接-获取钉钉授权URL', '应用集成配置', 'GET'),
(275, NOW(), NOW(), NULL, '/gaia/system/dingtalk/test-callback', '测试连接-钉钉回调验证', '应用集成配置', 'POST');
-- --------------- 14. Casbin 规则 casbin_rule (钉钉测试连接 888) ---------------
INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES
('p', '888', '/gaia/system/dingtalk/test-auth-url', 'GET'),
('p', '888', '/gaia/system/dingtalk/test-callback', 'POST');