First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
package notify
import (
"context"
"encoding/json"
"errors"
"fmt"
)
// weComMarkdownLimit 是企微群机器人 markdown content 的硬上限(字节,非字符)。
// 这是全部六个渠道里最紧的限制,也是 TruncateBytes 存在的主要原因。
const weComMarkdownLimit = 4096
// weComChannel 实现企业微信群机器人。
//
// 平台特性:
// - 唯一通过 URL 上的 key 鉴权,不支持加签——所以 webhook 地址本身就是全部凭据。
// - markdown content 上限 4096 **字节**,超长整条被拒(不是截断)。中文 3 字节/字,
// 意味着正文只有一千多字可写,必须客户端截断。
// - 限流 20 条/分钟,同样靠客户端限流兜住。
type weComChannel struct{}
func (weComChannel) Kind() string { return KindWeCom }
func (weComChannel) DefaultRatePerMin() int { return 20 }
// 企业微信只有 Webhook 一处凭据(URL 上的 key),且它不支持加签——
// 整个地址就是全部凭据,没有别的字段需要掩码。
func (weComChannel) SecretKeys() []string { return []string{"webhook"} }
// 企微只有 Webhook 一处字段,它既是目的地也是凭据,因此没有「改地址后残留的凭据」可言。
func (weComChannel) DestinationKeys() []string { return []string{"webhook"} }
func (weComChannel) Validate(cfg map[string]any) error {
hook := cfgString(cfg, "webhook")
if hook == "" {
return errors.New("Webhook 주소가 없습니다")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
}
return nil
}
func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
// 汇总批可能很长(50 条 × 每条一行 + 前缀),4096 字节很容易超。
// 截断在这里做而不是靠平台报错:被拒意味着这一批全丢,而截断至少送达前若干条。
content, kept := markdownBody(m, weComMarkdownLimit)
payload := map[string]any{
"msgtype": "markdown",
"markdown": map[string]any{"content": content},
}
raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload)
if err != nil {
return 0, err
}
var res struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("WeCom 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
}
if res.ErrCode != 0 {
// 45009 是接口调用超过限制——平台的限流窗口会滚动,退避后重试是有效的,
// 所以显式归为可重试。走到这里说明客户端 rate_per_min 配得过于激进,
// 重试只是兜底,真正的修法是调低该渠道的限流值。
if res.ErrCode == 45009 {
return 0, fmt.Errorf("WeCom 요청이 제한되었습니다 (%d): %s", res.ErrCode, res.ErrMsg)
}
// 93000 是 webhook key 无效——永久失败,重试不会自愈。
return 0, Permanent(fmt.Errorf("WeCom에서 오류가 발생했습니다 (%d): %s", res.ErrCode, res.ErrMsg))
}
return kept, nil
}