mirror of
https://github.com/YFGaia/dify-plus.git
synced 2026-06-04 10:14:00 +08:00
fix: 计费完善
This commit is contained in:
@@ -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 比对)
|
||||
|
||||
@@ -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 获取提供商的可用模型
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;"`
|
||||
|
||||
@@ -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"` // 转发集成配置
|
||||
}
|
||||
|
||||
@@ -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"` // 错误信息(可选)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+570
-185
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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', 'POST'),
|
||||
('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');
|
||||
|
||||
Reference in New Issue
Block a user