Files
dify-plus/admin/server/service/gaia/system.go
T
2026-03-11 12:05:53 +08:00

1008 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}