feat(notify): 漏洞发现支持推送到钉钉/飞书/企业微信等六种渠道

新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、
通用 Webhook、Telegram、邮件。同一类型可配任意多个机器人实例,每个实例独立
设启停、限流与过滤规则(最低级别 / 任务资产范围 / 漏洞类型关键词 / 是否接收
处置状态变更)。推送时机分实时与汇总两种模式,「高危实时、其余汇总」建两个
渠道即可表达,策略不写死在代码里。带投递历史与失败重发。

三个关键设计:

1. 写漏洞的事务只做一次盲 INSERT。notification_events 由 RecordFindingTx
   在同一事务内写入,保证「漏洞落库」与「推送任务存在」原子一致;这条 INSERT
   刻意不读渠道表、不跑用户的过滤规则,否则一条配错的过滤条件就能污染甚至
   中止事务,让高危漏洞存不进库。为把失败隔离在这一条语句上(PostgreSQL 中
   事务内任一语句报错会让整个事务作废、连 COMMIT 都失败),它被 SAVEPOINT
   包住,失败时只记日志。

2. 投递用租约领取而非长事务。FOR UPDATE SKIP LOCKED 领取后把行置为 sending、
   把 next_attempt_at 推到未来作为租约,提交后再做网络投递,因此投递期间不
   持有数据库锁;崩溃留下的 sending 行会被下一轮重新领取,自愈且不会无限重试。

3. 限流不消耗重试预算。先按渠道令牌桶算出本轮还能发几条,再按这个数量领取;
   顺序反过来会让被限流挡下的投递白计一次尝试,三次预算被等待耗光后落入失败。

审计并修复的四个问题:

- 推送凭据经错误信息外泄:http.Client.Do 返回的 *url.Error 会把完整 URL 打进
  错误文本,而钉钉 access_token、企业微信 key、飞书 hook id、Telegram
  /bot<token>/ 都就在 URL 里。该串流入 last_error(明文落库)、投递历史接口
  (绕过渠道掩码)、服务端日志与前端错误提示。现统一脱敏为 scheme://host
  + 底层原因,并拒绝跨主机重定向。
- 最低级别门槛打错字会让过滤器静默失效:未知级别序数为 0,判定退化成
  rank >= 0 恒真,用户以为限制「仅高危」实际全推。现于写入路径校验取值。
- 汇总批次无上限:一次领取把全部待发行读进内存、消息被渠道长度上限截掉大半
  且静默丢失。现单批上限 500 条,超出部分留库成为下一批。
- 邮件渠道把 SMTP 4xx(灰名单等临时失败)判成永久失败,导致这类最该重试的
  场景从不重试。现按应答码首位区分 4xx/5xx。

测试:notify 包 82% 覆盖(含用自建最小 SMTP 服务器驱动的完整邮件协议会话、
两种签名算法对 OpenSSL 基准值的比对、UTF-8 边界截断、以及模板能力边界的
反射断言);db 与 server 侧共 29 个用例跑真实 PostgreSQL,其中端到端用例
真发 HTTP 到假接收端并断言消息内容与投递状态。

Co-Authored-By: Claude Code <[email protected]>
This commit is contained in:
luckoneandClaude Code committed 2026-09-30 16:25:06 +08:00
1 parent 18589b270e
commit f3fb37f70d
41 files changed
+8740 -5

No files matched your search

