feat(钉钉通道): 支持仅使用 access_token 配置钉钉机器人
Motivation: 钉钉机器人的 webhook 前缀固定不变,要求用户填写完整 URL 冗余且容易出错。简化通道配置项,降低接入配置成本,同时保证存量配置不受影响。 Changes: * 钉钉通道配置改用 access_token,webhook 前缀由内部固定拼接 * 保留对旧 webhook_url 配置的兼容,access_token 存在时优先生效 * 新增 webhook 地址解析逻辑的单元测试,覆盖优先级与回退场景 * 同步更新 README、HTTP 请求示例及设计文档中的配置示例 * E2E 测试改用 E2E_DINGTALK_ACCESS_TOKEN 环境变量并按新格式创建通道
This commit is contained in:
@@ -388,12 +388,12 @@ Query:`page`、`page_size`(默认同 sources)。**200:** `{ "data": Temp
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
"access_token": "xxx",
|
||||||
"secret": "SEC..."
|
"secret": "SEC..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`secret` 可选(加签机器人时填写)。消息类型:markdown。
|
`secret` 可选(加签机器人时填写),webhook 前缀固定,无需填写完整 URL。消息类型:markdown。
|
||||||
|
|
||||||
**企业微信 `wecom`**
|
**企业微信 `wecom`**
|
||||||
|
|
||||||
@@ -605,7 +605,7 @@ curl -s -X POST http://localhost:8080/api/v1/channels \
|
|||||||
-d '{
|
-d '{
|
||||||
"name":"dingtalk-prod",
|
"name":"dingtalk-prod",
|
||||||
"type":"dingtalk",
|
"type":"dingtalk",
|
||||||
"config":{"webhook_url":"https://oapi.dingtalk.com/robot/send?access_token=xxx","secret":"SEC..."}
|
"config":{"access_token":"xxx","secret":"SEC..."}
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# 4. 创建规则
|
# 4. 创建规则
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ curl -X POST 'http://localhost:8080/api/v1/channels' \
|
|||||||
"name": "dingtalk-prod",
|
"name": "dingtalk-prod",
|
||||||
"type": "dingtalk",
|
"type": "dingtalk",
|
||||||
"config": {
|
"config": {
|
||||||
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
"access_token": "xxx",
|
||||||
"secret": "SEC..."
|
"secret": "SEC..."
|
||||||
},
|
},
|
||||||
"status": 1
|
"status": 1
|
||||||
|
|||||||
@@ -312,7 +312,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"name\": \"dingtalk-prod\",\n \"type\": \"dingtalk\",\n \"config\": {\n \"webhook_url\": \"https://oapi.dingtalk.com/robot/send?access_token=xxx\",\n \"secret\": \"SEC...\"\n },\n \"status\": 1\n}"
|
"raw": "{\n \"name\": \"dingtalk-prod\",\n \"type\": \"dingtalk\",\n \"config\": {\n \"access_token\": \"xxx\",\n \"secret\": \"SEC...\"\n },\n \"status\": 1\n}"
|
||||||
},
|
},
|
||||||
"url": "{{baseUrl}}/api/v1/channels"
|
"url": "{{baseUrl}}/api/v1/channels"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ POST /api/v1/channels
|
|||||||
"name": "dingtalk-prod",
|
"name": "dingtalk-prod",
|
||||||
"type": "dingtalk",
|
"type": "dingtalk",
|
||||||
"config": {
|
"config": {
|
||||||
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
|
"access_token": "xxx",
|
||||||
"secret": "SEC..."
|
"secret": "SEC..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 钉钉自定义机器人 webhook 前缀固定,配置时只需提供 access_token
|
||||||
|
const dingtalkWebhookPrefix = "https://oapi.dingtalk.com/robot/send?access_token="
|
||||||
|
|
||||||
type dingtalkConfig struct {
|
type dingtalkConfig struct {
|
||||||
WebhookURL string `json:"webhook_url"`
|
AccessToken string `json:"access_token"`
|
||||||
Secret string `json:"secret,omitempty"`
|
Secret string `json:"secret,omitempty"`
|
||||||
|
// WebhookURL 兼容旧配置;新配置优先使用 AccessToken
|
||||||
|
WebhookURL string `json:"webhook_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dingtalkConfig) webhookURL() string {
|
||||||
|
if c.AccessToken != "" {
|
||||||
|
return dingtalkWebhookPrefix + c.AccessToken
|
||||||
|
}
|
||||||
|
return c.WebhookURL
|
||||||
}
|
}
|
||||||
|
|
||||||
type dingtalkMessage struct {
|
type dingtalkMessage struct {
|
||||||
@@ -41,16 +53,16 @@ func (s *DingTalkSender) Send(title, content string, config json.RawMessage) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.limiter != nil {
|
if s.limiter != nil {
|
||||||
if err := s.limiter.Acquire(context.Background(), DingTalkLimitKey(cfg.WebhookURL)); err != nil {
|
if err := s.limiter.Acquire(context.Background(), DingTalkLimitKey(cfg.webhookURL())); err != nil {
|
||||||
return fmt.Errorf("dingtalk rate limit: %w", err)
|
return fmt.Errorf("dingtalk rate limit: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reqURL := cfg.WebhookURL
|
reqURL := cfg.webhookURL()
|
||||||
if cfg.Secret != "" {
|
if cfg.Secret != "" {
|
||||||
timestamp := time.Now().UnixMilli()
|
timestamp := time.Now().UnixMilli()
|
||||||
sign := dingtalkSign(timestamp, cfg.Secret)
|
sign := dingtalkSign(timestamp, cfg.Secret)
|
||||||
reqURL = fmt.Sprintf("%s×tamp=%d&sign=%s", cfg.WebhookURL, timestamp, sign)
|
reqURL = fmt.Sprintf("%s×tamp=%d&sign=%s", reqURL, timestamp, sign)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := dingtalkMessage{
|
msg := dingtalkMessage{
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package adapter
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDingtalkConfigWebhookURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg dingtalkConfig
|
||||||
|
wantURL string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "access token only",
|
||||||
|
cfg: dingtalkConfig{AccessToken: "tok-abc"},
|
||||||
|
wantURL: "https://oapi.dingtalk.com/robot/send?access_token=tok-abc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "access token takes precedence over legacy url",
|
||||||
|
cfg: dingtalkConfig{AccessToken: "tok-abc", WebhookURL: "https://legacy.example/webhook"},
|
||||||
|
wantURL: "https://oapi.dingtalk.com/robot/send?access_token=tok-abc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback to legacy url",
|
||||||
|
cfg: dingtalkConfig{WebhookURL: "https://legacy.example/webhook"},
|
||||||
|
wantURL: "https://legacy.example/webhook",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.cfg.webhookURL(); got != tt.wantURL {
|
||||||
|
t.Fatalf("webhookURL() = %q, want %q", got, tt.wantURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,23 +33,23 @@ type fixture struct {
|
|||||||
chEmail string
|
chEmail string
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireE2EEnv(t *testing.T) (base, adminKey, dingWebhook, dingSecret, barkURL, emailTo string) {
|
func requireE2EEnv(t *testing.T) (base, adminKey, dingAccessToken, dingSecret, barkURL, emailTo string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
base = envOr("E2E_BASE_URL", "http://82.157.251.93:8080")
|
base = envOr("E2E_BASE_URL", "http://82.157.251.93:8080")
|
||||||
adminKey = envOr("E2E_ADMIN_KEY", "admin-sk-change-me")
|
adminKey = envOr("E2E_ADMIN_KEY", "admin-sk-change-me")
|
||||||
dingWebhook = os.Getenv("E2E_DINGTALK_WEBHOOK")
|
dingAccessToken = os.Getenv("E2E_DINGTALK_ACCESS_TOKEN")
|
||||||
dingSecret = os.Getenv("E2E_DINGTALK_SECRET")
|
dingSecret = os.Getenv("E2E_DINGTALK_SECRET")
|
||||||
barkURL = os.Getenv("E2E_BARK_URL")
|
barkURL = os.Getenv("E2E_BARK_URL")
|
||||||
emailTo = os.Getenv("E2E_EMAIL_TO")
|
emailTo = os.Getenv("E2E_EMAIL_TO")
|
||||||
if dingWebhook == "" || dingSecret == "" || barkURL == "" || emailTo == "" {
|
if dingAccessToken == "" || dingSecret == "" || barkURL == "" || emailTo == "" {
|
||||||
t.Skip("missing E2E_DINGTALK_WEBHOOK / E2E_DINGTALK_SECRET / E2E_BARK_URL / E2E_EMAIL_TO")
|
t.Skip("missing E2E_DINGTALK_ACCESS_TOKEN / E2E_DINGTALK_SECRET / E2E_BARK_URL / E2E_EMAIL_TO")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupFixture(t *testing.T) *fixture {
|
func setupFixture(t *testing.T) *fixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
base, adminKey, dingAccessToken, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ func setupFixture(t *testing.T) *fixture {
|
|||||||
|
|
||||||
ding := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
ding := mustAdminJSON(t, client, base, adminKey, http.MethodPost, "/api/v1/channels", map[string]any{
|
||||||
"name": f.chDing, "type": "dingtalk", "status": 1,
|
"name": f.chDing, "type": "dingtalk", "status": 1,
|
||||||
"config": map[string]string{"webhook_url": dingWebhook, "secret": dingSecret},
|
"config": map[string]string{"access_token": dingAccessToken, "secret": dingSecret},
|
||||||
}, http.StatusCreated)
|
}, http.StatusCreated)
|
||||||
f.dingID = intFrom(ding["id"])
|
f.dingID = intFrom(ding["id"])
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
@@ -288,7 +288,7 @@ func TestNotifyFlow_DisableRule(t *testing.T) {
|
|||||||
// TestNotifyFlow_DifferentTemplatesPerRule verifies rule→template binding:
|
// TestNotifyFlow_DifferentTemplatesPerRule verifies rule→template binding:
|
||||||
// same source, three events, each with a distinct template and a single channel.
|
// same source, three events, each with a distinct template and a single channel.
|
||||||
func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
|
func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
|
||||||
base, adminKey, dingWebhook, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
base, adminKey, dingAccessToken, dingSecret, barkURL, emailTo := requireE2EEnv(t)
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
srcName := "e2e-mt-" + suffix
|
srcName := "e2e-mt-" + suffix
|
||||||
@@ -320,7 +320,7 @@ func TestNotifyFlow_DifferentTemplatesPerRule(t *testing.T) {
|
|||||||
marker: "DING-TMPL",
|
marker: "DING-TMPL",
|
||||||
chName: "e2e-mtd-" + suffix,
|
chName: "e2e-mtd-" + suffix,
|
||||||
chType: "dingtalk",
|
chType: "dingtalk",
|
||||||
chCfg: map[string]any{"webhook_url": dingWebhook, "secret": dingSecret},
|
chCfg: map[string]any{"access_token": dingAccessToken, "secret": dingSecret},
|
||||||
tmpl: "DING-TMPL {{.symbol}} ding price={{.price}}",
|
tmpl: "DING-TMPL {{.symbol}} ding price={{.price}}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user