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