feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`backthink IM push failed … weixin_ilink: proactive push outside 24h window`
—— 她的话**送进了 Web 活跃客户端,但没送进微信**,原因是**平台规则**(微信 iLink
只允许用户最后一条消息后 24h 窗口内主动推送),不是崩溃。

本片只做「如实说清」,不绕规则:
- **原因码**:新增契约层分类件 `internal/channel/adapter/delivery.go`
  (`DeliveryCategory` + 4 个码 + 可选自描述接口 `DeliveryFailure` / 读取函数),
  **沿用**既有 voice `Category(err)` 与 `SystemNoticeNormalizer` 的范式,**不造第二套枚举**;
  平台码由产生错误的层声明:`weixin_ilink` 的 `ErrProactiveWindow` 自报
  `proactive_window_closed`(哨兵文本与 `errors.Is` 判定**逐字未变**)。
- **三处可见**:① 结构化 WARN(补齐 category/channel/platform/route);
  ② 审计 `backthink.push.im_undelivered`(与 `backthink.self_wake.*` 同族);
  ③ 推送结果 `Detail` 追加稳定标签 `im_undelivered=<渠道>:<原因码>` —— 面板**已在
  渲染 Detail**(`Backthink.tsx:185`)⇒ 主人现在就能看到,**无需改前端**。
- **不丢消息**:IM 路失败照旧走活跃客户端路,且**只在 Web 真送达时** `pushed=true`;
  有测试钉住「IM 失败不影响另一条路」(同一条消息没被丢)。
- **不做的**:不重试刷屏、不换渠道冒充微信、不伪造送达、不新增 `.env` 键。

验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;4 个包 vet/测试全绿;
我用 -overlay 把 `ErrProactiveWindow` 退成裸 `errors.New` ⇒ weixin 包两条用例变红
(`category = "failed", want proactive_window_closed`),channelbridge/backthink 亦红。

