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

@@ -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)
}
}