+424
View File
@@ -0,0 +1,424 @@
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/notify"
)
// 全局设置键(存在 settings 键值表里,无需建表)。
const (
// settingNotifyEnabled 是推送总开关。默认开:它用于维护期一键止血,
// 而不是功能的启用条件——真正的启用条件是「有没有配渠道」。
settingNotifyEnabled = "notify_enabled"
// settingNotifyPublicBaseURL 是生成漏洞详情回链的外部访问地址
// (如 https://artex.example.com)。留空则消息里不带回链按钮。
// 项目里没有可复用的外部地址配置,所以这里新增一项。
settingNotifyPublicBaseURL = "notify_public_base_url"
// settingNotifyDigestMinutes 是汇总模式的周期(分钟)。
settingNotifyDigestMinutes = "notify_digest_interval_min"
)
const (
// notifyTick 是投递引擎的轮询间隔。3 秒是该引擎实时性的上限,
// 也是「漏洞落库」到「消息到达 IM」之间的主要延迟来源。
notifyTick = 3 * time.Second
// notifyLease 是领取投递时的租约时长。必须显著大于单次投递的最坏耗时
// (notify 包的 HTTP 客户端超时 15 秒),否则会出现同一行被两个
// dispatcher 同时投递。
notifyLease = 3 * time.Minute
// notifyFanOutPerTick 限制每轮分派的事件数,避免首次启用渠道时
// 一次性把历史积压全部展开成投递任务。
notifyFanOutPerTick = 200
// notifyDefaultDigestMinutes 是汇总周期的默认值。
notifyDefaultDigestMinutes = 30
// notifyUnlimitedBurstPerTick 是渠道未设限流时的每轮投递上限。
// 存在的意义是防止「一个渠道配成不限流 + 一次扫出上千条漏洞」把
// 单轮循环拖成长时间阻塞。
notifyUnlimitedBurstPerTick = 50
)
// notifyBackoff 是失败重试的退避序列,下标为已尝试次数。
// 3 次机会(含首次)与 db.MaxNotifyAttempts 对应,两者必须一起改。
var notifyBackoff = []time.Duration{
time.Second,
5 * time.Second,
30 * time.Second,
}
// Notifier 是漏洞推送的投递引擎。
//
// 与 Scheduler 并列,作为独立 goroutine 运行(见 server.New)。刻意不复用
// Scheduler 的 tick:推送的实时性要求(3 秒)与触发器的业务节奏不同,
// 且两者的失败互不牵连——推送卡住不该影响 agent 触发。
type Notifier struct {
s *Server
pg *db.DB
// mu 保护 buckets。渠道数量少、竞争低,一把互斥锁足够,
// 不值得为它引入更细粒度的结构。
mu sync.Mutex
buckets map[int64]*notifyBucket
}
// notifyBucket 是单渠道的令牌桶。
//
// 用令牌桶而不是「每分钟计数后清零」的滑动窗口,是因为后者的边界效应很糟:
// 在窗口末尾发满 20 条、下一瞬间再发 20 条,对平台来说是一秒内 40 条,
// 会被限流;令牌桶以恒定速率补充,天然避免这种突发。
type notifyBucket struct {
tokens float64
lastFill time.Time
}
func newNotifier(s *Server) *Notifier {
return &Notifier{s: s, pg: s.m.pg, buckets: map[int64]*notifyBucket{}}
}
// Run 循环直到 ctx 结束。由 server.New 启动一次。
func (n *Notifier) Run(ctx context.Context) {
if n.pg == nil {
return
}
t := time.NewTicker(notifyTick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
n.step(ctx)
}
}
}
// step 跑一轮:先分派新事件,再投递到期的任务。
//
// 任何一步失败都只记日志、不中断循环——通知系统的故障绝不能升级成进程级问题。
// 每个 tick 都是独立的,下一轮会自然重试。
func (n *Notifier) step(ctx context.Context) {
if !n.enabled() {
return
}
if _, _, err := n.pg.FanOutPendingEvents(ctx, notifyFanOutPerTick); err != nil {
log.Printf("[notify] 分派事件失败: %v", err)
return
}
channels, err := n.pg.ListNotificationChannels(ctx)
if err != nil {
log.Printf("[notify] 读取渠道失败: %v", err)
return
}
baseURL := n.publicBaseURL()
for _, ch := range channels {
if !ch.IsEnabled() {
continue
}
// 先问令牌桶这一轮还能发几条,再按这个数量去领——顺序不能反,
// 否则被限流挡下的投递已经消耗过重试次数。
allow := n.takeTokens(ch.ID, ch.RatePerMin, time.Now())
if allow <= 0 {
continue
}
if ch.Mode == db.NotifyModeDigest {
n.stepDigest(ctx, ch, baseURL)
continue
}
n.stepRealtime(ctx, ch, allow, baseURL)
}
}
// stepRealtime 领取并投递某渠道的实时任务,一条漏洞一条消息。
func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel, allow int, baseURL string) {
deliveries, err := n.pg.ClaimRealtimeDeliveries(ctx, ch.ID, allow, notifyLease)
if err != nil {
log.Printf("[notify] 领取实时投递失败 channel=%d: %v", ch.ID, err)
return
}
if len(deliveries) == 0 {
return
}
channel, cfg, ok := n.adapt(ch)
if !ok {
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
return
}
for _, dl := range deliveries {
msg, err := n.renderSingle(ctx, dl, baseURL)
if err != nil {
// 渲染失败是本地数据问题,重试不会变好。
_ = n.pg.FailDeliveries(ctx, []int64{dl.ID}, err.Error())
continue
}
n.send(ctx, channel, cfg, msg, []*db.NotificationDelivery{dl})
}
}
// stepDigest 在批次到期时把某渠道的待发投递聚合成一条消息发出。
func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, baseURL string) {
window := n.digestInterval()
due, err := n.pg.DigestBatchDue(ctx, ch.ID, window)
if err != nil {
log.Printf("[notify] 判断汇总批次失败 channel=%d: %v", ch.ID, err)
return
}
if !due {
return
}
deliveries, err := n.pg.ClaimDigestBatch(ctx, ch.ID, notifyLease)
if err != nil {
log.Printf("[notify] 领取汇总批次失败 channel=%d: %v", ch.ID, err)
return
}
if len(deliveries) == 0 {
return
}
channel, cfg, ok := n.adapt(ch)
if !ok {
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
return
}
msg, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()))
if err != nil {
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error())
return
}
n.send(ctx, channel, cfg, msg, deliveries)
}
// send 投递并按结果流转状态。
//
// 同一批投递(汇总模式下可能几十条)共享一个发送结果:要么整批送达、要么整批
// 重试。不做逐条重试——汇总消息是一条,重发其中一部分会让批次语义错乱。
func (n *Notifier) send(ctx context.Context, channel notify.Channel, cfg map[string]any, msg notify.Message, deliveries []*db.NotificationDelivery) {
ids := deliveryIDs(deliveries)
attempts := maxAttempts(deliveries)
// 单次投递设上限,避免某个渠道卡住把这一轮剩余渠道全部拖住。
sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
err := channel.Send(sendCtx, cfg, msg)
if err == nil {
if err := n.pg.MarkDeliveriesSent(ctx, ids); err != nil {
log.Printf("[notify] 标记已送达失败 channel=%s ids=%v: %v", channel.Kind(), ids, err)
}
return
}
// 永久失败与「重试次数用尽」都直接落 failed,等人工在投递历史里重发;
// 其余按退避序列重排。
if notify.IsPermanent(err) || attempts >= db.MaxNotifyAttempts {
reason := err.Error()
if !notify.IsPermanent(err) {
reason = fmt.Sprintf("重试 %d 次后仍失败: %s", attempts, err)
}
if failErr := n.pg.FailDeliveries(ctx, ids, reason); failErr != nil {
log.Printf("[notify] 标记失败状态出错 channel=%s ids=%v: %v", channel.Kind(), ids, failErr)
}
log.Printf("[notify] 投递失败 channel=%d kind=%s ids=%v: %s", deliveries[0].ChannelID, channel.Kind(), ids, reason)
return
}
delay := notifyBackoff[min(attempts, len(notifyBackoff)-1)]
if rErr := n.pg.RescheduleDeliveries(ctx, ids, delay, err.Error()); rErr != nil {
log.Printf("[notify] 重排投递失败 channel=%s ids=%v: %v", channel.Kind(), ids, rErr)
}
}
// adapt 取渠道实现并解析其配置。
// 返回 ok=false 表示类型未注册,投递应直接判失败而不是无限重试。
func (n *Notifier) adapt(ch *db.NotificationChannel) (notify.Channel, map[string]any, bool) {
channel, ok := notify.Get(ch.Kind)
if !ok {
return nil, nil, false
}
var cfg map[string]any
if len(ch.Config) > 0 {
// 配置解析失败时给一个空 map:渠道自身的 Validate 会报出「缺哪个字段」,
// 那个错误比 JSON 解析错误更能指导用户修复。
_ = json.Unmarshal(ch.Config, &cfg)
}
if cfg == nil {
cfg = map[string]any{}
}
return channel, cfg, true
}
// renderSingle 渲染单条漏洞消息。
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string) (notify.Message, error) {
snap, err := parseSnapshot(dl)
if err != nil {
return notify.Message{}, err
}
item, err := n.itemFor(ctx, snap, baseURL)
if err != nil {
return notify.Message{}, err
}
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL}, nil
}
// renderBatch 渲染汇总消息。逐条解析快照——单条坏了只跳过那一条,
// 不让它把整批汇总拖没。
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, error) {
items := make([]notify.Item, 0, len(deliveries))
for _, dl := range deliveries {
snap, err := parseSnapshot(dl)
if err != nil {
log.Printf("[notify] 汇总批次中跳过无法解析的快照 delivery=%d: %v", dl.ID, err)
continue
}
item, err := n.itemFor(ctx, snap, baseURL)
if err != nil {
return notify.Message{}, err
}
items = append(items, item)
}
if len(items) == 0 {
return notify.Message{}, fmt.Errorf("汇总批次 %d 条投递全部无法解析", len(deliveries))
}
return notify.Message{
Items: items,
Batch: true,
WindowMinutes: windowMinutes,
HomeURL: baseURL,
}, nil
}
// itemFor 把事件快照渲染成待推送条目,顺带解析资产名与详情回链。
func (n *Notifier) itemFor(ctx context.Context, snap notify.Snapshot, baseURL string) (notify.Item, error) {
assets, err := n.pg.NotificationAssetNames(ctx, snap.AssetIDs)
if err != nil {
// 资产名解析失败不该阻止推送:读不到名字比收不到通知轻得多,
// 消息里少一行资产而已。
log.Printf("[notify] 解析资产名失败 finding=%d: %v", snap.FindingID, err)
}
item := notify.Item{
FindingID: snap.FindingID,
Name: snap.Name,
VulnClass: snap.VulnClass,
Severity: snap.Severity,
Summary: snap.Summary,
Assets: assets,
FromStatus: snap.FromStatus,
ToStatus: snap.ToStatus,
}
if baseURL != "" {
// 详情页路由见 web/src/app/(main)/function/findings/detail/page.tsx,
// 它从 query 参数 id 读取漏洞 id。
item.DetailURL = fmt.Sprintf("%s/function/findings/detail?id=%d", baseURL, snap.FindingID)
}
return item, nil
}
// takeTokens 从渠道令牌桶里取出本轮可投递的条数。
//
// 桶容量为该渠道每分钟上限:积压时最多一次性冲这么多(平台规则允许),
// 之后按恒定速率补充。ratePerMin<=0 表示不限流,返回一个有限但足够大的值,
// 防止单轮循环被无限积压拖住。
func (n *Notifier) takeTokens(channelID int64, ratePerMin int, now time.Time) int {
if ratePerMin <= 0 {
return notifyUnlimitedBurstPerTick
}
n.mu.Lock()
defer n.mu.Unlock()
b := n.buckets[channelID]
if b == nil {
b = &notifyBucket{tokens: float64(ratePerMin), lastFill: now}
n.buckets[channelID] = b
}
// 按经过的真实时间补充,速率是 ratePerMin/60 每秒。
if elapsed := now.Sub(b.lastFill).Seconds(); elapsed > 0 {
b.tokens = minF(float64(ratePerMin), b.tokens+elapsed*float64(ratePerMin)/60)
b.lastFill = now
}
// 加一个极小 epsilon 再取整:令牌数是浮点累加出来的,分两次补满时
// 0.5 + 0.5 可能得到 0.9999999999,直接 int() 会被截成 0——
// 数学上已满的桶却取不出令牌。1e-9 远小于一个令牌,不会放过真正的欠额。
take := int(b.tokens + 1e-9)
if take <= 0 {
return 0
}
b.tokens -= float64(take)
return take
}
// enabled 读取总开关。
func (n *Notifier) enabled() bool {
return n.pg.GetBool(settingNotifyEnabled, true)
}
// publicBaseURL 返回回链用的外部地址,去掉尾部斜杠。
func (n *Notifier) publicBaseURL() string {
v, ok, err := n.pg.GetSetting(settingNotifyPublicBaseURL)
if err != nil || !ok {
return ""
}
return trimTrailingSlash(v)
}
// digestInterval 返回汇总周期,非法或未配置时回落到默认值。
func (n *Notifier) digestInterval() time.Duration {
v, ok, err := n.pg.GetSetting(settingNotifyDigestMinutes)
if err != nil || !ok {
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
}
m := 0
if _, err := fmt.Sscanf(v, "%d", &m); err != nil || m <= 0 {
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
}
return time.Duration(m) * time.Minute
}
// parseSnapshot 解析投递对应事件的快照。
func parseSnapshot(dl *db.NotificationDelivery) (notify.Snapshot, error) {
var snap notify.Snapshot
if len(dl.Snapshot) == 0 {
return snap, fmt.Errorf("投递 %d 的事件快照为空", dl.ID)
}
if err := json.Unmarshal(dl.Snapshot, &snap); err != nil {
return snap, fmt.Errorf("解析投递 %d 的事件快照失败: %w", dl.ID, err)
}
if snap.Kind == "" {
// 事件类型以事件行为准,快照里那份可能由旧版本写过。
snap.Kind = dl.EventKind
}
return snap, nil
}
func deliveryIDs(deliveries []*db.NotificationDelivery) []int64 {
out := make([]int64, 0, len(deliveries))
for _, dl := range deliveries {
out = append(out, dl.ID)
}
return out
}
// maxAttempts 取批次里最大的已尝试次数。批次会一起成败,用最大值决定是否
// 还有重试预算,避免新加入的行被老行的次数拖下水(反之亦然)。
func maxAttempts(deliveries []*db.NotificationDelivery) int {
m := 0
for _, dl := range deliveries {
if dl.Attempts > m {
m = dl.Attempts
}
}
return m
}
func trimTrailingSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
func minF(a, b float64) float64 {
if a < b {
return a
}
return b
}
+500
View File
@@ -0,0 +1,500 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/notify"
)
// 本文件是推送功能的 HTTP 接口。全部路由挂在 requireAuth 之后(见 Handler()),
// 与其它管理接口一致。
// notifyChannelDTO 是渠道的对外表述。
//
// Config 是**掩码后**的配置:凭据字段被替换成 notify.MaskedPrefix 开头的值。
// 前端把掩码值原样提交回来即表示「这个字段没改」,服务端据此保留库中原值
// (见 notify.MergeConfig)。
type notifyChannelDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Config map[string]any `json:"config"`
Filter notify.Filter `json:"filter"`
RatePerMin int `json:"rate_per_min"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// SecretKeys 告知前端哪些字段是凭据,据此渲染密码框与「留空即不改」的提示。
// 由渠道自己声明(notify.Channel.SecretKeys),前端不硬编码渠道知识。
SecretKeys []string `json:"secret_keys"`
}
// notifyDeliveryDTO 是投递历史的对外表述。
type notifyDeliveryDTO struct {
ID int64 `json:"id"`
FindingID int64 `json:"finding_id,string"`
EventKind string `json:"event_kind"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelKind string `json:"channel_kind"`
State string `json:"state"`
Attempts int `json:"attempts"`
LastError string `json:"last_error"`
BatchID *int64 `json:"batch_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
SentAt *time.Time `json:"sent_at,omitempty"`
NextAttempt time.Time `json:"next_attempt_at"`
// 消息标题摘要,让历史列表不必展开就能看出这条推的是什么。
Title string `json:"title"`
Severity string `json:"severity"`
}
func toNotifyChannelDTO(ch *db.NotificationChannel) notifyChannelDTO {
var cfg map[string]any
if len(ch.Config) > 0 {
_ = json.Unmarshal(ch.Config, &cfg)
}
if cfg == nil {
cfg = map[string]any{}
}
secrets := []string{}
if c, ok := notify.Get(ch.Kind); ok {
secrets = c.SecretKeys()
}
return notifyChannelDTO{
ID: ch.ID,
Name: ch.Name,
Kind: ch.Kind,
Enabled: ch.IsEnabled(),
Mode: ch.Mode,
Config: notify.MaskConfig(ch.Kind, cfg),
Filter: notify.ParseFilter(ch.Filter),
RatePerMin: ch.RatePerMin,
CreatedAt: ch.CreatedAt,
UpdatedAt: ch.UpdatedAt,
SecretKeys: secrets,
}
}
func toNotifyDeliveryDTO(dl *db.NotificationDelivery) notifyDeliveryDTO {
snap, _ := parseSnapshot(dl)
dto := notifyDeliveryDTO{
ID: dl.ID,
FindingID: dl.FindingID,
EventKind: dl.EventKind,
ChannelID: dl.ChannelID,
ChannelName: dl.ChannelName,
ChannelKind: dl.ChannelKind,
State: dl.State,
Attempts: dl.Attempts,
LastError: dl.LastError,
BatchID: dl.BatchID,
CreatedAt: dl.CreatedAt,
SentAt: dl.SentAt,
NextAttempt: dl.NextAttemptAt,
Severity: snap.Severity,
}
if snap.Name != "" {
dto.Title = snap.Name
} else {
dto.Title = snap.VulnClass
}
return dto
}
// notifyMeta 返回通知页需要的静态元数据与全局设置,一次请求拿全,
// 避免前端为了渲染一个下拉框发三次请求。
func (s *Server) notifyMeta(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
kinds := make([]map[string]any, 0, len(notify.Kinds()))
for _, k := range notify.Kinds() {
ch, _ := notify.Get(k)
kinds = append(kinds, map[string]any{
"kind": k,
"default_rate_per_min": ch.DefaultRatePerMin(),
"secret_keys": ch.SecretKeys(),
})
}
baseURL, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
digest, _, _ := pg.GetSetting(settingNotifyDigestMinutes)
stats, err := pg.NotificationStatsSnapshot(r.Context())
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{
"kinds": kinds,
"enabled": pg.GetBool(settingNotifyEnabled, true),
"public_base_url": baseURL,
"digest_interval_min": digest,
"defaults": map[string]any{
"digest_interval_min": notifyDefaultDigestMinutes,
},
"stats": stats,
})
}
func (s *Server) notifyListChannels(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
channels, err := pg.ListNotificationChannels(r.Context())
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]notifyChannelDTO, 0, len(channels))
for _, ch := range channels {
out = append(out, toNotifyChannelDTO(ch))
}
writeJSON(w, 200, map[string]any{"channels": out})
}
// notifyChannelRequest 是新建/更新渠道的请求体。
//
// 全部业务字段用指针,以便区分「没传」与「传了零值」:PATCH 语义下,
// 没传的字段必须保留库中原值。
type notifyChannelRequest struct {
Name *string `json:"name"`
Kind *string `json:"kind"`
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
Config map[string]any `json:"config"`
Filter *notify.Filter `json:"filter"`
RatePerMin *int `json:"rate_per_min"`
}
func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
var req notifyChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
return
}
if req.Kind == nil || !notify.ValidKind(*req.Kind) {
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
return
}
name := ""
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if name == "" {
writeErr(w, 400, "缺少渠道名称")
return
}
channel, _ := notify.Get(*req.Kind)
if err := channel.Validate(req.Config); err != nil {
writeErr(w, 400, err.Error())
return
}
ch := &db.NotificationChannel{
Name: name,
Kind: *req.Kind,
Enabled: req.Enabled,
Mode: db.NotifyModeRealtime,
RatePerMin: channel.DefaultRatePerMin(),
}
if req.Mode != nil {
if !db.ValidNotifyMode(*req.Mode) {
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
return
}
ch.Mode = *req.Mode
}
if req.RatePerMin != nil {
if *req.RatePerMin < 0 {
writeErr(w, 400, "限流值不能为负")
return
}
ch.RatePerMin = *req.RatePerMin
}
if req.Filter != nil {
// 写入时校验取值受限的过滤字段(如 min_severity)。详见 notify.Filter.Validate:
// 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。
if err := req.Filter.Validate(); err != nil {
writeErr(w, 400, err.Error())
return
}
raw, _ := json.Marshal(req.Filter)
ch.Filter = raw
}
rawCfg, _ := json.Marshal(req.Config)
ch.Config = rawCfg
id, err := pg.SaveNotificationChannel(r.Context(), ch)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"id": id})
}
func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "渠道 id 无效")
return
}
current, err := pg.NotificationChannelByID(r.Context(), id)
if err != nil {
notifyChannelLookupErr(w, err)
return
}
var req notifyChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
return
}
// kind 允许修改,但改类型意味着凭据字段整套替换,不能与旧配置合并。
kind := current.Kind
if req.Kind != nil {
if !notify.ValidKind(*req.Kind) {
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
return
}
kind = *req.Kind
}
channel, _ := notify.Get(kind)
var stored map[string]any
if kind == current.Kind {
if len(current.Config) > 0 {
_ = json.Unmarshal(current.Config, &stored)
}
}
if stored == nil {
stored = map[string]any{}
}
merged := notify.MergeConfig(stored, req.Config)
if err := channel.Validate(merged); err != nil {
writeErr(w, 400, err.Error())
return
}
rawCfg, _ := json.Marshal(merged)
ch := &db.NotificationChannel{
ID: id,
Name: current.Name,
Kind: kind,
Enabled: current.Enabled,
Mode: current.Mode,
Config: rawCfg,
Filter: current.Filter,
RatePerMin: current.RatePerMin,
}
if req.Name != nil {
if ch.Name = strings.TrimSpace(*req.Name); ch.Name == "" {
writeErr(w, 400, "渠道名称不能为空")
return
}
}
if req.Enabled != nil {
ch.Enabled = req.Enabled
}
if req.Mode != nil {
if !db.ValidNotifyMode(*req.Mode) {
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
return
}
ch.Mode = *req.Mode
}
if req.RatePerMin != nil {
if *req.RatePerMin < 0 {
writeErr(w, 400, "限流值不能为负")
return
}
ch.RatePerMin = *req.RatePerMin
}
if req.Filter != nil {
if err := req.Filter.Validate(); err != nil {
writeErr(w, 400, err.Error())
return
}
raw, _ := json.Marshal(req.Filter)
ch.Filter = raw
}
// 走 SetNotificationChannelEnabled 而非 SaveNotificationChannel 的路径,
// 是为了让「停用」同时把存量待发投递标记为 skipped,避免重新启用时收到
// 一批已过时的积压消息。
enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled
if enabledChanged {
// 先把配置更新落库(此时 enabled 用旧值,避免提前触发跳过逻辑),
// 再单独切开关。两步之间没有并发窗口:本接口是唯一改这两个字段的入口。
prev := ch.Enabled
ch.Enabled = current.Enabled
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := pg.SetNotificationChannelEnabled(r.Context(), id, *prev); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"id": id})
return
}
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"id": id})
}
func (s *Server) notifyDeleteChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "渠道 id 无效")
return
}
if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil {
notifyChannelLookupErr(w, err)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// notifyTestChannel 用当前保存的配置发一条测试消息。
//
// 直接调用渠道 Send 而不经投递队列:测试的目的是立刻告诉用户「这套配置能不能
// 发出去」,走队列会把结果藏进投递历史,用户得再去翻一遍才知道成没成。
// 因此本接口是**同步**的,超时上限由 notify 包的 HTTP 客户端决定(15 秒)。
func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "渠道 id 无效")
return
}
ch, err := pg.NotificationChannelByID(r.Context(), id)
if err != nil {
notifyChannelLookupErr(w, err)
return
}
channel, ok := notify.Get(ch.Kind)
if !ok {
writeErr(w, 400, fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
return
}
var cfg map[string]any
if len(ch.Config) > 0 {
_ = json.Unmarshal(ch.Config, &cfg)
}
if err := channel.Validate(cfg); err != nil {
writeErr(w, 400, err.Error())
return
}
msg := notifyTestMessage(s.notifierBaseURL(pg))
start := time.Now()
if err := channel.Send(r.Context(), cfg, msg); err != nil {
// 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。
writeErr(w, 502, err.Error())
return
}
writeJSON(w, 200, map[string]any{
"ok": true,
"latency_ms": time.Since(start).Milliseconds(),
})
}
// notifyTestMessage 构造测试消息。刻意用一眼能看出是测试的内容:
// 收到的人不应该把它误判成真实漏洞。
func notifyTestMessage(baseURL string) notify.Message {
return notify.Message{
Items: []notify.Item{{
FindingID: 0,
Name: "测试消息 · 渠道配置正常",
VulnClass: "连通性测试",
Severity: "low",
Summary: "这是 ARTEX 推送渠道的测试消息,收到即表示该渠道配置可用。",
Assets: []string{"artex.example.com"},
DetailURL: baseURL,
}},
HomeURL: baseURL,
}
}
// notifierBaseURL 读回链用的外部地址。
func (s *Server) notifierBaseURL(pg *db.DB) string {
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
return trimTrailingSlash(v)
}
func (s *Server) notifyListDeliveries(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
f := db.NotificationDeliveryFilter{
State: r.URL.Query().Get("state"),
EventKind: r.URL.Query().Get("event_kind"),
}
if v := r.URL.Query().Get("channel_id"); v != "" {
f.ChannelID = int64(atoiDefault(v, 0))
}
page := queryInt(r, "page", 1)
pageSize := queryInt(r, "page_size", 50)
items, total, err := pg.ListNotificationDeliveries(r.Context(), f, page, pageSize)
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]notifyDeliveryDTO, 0, len(items))
for _, dl := range items {
out = append(out, toNotifyDeliveryDTO(dl))
}
writeJSON(w, 200, map[string]any{"deliveries": out, "total": total, "page": page, "page_size": pageSize})
}
func (s *Server) notifyRetryDelivery(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "投递 id 无效")
return
}
if err := pg.RetryNotificationDelivery(r.Context(), id); err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// notifyChannelLookupErr 把「渠道不存在」翻译成 404,其余错误 500。
func notifyChannelLookupErr(w http.ResponseWriter, err error) {
if errors.Is(err, db.ErrNotificationChannelNotFound) {
writeErr(w, 404, "通知渠道不存在")
return
}
writeErr(w, 500, err.Error())
}
+765
View File
@@ -0,0 +1,765 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/notify"
)
// 本文件覆盖推送功能的端到端行为:漏洞落库 → 事件 → 分派 → 真发 HTTP。
//
// 一个安全上的注意点:这些用例**不调用全局的 Notifier.step()**,只对自己创建的
// 渠道调用 stepRealtime/stepDigest。原因是 step() 会遍历库里所有启用渠道——
// 在一个已配好真实钉钉/企微机器人的开发库上跑测试,全局 step 会把测试期间
// 产生的漏洞真推到那些群里。逐渠道调用把影响面严格限制在测试自造的假接收端上。
//
// 清理:用例结束时删掉本用例产生的事件(级联删投递)与渠道,不给真实渠道留积压。
//
// 断言口径:stepRealtime/stepDigest 不返回值、内部记日志,因此这里断言的是
// **可观测的外部行为**(假接收端收到了什么、投递行落到什么状态),而不是函数的
// 返回值——这比对返回值打桩更接近真实调用路径。
// notifyFixture 是本文件用例的公共装置。
type notifyFixture struct {
s *Server
pg *db.DB
request func(method, path, body string) *httptest.ResponseRecorder
n *Notifier
// 自建的 task/exploration:用例把漏洞记到这里,与其它用例的数据隔离。
taskID int64
expID int64
// cleanupMark 之后产生的事件在清理时一并删除。
cleanupMark int64
}
func newNotifyFixture(t *testing.T) *notifyFixture {
t.Helper()
s, _, request := trafficEvidenceServer(t)
pg := s.m.pg
// 自建一个 task:共享装置 trafficEvidenceServer 造的 task 拿不到 exploration id,
// 而记录漏洞必须提供它。
task, err := s.m.CreateTask("通知推送测试", "推送行为验证", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
taskID, err := strconv.ParseInt(task.ID, 10, 64)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pg.Exec(`DELETE FROM tasks WHERE id=$1`, taskID) })
var mark int64
if err := pg.QueryRow(`SELECT COALESCE(max(id),0) FROM notification_events`).Scan(&mark); err != nil {
t.Fatal(err)
}
// 让装置自成闭环:把建 fixture 之前就存在的事件一次性标记为已分派。
//
// 为什么必须做:FanOutPendingEvents 是**全局**的,会把库里所有未分派事件
// 展开到所有匹配渠道上。而共享装置 trafficEvidenceServer 自己就会记一条漏洞
// (正是它返回的那个初始 finding),其它用例也可能有残留。不隔离的话,
// 这些杂散事件会被分派到本用例的渠道上,让「应有 N 条投递」这类断言
// 时对时错——而且错法取决于用例执行顺序,比直接失败更难查。
if _, err := pg.Exec(`UPDATE notification_events SET fanned_out = true WHERE id <= $1 AND NOT fanned_out`, mark); err != nil {
t.Fatal(err)
}
f := &notifyFixture{s: s, pg: pg, request: request, n: newNotifier(s), taskID: taskID, expID: task.ExpID, cleanupMark: mark}
t.Cleanup(func() {
if _, err := pg.Exec(`DELETE FROM notification_events WHERE id > $1`, f.cleanupMark); err != nil {
t.Logf("清理通知事件失败: %v", err)
}
})
// 总开关必须是开的(其它用例可能关过它)。
if err := pg.SetBool(settingNotifyEnabled, true); err != nil {
t.Fatal(err)
}
return f
}
// record 走真实的证据写入路径落一条漏洞,返回 finding id。
// 这条路会在**同一事务**里登记推送事件——正是本功能的挂点。
func (f *notifyFixture) record(t *testing.T, vulnclass, severity string) int64 {
t.Helper()
out, err := f.s.evidenceStore().Record(context.Background(), db.RecordFindingInput{
TaskID: f.taskID,
ExplorationID: f.expID,
Worker: "test",
VulnClass: vulnclass,
Name: vulnclass,
Severity: severity,
Summary: vulnclass + " 的摘要",
Evidence: "poc",
}, nil)
if err != nil {
t.Fatalf("记录漏洞失败: %v", err)
}
return out.FindingID
}
// channel 读回渠道配置(供逐渠道调用 stepX 使用)。
func (f *notifyFixture) channel(t *testing.T, id int64) *db.NotificationChannel {
t.Helper()
ch, err := f.pg.NotificationChannelByID(context.Background(), id)
if err != nil {
t.Fatalf("读渠道失败: %v", err)
}
return ch
}
// deliver 分派事件并只对指定渠道跑一轮投递。
func (f *notifyFixture) deliver(t *testing.T, chID int64, baseURL string) {
t.Helper()
ctx := context.Background()
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
t.Fatalf("分派失败: %v", err)
}
f.n.stepRealtime(ctx, f.channel(t, chID), 50, baseURL)
}
// createChannel 通过 HTTP 接口建渠道,顺带覆盖接口自身的校验路径。
func (f *notifyFixture) createChannel(t *testing.T, payload map[string]any) int64 {
t.Helper()
raw, _ := json.Marshal(payload)
r := f.request("POST", "/api/notify/channels", string(raw))
if r.Code != 200 {
t.Fatalf("建渠道失败 %d: %s", r.Code, r.Body)
}
var res struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(r.Body.Bytes(), &res); err != nil || res.ID == 0 {
t.Fatalf("建渠道返回异常: %s (%v)", r.Body, err)
}
t.Cleanup(func() { f.pg.Exec(`DELETE FROM notification_channels WHERE id=$1`, res.ID) })
return res.ID
}
// fakeWebhook 是记录收到的请求体的假接收端。
type fakeWebhook struct {
*httptest.Server
mu sync.Mutex
bodies []map[string]any
}
func newFakeWebhook(t *testing.T) *fakeWebhook {
t.Helper()
f := &fakeWebhook{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(raw, &body)
f.mu.Lock()
f.bodies = append(f.bodies, body)
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
}))
t.Cleanup(f.Close)
return f
}
func (f *fakeWebhook) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.bodies)
}
func (f *fakeWebhook) body(t *testing.T, i int) map[string]any {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if i >= len(f.bodies) {
t.Fatalf("假接收端只收到 %d 条请求,取不到第 %d 条", len(f.bodies), i)
}
return f.bodies[i]
}
func (f *fakeWebhook) last(t *testing.T) map[string]any {
t.Helper()
if f.count() == 0 {
t.Fatal("假接收端没有收到任何请求")
}
return f.body(t, f.count()-1)
}
// markdownText 从钉钉 markdown/actionCard 请求体里取出正文。
func markdownText(t *testing.T, body map[string]any) string {
t.Helper()
if md, ok := body["markdown"].(map[string]any); ok {
s, _ := md["text"].(string)
return s
}
if card, ok := body["actionCard"].(map[string]any); ok {
s, _ := card["text"].(string)
return s
}
t.Fatalf("请求体里没有 markdown/actionCard 正文: %v", body)
return ""
}
// agePendingBatch 把该渠道的待发投递催老,用于测试汇总批次到期。
func (f *notifyFixture) agePendingBatch(t *testing.T, chID int64) {
t.Helper()
if _, err := f.pg.Exec(`UPDATE notification_deliveries SET created_at = now() - interval '2 hours'
WHERE channel_id=$1 AND state=$2`, chID, db.NotifyStatePending); err != nil {
t.Fatal(err)
}
}
func TestNotifyEndToEndRealtimeDelivery(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "实时推送",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
})
f.record(t, "SQL注入", "high")
f.deliver(t, chID, "")
if hook.count() != 1 {
t.Fatalf("应发出 1 条消息,实际 %d", hook.count())
}
text := markdownText(t, hook.last(t))
for _, want := range []string{"SQL注入", "高危", "摘要"} {
if !strings.Contains(text, want) {
t.Fatalf("消息正文缺少 %q:\n%s", want, text)
}
}
// 投递应流转为 sent。
var pending int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1 AND state <> $2`,
chID, db.NotifyStateSent).Scan(&pending); err != nil {
t.Fatal(err)
}
if pending != 0 {
t.Fatalf("投递后仍有 %d 条未标记 sent", pending)
}
}
func TestNotifyChannelAPIMasksSecretsAndPreservesOnUpdate(t *testing.T) {
f := newNotifyFixture(t)
chID := f.createChannel(t, map[string]any{
"name": "掩码用例",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=abc123456", "secret": "SECabcdef123456"},
})
r := f.request("GET", "/api/notify/channels", "")
if r.Code != 200 {
t.Fatalf("列渠道失败 %d: %s", r.Code, r.Body)
}
if strings.Contains(r.Body.String(), "abc123456") || strings.Contains(r.Body.String(), "SECabcdef123456") {
t.Fatalf("接口回显泄露了凭据: %s", r.Body)
}
var listed struct {
Channels []struct {
ID int64 `json:"id"`
Config map[string]any `json:"config"`
SecretKeys []string `json:"secret_keys"`
} `json:"channels"`
}
if err := json.Unmarshal(r.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
var mine *struct {
ID int64 `json:"id"`
Config map[string]any `json:"config"`
SecretKeys []string `json:"secret_keys"`
}
for i := range listed.Channels {
if listed.Channels[i].ID == chID {
mine = &listed.Channels[i]
}
}
if mine == nil {
t.Fatal("新建的渠道未出现在列表里")
}
if !notify.IsMasked(fmt.Sprint(mine.Config["webhook"])) || !notify.IsMasked(fmt.Sprint(mine.Config["secret"])) {
t.Fatalf("凭据字段应为掩码值: %v", mine.Config)
}
if len(mine.SecretKeys) == 0 {
t.Fatal("接口应告知前端哪些字段是凭据")
}
// PATCH 只改名 + 回传掩码凭据:真凭据必须原样保留。
body, _ := json.Marshal(map[string]any{
"name": "改名后",
"config": map[string]any{"webhook": fmt.Sprint(mine.Config["webhook"]), "secret": fmt.Sprint(mine.Config["secret"])},
})
if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 {
t.Fatalf("更新失败 %d: %s", r.Code, r.Body)
}
cfg := f.channelConfig(t, chID)
if cfg["webhook"] != "https://oapi.dingtalk.com/robot/send?access_token=abc123456" {
t.Fatalf("掩码回传把真凭据覆盖了: %v", cfg["webhook"])
}
if cfg["secret"] != "SECabcdef123456" {
t.Fatalf("掩码回传把 secret 覆盖了: %v", cfg["secret"])
}
if f.channel(t, chID).Name != "改名后" {
t.Fatal("名字未更新")
}
// 显式清空 secret 应生效(区别于「回传掩码=保持不变」)。
body, _ = json.Marshal(map[string]any{"config": map[string]any{"secret": ""}})
if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 {
t.Fatalf("清空 secret 失败 %d: %s", r.Code, r.Body)
}
if _, still := f.channelConfig(t, chID)["secret"]; still {
t.Fatal("空串应清空 secret")
}
}
func (f *notifyFixture) channelConfig(t *testing.T, id int64) map[string]any {
t.Helper()
var cfg map[string]any
if err := json.Unmarshal(f.channel(t, id).Config, &cfg); err != nil {
t.Fatal(err)
}
return cfg
}
func TestNotifyChannelAPICreateValidation(t *testing.T) {
f := newNotifyFixture(t)
cases := []struct {
name string
payload map[string]any
wantSub string
}{
{"类型非法", map[string]any{"name": "x", "kind": "nope", "config": map[string]any{}}, "渠道类型无效"},
{"缺名称", map[string]any{"kind": notify.KindDingTalk, "config": map[string]any{"webhook": "https://e.com/h"}}, "缺少渠道名称"},
{"缺 webhook", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{}}, "Webhook"},
{"webhook 协议非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "file:///etc/passwd"}}, "Webhook 地址无效"},
{"模式非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "mode": "sometimes", "config": map[string]any{"webhook": "https://e.com/h"}}, "推送模式无效"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw, _ := json.Marshal(tc.payload)
r := f.request("POST", "/api/notify/channels", string(raw))
if r.Code != 400 {
t.Fatalf("应返回 400,得到 %d: %s", r.Code, r.Body)
}
if !strings.Contains(r.Body.String(), tc.wantSub) {
t.Fatalf("错误信息应提到 %q,得到 %s", tc.wantSub, r.Body)
}
})
}
if r := f.request("DELETE", "/api/notify/channels/99999999", ""); r.Code != 404 {
t.Fatalf("删除不存在的渠道应 404,得到 %d", r.Code)
}
}
func TestNotifyFilterBlocksBelowThreshold(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "仅严重",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
"filter": map[string]any{"min_severity": "critical"},
})
f.record(t, "低危问题", "low")
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
t.Fatal(err)
}
var n int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("低于阈值的漏洞不该产生投递,得到 %d 条", n)
}
f.n.stepRealtime(context.Background(), f.channel(t, chID), 50, "")
if hook.count() != 0 {
t.Fatal("被过滤的漏洞不应发出消息")
}
}
func TestNotifyDigestBatchesMultipleFindingsIntoOneMessage(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "汇总推送",
"kind": notify.KindDingTalk,
"mode": db.NotifyModeDigest,
"config": map[string]any{"webhook": hook.URL},
})
for i := 0; i < 3; i++ {
f.record(t, fmt.Sprintf("汇总漏洞%d", i+1), "high")
}
ctx := context.Background()
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
t.Fatal(err)
}
ch := f.channel(t, chID)
// 未到期:不发。
f.n.stepDigest(ctx, ch, "")
if hook.count() != 0 {
t.Fatal("汇总批次未到期就发了")
}
// 催老批次后:三条合成一条消息。
f.agePendingBatch(t, chID)
f.n.stepDigest(ctx, ch, "")
if got := hook.count(); got != 1 {
t.Fatalf("三条应汇总成一条消息,实际发了 %d 条", got)
}
text := markdownText(t, hook.last(t))
if !strings.Contains(text, "近") || !strings.Contains(text, "3 个漏洞") {
t.Fatalf("汇总消息缺少条数/时间窗文案:\n%s", text)
}
for i := 1; i <= 3; i++ {
if !strings.Contains(text, fmt.Sprintf("汇总漏洞%d", i)) {
t.Fatalf("汇总消息缺少第 %d 条:\n%s", i, text)
}
}
// 同一批次应共享 batch_id。
var distinct, total int
if err := f.pg.QueryRow(`SELECT count(DISTINCT batch_id), count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&distinct, &total); err != nil {
t.Fatal(err)
}
if total != 3 || distinct != 1 {
t.Fatalf("三条投递应共享一个 batch_id,得到 distinct=%d total=%d", distinct, total)
}
}
func TestNotifyDisabledChannelDoesNotSend(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "停用渠道",
"kind": notify.KindDingTalk,
"enabled": false,
"config": map[string]any{"webhook": hook.URL},
})
f.record(t, "停用期间的漏洞", "critical")
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
t.Fatal(err)
}
var n int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("停用渠道不该产生投递,得到 %d 条", n)
}
}
func TestNotifyStatusChangeDelivery(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "状态变更订阅",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
"filter": map[string]any{"on_status_change": true},
})
finding := f.record(t, "状态变更用例", "high")
r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"fixed"}`)
if r.Code != 200 {
t.Fatalf("改状态失败 %d: %s", r.Code, r.Body)
}
f.deliver(t, chID, "")
// 应有两条:fixed 那一条是状态变更;finding_created 那条也可能在同一轮发出。
// 状态变更的实际上更晚创建,但不依赖顺序,全量找。
found := false
for i := 0; i < hook.count(); i++ {
text := markdownText(t, hook.body(t, i))
if strings.Contains(text, "状态变更") && strings.Contains(text, "已修复") {
found = true
}
}
if !found {
t.Fatalf("没有收到含「状态变更 → 已修复」的消息(共 %d 条)", hook.count())
}
}
func TestNotifyStatusChangeSuppressedByDefault(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "不订阅状态变更",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
})
finding := f.record(t, "不订阅变更", "high")
if r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"false_positive"}`); r.Code != 200 {
t.Fatalf("改状态失败 %d: %s", r.Code, r.Body)
}
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
t.Fatal(err)
}
var n int
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries d
JOIN notification_events e ON e.id = d.event_id
WHERE d.channel_id=$1 AND e.kind=$2`, chID, notify.EventFindingStatusChanged).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("未订阅状态变更的渠道不该收到状态变更投递,得到 %d 条", n)
}
}
func TestNotifyTestMessageEndpoint(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "测试发送",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
})
if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", chID), ""); r.Code != 200 {
t.Fatalf("测试发送失败 %d: %s", r.Code, r.Body)
}
if hook.count() != 1 {
t.Fatalf("假接收端应收到 1 条测试消息,得到 %d", hook.count())
}
// 测试消息必须一眼能看出是测试,不能被误当成真实漏洞。
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "测试") {
t.Fatalf("测试消息应标明是测试: %s", text)
}
// 配置坏掉时应把渠道的原始错误如实回给用户。
badID := f.createChannel(t, map[string]any{
"name": "坏地址",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": "http://127.0.0.1:1/hook"},
})
if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", badID), ""); r.Code != 502 {
t.Fatalf("投递失败应回 502,得到 %d: %s", r.Code, r.Body)
}
}
func TestNotifyDeliveriesHistoryAndRetry(t *testing.T) {
f := newNotifyFixture(t)
// 指向必然失败的地址,制造 failed 投递。
chID := f.createChannel(t, map[string]any{
"name": "失败重试",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": "http://127.0.0.1:1/hook"},
})
f.record(t, "会失败的推送", "high")
ctx := context.Background()
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
t.Fatal(err)
}
ch := f.channel(t, chID)
// 连投到耗尽重试预算。
for i := 0; i < db.MaxNotifyAttempts; i++ {
f.n.stepRealtime(ctx, ch, 50, "")
if _, err := f.pg.Exec(`UPDATE notification_deliveries SET next_attempt_at = now() - interval '1 minute' WHERE channel_id=$1`, chID); err != nil {
t.Fatal(err)
}
}
var state string
if err := f.pg.QueryRow(`SELECT state FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&state); err != nil {
t.Fatal(err)
}
if state != db.NotifyStateFailed {
t.Fatalf("重试耗尽后应为 failed,得到 %s", state)
}
r := f.request("GET", fmt.Sprintf("/api/notify/deliveries?channel_id=%d&state=failed", chID), "")
if r.Code != 200 {
t.Fatalf("查历史失败 %d: %s", r.Code, r.Body)
}
var hist struct {
Deliveries []struct {
ID int64 `json:"id"`
State string `json:"state"`
LastError string `json:"last_error"`
Attempts int `json:"attempts"`
Title string `json:"title"`
} `json:"deliveries"`
Total int `json:"total"`
}
if err := json.Unmarshal(r.Body.Bytes(), &hist); err != nil {
t.Fatal(err)
}
if hist.Total != 1 || len(hist.Deliveries) != 1 {
t.Fatalf("应查到 1 条失败投递,得到 total=%d len=%d", hist.Total, len(hist.Deliveries))
}
if hist.Deliveries[0].LastError == "" {
t.Fatal("历史里应带上失败原因,否则用户无法排查")
}
if hist.Deliveries[0].Attempts < db.MaxNotifyAttempts {
t.Fatalf("尝试次数应被记录,得到 %d", hist.Deliveries[0].Attempts)
}
if hist.Deliveries[0].Title != "会失败的推送" {
t.Fatalf("历史应带出漏洞标题,得到 %q", hist.Deliveries[0].Title)
}
// 手动重发:应回到 pending 且清零计数。
if r := f.request("POST", fmt.Sprintf("/api/notify/deliveries/%d/retry", hist.Deliveries[0].ID), ""); r.Code != 200 {
t.Fatalf("重发失败 %d: %s", r.Code, r.Body)
}
var attempts int
if err := f.pg.QueryRow(`SELECT state, attempts FROM notification_deliveries WHERE id=$1`, hist.Deliveries[0].ID).Scan(&state, &attempts); err != nil {
t.Fatal(err)
}
if state != db.NotifyStatePending || attempts != 0 {
t.Fatalf("重发后应为 pending 且 attempts=0,得到 %s/%d", state, attempts)
}
}
func TestNotifyMetaAndSettingsRoundTrip(t *testing.T) {
f := newNotifyFixture(t)
r := f.request("GET", "/api/notify/meta", "")
if r.Code != 200 {
t.Fatalf("meta 失败: %s", r.Body)
}
var meta struct {
Kinds []struct {
Kind string `json:"kind"`
SecretKeys []string `json:"secret_keys"`
} `json:"kinds"`
}
if err := json.Unmarshal(r.Body.Bytes(), &meta); err != nil {
t.Fatal(err)
}
if len(meta.Kinds) != len(notify.Kinds()) {
t.Fatalf("meta 应列出全部 %d 个渠道,得到 %d", len(notify.Kinds()), len(meta.Kinds))
}
for _, k := range meta.Kinds {
if len(k.SecretKeys) == 0 {
t.Errorf("渠道 %s 未上报凭据字段", k.Kind)
}
}
// 三项全局设置往返。尾部斜杠应被规范化掉,否则回链会拼出 "//function/..."。
if r := f.request("PUT", "/api/settings", `{"notify_public_base_url":"https://artex.example.com/","notify_digest_interval_min":15,"notify_enabled":true}`); r.Code != 200 {
t.Fatalf("写设置失败 %d: %s", r.Code, r.Body)
}
t.Cleanup(func() {
f.pg.Exec(`DELETE FROM settings WHERE key IN ($1,$2)`, settingNotifyPublicBaseURL, settingNotifyDigestMinutes)
})
payload := f.s.settingsPayload()
if payload["notify_public_base_url"] != "https://artex.example.com" {
t.Fatalf("回链地址未规范化: %v", payload["notify_public_base_url"])
}
if payload["notify_digest_interval_min"] != 15 {
t.Fatalf("汇总周期未生效: %v", payload["notify_digest_interval_min"])
}
// 非法值应被拒。
for _, body := range []string{
`{"notify_public_base_url":"ftp://x"}`,
`{"notify_digest_interval_min":0}`,
`{"notify_digest_interval_min":99999}`,
} {
if r := f.request("PUT", "/api/settings", body); r.Code != 400 {
t.Errorf("%s 应返回 400,得到 %d", body, r.Code)
}
}
}
// TestNotifyDeepLinkUsesPublicBaseURL 覆盖回链拼接:配了 public_base_url 时
// 单条消息必须用带按钮的 ActionCard,且链接指向漏洞详情页。
func TestNotifyDeepLinkUsesPublicBaseURL(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "回链",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
})
finding := f.record(t, "带回链的漏洞", "high")
f.deliver(t, chID, "https://artex.example.com")
body := hook.last(t)
card, _ := body["actionCard"].(map[string]any)
if card == nil {
t.Fatalf("有回链时应用 ActionCard,得到 msgtype=%v", body["msgtype"])
}
want := fmt.Sprintf("https://artex.example.com/function/findings/detail?id=%d", finding)
if card["singleURL"] != want {
t.Fatalf("回链不对\n期望 %s\n得到 %v", want, card["singleURL"])
}
}
// TestNotifyNoDeepLinkWithoutBaseURL 反向覆盖:没配外部地址时不该产生坏链接
// (比如指向 localhost 或相对路径),应退回纯 markdown。
func TestNotifyNoDeepLinkWithoutBaseURL(t *testing.T) {
f := newNotifyFixture(t)
hook := newFakeWebhook(t)
chID := f.createChannel(t, map[string]any{
"name": "无回链",
"kind": notify.KindDingTalk,
"config": map[string]any{"webhook": hook.URL},
})
f.record(t, "无回链的漏洞", "high")
f.deliver(t, chID, "")
body := hook.last(t)
if body["msgtype"] != "markdown" {
t.Fatalf("未配外部地址时应发 markdown,得到 %v", body["msgtype"])
}
if text := markdownText(t, body); strings.Contains(text, "查看详情") {
t.Fatalf("未配外部地址时不该出现详情链接:\n%s", text)
}
}
// TestNotifyBackoffTableMatchesAttemptBudget 是防漂移断言。
//
// 重试预算(db.MaxNotifyAttempts)与退避序列表(notifyBackoff)分居两个包:
// 前者是状态机的策略、后者是引擎的执行节拍。若只改其中一个——比如把预算提到 5
// 次而忘了加退避档位——代码不会报错,只会让第 4、5 次重试沿用最后一档间隔,
// 表现为「重试节奏莫名变慢」,排查时很难联想到是这里。
// 断言两者长度一致,让这种漂移在 CI 里就暴露。
func TestNotifyBackoffTableMatchesAttemptBudget(t *testing.T) {
if len(notifyBackoff) != db.MaxNotifyAttempts {
t.Fatalf("退避档位数(%d)与最大尝试次数(%d)不一致——改一个必须同时改另一个",
len(notifyBackoff), db.MaxNotifyAttempts)
}
// 退避间隔必须单调不减,否则重试会越试越急,反而加剧限流。
for i := 1; i < len(notifyBackoff); i++ {
if notifyBackoff[i] < notifyBackoff[i-1] {
t.Fatalf("退避间隔必须单调不减:第 %d 档 %v < 第 %d 档 %v",
i, notifyBackoff[i], i-1, notifyBackoff[i-1])
}
}
}
// TestNotifyRateLimitDoesNotConsumeRetryBudget 锁住「先取令牌再领取」的顺序。
// 若反了(先领后弃),被限流挡下的投递已经计过一次 attempts,
// 预算会被纯粹的等待耗光,最后落进 failed。
func TestNotifyRateLimitDoesNotConsumeRetryBudget(t *testing.T) {
// 只测令牌桶本身,不需要 Server(也不该为它造一个)。
n := &Notifier{buckets: map[int64]*notifyBucket{}}
now := time.Now()
// 每分钟 1 条:满桶时最多 1 条。
if got := n.takeTokens(1, 1, now); got != 1 {
t.Fatalf("满桶时每分钟 1 条应取 1 个令牌,得到 %d", got)
}
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
t.Fatalf("令牌耗尽后应立即返回 0,得到 %d", got)
}
if got := n.takeTokens(1, 1, now.Add(30*time.Second)); got != 0 {
t.Fatalf("半程不应补满一个令牌,得到 %d", got)
}
if got := n.takeTokens(1, 1, now.Add(time.Minute)); got != 1 {
t.Fatalf("满一个周期应补回 1 个令牌,得到 %d", got)
}
// 不限流渠道走有限上限,避免单轮被无限积压拖住。
if got := n.takeTokens(2, 0, now); got != notifyUnlimitedBurstPerTick {
t.Fatalf("不限流应返回每轮上限 %d,得到 %d", notifyUnlimitedBurstPerTick, got)
}
// 渠道之间的令牌桶互相独立。
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
t.Fatalf("渠道 1 的桶应仍然为空,得到 %d", got)
}
}
+82 -2
View File
@@ -228,6 +228,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
go s.evidenceStore().RunGC(s.ctx)
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
// 漏洞 IM 推送投递引擎。与 Scheduler 并列但独立:推送的实时性要求(3s)
// 与触发器的业务节奏不同,且两者失败互不牵连——推送卡住不该影响 agent 触发。
go newNotifier(s).Run(s.ctx)
// Fill the tool cache for any enabled MCP that has none yet (notably the
// seeded browser MCP on first run). Async so it never blocks startup.
go s.discoverEmptyMCPsOnStartup()
@@ -796,6 +799,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord)
mux.HandleFunc("GET /api/settings", s.getSettings)
mux.HandleFunc("PUT /api/settings", s.putSettings)
// 漏洞 IM 推送。渠道是「多实例 + 各自过滤规则」的资源,因此独立成一组
// REST 接口,而不是塞进扁平的 /api/settings 键值里。
mux.HandleFunc("GET /api/notify/meta", s.notifyMeta)
mux.HandleFunc("GET /api/notify/channels", s.notifyListChannels)
mux.HandleFunc("POST /api/notify/channels", s.notifyCreateChannel)
mux.HandleFunc("PATCH /api/notify/channels/{id}", s.notifyUpdateChannel)
mux.HandleFunc("DELETE /api/notify/channels/{id}", s.notifyDeleteChannel)
mux.HandleFunc("POST /api/notify/channels/{id}/test", s.notifyTestChannel)
mux.HandleFunc("GET /api/notify/deliveries", s.notifyListDeliveries)
mux.HandleFunc("POST /api/notify/deliveries/{id}/retry", s.notifyRetryDelivery)
mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch)
mux.HandleFunc("GET /api/report", s.getReport)
mux.HandleFunc("GET /api/chat/mentions", s.searchChatMentions)
@@ -2266,15 +2279,21 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "bad status: "+*body.Status)
return
}
n, err := s.m.pg.SetFindingStatus(id, *body.Status)
// 走带通知的版本:状态更新与「状态变更推送事件」在同一事务里落库,
// 避免出现状态已改而推送事件丢失的窗口。事件登记失败不影响状态更新,
// 所以只记日志、不向调用方报错。
from, found, notified, err := s.m.pg.SetFindingStatusWithNotify(r.Context(), id, *body.Status)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if n == 0 {
if !found {
writeErr(w, 404, "finding not found")
return
}
if !notified && from != *body.Status {
log.Printf("[notify] 状态变更事件未登记 finding=%d %s→%s(状态已更新)", id, from, *body.Status)
}
}
if body.Severity != nil {
if !db.ValidSeverity(*body.Severity) {
@@ -3293,9 +3312,34 @@ func (s *Server) settingsPayload() map[string]any {
// 实验功能:noa 模型驱动上下文压缩(默认关)。开启后平台接入的四类 agent 由 noa
// 接管上下文压缩,取代内置 compaction;每 run 读一次,对之后启动的 run 生效。
"noa_compaction": s.m.NoaCompactionEnabled(),
// 漏洞 IM 推送的全局项。渠道本身是独立资源,走 /api/notify/* 管理;
// 这里只放「作用于全部渠道」的三项。
"notify_enabled": s.m.pg.GetBool(settingNotifyEnabled, true),
"notify_public_base_url": notifyPublicBaseURL(s.m.pg),
"notify_digest_interval_min": notifyDigestIntervalMin(s.m.pg),
}
}
// notifyPublicBaseURL 读推送回链用的外部地址。
func notifyPublicBaseURL(pg *db.DB) string {
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
return v
}
// notifyDigestIntervalMin 读汇总周期(分钟),非法或未配置时回落到默认值。
// 回显默认值而不是空串,UI 才能把当前生效值填进输入框。
func notifyDigestIntervalMin(pg *db.DB) int {
v, ok, _ := pg.GetSetting(settingNotifyDigestMinutes)
if !ok {
return notifyDefaultDigestMinutes
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n <= 0 {
return notifyDefaultDigestMinutes
}
return n
}
// pgDetectPython re-runs interpreter detection, stores + returns it.
func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) {
p := detectPython()
@@ -3340,6 +3384,11 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
ConstraintsInjectWorker *bool `json:"constraints_inject_worker"`
// 实验功能:noa 上下文压缩开关(默认关);每 run 读,对之后启动的 run 生效,无需重建 agent。
NoaCompaction *bool `json:"noa_compaction"`
// 漏洞 IM 推送的全局项。三者都由投递引擎每轮读一次,改完即时生效,
// 不需要重建 agent 或重启。
NotifyEnabled *bool `json:"notify_enabled"`
NotifyBaseURL *string `json:"notify_public_base_url"`
NotifyDigestMins *int `json:"notify_digest_interval_min"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
@@ -3364,6 +3413,37 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
return
}
}
// 推送全局项:投递引擎每轮重新读取,所以即时生效、无需重启。
if req.NotifyEnabled != nil {
if err := s.m.pg.SetBool(settingNotifyEnabled, *req.NotifyEnabled); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.NotifyBaseURL != nil {
// 统一裁掉尾部斜杠:回链拼接用的是 fmt.Sprintf("%s/function/..."),
// 留着尾部斜杠会产出 "//function/..." 这种双斜杠路径。
base := trimTrailingSlash(strings.TrimSpace(*req.NotifyBaseURL))
if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
writeErr(w, 400, "回链地址需以 http:// 或 https:// 开头")
return
}
if err := s.m.pg.SetSetting(settingNotifyPublicBaseURL, base); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.NotifyDigestMins != nil {
// 下限 1 分钟:更短的周期等于实时推送,那样应该直接把渠道改成 realtime 模式。
if *req.NotifyDigestMins < 1 || *req.NotifyDigestMins > 24*60 {
writeErr(w, 400, "汇总周期需在 1 到 1440 分钟之间")
return
}
if err := s.m.pg.SetSetting(settingNotifyDigestMinutes, strconv.Itoa(*req.NotifyDigestMins)); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.Workers != nil {
if err := s.m.SetWorkers(*req.Workers); err != nil {
writeErr(w, 400, err.Error())