主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
75 lines
3.0 KiB
Go
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)
|
|
}
|
|
}
|