feat(backthink): 事件驱动主动消息绕过PushGate——IoT等事件该发就发(heartbeat仍受60min节流)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- trigger.go: 新增 TriggerKind(TriggerHeartbeat/TriggerEvent) + Trigger.Kind 标记触发来源
- push.go: PushRequest.TriggerKind 分流——event 跳过 gate.Allow/RecordPush(该发就发),heartbeat 仍受 60min≤1/冷却/分类限频;PushResult.Bypassed 标记 + detail=event_bypass 区分
- backthink.go: think() 传 TriggerKind + 过同事件去重门
- event_dedup.go(新): 同事件(Source+Summary)去重防刷,无 60min 节流;不同事件/心跳后重置链
- 测试: event 连续 3 次全推 bypass / heartbeat 第2次 rate_limited / event 不消耗心跳限 / 同事件去重
- build/vet/test 全绿 + windows/darwin 交叉编译通过
This commit is contained in:
Aska committed 2026-09-08 13:46:43 +08:00
1 parent 3e4efa0e41
commit d09b25071d
6 files changed
+266 -22

No files matched your search

+36 -7
View File
@@ -50,6 +50,10 @@ type PushResult struct {
Pushed bool
// Route is the delivery route taken.
Route Route
// Bypassed reports whether this push was event-driven and skipped the PushGate
// (a real event, "该发就发"). A heartbeat push is gate-governed and never
// bypassed.
Bypassed bool
// Detail is a short human-readable note (e.g. client count, decision reason).
Detail string
// Err is the underlying error text when delivery failed, or that of the
@@ -60,9 +64,9 @@ type PushResult struct {
// String returns a concise summary for logs.
func (p PushResult) String() string {
if p.Err != "" {
return fmt.Sprintf("pushed=%v route=%s detail=%q err=%q", p.Pushed, p.Route, p.Detail, p.Err)
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err)
}
return fmt.Sprintf("pushed=%v route=%s detail=%q", p.Pushed, p.Route, p.Detail)
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
}
// ActiveClient delivers a proactive message to every currently active web/native
@@ -106,6 +110,9 @@ type PushRequest struct {
Message adapter.UnifiedMessage
// ChannelHint is the requested target channel, "" when she did not choose one.
ChannelHint string
// TriggerKind records what woke this pass. An event-driven push bypasses the
// PushGate (owner mandate: "该发就发"); a heartbeat stays gate-governed.
TriggerKind TriggerKind
}
// Pusher delivers a proactive message to ONE target channel. It is bounded by the
@@ -148,7 +155,12 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
}
kind := classifyPush(req.Message.Content)
if p.gate != nil && !p.gate.Allow(ctx, kind) {
// A real event-driven push bypasses the PushGate: it should go out as-is ("该发就发"),
// independent of the heartbeat throttle (60min≤1 / cooldown / per-flavor window).
// Only the periodic heartbeat stays gate-governed, so the owner is never spammed
// by an empty reflection. Same-event dedup is applied upstream in think().
eventDriven := req.TriggerKind == TriggerEvent
if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) {
p.log.Warn("backthink push rate limited", "kind", kind.String())
return PushResult{Pushed: false, Route: RouteRateLimited, Detail: "proactive push rate limited"}
}
@@ -174,12 +186,18 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
res = p.pushActive(ctx, msg)
}
}
// Only a real delivery advances the cooldown/per-kind windows, so an
// attempted-but-failed push does not suppress a later genuine one.
if res.Pushed && p.gate != nil {
// Only a real delivery advances the cooldown/per-kind windows (and only for a
// gate-governed push), so an attempted-but-failed push does not suppress a later
// genuine one, and an event push never contributes to the heartbeat throttle.
if res.Pushed && p.gate != nil && !eventDriven {
p.gate.RecordPush(kind)
}
return annotateHint(res, req.ChannelHint)
res = annotateHint(res, req.ChannelHint)
if eventDriven {
res.Bypassed = true
res = annotateBypass(res)
}
return res
}
// pushActive delivers only to the active web/native client.
@@ -246,6 +264,17 @@ func annotateHint(res PushResult, hint string) PushResult {
return res
}
// annotateBypass tags an event-driven push result so the record/panel shows it
// skipped the PushGate, distinct from a heartbeat push that was rate-limited.
func annotateBypass(res PushResult) PushResult {
if res.Detail == "" {
res.Detail = "event_bypass"
} else {
res.Detail = res.Detail + ", event_bypass"
}
return res
}
// rateGate is the production PushGate: a sliding-window counter over
// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push
// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never