主人原话:「可以,这有必要,不然我老是收不到」。真机日志:
`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` 两列 + 下一轮注入只读一行)与面板结构化徽标 ——
红线:回执只能用来换渠道/换措辞/等窗口,**不得演变成绕平台规则**。
72 lines
2.7 KiB
Go
72 lines
2.7 KiB
Go
package backthink
|
||
|
||
// 生产组合根在 backthink.New *之后* 才构建 gateway hub 与 channel
|
||
// bridge(hub 需要 loop,bridge 需要 loop 与主人档案,
|
||
// 两者都在更晚时才组装),因此构造时拿不到 ActiveClient 与
|
||
// IMAdapter 槽。这些设置器在 gateway / bridge 存在后注入它们,
|
||
// 并重建推送器,同时保留已解出的
|
||
// 频率门,使之后的推送路由正确。
|
||
|
||
// SetActiveSink 接入生产用的活跃 Web/原生客户端槽(gateway
|
||
// hub)并重建推送器。请在 gateway 存在之后、Start 之前调用。
|
||
func (b *Backthink) SetActiveSink(active ActiveClient) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.deps.Active = active
|
||
b.rebuildPusherLocked()
|
||
}
|
||
|
||
// SetIMSink 接入生产用的 IM 桥槽(channel bridge)并重建
|
||
// 推送器。请在 channel bridge 存在之后、Start 之前调用。
|
||
func (b *Backthink) SetIMSink(im IMAdapter) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.deps.IM = im
|
||
b.rebuildPusherLocked()
|
||
}
|
||
|
||
// SetAuditSink 接入审计出口(网关,design/12 §N C.5)。它与上面两个 setter 同属
|
||
// **晚绑定**:网关比 backthink 晚组装,构造时拿不到。它不重建推送器——审计不在推送
|
||
// 路径上,换一个出口(或注入 nil)都不影响投递。
|
||
func (b *Backthink) SetAuditSink(a Auditor) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.deps.Audit = a
|
||
}
|
||
|
||
// currentAuditor 在生命周期锁下返回当前审计出口,因此在途的一轮自排拿到的是
|
||
// 一致快照,而不是「一半旧一半新」的两个出口。
|
||
func (b *Backthink) currentAuditor() Auditor {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.deps.Audit
|
||
}
|
||
|
||
// rebuildPusherLocked 用当前槽位、频率门与推送出口替换
|
||
// 推送器。调用方必须持有 b.mu。
|
||
//
|
||
// 审计出口挂的是 `b.auditEvent` 这个**读取当前出口**的回调,而不是一次性快照:
|
||
// SetAuditSink 是晚绑定的(网关比 backthink 晚组装),持快照会让稍后接上的网关
|
||
// 永远收不到推送审计 —— 与「未接线则静默跳过」同一条诚实口径。
|
||
func (b *Backthink) rebuildPusherLocked() {
|
||
b.pusher = newPusher(b.deps.Active, b.deps.IM, b.gate, b.log).
|
||
withExporter(b.deps.Exporter).
|
||
withAuditor(b.auditEvent)
|
||
}
|
||
|
||
// rebuildPusher 是不持锁版本的 rebuildPusherLocked,供 New 在结构体刚构造好、
|
||
// 尚无并发访问者时使用。
|
||
func (b *Backthink) rebuildPusher() {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.rebuildPusherLocked()
|
||
}
|
||
|
||
// currentPusher 在生命周期锁下返回当前推送器,因此一次槽位
|
||
// 换入绝不会与即将推送的并发思考轮发生竞态。
|
||
func (b *Backthink) currentPusher() Pusher {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.pusher
|
||
}
|