根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。 改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。 线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。 凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。 验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
137 lines
5.5 KiB
Go
137 lines
5.5 KiB
Go
package qq_obv11
|
||
|
||
// 本文件实现 **ws + client**(Cyrene 主动拨号 OneBot 正向 WS)的首连/重连退避策略。
|
||
//
|
||
// 为什么需要它:NapCat 等 OneBot 实现可能比 Cyrene 后启动(或中途重启),若首连失败
|
||
// 就判定渠道起不来,「谁先起来」就成了部署顺序的硬约束。因此首连失败必须非致命:
|
||
// 交给后台重试循环按指数退避继续拨号——退避带抖动(多实例同时重连时错开)且有上限
|
||
// (对面长期不在线时按固定上限间隔探测,不做无界增长)。连接状态始终以真实连接为准:
|
||
// 没连上就是 connected=false,绝不假装已连(面板与 HealthCheck 都照此判断)。
|
||
//
|
||
// 设计依据:DevDocs/design/08-渠道接入.md §4 与 AGENTS §9——client 模式不开监听端口,
|
||
// 重试只发生在出站拨号上。
|
||
//
|
||
// 既有实现对照(AGENTS §9.5):项目已有 `internal/channel/adapter/wsutil.Backoff`
|
||
// (2s 起步、×2、60s 封顶的网关重连调度器,KOOK 用;design/08 §7「WebSocket 有状态
|
||
// channel 的公共基座」计划让各 WS 平台共用这套重连基座)。本包**口径与之对齐**(同样
|
||
// 2s/60s),但不直接 import 它,理由有三:
|
||
// ① 本需求要 ±抖动,wsutil.Backoff 没有这个能力;② import wsutil 会把 gorilla/websocket
|
||
// 拖进本包依赖图,而本包刻意只用标准库携带 WS 帧编解码(见 websocket.go 抬头);
|
||
// ③ 真正收敛的做法是给 wsutil.Backoff 加可选抖动再迁走,那要改 internal/**,属后续工作。
|
||
|
||
import (
|
||
"math/rand/v2"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
// reconnectBaseDelay 是重试等待时长的初值(第一次失败后按因子翻倍)。取值与
|
||
// wsutil.Backoff 的网关重连口径一致(2s 起步、60s 封顶)。
|
||
reconnectBaseDelay = 2 * time.Second
|
||
// reconnectMaxDelay 是退避上限:到顶后按固定间隔继续探测,不再增长。
|
||
reconnectMaxDelay = 60 * time.Second
|
||
// reconnectBackoffFactor 是每多一次连续失败,等待时长的放大倍数。
|
||
reconnectBackoffFactor = 2
|
||
// reconnectMaxDoublings 限制翻倍次数,避免移位/乘法溢出(结果随后仍被上限裁剪)。
|
||
reconnectMaxDoublings = 6
|
||
// reconnectJitterRatio 是抖动幅度(±25%),让多实例的重连打点错开。
|
||
reconnectJitterRatio = 0.25
|
||
)
|
||
|
||
// reconnectPolicy 是退避参数的可注入载体:生产用 defaultReconnectPolicy()(由上面的
|
||
// 具名常量构造),测试替换成毫秒级参数即可确定性验证重试,不必真等秒级退避。
|
||
type reconnectPolicy struct {
|
||
baseDelay time.Duration
|
||
maxDelay time.Duration
|
||
factor int
|
||
maxDoublings int
|
||
jitterRatio float64
|
||
}
|
||
|
||
// defaultReconnectPolicy 返回生产退避参数。
|
||
func defaultReconnectPolicy() reconnectPolicy {
|
||
return reconnectPolicy{
|
||
baseDelay: reconnectBaseDelay,
|
||
maxDelay: reconnectMaxDelay,
|
||
factor: reconnectBackoffFactor,
|
||
maxDoublings: reconnectMaxDoublings,
|
||
jitterRatio: reconnectJitterRatio,
|
||
}
|
||
}
|
||
|
||
// delayFor 返回「已连续失败 failures 次」之后应等待的时长:
|
||
// baseDelay × factor^failures,叠加 ±jitterRatio 抖动后裁剪到 [0, maxDelay]。
|
||
// 指数项先按 maxDoublings 收敛再来抖动、最后裁上限,因此退避有界、绝不无限增长。
|
||
func (p reconnectPolicy) delayFor(failures int) time.Duration {
|
||
steps := failures
|
||
if steps < 0 {
|
||
steps = 0
|
||
}
|
||
if steps > p.maxDoublings {
|
||
steps = p.maxDoublings
|
||
}
|
||
d := p.baseDelay
|
||
for i := 0; i < steps; i++ {
|
||
d *= time.Duration(p.factor)
|
||
}
|
||
if p.jitterRatio > 0 {
|
||
d = time.Duration(float64(d) * (1 + jitterOffset(p.jitterRatio)))
|
||
}
|
||
if d > p.maxDelay {
|
||
d = p.maxDelay
|
||
}
|
||
if d < 0 {
|
||
d = 0
|
||
}
|
||
return d
|
||
}
|
||
|
||
// jitterOffset 返回 [-ratio, +ratio] 上的均匀随机偏移。
|
||
func jitterOffset(ratio float64) float64 {
|
||
return (rand.Float64()*2 - 1) * ratio
|
||
}
|
||
|
||
// reconnectState 是 client 模式的连续失败计数器。Connect 的首连失败与 readLoop 的
|
||
// 重连失败共享同一份状态,因此退避在一轮启动内连续;成功连上即清零。
|
||
type reconnectState struct {
|
||
mu sync.Mutex
|
||
failures int
|
||
}
|
||
|
||
// fail 记一次失败并返回下次重试前应等待的时长(该值就是要写进日志的 retryIn)。
|
||
func (s *reconnectState) fail(p reconnectPolicy) time.Duration {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.failures++
|
||
return p.delayFor(s.failures)
|
||
}
|
||
|
||
// wait 返回当前应等待的时长(不推进计数):与上一次失败记录的 retryIn 一致,
|
||
// 保证日志里承诺的间隔就是下一次真正拨号前的等待。
|
||
func (s *reconnectState) wait(p reconnectPolicy) time.Duration {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return p.delayFor(s.failures)
|
||
}
|
||
|
||
// reset 清零连续失败计数(成功连上或重新 Connect 时调用)。
|
||
func (s *reconnectState) reset() {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.failures = 0
|
||
}
|
||
|
||
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,把**脱敏后的**原因留在
|
||
// 适配器上(供 ConnectOutcome 如实回答「为什么没连上」,见 connect_outcome.go),并写一条
|
||
// 带来源的 WARN(含原因与下次重试间隔)。原因与日志都走同一道脱敏,因此 access_token
|
||
// 既不落日志、也不进对外文案。
|
||
func (a *Adapter) noteConnectFailure(err error) {
|
||
delay := a.reconnect.fail(a.policy)
|
||
reason := a.connectFailureReason(err)
|
||
a.mu.Lock()
|
||
a.lastConnectErr = reason
|
||
a.mu.Unlock()
|
||
a.log.Warn("onebot connect failed; retrying", "err", reason, "retryIn", delay)
|
||
}
|