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"` }