2026-07-02 10:53:44 +08:00

175 lines
4.6 KiB
Go
Raw Permalink 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 dingtalkrobot
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Config 保存钉钉机器人请求所需的敏感参数;调用方必须从运行环境注入真实 webhook。
type Config struct {
WebhookURL string
Secret string
AtMobiles []string
AtAll bool
RequestTimeout time.Duration
}
// MarkdownMessage 是当前财务通知使用的唯一消息格式,避免业务层拼钉钉专属 JSON。
type MarkdownMessage struct {
Title string
Text string
}
// Client 封装钉钉机器人加签、HTTP 请求和返回码校验gateway/admin 复用同一套边界。
type Client struct {
webhookURL string
secret string
atMobiles []string
atAll bool
httpClient *http.Client
now func() time.Time
}
// New 创建钉钉机器人客户端httpClient 仅用于测试或特殊运行时注入。
func New(config Config, httpClient *http.Client) (*Client, error) {
webhookURL := strings.TrimSpace(config.WebhookURL)
if webhookURL == "" {
return nil, errors.New("dingtalk webhook url is required")
}
if _, err := url.ParseRequestURI(webhookURL); err != nil {
return nil, fmt.Errorf("dingtalk webhook url is invalid: %w", err)
}
timeout := config.RequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
if httpClient == nil {
httpClient = &http.Client{Timeout: timeout}
}
return &Client{
webhookURL: webhookURL,
secret: strings.TrimSpace(config.Secret),
atMobiles: compactStrings(config.AtMobiles),
atAll: config.AtAll,
httpClient: httpClient,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
// SendMarkdown 发送 markdown 消息;钉钉业务错误码也视为失败,调用方决定是否回滚业务。
func (c *Client) SendMarkdown(ctx context.Context, message MarkdownMessage) error {
if c == nil || c.httpClient == nil {
return errors.New("dingtalk client is not configured")
}
title := strings.TrimSpace(message.Title)
text := strings.TrimSpace(message.Text)
if title == "" || text == "" {
return errors.New("dingtalk markdown title and text are required")
}
body, err := json.Marshal(markdownPayload{
MsgType: "markdown",
Markdown: markdownBody{
Title: title,
Text: text,
},
At: atBody{
AtMobiles: c.atMobiles,
IsAtAll: c.atAll,
},
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.signedWebhookURL(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("dingtalk returned http %d: %s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
var result sendResponse
if err := json.Unmarshal(payload, &result); err != nil {
return fmt.Errorf("decode dingtalk response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("dingtalk returned errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
return nil
}
func (c *Client) signedWebhookURL() string {
if c.secret == "" {
return c.webhookURL
}
parsed, err := url.Parse(c.webhookURL)
if err != nil {
return c.webhookURL
}
timestamp := c.now().UnixMilli()
query := parsed.Query()
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
query.Set("sign", sign(timestamp, c.secret))
parsed.RawQuery = query.Encode()
return parsed.String()
}
func sign(timestamp int64, secret string) string {
// 钉钉加签要求 timestamp + "\n" + secret 作为明文secret 同时作为 HMAC key。
payload := fmt.Sprintf("%d\n%s", timestamp, secret)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
type markdownPayload struct {
MsgType string `json:"msgtype"`
Markdown markdownBody `json:"markdown"`
At atBody `json:"at"`
}
type markdownBody struct {
Title string `json:"title"`
Text string `json:"text"`
}
type atBody struct {
AtMobiles []string `json:"atMobiles"`
IsAtAll bool `json:"isAtAll"`
}
type sendResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}