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 }