fix(channels): QQ 适配器接上注入 logger(帧取证复活)+ 兜底不再静默降级(Warn 说明 Debug 会被丢)
This commit is contained in:
1 parent
e1bd7c3009
commit
b82870f41e
4 files changed
+272
-8
No files matched your search
@@ -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".
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user