From b82870f41e6d2e1fc107e5c069e52d7b8c36fc19 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 12:53:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20QQ=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E6=8E=A5=E4=B8=8A=E6=B3=A8=E5=85=A5=20logger=EF=BC=88?= =?UTF-8?q?=E5=B8=A7=E5=8F=96=E8=AF=81=E5=A4=8D=E6=B4=BB=EF=BC=89+=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=EF=BC=88Warn=20=E8=AF=B4=E6=98=8E=20Debug=20=E4=BC=9A?= =?UTF-8?q?=E8=A2=AB=E4=B8=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/cyrene/channels.go | 2 +- cmd/cyrene/channels_obv11_wiring_test.go | 106 ++++++++++++++++ plugins/channels/qq_obv11/adapter.go | 55 ++++++-- .../channels/qq_obv11/logger_wiring_test.go | 117 ++++++++++++++++++ 4 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 cmd/cyrene/channels_obv11_wiring_test.go create mode 100644 plugins/channels/qq_obv11/logger_wiring_test.go diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index de776ec..282f12e 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -255,7 +255,7 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog. if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode qq-obv11 config: %w", err) } - inner = qq_obv11.NewAdapter(cfg) + inner = qq_obv11.NewAdapter(cfg, qq_obv11.WithLogger(log)) case "kook": var cfg kook.Config if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { diff --git a/cmd/cyrene/channels_obv11_wiring_test.go b/cmd/cyrene/channels_obv11_wiring_test.go new file mode 100644 index 0000000..eb54ea0 --- /dev/null +++ b/cmd/cyrene/channels_obv11_wiring_test.go @@ -0,0 +1,106 @@ +package main + +// 钉住组合根侧的接线契约:buildAccountAdapter 构造 qq-obv11 适配器时必须把 channelFactory +// 手上的 logger 传进去(与 kook/telegram/webhook 同口径,见 cmd/cyrene/channels.go)。缺陷 +// 形态:漏传 ⇒ 适配器自造默认 INFO 兜底 ⇒ 入站帧取证(DEBUG)全部静默丢弃,主人要查的 +// 「群友 @ 没回 / 戳一戳没反应」在日志里一片空白,且与「上游压根没上报」无法区分。 +// +// 断言走**真实入站路径**:真开一次 http+server 端、真 POST 一条 OneBot 上报,再看注入的 +// 捕获器里有没有 `msg="inbound frame"`。所以只要接线一断,这条测试必红。 + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "strings" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11" +) + +// logSyncBuf 是并发安全的日志捕获目标:上报由 http.Server 自己的 goroutine 处理,断言在 +// 测试 goroutine 上读。 +type logSyncBuf struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logSyncBuf) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logSyncBuf) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// qqObv11WiringFrame 是一条最普通的群消息事件(与 plugins/channels/qq_obv11 的取证测试 +// 同口径):走到 handlePayload 就必然留一行入站帧。 +const qqObv11WiringFrame = `{"post_type":"message","message_type":"group","sub_type":"normal", + "self_id":10001,"user_id":20002,"group_id":1070898847, + "raw_message":"hello","message":[{"type":"text","data":{"text":"hello"}}]}` + +// TestBuildAccountAdapter_QQObv11WiresLoggerIntoInboundForensics 断言注入的 logger 真的 +// 到了适配器手里。捕获器级别设为 DEBUG:取证日志本身是 DEBUG 级,用默认 INFO 的捕获器 +// 「丢了」与「没丢」都看不见,测不出这条接线。 +func TestBuildAccountAdapter_QQObv11WiresLoggerIntoInboundForensics(t *testing.T) { + buf := &logSyncBuf{} + log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + acct := config.ChannelAccount{Config: map[string]any{ + "transport": "http", + "mode": "server", + "listen_addr": "127.0.0.1:0", + "report_path": "/onebot/report", + }} + built, err := buildAccountAdapter("qq-obv11", acct, log) + if err != nil { + t.Fatalf("buildAccountAdapter: %v", err) + } + ctx := context.Background() + if cerr := built.Connect(ctx); cerr != nil { + t.Fatalf("Connect: %v", cerr) + } + t.Cleanup(func() { _ = built.Disconnect(context.Background()) }) + + post := "http://" + qqObv11ListenAddr(t, built) + "/onebot/report" + resp, perr := http.Post(post, "application/json", strings.NewReader(qqObv11WiringFrame)) + if perr != nil { + t.Fatalf("POST %s: %v", post, perr) + } + if cerr := resp.Body.Close(); cerr != nil { + t.Fatalf("close response body: %v", cerr) + } + + if got := buf.String(); !strings.Contains(got, `msg="inbound frame"`) { + t.Fatalf("注入的 logger 没收到入站帧日志——组合根没把 logger 接进 qq-obv11 适配器?\n捕获到的日志:\n%s", got) + } + if got := buf.String(); !strings.Contains(got, "source=plugins/channels/qq_obv11") { + t.Errorf("入站帧日志缺来源标注:\n%s", got) + } +} + +// qqObv11ListenAddr 取出适配器实际绑定的监听地址(listen_addr 用 :0 让内核挑端口)。 +func qqObv11ListenAddr(t *testing.T, built adapter.PlatformAdapter) string { + t.Helper() + named, ok := built.(*namedAdapter) + if !ok { + t.Fatalf("适配器类型 = %T, want *namedAdapter", built) + } + inner, ok := named.PlatformAdapter.(*qq_obv11.Adapter) + if !ok { + t.Fatalf("内层适配器类型 = %T, want *qq_obv11.Adapter", named.PlatformAdapter) + } + addr := inner.Addr() + if addr == "" { + t.Fatal("适配器没绑定监听地址:http+server 端没起来") + } + return addr +} diff --git a/plugins/channels/qq_obv11/adapter.go b/plugins/channels/qq_obv11/adapter.go index 11ded51..109bb29 100644 --- a/plugins/channels/qq_obv11/adapter.go +++ b/plugins/channels/qq_obv11/adapter.go @@ -29,6 +29,9 @@ import ( ) const ( + // adapterLogSource 是本包产出的日志的来源标签(AGENTS §10:日志必须标来源)。 + adapterLogSource = "plugins/channels/qq_obv11" + // defaultMaxMessageLength bounds a single outgoing QQ message length. defaultMaxMessageLength = 4000 // defaultRecommendBurstMax is the safe burst size for one reply. @@ -142,9 +145,25 @@ type Adapter struct { // Compile-time conformance with the channel contract. var _ adapter.PlatformAdapter = (*Adapter)(nil) +// Option customizes an Adapter at construction time. +type Option func(*Adapter) + +// WithLogger injects the logger the adapter logs through — the wiring path the composition +// root uses (cmd/cyrene/channels.go), mirroring kook/telegram/webhook. A Config decoded from +// channels.json never carries a logger, so without this option an adapter would fall back to +// resolveLogger's default and every DEBUG record (the inbound frame forensics) would vanish. +// It takes precedence over Config.Logger when both are given. +func WithLogger(l *slog.Logger) Option { + return func(a *Adapter) { + if l != nil { + a.log = logger.WithSource(l, adapterLogSource) + } + } +} + // NewAdapter builds a qq_obv11 Adapter from cfg, applying safe defaults. 默认值口径 // (裁决 1.4):transport=ws、mode=server,即**不给出任何新配置时行为与改造前一致**。 -func NewAdapter(cfg Config) *Adapter { +func NewAdapter(cfg Config, opts ...Option) *Adapter { cfg.Transport = normalizeTransport(cfg.Transport) cfg.Mode = normalizeMode(cfg.Mode) cfg.ReportPath = normalizeReportPath(cfg.ReportPath) @@ -154,13 +173,8 @@ func NewAdapter(cfg Config) *Adapter { if cfg.ProactiveWindow <= 0 { cfg.ProactiveWindow = defaultProactiveWindow } - base := cfg.Logger - if base == nil { - base = logger.New(logger.Options{}) - } - return &Adapter{ + a := &Adapter{ cfg: cfg, - log: logger.WithSource(base, "plugins/channels/qq_obv11"), groups: map[string]string{}, newConn: make(chan struct{}, 1), limiter: newRateLimiter(cfg.MaxProactivePerWindow, cfg.ProactiveWindow), @@ -169,6 +183,33 @@ func NewAdapter(cfg Config) *Adapter { probeEvery: defaultProbeInterval, probeTimeout: defaultProbeTimeout, } + for _, opt := range opts { + opt(a) + } + a.log = resolveLogger(a.log, cfg.Logger) + return a +} + +// resolveLogger 选定适配器的日志器(并统一加来源标注)。优先级:WithLogger 选项 → +// Config.Logger → 兜底。 +// +// 兜底**不静默**:它用默认 INFO logger 并当场记一条 WARN。理由是入站帧取证 +// (inbound_debug.go,级固定 DEBUG)是排查「群友 @ 没回」的地基——静默兜底会让「没人 +// 注入 logger」在日志里毫无痕迹,与「上游压根没上报」无法区分(2026-10-05 实测:组合根 +// 漏传 logger,全部归档里 `msg="inbound frame"` 一条都没有)。因此宁可留一条噪音,也要 +// 让「Debug 会被丢弃」显式可见。兜底只服务裸适配器(测试/库内直构),生产路径由组合根 +// 经 WithLogger 正式接线。 +func resolveLogger(option, cfg *slog.Logger) *slog.Logger { + if option != nil { + return option + } + if cfg != nil { + return logger.WithSource(cfg, adapterLogSource) + } + fallback := logger.WithSource(logger.New(logger.Options{}), adapterLogSource) + fallback.Warn("qq-obv11: no logger injected, Debug forensics (inbound frame) will be dropped", + "hint", "inject one via qq_obv11.WithLogger from the composition root") + return fallback } // Name returns the platform identifier "qq-obv11". diff --git a/plugins/channels/qq_obv11/logger_wiring_test.go b/plugins/channels/qq_obv11/logger_wiring_test.go new file mode 100644 index 0000000..6f178cd --- /dev/null +++ b/plugins/channels/qq_obv11/logger_wiring_test.go @@ -0,0 +1,117 @@ +package qq_obv11 + +// 本文件钉住「适配器到底用哪个 logger」这条接线契约。入站帧取证(inbound_debug.go)级别 +// 固定 DEBUG,只有**注入进来**的 logger 才打得出来;缺陷形态是组合根漏传 logger,适配器 +// 自造默认 INFO 兜底,于是全部 Debug 静默消失(2026-10-05 实测:所有归档里 +// `msg="inbound frame"` 一条都没有),而直接写 Config.Logger 的单元测试照样全绿。 +// 这里用四条断言把接线钉死:注入即出、INFO 级即丢、WithLogger 压过 Config.Logger、 +// 兜底留痕不静默。 + +import ( + "bytes" + "context" + "io" + "log/slog" + "os" + "strings" + "testing" +) + +// wiringFrame 是一条最普通的群消息事件:只要 handlePayload 走到它,就必然留一行入站帧。 +const wiringFrame = `{"post_type":"message","message_type":"group","sub_type":"normal", + "self_id":10001,"user_id":20002,"group_id":1070898847, + "raw_message":"hello","message":[{"type":"text","data":{"text":"hello"}}]}` + +// TestLoggerWiringDecidesInboundFrameVisibility 断言「注入的 logger 决定取证日志的可见性」。 +func TestLoggerWiringDecidesInboundFrameVisibility(t *testing.T) { + t.Parallel() + + // 1) WithLogger 注入 DEBUG 级 logger ⇒ 入站帧必须出现。 + dbg, dbgLog := logCapture() + NewAdapter(Config{}, WithLogger(dbgLog)).handlePayload(context.Background(), []byte(wiringFrame)) + lines := frameLines(dbg) + if len(lines) != 1 { + t.Fatalf("WithLogger 注入的 logger 未收到入站帧日志,接线断了?\n全量日志:\n%s", dbg.String()) + } + if !strings.Contains(lines[0], "level=DEBUG") { + t.Errorf("入站帧日志应为 DEBUG 级: %s", lines[0]) + } + + // 2) 反证:同一条事件喂给 INFO 级 logger 就什么都抓不到 —— 这正是「没人注入 ⇒ 兜底 + // INFO」时取证地基消失的机制(修前组合根的真实形态)。 + info := &bytes.Buffer{} + NewAdapter(Config{}, WithLogger(slog.New(slog.NewTextHandler(info, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })))).handlePayload(context.Background(), []byte(wiringFrame)) + if got := frameLines(info); len(got) != 0 { + t.Errorf("INFO 级 logger 不该收到入站帧日志,实得 %d 行:\n%s", len(got), info.String()) + } + + // 3) WithLogger 压过 Config.Logger:两者都给时,由组合根注入的那个说了算。 + optBuf, optLog := logCapture() + cfgBuf := &bytes.Buffer{} + NewAdapter( + Config{Logger: slog.New(slog.NewTextHandler(cfgBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))}, + WithLogger(optLog), + ).handlePayload(context.Background(), []byte(wiringFrame)) + if got := frameLines(optBuf); len(got) != 1 { + t.Errorf("WithLogger 应当胜出,实得 %d 行:\n%s", len(got), optBuf.String()) + } + if got := frameLines(cfgBuf); len(got) != 0 { + t.Errorf("Config.Logger 不该同时收到(WithLogger 优先),实得 %d 行:\n%s", len(got), cfgBuf.String()) + } +} + +// TestBareAdapterFallbackKeepsDebugOut 钉住兜底的级别口径:没有注入时兜底是默认 INFO, +// 因此 Debug 取证被丢弃——这是**已知且被 WARN 点名**的取舍,而不是悄悄发生的事。 +func TestBareAdapterFallbackKeepsDebugOut(t *testing.T) { + t.Parallel() + + fallback := resolveLogger(nil, nil) + if fallback == nil { + t.Fatal("兜底 logger 不得为 nil") + } + if fallback.Enabled(context.Background(), slog.LevelDebug) { + t.Error("兜底 logger 不该打开 DEBUG:兜底只是兜底,正式接线走 WithLogger") + } + if !fallback.Enabled(context.Background(), slog.LevelInfo) { + t.Error("兜底 logger 至少要能打 INFO/WARN") + } +} + +// TestBareAdapterFallbackIsNotSilent 钉住兜底「不静默」:裸适配器构造时必须在 stderr 上 +// 留一条 WARN,说明 Debug 取证会被丢弃。静默降级正是这次事故的成因,所以这里真的去抓 +// stderr,而不是只断言级别。 +// +// 本测试**不并行**:它临时替换 os.Stderr。Go 的顶层并行测试要等顺序测试全部结束后才 +// 恢复执行,因此替换窗口内没有别的测试在跑。 +func TestBareAdapterFallbackIsNotSilent(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stderr + os.Stderr = w + + NewAdapter(Config{}) // 裸适配器:既无 WithLogger 也无 Config.Logger + + os.Stderr = orig + if cerr := w.Close(); cerr != nil { + t.Fatalf("close pipe: %v", cerr) + } + out, rerr := io.ReadAll(r) + if rerr != nil { + t.Fatalf("read captured stderr: %v", rerr) + } + + got := string(out) + if !strings.Contains(got, "level=WARN") { + t.Errorf("兜底留痕不是 WARN 级:\n%s", got) + } + if !strings.Contains(got, "no logger injected") || !strings.Contains(got, "inbound frame") { + t.Errorf("兜底 WARN 必须说清「没注入 logger ⇒ Debug 取证被丢」:\n%s", got) + } + if !strings.Contains(got, "source="+adapterLogSource) { + t.Errorf("兜底留痕缺来源标注 %q:\n%s", adapterLogSource, got) + } +}