mirror of
https://github.com/YFGaia/dify-plus.git
synced 2026-06-04 10:14:00 +08:00
1008 lines
31 KiB
Go
1008 lines
31 KiB
Go
package gaia
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/faabiosr/cachego/file"
|
||
"github.com/fastwego/dingding"
|
||
"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"
|
||
)
|
||
|
||
type SystemIntegratedService struct{}
|
||
|
||
// GetIntegratedConfig
|
||
// @Tags System Integrated
|
||
// @Summary 获取系统集成配置
|
||
// @Security ApiKeyAuth
|
||
// @accept application/json
|
||
// @Produce application/json
|
||
func (e *SystemIntegratedService) GetIntegratedConfig(classID uint) (integrate gaia.SystemIntegration) {
|
||
// classID是否在
|
||
var err error
|
||
if err = global.GVA_DB.Where("classify = ?", classID).First(&integrate).Error; err != nil {
|
||
integrate = gaia.SystemIntegration{
|
||
Classify: classID,
|
||
Status: false,
|
||
}
|
||
// 创建相关集成信息
|
||
global.GVA_DB.Create(&integrate)
|
||
}
|
||
// 隐藏部分加密信息
|
||
var secret string
|
||
if secret, err = utils.DecryptBlowfish(integrate.AppSecret, global.GVA_CONFIG.JWT.SigningKey); err == nil {
|
||
integrate.AppSecret = utils.AddAsteriskToString(secret)
|
||
}
|
||
integrate.CorpID = utils.AddAsteriskToString(integrate.CorpID)
|
||
return integrate
|
||
}
|
||
|
||
// SetIntegratedConfig
|
||
// @Tags System Integrated
|
||
// @Summary 设置系统集成配置
|
||
// @Security ApiKeyAuth
|
||
// @accept application/json
|
||
// @Produce application/json
|
||
// @param: integrate gaia.SystemIntegration, code string, test bool
|
||
// @return: err error
|
||
func (e *SystemIntegratedService) SetIntegratedConfig(
|
||
integrate gaia.SystemIntegration, code string, test bool) (err error) {
|
||
// classID是否在
|
||
var log gaia.SystemIntegration
|
||
if err = global.GVA_DB.Where("classify = ?", integrate.Classify).First(&log).Error; err != nil {
|
||
return err
|
||
}
|
||
// AppSecret
|
||
var secret string
|
||
if secret, err = utils.DecryptBlowfish(log.AppSecret, global.GVA_CONFIG.JWT.SigningKey); err == nil {
|
||
encodeSecret := utils.AddAsteriskToString(secret)
|
||
if encodeSecret != integrate.AppSecret {
|
||
if secret, err = utils.EncryptBlowfish(
|
||
[]byte(integrate.AppSecret), global.GVA_CONFIG.JWT.SigningKey); err != nil {
|
||
return errors.New("AppSecret加密失败")
|
||
}
|
||
// save
|
||
log.AppSecret = secret
|
||
} else {
|
||
// 为什么不用 integrate.AppSecret, 被加*了
|
||
if secret, err = utils.DecryptBlowfish(log.AppSecret, global.GVA_CONFIG.JWT.SigningKey); err != nil {
|
||
return errors.New("AppSecret解析失败")
|
||
}
|
||
integrate.AppSecret = secret
|
||
}
|
||
}
|
||
// CorpID
|
||
if utils.AddAsteriskToString(log.CorpID) != integrate.CorpID {
|
||
log.CorpID = integrate.CorpID
|
||
}
|
||
// AppID 不加密,直接赋值
|
||
log.AppID = integrate.AppID
|
||
// 关闭不需要请求
|
||
if integrate.Status || test {
|
||
// 测试连接
|
||
if err = e.TestConnection(integrate, code); err != nil {
|
||
return errors.New("连接失败:" + err.Error())
|
||
}
|
||
}
|
||
// Test completed
|
||
if test {
|
||
return err
|
||
}
|
||
// save
|
||
if err = global.GVA_DB.Model(&gaia.SystemIntegration{}).Where(
|
||
"id=?", log.Id).Updates(&map[string]interface{}{
|
||
"config": integrate.Config,
|
||
"status": integrate.Status,
|
||
"agent_id": integrate.AgentID,
|
||
"app_key": integrate.AppKey,
|
||
"app_secret": log.AppSecret,
|
||
"corp_id": log.CorpID,
|
||
"app_id": log.AppID,
|
||
}).Error; err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DingTalkConfigAvailable
|
||
// @Tags System Integrated
|
||
// @Summary 测试钉钉配置是否可用
|
||
// @Security ApiKeyAuth
|
||
// @accept application/json
|
||
// @Produce application/json
|
||
// @param: req gaia.SystemIntegration
|
||
// @return: *dingding.Client, error
|
||
func (e *SystemIntegratedService) DingTalkConfigAvailable(req gaia.SystemIntegration) (*dingding.Client, 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"
|
||
client := dingding.NewClient(&dingding.DefaultAccessTokenManager{
|
||
Id: uuid.New().String(),
|
||
Cache: file.New(os.TempDir()),
|
||
Name: "x-acs-dingtalk-access-token",
|
||
GetRefreshRequestFunc: func() *http.Request {
|
||
// 这里沿用原来的 token 刷新逻辑
|
||
reqs, _ = http.NewRequest(http.MethodGet, "https://oapi.dingtalk.com/gettoken?"+params.Encode(), nil)
|
||
return reqs
|
||
},
|
||
})
|
||
|
||
return client, nil
|
||
}
|
||
|
||
// TestConnection 测试连接
|
||
// @Tags System Integrated
|
||
// @Summary 测试系统集成连接
|
||
// @Security ApiKeyAuth
|
||
// @accept application/json
|
||
// @Produce application/json
|
||
// @param: integrate gaia.SystemIntegration, code string
|
||
// @return: error
|
||
func (e *SystemIntegratedService) TestConnection(integrate gaia.SystemIntegration, code string) error {
|
||
switch integrate.Classify {
|
||
case gaia.SystemIntegrationDingTalk:
|
||
// 测试钉钉连接
|
||
if _, err := e.DingTalkConfigAvailable(integrate); err != nil {
|
||
return errors.New("钉钉链接失败: " + err.Error())
|
||
}
|
||
// 验证第三方邮箱API配置
|
||
if err := e.ValidateEmailApiConfig(integrate); err != nil {
|
||
global.GVA_LOG.Warn("第三方邮箱API配置验证失败", zap.Error(err))
|
||
// 不阻止保存,只记录警告
|
||
}
|
||
// 验证转发集成配置
|
||
if err := e.ValidateForwardConfig(integrate); err != nil {
|
||
global.GVA_LOG.Warn("转发集成配置验证失败", zap.Error(err))
|
||
// 不阻止保存,只记录警告
|
||
}
|
||
return nil
|
||
case gaia.SystemIntegrationOAuth2:
|
||
// 测试OAuth2连接
|
||
return e.TestOAuth2Connection(integrate, code)
|
||
default:
|
||
return errors.New("不支持的集成类型")
|
||
}
|
||
}
|
||
|
||
// ParseDingTalkConfig 解析钉钉集成配置,自动处理新旧格式兼容
|
||
func (e *SystemIntegratedService) ParseDingTalkConfig(configJSON string) (request.DingTalkConfigRequest, error) {
|
||
var configMap request.DingTalkConfigRequest
|
||
if configJSON == "" {
|
||
return configMap, 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())
|
||
}
|
||
|
||
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 cfg.Method == "" {
|
||
cfg.Method = "GET"
|
||
}
|
||
|
||
// 新格式不强制要求 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("邮箱请求字段不能为空")
|
||
}
|
||
}
|
||
|
||
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 cfg.Authorization.Token == "" {
|
||
return errors.New("Bearer Token不能为空")
|
||
}
|
||
} else if authType == "basic" {
|
||
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", 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连接
|
||
// @Security ApiKeyAuth
|
||
// @accept application/json
|
||
// @Produce application/json
|
||
// @param: integrate gaia.SystemIntegration, code string
|
||
// @return: error
|
||
func (e *SystemIntegratedService) TestOAuth2Connection(integrate gaia.SystemIntegration, code string) (err error) {
|
||
// 解析Config字段
|
||
var configMap request.SystemOAuth2Request
|
||
if err = json.Unmarshal([]byte(integrate.Config), &configMap); err != nil {
|
||
global.GVA_LOG.Error("解析OAuth2配置失败!", zap.Error(err))
|
||
return err
|
||
}
|
||
// 没有code的(保存操作)
|
||
if len(code) == 0 {
|
||
return nil
|
||
}
|
||
// 检查必要字段
|
||
if configMap.ServerURL == "" || configMap.TokenURL == "" || integrate.AppID == "" || integrate.AppSecret == "" {
|
||
return errors.New("请填写完整的 OAuth2 配置信息")
|
||
}
|
||
|
||
// 合成请求byte
|
||
formData := url.Values{}
|
||
formData.Set("grant_type", "authorization_code")
|
||
formData.Set("code", code)
|
||
// redirect_uri 必须与授权时一致
|
||
formData.Set("redirect_uri", strings.TrimSpace(configMap.RedirectUri))
|
||
// 支持basic与post两种认证方式
|
||
// 默认使用client_secret_post,除非配置为basic
|
||
tokenAuthMethod := strings.ToLower(strings.TrimSpace(configMap.TokenAuthMethod))
|
||
useBasic := tokenAuthMethod == "client_secret_basic"
|
||
if !useBasic {
|
||
formData.Set("client_secret", integrate.AppSecret)
|
||
formData.Set("client_id", integrate.AppID)
|
||
}
|
||
|
||
// 发送请求
|
||
var req *http.Request
|
||
client := &http.Client{}
|
||
req, err = http.NewRequest("POST", fmt.Sprintf(
|
||
"%s%s", configMap.ServerURL, configMap.TokenURL), strings.NewReader(formData.Encode()))
|
||
if err != nil {
|
||
global.GVA_LOG.Error("创建测试请求失败", zap.Error(err))
|
||
return errors.New(fmt.Sprintf("创建测试请求失败: %s", err.Error()))
|
||
}
|
||
|
||
// 设置认证与Content-Type
|
||
if useBasic {
|
||
req.SetBasicAuth(integrate.AppID, integrate.AppSecret)
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
} else {
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
}
|
||
|
||
// 发送请求
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
global.GVA_LOG.Error("测试 OAuth2 连接失败", zap.Error(err))
|
||
return errors.New(fmt.Sprintf("连接 OAuth2 服务器失败: %s", err.Error()))
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
global.GVA_LOG.Error("测试 OAuth2 连接失败", zap.Int("status", resp.StatusCode))
|
||
return errors.New(fmt.Sprintf("OAuth2 服务器返回错误状态码: %d", resp.StatusCode))
|
||
}
|
||
var bodyByte []byte
|
||
if bodyByte, err = io.ReadAll(resp.Body); err != nil {
|
||
return fmt.Errorf("OAuth2 request io.ReadAll: %s", resp.Status)
|
||
}
|
||
|
||
var tokenMap request.SystemOAuth2Error
|
||
if err = json.Unmarshal(bodyByte, &tokenMap); err == nil && tokenMap.Code != 0 {
|
||
return fmt.Errorf("OAuth2 Eroor: %s", tokenMap.Info)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ValidateForwardConfig 验证转发集成配置
|
||
// @Tags System Integrated
|
||
// @Summary 验证转发集成配置
|
||
// @param: integrate gaia.SystemIntegration
|
||
// @return: error
|
||
func (e *SystemIntegratedService) ValidateForwardConfig(integrate gaia.SystemIntegration) error {
|
||
// 解析 Config 字段
|
||
if integrate.Config == "" {
|
||
return nil // 配置为空不算错误
|
||
}
|
||
|
||
var configMap request.DingTalkConfigRequest
|
||
if err := json.Unmarshal([]byte(integrate.Config), &configMap); err != nil {
|
||
return fmt.Errorf("解析配置失败:%s", err.Error())
|
||
}
|
||
|
||
// 若未配置转发 Token,则认为未使用转发能力,不强制校验
|
||
if len(configMap.ForwardConfig.Tokens) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// 使用转发能力的前置条件:至少 1 个 Token + 启用并配置「第三方邮箱配置」
|
||
if !configMap.EmailApi.Enabled || strings.TrimSpace(configMap.EmailApi.URL) == "" {
|
||
return errors.New("使用转发能力前请先启用并配置「第三方邮箱配置」")
|
||
}
|
||
|
||
// 验证 Token 数量
|
||
if len(configMap.ForwardConfig.Tokens) > 20 {
|
||
return errors.New("转发 Token 最多 20 个")
|
||
}
|
||
|
||
// 验证每个 Token 的必填字段
|
||
for i, token := range configMap.ForwardConfig.Tokens {
|
||
if token.ID == "" {
|
||
return fmt.Errorf("第%d个 Token 的 ID 不能为空", i+1)
|
||
}
|
||
if token.TokenHash == "" {
|
||
return fmt.Errorf("第%d个 Token 的 TokenHash 不能为空", i+1)
|
||
}
|
||
}
|
||
|
||
global.GVA_LOG.Info("转发集成配置验证通过",
|
||
zap.Int("token_count", len(configMap.ForwardConfig.Tokens)))
|
||
|
||
return nil
|
||
}
|
||
|
||
// ValidateDingIdApiConfig 验证第三方钉钉 ID 匹配 API 配置
|
||
// @Tags System Integrated
|
||
// @Summary 验证第三方钉钉 ID 匹配 API 配置
|
||
// @param: integrate gaia.SystemIntegration
|
||
// @return: error
|
||
// extractJSONPath 按点分路径从 JSON 对象中提取字符串值,支持 "data.username" 等多层路径
|
||
func extractJSONPath(data map[string]interface{}, path string) string {
|
||
parts := strings.SplitN(path, ".", 2)
|
||
val, ok := data[parts[0]]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
if len(parts) == 1 {
|
||
if s, ok := val.(string); ok {
|
||
return s
|
||
}
|
||
return fmt.Sprintf("%v", val)
|
||
}
|
||
if nested, ok := val.(map[string]interface{}); ok {
|
||
return extractJSONPath(nested, parts[1])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 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":
|
||
raw := strings.ReplaceAll(config.BodyData.Raw, "{{ding_id}}", dingId)
|
||
raw = strings.ReplaceAll(raw, request.DingIDMarker, dingId)
|
||
bodyReader = strings.NewReader(raw)
|
||
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"
|
||
}
|
||
}
|
||
|
||
apiURL := buildURL(config.URL, config, dingId)
|
||
|
||
req, err := http.NewRequest(method, apiURL, bodyReader)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("构建请求失败:%s", err.Error())
|
||
}
|
||
|
||
// 设置 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)
|
||
}
|
||
|
||
// 设置 Authorization
|
||
authType := strings.ToLower(config.Authorization.Type)
|
||
switch authType {
|
||
case "bearer":
|
||
req.Header.Set("Authorization", "Bearer "+config.Authorization.Token)
|
||
case "basic":
|
||
req.SetBasicAuth(config.Authorization.Username, config.Authorization.Password)
|
||
}
|
||
|
||
client := &http.Client{Timeout: 15 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("请求失败:%s", err.Error())
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
respBody, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, resp.StatusCode, fmt.Errorf("读取响应失败:%s", err.Error())
|
||
}
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return respBody, resp.StatusCode, fmt.Errorf("第三方 API 返回错误状态码:%d", resp.StatusCode)
|
||
}
|
||
|
||
return respBody, resp.StatusCode, 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 本地表 → 第三方 EmailApi(邮箱 API)
|
||
func (e *SystemIntegratedService) ResolveAccountByDingId(dingId string, apiConfig request.EmailApiConfig) (string, error) {
|
||
ctx := context.Background()
|
||
redisKey := "gaia:forward:ding:" + dingId
|
||
|
||
// 1. 查 Redis 缓存
|
||
if cached, err := global.GVA_REDIS.Get(ctx, redisKey).Result(); err == nil && cached != "" {
|
||
global.GVA_LOG.Info("ResolveAccountByDingId: Redis 命中", zap.String("ding_id", dingId), zap.String("account_id", cached))
|
||
return cached, nil
|
||
}
|
||
|
||
// 2. 查本地 AccountDingTalkExtend 表
|
||
var extend gaia.AccountDingTalkExtend
|
||
if err := global.GVA_DB.Where("ding_talk = ?", dingId).First(&extend).Error; err == nil {
|
||
accountID := extend.ID.String()
|
||
global.GVA_LOG.Info("ResolveAccountByDingId: 本地表命中", zap.String("ding_id", dingId), zap.String("account_id", accountID))
|
||
global.GVA_REDIS.Set(ctx, redisKey, accountID, 24*time.Hour)
|
||
return accountID, nil
|
||
}
|
||
|
||
// 3. 第三方邮箱 API(若配置)
|
||
if !apiConfig.Enabled || strings.TrimSpace(apiConfig.URL) == "" {
|
||
return "", fmt.Errorf("未找到 ding_id=%s 对应的用户,且未配置第三方邮箱 API", dingId)
|
||
}
|
||
|
||
email, err := e.callEmailApi(dingId, apiConfig)
|
||
if err != nil {
|
||
return "", fmt.Errorf("调用第三方邮箱 API 失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 按邮箱查 accounts 表(匹配 email 字段)
|
||
var account gaia.Account
|
||
if err = global.GVA_DB.Where("email = ?", email).First(&account).Error; err != nil {
|
||
return "", fmt.Errorf("邮箱 %s 不存在(来自第三方邮箱 API)", email)
|
||
}
|
||
|
||
accountID := account.ID.String()
|
||
|
||
// 5. 写回 AccountDingTalkExtend,方便下次本地命中
|
||
global.GVA_DB.Create(&gaia.AccountDingTalkExtend{
|
||
ID: account.ID,
|
||
DingTalk: dingId,
|
||
})
|
||
|
||
// 6. 写 Redis 缓存
|
||
global.GVA_REDIS.Set(ctx, redisKey, accountID, 24*time.Hour)
|
||
|
||
global.GVA_LOG.Info("ResolveAccountByDingId: 第三方邮箱 API 解析成功",
|
||
zap.String("ding_id", dingId),
|
||
zap.String("email", email),
|
||
zap.String("account_id", accountID))
|
||
|
||
return accountID, nil
|
||
}
|