⚠️ 未落地的建议(已登记,未硬改):给她的"推送回执"(`backthink_records` 加
`push_category`/`push_channel` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
This commit is contained in:
AskaEth committed 2026-10-07 10:59:22 +08:00
1 parent bff57bd1ae
commit 353293e807
9 files changed
+772 -31

No files matched your search

+8 -5
View File
@@ -170,9 +170,6 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
gate = newRateGate(cfg.PushLimit, cfg.PushWindow, cfg.PushCooldown, cfg.KindWindow, log)
}
pusher := deps.Pusher
if pusher == nil {
pusher = newPusher(deps.Active, deps.IM, gate, log).withExporter(deps.Exporter)
}
now := deps.Now
if now == nil {
@@ -183,7 +180,7 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
sessionID = defaultSessionID
}
return &Backthink{
b := &Backthink{
cfg: cfg,
deps: deps,
log: log,
@@ -195,7 +192,13 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
// 闸只认**已 normalize** 的旋钮:未回落默认的 ≤0 会被滑窗读成「无规则」
// (恒放行且不记录),那就把「≤0 回落默认」静默变成「无上限」(G3)。
selfWake: newSelfWakeGate(cfg.SelfWakeLimit, cfg.SelfWakeWindow),
}, nil
}
// 未注入自定义推送器时,默认推送器在结构体构造**之后**组装:它要挂上
// b.auditEvent(读取当前审计出口的回调),而该回调需要 b 本身。
if pusher == nil {
b.rebuildPusher()
}
return b, nil
}
// Start 启动后台工作:心跳定时器,以及(配置了 Bus 时)
+159 -18
View File
@@ -158,6 +158,11 @@ type pusher struct {
gate PushGate
exporter PushExporter
log *slog.Logger
// audit 是可选审计出口(what/result 两字段,与 backthink 的 Auditor 同族)。
// 它在**推送失败但渠道是主人能感知的那个**时留一行痕,因此主人不必等面板翻记录
// 才能知道「她要发微信、微信没送到」。nil ⇒ 静默跳过:审计是留痕,不是门,
// 缺它绝不改变投递结果。
audit func(what, result string)
// exportDone 非 nil 时,会在每次异步导出完成后收到一个值。
// 它存在是为了让测试能确定性地观测该钩子;发送是非阻塞的,
// 所以通道满也不会卡住导出器。
@@ -178,6 +183,13 @@ func (p *pusher) withExporter(exporter PushExporter) *pusher {
return p
}
// withAuditor 挂上审计出口并返回 pusher 以便链式调用。传 nil 则关闭该钩子
// (测试里的裸 pusher 因此不产生审计)。
func (p *pusher) withAuditor(audit func(what, result string)) *pusher {
p.audit = audit
return p
}
// Push 实现 Pusher:它套用频率门,然后把消息路由到
// 单个目标。"web" 提示到达活跃 Web/原生客户端;其它任何
// 提示("im"、"auto" 或具体平台)经 IM 桥走一次,由
@@ -205,6 +217,10 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
msg := req.Message
var res PushResult
// imFault 非 nil ⇒ IM 路**确实失败过**(带着错因)。它必须活到最终结果:过去
// 降级到活跃客户端后 res 被整条覆盖,于是「微信没送到」在那次推送里彻底消失
// —— 正是主人反复收不到、面板却显示「已送达」的成因。
var imFault *imUndelivered
if hint == channelWeb {
res = p.pushActive(ctx, msg)
} else {
@@ -217,10 +233,17 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
} else {
msg.Platform = hint
}
res = p.pushIM(ctx, msg)
im, fault := p.pushIM(ctx, msg)
res = im
if !res.Pushed {
// 降级路照旧兜底(主人仍要被触达),但 IM 那次的失败如实带上:
// 降级成功 ≠ 她的话送到了她选的渠道。
res = p.pushActive(ctx, msg)
if fault != nil {
res = annotateIMUndelivered(res, *fault, im.Err)
}
}
imFault = fault
}
// 只有真正的投递才推进冷却/按风格窗口(且只对
// 受门控的推送),因此尝试过但失败的推送不会抑制之后的
@@ -238,6 +261,11 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
res.Bypassed = true
res = annotateBypass(res)
}
// IM 路的失败必须**有可观测出口**:一条带原因码的结构化 WARN 已在 pushIM 落过,
// 这里再落一条主人能读懂的审计(此刻才知道这一轮最终落到哪条路)。
if imFault != nil {
p.auditIMUndelivered(*imFault, res)
}
return res
}
@@ -305,16 +333,138 @@ func activeDeliveryDetail(d ProactiveDelivery) string {
return fmt.Sprintf("delivered to %d active client(s), %s", d.Connections, state)
}
// pushIM 只投递给 IM 桥。
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
// whatPushIMUndelivered 是「IM 路未送达」审计行的动作标签(点号小写稳定标签,与
// backthink.self_wake.* 同族;who 由 backthink.auditEvent 统一给 "backthink")。
const whatPushIMUndelivered = "backthink.push.im_undelivered"
// imChannelUnknown 是渠道的显示名兜底:适配器没自报渠道、且这次请求的是泛化提示
// (auto/im,msg.Platform 只写着一个路由哨兵)时用它。它如实表示「不知道是哪个渠道」,
// 绝不拿哨兵值冒充平台名。
const imChannelUnknown = "unknown"
// imUndelivered 是一次 IM 路失败的结构化事实:稳定原因码、出错渠道(可能为空 =
// 未自报)、以及底层错误。它存在的理由是 PushResult.Err 是**字符串** —— 原因码一旦
// 落成文本就再也读不出来,而日志、记录标签与审计三处都要那个码。
type imUndelivered struct {
channel string
category adapter.DeliveryCategory
err error
}
// newIMUndelivered 把一次 IM 路的错误解成结构化事实。渠道取适配器**自报**值
// (沿 %w 链找 adapter.DeliveryFailure);未自报且这次请求的是具体平台时,退到
// 该平台(此刻 msg.Platform 就是那个渠道,不是猜的);两者都没有 ⇒ 空串 = 未知。
func newIMUndelivered(err error, platform string) *imUndelivered {
f := &imUndelivered{
channel: adapter.DeliveryChannelOf(err),
category: adapter.DeliveryCategoryOf(err),
err: err,
}
if f.channel == "" && platform != defaultProactivePlatform {
f.channel = platform
}
return f
}
// channelName 是渠道的显示名;未自报时为 imChannelUnknown。
func (f imUndelivered) channelName() string {
if f.channel == "" {
return imChannelUnknown
}
return f.channel
}
// categoryName 是原因码的显示值;没有码时如实落到兜底码 failed(与
// adapter.DeliveryCategoryOf 同口径,绝不编一个更精确的码)。
func (f imUndelivered) categoryName() adapter.DeliveryCategory {
if f.category == "" {
return adapter.CategoryFailed
}
return f.category
}
// tag 是写进 PushResult.Detail 的**稳定可解析**标签
// `im_undelivered=<渠道>:<原因码>`。管理面板的「推送说明」直接读 Detail
// (frontend/web/src/admin/modules/Backthink.tsx),因此主人不必等一次代码改动
// 就能看到「微信为什么没送到」。
func (f imUndelivered) tag() string {
return fmt.Sprintf("im_undelivered=%s:%s", f.channelName(), f.categoryName())
}
// pushIM 只投递给 IM 桥。返回的第二个值非 nil 表示**这次 IM 投递失败**(带原因码与
// 渠道);IM 槽未接线不是渠道故障,因此不算失败事实(降级到活跃客户端是诚实的兜底)。
//
// 失败至少落一条**带来源与原因码**的 WARN:它同时给出 category(机器可读)、channel
// (适配器自报的真实渠道)、platform(这次请求的平台)与原文错误,因此「收不到」永远
// 有一个可观测出口 —— 它绝不静默。
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) (PushResult, *imUndelivered) {
if p.im == nil {
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}, nil
}
if err := p.im.SendProactive(ctx, msg); err != nil {
p.log.Warn("backthink IM push failed", "error", err)
return PushResult{Pushed: false, Route: RouteIMBridge, Err: err.Error()}
fault := newIMUndelivered(err, msg.Platform)
p.log.Warn("backthink IM push failed",
"category", string(fault.categoryName()),
"channel", fault.channelName(),
"platform", msg.Platform,
"route", RouteIMBridge,
"error", err)
return PushResult{Pushed: false, Route: RouteIMBridge, Err: err.Error()}, fault
}
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}, nil
}
// annotateIMUndelivered 把 IM 路的失败如实拼进**降级后**的结果:Detail 追加稳定标签
// (渠道 + 原因码),因此记录与面板都明说「这个渠道没送到」。
//
// 降级路也没成时,IM 那次的错因是「这一轮为什么没送到」的主因,落进 Err;降级路自己
// 的错因(若有)另起一段附在后面 —— 两条都不被吞,也不互相冒充。
func annotateIMUndelivered(res PushResult, fault imUndelivered, imErr string) PushResult {
res.Detail = appendDetail(res.Detail, fault.tag())
if res.Pushed || imErr == "" {
return res
}
switch {
case res.Err == "":
res.Err = imErr
case !strings.Contains(res.Err, imErr):
res.Err = imErr + " | fallback: " + res.Err
}
return res
}
// auditIMUndelivered 把一次 IM 路未送达写进审计。审计是留痕不是门:出口未接线
// (p.audit == nil,例如测试里的裸 pusher)时静默跳过,投递结果不受影响。
func (p *pusher) auditIMUndelivered(fault imUndelivered, final PushResult) {
if p.audit == nil {
return
}
p.audit(whatPushIMUndelivered, imUndeliveredAuditText(fault, final))
}
// imUndeliveredAuditText 是给主人看的中文一行:哪个渠道、什么原因码、以及这一轮最终
// 落到了哪里。它**绝不**把「降级送到活跃客户端」说成「已送达那个渠道」。
func imUndeliveredAuditText(fault imUndelivered, final PushResult) string {
outcome := "本轮没有在线 Web 客户端,这条消息没有送达"
if final.Pushed {
outcome = "已改由 Web 活跃客户端送达"
}
return fmt.Sprintf("主动消息未能送达 %s(原因码 %s):%v;%s",
fault.channelName(), fault.categoryName(), fault.err, outcome)
}
// appendDetail 把 tag 追加到详情串(逗号分隔),空详情则直接成为详情;空 tag 不改变
// 详情。它是 Detail 拼接的唯一处(channel_hint / event_bypass / im_undelivered 三个
// 标签共用同一口径,避免三份各写一遍)。
func appendDetail(detail, tag string) string {
switch {
case tag == "":
return detail
case detail == "":
return tag
default:
return detail + ", " + tag
}
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}
}
// normalizeChannelHint 把渠道提示转小写并去空白,把同义词映射到
@@ -347,23 +497,14 @@ func annotateHint(res PushResult, hint string) PushResult {
if h == "" || h == channelAuto {
return res
}
tag := "channel_hint=" + h
if res.Detail == "" {
res.Detail = tag
} else {
res.Detail = res.Detail + ", " + tag
}
res.Detail = appendDetail(res.Detail, "channel_hint="+h)
return res
}
// annotateBypass 给事件驱动的推送结果打标记,使记录/面板显示它
// 跳过了 PushGate,与因限流被拒的心跳推送区分开。
func annotateBypass(res PushResult) PushResult {
if res.Detail == "" {
res.Detail = "event_bypass"
} else {
res.Detail = res.Detail + ", event_bypass"
}
res.Detail = appendDetail(res.Detail, "event_bypass")
return res
}
@@ -0,0 +1,214 @@
package backthink
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// 本文件锁定「主动消息没能送到微信」这件事**被如实说清楚**(主人 2026-10-07:
// 「可以,这有必要,不然我老是收不到」)。真机日志是:
//
// level=WARN msg="backthink IM push failed" ... error="channelbridge: send proactive via
// \"weixin-ilink\": channelbridge: send bubble #1 of 8: weixin_ilink: proactive push outside 24h window"
// level=INFO msg="backthink proactive push" ... route=active_client pushed=true
// reason="delivered to 2 active client(s), channel_hint=weixin-ilink"
//
// 第二行是对的,但它让第一行彻底消失:记录与面板只说「已送达」,于是「她的话没进微信」
// 只存在于一条没人看的自由文本 WARN 里 —— 主人反复收不到,面板却是绿的。
//
// 三条不可退让的口径(对应主人的三条要求):
// 1. **分类化 + 不假成功**:窗口关闭必须带稳定原因码,且 IM 那条路绝不进 pushed=true;
// 2. **可见**:结构化 WARN(category/channel)+ 审计 + 思考记录的 push_detail 都带上它;
// 3. **不丢消息**:IM 路失败**不得**影响活跃客户端那条路(消息照旧送达并落库)。
// captureLog 返回一个把日志写进缓冲区的日志器,供断言结构化字段。
func captureLog() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewTextHandler(buf, nil)), buf
}
// auditRecorder 记录 pusher 审计出口的 (what, result) 对。
type auditRecorder struct {
rows []auditRow
}
func (a *auditRecorder) record(what, result string) {
a.rows = append(a.rows, auditRow{who: "backthink", what: what, result: result})
}
// TestPusher_IMWindowClosed_FallbackKeepsFailureVisible:IM 路被 24 小时窗口拒绝、
// 活跃客户端路成功时,**两条事实都要在结果里**:消息确实送到了 Web(不假失败),
// 但微信那条路没送到、原因是 proactive_window_closed(不假成功)。
func TestPusher_IMWindowClosed_FallbackKeepsFailureVisible(t *testing.T) {
t.Parallel()
logger, logs := captureLog()
audit := &auditRecorder{}
active := &mockActive{n: 2, persisted: true}
im := &mockIMAdapter{err: weixin_ilink.ErrProactiveWindow}
p := newPusher(active, im, &mockGate{allow: true}, logger).withAuditor(audit.record)
res := p.Push(context.Background(), PushRequest{
ChannelHint: "weixin-ilink",
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
})
// ③ 不丢消息:活跃客户端那条路照旧成功,且拿到的是**同一条**消息。
if !res.Pushed || res.Route != RouteActiveClient {
t.Fatalf("res = %+v, want pushed via active_client (IM 失败不得连带丢掉另一条路)", res)
}
if active.calls != 1 || active.last.Content != "记得喝水哦" {
t.Fatalf("active calls = %d last = %q, want 1 call with the message text", active.calls, active.last.Content)
}
// ① 微信未送达这件事留在结果里(面板「推送说明」直接读 Detail)。
if !strings.Contains(res.Detail, "im_undelivered=weixin-ilink:proactive_window_closed") {
t.Fatalf("Detail = %q, want im_undelivered=weixin-ilink:proactive_window_closed", res.Detail)
}
if !strings.Contains(res.Detail, "channel_hint=weixin-ilink") {
t.Fatalf("Detail = %q, want the requested channel hint too", res.Detail)
}
if res.Err != "" {
t.Fatalf("Err = %q, want empty:消息已由活跃客户端送达,错因在 Detail 里如实标注", res.Err)
}
// ② 结构化日志:来源(调用方注入的 logger)+ 错误类别 + 渠道 + 原文。
log := logs.String()
for _, want := range []string{
`msg="backthink IM push failed"`,
"category=proactive_window_closed",
"channel=weixin-ilink",
"route=im_bridge",
"outside 24h window",
} {
if !strings.Contains(log, want) {
t.Fatalf("log = %q, want it to contain %q", log, want)
}
}
// ② 审计:一条可读的行,说清渠道、原因码与这一轮最终落到哪。
if len(audit.rows) != 1 {
t.Fatalf("audit rows = %+v, want exactly 1", audit.rows)
}
row := audit.rows[0]
if row.what != whatPushIMUndelivered {
t.Fatalf("audit what = %q, want %q", row.what, whatPushIMUndelivered)
}
for _, want := range []string{"weixin-ilink", "proactive_window_closed", "Web 活跃客户端"} {
if !strings.Contains(row.result, want) {
t.Fatalf("audit result = %q, want it to contain %q", row.result, want)
}
}
}
// TestPusher_IMWindowClosed_NoFallbackNotPushed:两条路都没成时**绝不**报 pushed=true,
// 且 IM 那次的错因是主因(落进 Err),失败仍有日志与审计两个出口 —— 不静默。
func TestPusher_IMWindowClosed_NoFallbackNotPushed(t *testing.T) {
t.Parallel()
logger, logs := captureLog()
audit := &auditRecorder{}
// 没有在线客户端、也没落库 ⇒ 降级路同样失败。
active := &mockActive{n: 0, persisted: false}
im := &mockIMAdapter{err: weixin_ilink.ErrProactiveWindow}
p := newPusher(active, im, &mockGate{allow: true}, logger).withAuditor(audit.record)
res := p.Push(context.Background(), PushRequest{
ChannelHint: "weixin-ilink",
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
})
if res.Pushed {
t.Fatalf("res = %+v, want Pushed=false (窗口关闭 + 无客户端 = 真的没送到)", res)
}
if res.Route == RouteIMBridge {
t.Fatalf("Route = %q, want it NOT to claim the IM route delivered", res.Route)
}
if !strings.Contains(res.Detail, "im_undelivered=weixin-ilink:proactive_window_closed") {
t.Fatalf("Detail = %q, want the undelivered tag", res.Detail)
}
if !strings.Contains(res.Err, "outside 24h window") {
t.Fatalf("Err = %q, want the IM failure as the primary error", res.Err)
}
if log := logs.String(); !strings.Contains(log, "category=proactive_window_closed") {
t.Fatalf("log = %q, want the categorized WARN", log)
}
if len(audit.rows) != 1 {
t.Fatalf("audit rows = %+v, want exactly 1", audit.rows)
}
if !strings.Contains(audit.rows[0].result, "没有送达") {
t.Fatalf("audit result = %q, want it to say the round was not delivered", audit.rows[0].result)
}
}
// TestPusher_NoIMTargetIsNotChannelFailure:IM 槽未接线**不是**渠道故障(没有渠道
// 可失败),因此不该被标成「某渠道未送达」,也不该产生审计噪声。
func TestPusher_NoIMTargetIsNotChannelFailure(t *testing.T) {
t.Parallel()
audit := &auditRecorder{}
active := &mockActive{n: 1, persisted: true}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog()).withAuditor(audit.record)
res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "晚安"}})
if !res.Pushed || res.Route != RouteActiveClient {
t.Fatalf("res = %+v, want pushed via active_client", res)
}
if strings.Contains(res.Detail, "im_undelivered") {
t.Fatalf("Detail = %q, want no im_undelivered tag (nothing failed)", res.Detail)
}
if len(audit.rows) != 0 {
t.Fatalf("audit rows = %+v, want none", audit.rows)
}
}
// TestThink_IMWindowClosed_RecordAndAuditCarryTheCode 是**端到端**探针:她真的决定
// 推送到 weixin-ilink、桥那边因窗口关闭拒绝、Web 客户端接手 —— 落库的那条思考记录
// (面板读侧)必须带着「微信未送达 + 原因码」,审计也必须有一行。
func TestThink_IMWindowClosed_RecordAndAuditCarryTheCode(t *testing.T) {
t.Parallel()
logger, logs := captureLog()
audit := &fakeAuditor{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: &mockThinker{text: "[我决定:push]\n[渠道:weixin-ilink]\n记得喝水哦"},
State: &mockState{state: UserState{}},
Active: &mockActive{n: 2, persisted: true},
IM: &mockIMAdapter{err: weixin_ilink.ErrProactiveWindow},
Gate: &mockGate{allow: true},
Audit: audit,
Log: logger,
Now: func() time.Time { return dayAt() },
})
b.think(context.Background(), heartbeatTrigger())
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(recs) == 0 {
t.Fatal("no record persisted")
}
rec := recs[0].PushResult
if !rec.Pushed || rec.Route != RouteActiveClient {
t.Fatalf("record push = %+v, want pushed via active_client", rec)
}
if !strings.Contains(rec.Detail, "im_undelivered=weixin-ilink:proactive_window_closed") {
t.Fatalf("record Detail = %q, want the undelivered tag (面板「推送说明」读它)", rec.Detail)
}
if got := audit.countOf(whatPushIMUndelivered); got != 1 {
t.Fatalf("audit %q = %d 条, want 1(whats=%v)", whatPushIMUndelivered, got, audit.whats())
}
if who := audit.rows[0].who; who != "backthink" {
t.Fatalf("audit who = %q, want backthink(沿用后台思考既有口径)", who)
}
// 日志带来源(services/backthink),因此这条失败在整机日志里可回溯。
if log := logs.String(); !strings.Contains(log, "source=services/backthink") ||
!strings.Contains(log, "category=proactive_window_closed") {
t.Fatalf("log = %q, want a sourced, categorized WARN", log)
}
}
+15 -1
View File
@@ -44,8 +44,22 @@ func (b *Backthink) currentAuditor() Auditor {
// rebuildPusherLocked 用当前槽位、频率门与推送出口替换
// 推送器。调用方必须持有 b.mu。
//
// 审计出口挂的是 `b.auditEvent` 这个**读取当前出口**的回调,而不是一次性快照:
// SetAuditSink 是晚绑定的(网关比 backthink 晚组装),持快照会让稍后接上的网关
// 永远收不到推送审计 —— 与「未接线则静默跳过」同一条诚实口径。
func (b *Backthink) rebuildPusherLocked() {
b.pusher = newPusher(b.deps.Active, b.deps.IM, b.gate, b.log).withExporter(b.deps.Exporter)
b.pusher = newPusher(b.deps.Active, b.deps.IM, b.gate, b.log).
withExporter(b.deps.Exporter).
withAuditor(b.auditEvent)
}
// rebuildPusher 是不持锁版本的 rebuildPusherLocked,供 New 在结构体刚构造好、
// 尚无并发访问者时使用。
func (b *Backthink) rebuildPusher() {
b.mu.Lock()
defer b.mu.Unlock()
b.rebuildPusherLocked()
}
// currentPusher 在生命周期锁下返回当前推送器,因此一次槽位