Files
Cyrene_v2/internal/services/backthink/sinks.go
T
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

72 lines
2.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}