Files
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

75 lines
3.0 KiB
Go

package adapter
import (
"errors"
"fmt"
"testing"
)
// selfDescribedFailure 是自描述投递失败的替身(适配器实现 DeliveryFailure 的最小形状)。
type selfDescribedFailure struct {
category DeliveryCategory
channel string
text string
}
func (e *selfDescribedFailure) Error() string { return e.text }
func (e *selfDescribedFailure) DeliveryCategory() DeliveryCategory { return e.category }
func (e *selfDescribedFailure) DeliveryChannel() string { return e.channel }
// TestDeliveryCategoryOf 锁定原因码口径:自报优先、契约层哨兵次之、兜底
// CategoryFailed —— 绝不替一个没说清楚原因的错误编一个更精确的码。
func TestDeliveryCategoryOf(t *testing.T) {
t.Parallel()
window := &selfDescribedFailure{
category: "proactive_window_closed",
channel: "weixin-ilink",
text: "weixin_ilink: proactive push outside 24h window",
}
tests := []struct {
name string
err error
want DeliveryCategory
}{
{name: "没有失败就没有码", err: nil, want: ""},
{name: "适配器自报的码被采信", err: window, want: "proactive_window_closed"},
{name: "多气泡包装不藏自报码", err: fmt.Errorf("channelbridge: send bubble #1 of 2: %w", window), want: "proactive_window_closed"},
{name: "桥接器包装不藏自报码", err: fmt.Errorf("channelbridge: send proactive via %q: %w", "weixin-ilink", window), want: "proactive_window_closed"},
{name: "未连接", err: ErrNotConnected, want: CategoryNotConnected},
{name: "不支持", err: fmt.Errorf("adapter: send: %w", ErrUnsupported), want: CategoryUnsupported},
{name: "已关闭", err: ErrClosed, want: CategoryClosed},
{name: "无自报的裸错误落兜底", err: errors.New("boom"), want: CategoryFailed},
{name: "自报空码落兜底(不编更精确的码)", err: &selfDescribedFailure{text: "boom"}, want: CategoryFailed},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := DeliveryCategoryOf(tc.err); got != tc.want {
t.Fatalf("DeliveryCategoryOf(%v) = %q, want %q", tc.err, got, tc.want)
}
})
}
}
// TestDeliveryChannelOf 锁定渠道自报口径:只有自报才有渠道名,未自报是**空串**
// (= 不知道),绝不替它猜一个平台名。
func TestDeliveryChannelOf(t *testing.T) {
t.Parallel()
window := &selfDescribedFailure{category: "proactive_window_closed", channel: "weixin-ilink", text: "closed"}
if got := DeliveryChannelOf(fmt.Errorf("channelbridge: send bubble #1 of 2: %w", window)); got != "weixin-ilink" {
t.Fatalf("DeliveryChannelOf(wrapped) = %q, want weixin-ilink", got)
}
if got := DeliveryChannelOf(fmt.Errorf("channelbridge: no connected owner IM channel for proactive push")); got != "" {
t.Fatalf("DeliveryChannelOf(unreported) = %q, want empty (unknown)", got)
}
if got := DeliveryChannelOf(nil); got != "" {
t.Fatalf("DeliveryChannelOf(nil) = %q, want empty", got)
}
if got := DeliveryChannelOf(ErrNotConnected); got != "" {
t.Fatalf("DeliveryChannelOf(ErrNotConnected) = %q, want empty", got)
}
}