// 钉钉开放平台 API 封装 package dingtalk import ( "bytes" "encoding/json" "fmt" "io" "net/http" "sync" "time" myconfig "com.sclktx/m/v2/internal/pkg/config" "github.com/sirupsen/logrus" ) // Client 钉钉客户端 type Client struct { cfg *myconfig.DingTalkConfig httpCl *http.Client token string tokenExp time.Time tokenLock sync.RWMutex } // NewClient 创建钉钉客户端 func NewClient(cfg *myconfig.DingTalkConfig) *Client { return &Client{ cfg: cfg, httpCl: &http.Client{Timeout: 10 * time.Second}, } } // tokenResponse 获取 access_token 的响应 type tokenResponse struct { AccessToken string `json:"accessToken"` ExpireIn int64 `json:"expireIn"` // 秒 } // getAccessToken 获取钉钉 access_token(带缓存) func (c *Client) getAccessToken() (string, error) { c.tokenLock.RLock() if c.token != "" && time.Now().Before(c.tokenExp) { c.tokenLock.RUnlock() return c.token, nil } c.tokenLock.RUnlock() c.tokenLock.Lock() defer c.tokenLock.Unlock() // 双重检查 if c.token != "" && time.Now().Before(c.tokenExp) { return c.token, nil } body := map[string]string{ "appKey": c.cfg.AppKey, "appSecret": c.cfg.AppSecret, } data, _ := json.Marshal(body) logrus.Debug("钉钉获取 access_token 开始") resp, err := c.httpCl.Post("https://api.dingtalk.com/v1.0/oauth2/accessToken", "application/json", bytes.NewReader(data)) if err != nil { logrus.WithError(err).Error("钉钉获取 access_token HTTP 请求失败") return "", fmt.Errorf("dingtalk getAccessToken request failed: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) logrus.WithFields(logrus.Fields{ "status_code": resp.StatusCode, "response": string(respBody), }).Debug("钉钉获取 access_token 响应") var tr tokenResponse if err := json.Unmarshal(respBody, &tr); err != nil { logrus.WithError(err).WithField("response", string(respBody)).Error("钉钉获取 access_token 解析失败") return "", fmt.Errorf("dingtalk getAccessToken parse failed: %s", string(respBody)) } if tr.AccessToken == "" { logrus.WithField("response", string(respBody)).Error("钉钉获取 access_token 返回空") return "", fmt.Errorf("dingtalk getAccessToken empty token: %s", string(respBody)) } c.token = tr.AccessToken c.tokenExp = time.Now().Add(time.Duration(tr.ExpireIn-60) * time.Second) // 提前 60 秒刷新 logrus.WithFields(logrus.Fields{ "expire_in": tr.ExpireIn, }).Info("钉钉 access_token 获取成功") return c.token, nil } // RobotBatchSendReq 批量发送机器人消息请求 type RobotBatchSendReq struct { RobotCode string `json:"robotCode"` UserIDs []string `json:"userIds"` MsgKey string `json:"msgKey"` MsgParam string `json:"msgParam"` } // BatchSendRobotMessage 批量发送人与机器人会话中机器人消息 // userIDs: 钉钉用户 unionId 列表 // title: 消息标题 // markdown: 消息内容(Markdown 格式) func (c *Client) BatchSendRobotMessage(userIDs []string, title, markdown string) error { if len(userIDs) == 0 { logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 userIDs 为空") return nil } if c.cfg.RobotCode == "" { logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 RobotCode 为空") return nil } logrus.WithFields(logrus.Fields{ "user_ids": userIDs, "title": title, "robot_code": c.cfg.RobotCode, }).Info("钉钉 BatchSendRobotMessage 开始") token, err := c.getAccessToken() if err != nil { logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 获取 token 失败") return err } msgParam := map[string]string{ "title": title, "text": markdown, } msgParamJSON, _ := json.Marshal(msgParam) req := RobotBatchSendReq{ RobotCode: c.cfg.RobotCode, UserIDs: userIDs, MsgKey: "sampleMarkdown", MsgParam: string(msgParamJSON), } reqBody, _ := json.Marshal(req) logrus.WithFields(logrus.Fields{ "request_body": string(reqBody), }).Debug("钉钉 BatchSendRobotMessage 请求体") httpReq, err := http.NewRequest("POST", "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend", bytes.NewReader(reqBody)) if err != nil { logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 创建请求失败") return fmt.Errorf("dingtalk BatchSendRobotMessage create request failed: %w", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("x-acs-dingtalk-access-token", token) resp, err := c.httpCl.Do(httpReq) if err != nil { logrus.WithError(err).Error("钉钉 BatchSendRobotMessage HTTP 请求失败") return fmt.Errorf("dingtalk BatchSendRobotMessage request failed: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) logrus.WithFields(logrus.Fields{ "status_code": resp.StatusCode, "response": string(respBody), }).Debug("钉钉 BatchSendRobotMessage 响应") // 成功时钉钉返回 200 空 body 或 processQueryKey if resp.StatusCode >= 400 { errMsg := fmt.Errorf("dingtalk BatchSendRobotMessage failed: status=%d body=%s", resp.StatusCode, string(respBody)) logrus.WithFields(logrus.Fields{ "status_code": resp.StatusCode, "response": string(respBody), }).Error("钉钉 BatchSendRobotMessage 请求返回错误状态码") return errMsg } logrus.WithFields(logrus.Fields{ "user_ids": userIDs, "title": title, "response": string(respBody), }).Info("钉钉机器人消息发送成功") return nil }