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

+90
View File
@@ -0,0 +1,90 @@
package adapter
import "errors"
// 本文件给**出站投递失败**一个稳定的原因码。此前一次失败只有自由文本(真机日志:
// `weixin_ilink: proactive push outside 24h window`),调用方只能把整串原文塞进日志与
// 记录 —— 于是「她的话没送到微信」在面板上只表现为一句人读的英文,既没法稳定地告诉
// 主人「哪个渠道、因为哪条平台规则」,也没法按原因聚合。
//
// 口径与 internal/runtime/voice 的 `Category(err)` 同族(那处给语音转码失败分类):
// **产生错误的那一层**声明码值,读取侧只调一个访问函数;错误**没有自报**更具体的
// 原因码时一律落到兜底码 CategoryFailed —— 绝不替它编一个更精确的码。
//
// 为什么放在 adapter 契约层:能见到真实错误的层(plugin 适配器)与要记录它的层
// (channelbridge / backthink)之间只共享本包。契约层是唯一不需要任何一方反向依赖
// 具体插件的落点。范式与 SystemNoticeNormalizer 逐字同族:**可选接口 + 调用方探测**。
// DeliveryCategory 是出站投递失败的**稳定原因码**(snake_case,可直接进日志字段、
// 记录字段与审计文案)。机器读它,人读文案 —— 所以文案会变,码不变。
type DeliveryCategory string
// 契约层自带的四个码。平台特有的码(如 weixin-ilink 的 proactive_window_closed)
// 由各插件在自己的包内声明并自报,本包不枚举它们。
const (
// CategoryNotConnected 表示适配器当前没有可用会话(ErrNotConnected)。
CategoryNotConnected DeliveryCategory = "not_connected"
// CategoryUnsupported 表示该能力/操作不被平台支持(ErrUnsupported)。
CategoryUnsupported DeliveryCategory = "unsupported"
// CategoryClosed 表示适配器已关闭(ErrClosed)。
CategoryClosed DeliveryCategory = "closed"
// CategoryFailed 是**兜底**码:错误没有自报更具体的原因码时使用。它如实表示
// 「只知道失败了,不知道平台规则层面的原因」,既不是成功,也不是猜测。
CategoryFailed DeliveryCategory = "failed"
)
// DeliveryFailure 是投递错误**可选**的自我描述(适配器按需实现,调用方用
// errors.As 探测)。实现必须无副作用:不做 I/O、不改状态。
type DeliveryFailure interface {
// DeliveryCategory 返回该错误的稳定原因码。返回空串表示「不自报」,读取侧
// 退到共享哨兵判定与兜底码。
DeliveryCategory() DeliveryCategory
// DeliveryChannel 返回出错渠道的平台名(例如 "weixin-ilink")。空串表示
// **不知道**——绝不填一个猜出来的平台名。
DeliveryChannel() string
}
// DeliveryCategoryOf 返回 err 的稳定原因码:err 自报则用自报值,否则按契约层
// 哨兵判定,最后落到 CategoryFailed。err 为 nil 时返回空串 —— 「没有失败」不是
// 一种失败码。
//
// errors.As 沿 %w 链走,因此中间层的包装(channelbridge 的
// `send bubble #1 of 8: …`、`send proactive via "weixin-ilink": …`)都不会把
// 自报的码藏起来。
func DeliveryCategoryOf(err error) DeliveryCategory {
if err == nil {
return ""
}
if f := deliveryFailureOf(err); f != nil {
if c := f.DeliveryCategory(); c != "" {
return c
}
}
switch {
case errors.Is(err, ErrNotConnected):
return CategoryNotConnected
case errors.Is(err, ErrUnsupported):
return CategoryUnsupported
case errors.Is(err, ErrClosed):
return CategoryClosed
default:
return CategoryFailed
}
}
// DeliveryChannelOf 返回 err 自报的出错渠道;未自报时返回空串(= 未知)。
func DeliveryChannelOf(err error) string {
if f := deliveryFailureOf(err); f != nil {
return f.DeliveryChannel()
}
return ""
}
// deliveryFailureOf 在错误链上找出第一个自描述失败;没有则返回 nil。
func deliveryFailureOf(err error) DeliveryFailure {
var f DeliveryFailure
if errors.As(err, &f) {
return f
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
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)
}
}