2026-07-19 23:21:31 +08:00

133 lines
3.6 KiB
Go

package dingtalkrobot
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Config struct {
WebhookURL string
Secret string
AtMobiles []string
AtAll bool
RequestTimeout time.Duration
}
// Client 位于 admin 自己的 platform 边界,避免独立 module 反向依赖根模块 hyapp/pkg。
type Client struct {
webhookURL string
secret string
atMobiles []string
atAll bool
httpClient *http.Client
}
func New(config Config) (*Client, error) {
webhookURL := strings.TrimSpace(config.WebhookURL)
if webhookURL == "" {
return nil, errors.New("dingtalk webhook url is required")
}
parsed, err := url.ParseRequestURI(webhookURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return nil, errors.New("dingtalk webhook url must be an absolute http url")
}
timeout := config.RequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
return &Client{
webhookURL: webhookURL,
secret: strings.TrimSpace(config.Secret),
atMobiles: compactStrings(config.AtMobiles),
atAll: config.AtAll,
httpClient: &http.Client{Timeout: timeout},
}, nil
}
// SendMarkdown 将钉钉 HTTP 状态和业务 errcode 都视为失败;业务层决定通知失败不能回滚审核事实。
func (c *Client) SendMarkdown(ctx context.Context, title string, text string) error {
if c == nil || c.httpClient == nil {
return errors.New("dingtalk client is not configured")
}
title, text = strings.TrimSpace(title), strings.TrimSpace(text)
if title == "" || text == "" {
return errors.New("dingtalk markdown title and text are required")
}
body, err := json.Marshal(map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{"title": title, "text": text},
"at": map[string]any{"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", resp.StatusCode)
}
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
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 := time.Now().UTC().UnixMilli()
payload := fmt.Sprintf("%d\n%s", timestamp, c.secret)
mac := hmac.New(sha256.New, []byte(c.secret))
_, _ = mac.Write([]byte(payload))
query := parsed.Query()
query.Set("timestamp", fmt.Sprintf("%d", timestamp))
query.Set("sign", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
parsed.RawQuery = query.Encode()
return parsed.String()
}
func compactStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
out = append(out, value)
}
}
return out
}