Files
AskaEth b4d55f412e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因: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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00

137 lines
5.5 KiB
Go
Raw Permalink 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 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)
}