Files
Cyrene_v2/internal/services/backthink/push_im_undelivered_test.go
T
AskaEth 353293e807
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(im): 主动消息「没送达微信」现在说得清(原因码 + 三处可见)
主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
2026-10-07 10:59:22 +08:00

215 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}